From 0374e18ba00629fde89cb9328790179c6049701b Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Thu, 23 Oct 2025 07:01:59 -0400 Subject: [PATCH 01/13] patch to compile wip add borsh_compat compressed_proof add new_address_owner to instructiondata trait add derive_compressed_address remaining new_address_owner impl add csdk-anchor-test program lint add address_owner trait impl add sdk libs - wip add transfer_interface, transfer_ctoken, transfer_spl_to_ctoken, transfer_ctoken_to_spl, signed, instructions rename consistently transfer_x rename file transfer_decompressed to transfer_ctoken add todos add create_ctoken_account_signed, rename create_compressible_token_account to create_compressible_token_account_instruction add create_associated_ctoken_account add inline comment to copyLocalProgramBinaries.sh remove new_address_owner add pack and unpack for tokendata first pass, compressible helpers for light-sdk wip compiles lint compressAccountsIdempotent csdk works, adjust test asserts to account is_none ctoken add signer flags for decompress_full_ctoken_accounts_with_indices wip stash: removing ctoken from compression all tests working add auto-comp, clean up tests rm dependency on patch fmt lint lint refactor rm wip clean fmt clean clean clean rm macro clean clean dedupe derivation methods clean fmt revert copyLocalProgramBinaries.sh diff add csdk_anchor_test binary to ci fix indexer fix doctest fix cli ci build target fix cli build clean address nits fix cli cache fix cache clean fix csdk anchor test program build add pkg.json csdk rebuild fix syntax fix nx rm panics fix ci fix build sdk-anchor-test try fix bmt keccak spawn_prover fix fix lint fix clock sysvar add test feature to account-compression revert profiler refactor csdk-anchor-test program lib.rs split tests fmt revert cli script reset close_for_compress_and_close to main fmt try revert build account-compression with test flag fmt fix workflow to ensure we build account-compression with test feature fix sdk test nav try sdk-tests.yml with hyphen rm idl build csdk anchor test artifact wip reuse ctoken_types move ctoken to light-compressed-token-sdk clean move pack to compressed-token-sdk clean clean clean clean clean clean wip - add macro lint clean clean fmt clean, rename to sdk-compressible-test cargo lock default auto compress false wip patch to compile wip add borsh_compat compressed_proof add new_address_owner to instructiondata trait add derive_compressed_address remaining new_address_owner impl add csdk-anchor-test program lint add address_owner trait impl add sdk libs - wip add transfer_interface, transfer_ctoken, transfer_spl_to_ctoken, transfer_ctoken_to_spl, signed, instructions rename consistently transfer_x rename file transfer_decompressed to transfer_ctoken add todos add create_ctoken_account_signed, rename create_compressible_token_account to create_compressible_token_account_instruction add create_associated_ctoken_account add inline comment to copyLocalProgramBinaries.sh remove new_address_owner add pack and unpack for tokendata first pass, compressible helpers for light-sdk wip compiles lint compressAccountsIdempotent csdk works, adjust test asserts to account is_none ctoken add signer flags for decompress_full_ctoken_accounts_with_indices wip stash: removing ctoken from compression all tests working add auto-comp, clean up tests rm dependency on patch fmt lint lint refactor rm wip clean fmt clean clean clean rm macro clean clean dedupe derivation methods clean fmt revert copyLocalProgramBinaries.sh diff add csdk_anchor_test binary to ci fix indexer fix doctest fix cli ci build target fix cli build clean address nits fix cli cache fix cache clean fix csdk anchor test program build add pkg.json csdk rebuild fix syntax fix nx rm panics fix ci fix build sdk-anchor-test try fix bmt keccak spawn_prover fix fix lint fix clock sysvar add test feature to account-compression revert profiler refactor csdk-anchor-test program lib.rs split tests fmt revert cli script reset close_for_compress_and_close to main fmt try revert build account-compression with test flag fmt fix workflow to ensure we build account-compression with test feature fix sdk test nav try sdk-tests.yml with hyphen rm idl build csdk anchor test artifact wip reuse ctoken_types move ctoken to light-compressed-token-sdk clean move pack to compressed-token-sdk clean clean clean clean clean clean wip - add macro push macros refactor compressible_instructions macro split into compressible_instructions_decompress modularized decompressaccountsidempotent add decompresscontext derive macro clean macros done compress runtime and clean fmt use small derive macros wip csdk anchor derived test using derive macros lint wip clean rm dead code clean lint fmt clean fmt dry clean lint clean rent cpi wip fmt and lint clean avoid reallocs in decompress_accounts_idempotent ixn builder remove rent sponsor and compression authority optional ctoken keys for decompress_accounts_idempotent wip auto compress pda auto compress test derive_rent_sponsor macro add disable cold state mining flag wip add complex seed test wip clean clean ignore doctest wip revert to devnenv for lightprogramtest until we can remove it clean wip address comments fixes apply suggestion in decompress_runtime.rs lint wip fix lint fix macros lint fix macro lint add standard derive_rent_sponsor helper remove unused ctoken-types dep from sdk patch rm unwrap from nested field access address final comments move decompress_runtime.rs rm derive_light_cpi_signer impl additional suggestions fix forester deps add c-token/compressible TS rename to grpc-url make grpc url opt fix tests, mint bump photon all js tests working, update ci clean up getAccountInfoInterface add v2 bound for getAccountInfoInterface wip wip add mintinstructiondata type, add serde tests. use borsh serde with overrides replace with borsh clean clean clean remove uploaders, add schema converters add unit tests for metadata json conv include unit tests in js ci cov wip fix token delegate coption parser clean fmt and lint fix v2 stateless.js ci try fix forester test with wait_for_queue_space revert stateless js skip createAccount if v2 update photon commit to parse-token + rebased to sergey/get_queue_elements_v2_rpc add load, decompress2, transferInterface, and various other helpers ts wip fmt lint, skipIf v2 for create-account js tests bump photon commit bump photon skip in rpc-multi-trees rm logs renamings fix buffer-layout and bosh upd Ata -> ATA --- .github/workflows/js-v2.yml | 21 +- cli/src/commands/create-mint/index.ts | 1 + cli/src/utils/constants.ts | 2 +- cli/src/utils/processPhotonIndexer.ts | 1 + cli/test/helpers/helpers.ts | 1 + js/compressed-token/PAYMENT_MIGRATION.md | 241 +++ js/compressed-token/package.json | 25 +- js/compressed-token/rollup.config.js | 15 +- .../src/actions/create-mint.ts | 9 +- .../src/compressible/derivation.ts | 62 + .../src/compressible/helpers.ts | 204 +++ js/compressed-token/src/compressible/index.ts | 4 + js/compressed-token/src/compressible/serde.ts | 121 ++ .../src/compressible/unified-load.ts | 539 +++++++ js/compressed-token/src/constants.ts | 16 + js/compressed-token/src/index.ts | 99 ++ js/compressed-token/src/layout-transfer2.ts | 272 ++++ .../mint/actions/create-associated-ctoken.ts | 108 ++ .../src/mint/actions/create-ata-interface.ts | 270 ++++ .../src/mint/actions/create-mint-interface.ts | 138 ++ .../src/mint/actions/decompress2.ts | 168 +++ .../actions/get-or-create-ata-interface.ts | 120 ++ js/compressed-token/src/mint/actions/index.ts | 12 + .../src/mint/actions/mint-to-compressed.ts | 107 ++ .../src/mint/actions/mint-to-interface.ts | 110 ++ .../src/mint/actions/mint-to.ts | 110 ++ .../src/mint/actions/transfer-interface.ts | 341 +++++ .../src/mint/actions/update-metadata.ts | 269 ++++ .../src/mint/actions/update-mint.ts | 185 +++ js/compressed-token/src/mint/actions/wrap.ts | 120 ++ .../src/mint/get-account-interface.ts | 697 +++++++++ js/compressed-token/src/mint/helpers.ts | 246 ++++ js/compressed-token/src/mint/index.ts | 6 + .../instructions/create-associated-ctoken.ts | 324 ++++ .../src/mint/instructions/create-mint.ts | 223 +++ .../src/mint/instructions/decompress2.ts | 255 ++++ .../src/mint/instructions/index.ts | 10 + .../mint/instructions/mint-action-layout.ts | 348 +++++ .../mint/instructions/mint-to-compressed.ts | 186 +++ .../mint/instructions/mint-to-interface.ts | 99 ++ .../src/mint/instructions/mint-to.ts | 189 +++ .../mint/instructions/transfer-interface.ts | 150 ++ .../src/mint/instructions/update-metadata.ts | 385 +++++ .../src/mint/instructions/update-mint.ts | 282 ++++ .../src/mint/instructions/wrap.ts | 149 ++ js/compressed-token/src/mint/serde.ts | 510 +++++++ js/compressed-token/src/mint/upload.ts | 75 + js/compressed-token/src/program.ts | 2 +- js/compressed-token/src/utils/ata-utils.ts | 19 + js/compressed-token/src/utils/index.ts | 1 + .../e2e/compress-spl-token-account.test.ts | 2 + .../tests/e2e/compress.test.ts | 4 +- .../tests/e2e/compressible-load.test.ts | 448 ++++++ .../e2e/create-associated-ctoken.test.ts | 580 ++++++++ .../tests/e2e/create-compressed-mint.test.ts | 207 +++ .../tests/e2e/create-mint.test.ts | 18 +- .../tests/e2e/create-token-pool.test.ts | 2 + .../tests/e2e/decompress-delegated.test.ts | 1 + .../tests/e2e/decompress.test.ts | 1 + .../tests/e2e/decompress2.test.ts | 569 +++++++ .../tests/e2e/delegate.test.ts | 1 + js/compressed-token/tests/e2e/layout.test.ts | 4 +- .../tests/e2e/merge-token-accounts.test.ts | 1 + .../tests/e2e/mint-to-compressed.test.ts | 155 ++ .../tests/e2e/mint-to-ctoken.test.ts | 124 ++ .../tests/e2e/mint-to-interface.test.ts | 454 ++++++ js/compressed-token/tests/e2e/mint-to.test.ts | 1 + .../tests/e2e/mint-workflow.test.ts | 677 +++++++++ .../tests/e2e/multi-pool.test.ts | 1 + .../tests/e2e/payment-flows.test.ts | 586 ++++++++ .../tests/e2e/rpc-multi-trees.test.ts | 1 + .../tests/e2e/rpc-token-interop.test.ts | 2 + .../tests/e2e/transfer-delegated.test.ts | 3 + .../tests/e2e/transfer-interface.test.ts | 509 +++++++ .../tests/e2e/transfer.test.ts | 3 + .../tests/e2e/update-metadata.test.ts | 511 +++++++ .../tests/e2e/update-mint.test.ts | 290 ++++ js/compressed-token/tests/e2e/wrap.test.ts | 548 +++++++ js/compressed-token/tests/unit/serde.test.ts | 1302 +++++++++++++++++ js/compressed-token/tests/unit/upload.test.ts | 438 ++++++ .../types/buffer-layout/index.d.ts | 7 +- js/stateless.js/package.json | 6 +- js/stateless.js/src/actions/create-account.ts | 32 +- js/stateless.js/src/actions/transfer.ts | 1 + js/stateless.js/src/constants.ts | 28 +- js/stateless.js/src/rpc-interface.ts | 31 +- js/stateless.js/src/rpc.ts | 173 ++- .../test-rpc/get-compressed-token-accounts.ts | 63 +- .../src/test-helpers/test-rpc/test-rpc.ts | 30 + js/stateless.js/src/utils/index.ts | 1 + js/stateless.js/src/utils/pack-decompress.ts | 82 ++ js/stateless.js/tests/e2e/compress.test.ts | 275 ++-- js/stateless.js/tests/e2e/rpc-interop.test.ts | 582 ++++---- .../tests/e2e/rpc-multi-trees.test.ts | 220 +-- pnpm-lock.yaml | 6 + scripts/devenv/install-photon.sh | 8 +- scripts/devenv/versions.sh | 1 + sdk-libs/macros/src/compressible/GUIDE.md | 2 +- .../macros/src/compressible/instructions.rs | 2 +- .../macros/src/compressible/seed_providers.rs | 4 +- 100 files changed, 16273 insertions(+), 571 deletions(-) create mode 100644 js/compressed-token/PAYMENT_MIGRATION.md create mode 100644 js/compressed-token/src/compressible/derivation.ts create mode 100644 js/compressed-token/src/compressible/helpers.ts create mode 100644 js/compressed-token/src/compressible/index.ts create mode 100644 js/compressed-token/src/compressible/serde.ts create mode 100644 js/compressed-token/src/compressible/unified-load.ts create mode 100644 js/compressed-token/src/layout-transfer2.ts create mode 100644 js/compressed-token/src/mint/actions/create-associated-ctoken.ts create mode 100644 js/compressed-token/src/mint/actions/create-ata-interface.ts create mode 100644 js/compressed-token/src/mint/actions/create-mint-interface.ts create mode 100644 js/compressed-token/src/mint/actions/decompress2.ts create mode 100644 js/compressed-token/src/mint/actions/get-or-create-ata-interface.ts create mode 100644 js/compressed-token/src/mint/actions/index.ts create mode 100644 js/compressed-token/src/mint/actions/mint-to-compressed.ts create mode 100644 js/compressed-token/src/mint/actions/mint-to-interface.ts create mode 100644 js/compressed-token/src/mint/actions/mint-to.ts create mode 100644 js/compressed-token/src/mint/actions/transfer-interface.ts create mode 100644 js/compressed-token/src/mint/actions/update-metadata.ts create mode 100644 js/compressed-token/src/mint/actions/update-mint.ts create mode 100644 js/compressed-token/src/mint/actions/wrap.ts create mode 100644 js/compressed-token/src/mint/get-account-interface.ts create mode 100644 js/compressed-token/src/mint/helpers.ts create mode 100644 js/compressed-token/src/mint/index.ts create mode 100644 js/compressed-token/src/mint/instructions/create-associated-ctoken.ts create mode 100644 js/compressed-token/src/mint/instructions/create-mint.ts create mode 100644 js/compressed-token/src/mint/instructions/decompress2.ts create mode 100644 js/compressed-token/src/mint/instructions/index.ts create mode 100644 js/compressed-token/src/mint/instructions/mint-action-layout.ts create mode 100644 js/compressed-token/src/mint/instructions/mint-to-compressed.ts create mode 100644 js/compressed-token/src/mint/instructions/mint-to-interface.ts create mode 100644 js/compressed-token/src/mint/instructions/mint-to.ts create mode 100644 js/compressed-token/src/mint/instructions/transfer-interface.ts create mode 100644 js/compressed-token/src/mint/instructions/update-metadata.ts create mode 100644 js/compressed-token/src/mint/instructions/update-mint.ts create mode 100644 js/compressed-token/src/mint/instructions/wrap.ts create mode 100644 js/compressed-token/src/mint/serde.ts create mode 100644 js/compressed-token/src/mint/upload.ts create mode 100644 js/compressed-token/src/utils/ata-utils.ts create mode 100644 js/compressed-token/tests/e2e/compressible-load.test.ts create mode 100644 js/compressed-token/tests/e2e/create-associated-ctoken.test.ts create mode 100644 js/compressed-token/tests/e2e/create-compressed-mint.test.ts create mode 100644 js/compressed-token/tests/e2e/decompress2.test.ts create mode 100644 js/compressed-token/tests/e2e/mint-to-compressed.test.ts create mode 100644 js/compressed-token/tests/e2e/mint-to-ctoken.test.ts create mode 100644 js/compressed-token/tests/e2e/mint-to-interface.test.ts create mode 100644 js/compressed-token/tests/e2e/mint-workflow.test.ts create mode 100644 js/compressed-token/tests/e2e/payment-flows.test.ts create mode 100644 js/compressed-token/tests/e2e/transfer-interface.test.ts create mode 100644 js/compressed-token/tests/e2e/update-metadata.test.ts create mode 100644 js/compressed-token/tests/e2e/update-mint.test.ts create mode 100644 js/compressed-token/tests/e2e/wrap.test.ts create mode 100644 js/compressed-token/tests/unit/serde.test.ts create mode 100644 js/compressed-token/tests/unit/upload.test.ts create mode 100644 js/stateless.js/src/utils/pack-decompress.ts diff --git a/.github/workflows/js-v2.yml b/.github/workflows/js-v2.yml index f10e2e55d2..077bb4f5a0 100644 --- a/.github/workflows/js-v2.yml +++ b/.github/workflows/js-v2.yml @@ -79,9 +79,9 @@ jobs: done echo "Tests passed on attempt $attempt" - - name: Run compressed-token tests with V2 + - name: Run compressed-token legacy tests with V2 run: | - echo "Running compressed-token tests with retry logic (max 2 attempts)..." + echo "Running compressed-token legacy tests with retry logic (max 2 attempts)..." attempt=1 max_attempts=2 until npx nx test @lightprotocol/compressed-token; do @@ -95,6 +95,23 @@ jobs: done echo "Tests passed on attempt $attempt" + - name: Run compressed-token ctoken tests with V2 + run: | + echo "Running compressed-token ctoken tests with retry logic (max 2 attempts)..." + attempt=1 + max_attempts=2 + cd js/compressed-token + until LIGHT_PROTOCOL_VERSION=V2 pnpm test:e2e:ctoken:all; do + attempt=$((attempt + 1)) + if [ $attempt -gt $max_attempts ]; then + echo "Tests failed after $max_attempts attempts" + exit 1 + fi + echo "Attempt $attempt/$max_attempts failed, retrying..." + sleep 5 + done + echo "Tests passed on attempt $attempt" + - name: Run sdk-anchor-test TypeScript tests with V2 run: | npx nx build @lightprotocol/sdk-anchor-test diff --git a/cli/src/commands/create-mint/index.ts b/cli/src/commands/create-mint/index.ts index 1ed4fcf2b4..70afe53c89 100644 --- a/cli/src/commands/create-mint/index.ts +++ b/cli/src/commands/create-mint/index.ts @@ -49,6 +49,7 @@ class CreateMintCommand extends Command { rpc(), payer, mintAuthority, + null, mintDecimals, mintKeypair, ); diff --git a/cli/src/utils/constants.ts b/cli/src/utils/constants.ts index d86f588678..8253b49de5 100644 --- a/cli/src/utils/constants.ts +++ b/cli/src/utils/constants.ts @@ -25,7 +25,7 @@ export const PHOTON_VERSION = "0.51.2"; export const USE_PHOTON_FROM_GIT = true; // If true, will show git install command instead of crates.io. export const PHOTON_GIT_REPO = "https://github.com/lightprotocol/photon.git"; export const PHOTON_GIT_COMMIT = "711c47b20330c6bb78feb0a2c15e8292fcd0a7b0"; // If empty, will use main branch. - +//df1087d55a8ff237ff69495a48542461a972f4fe export const LIGHT_PROTOCOL_PROGRAMS_DIR_ENV = "LIGHT_PROTOCOL_PROGRAMS_DIR"; export const BASE_PATH = "../../bin/"; diff --git a/cli/src/utils/processPhotonIndexer.ts b/cli/src/utils/processPhotonIndexer.ts index cabbb2032e..a2407f82f3 100644 --- a/cli/src/utils/processPhotonIndexer.ts +++ b/cli/src/utils/processPhotonIndexer.ts @@ -61,6 +61,7 @@ export async function startIndexer( if (photonDatabaseUrl) { args.push("--db-url", photonDatabaseUrl); } + spawnBinary(INDEXER_PROCESS_NAME, args); await waitForServers([{ port: indexerPort, path: "/getIndexerHealth" }]); console.log("Indexer started successfully!"); diff --git a/cli/test/helpers/helpers.ts b/cli/test/helpers/helpers.ts index c2a2873c61..acb0c52109 100644 --- a/cli/test/helpers/helpers.ts +++ b/cli/test/helpers/helpers.ts @@ -38,6 +38,7 @@ export async function createTestMint(mintKeypair: Keypair) { rpc, await getPayer(), (await getPayer()).publicKey, + null, 9, mintKeypair, ); diff --git a/js/compressed-token/PAYMENT_MIGRATION.md b/js/compressed-token/PAYMENT_MIGRATION.md new file mode 100644 index 0000000000..1a67f86679 --- /dev/null +++ b/js/compressed-token/PAYMENT_MIGRATION.md @@ -0,0 +1,241 @@ +# SPL Token to CToken Payment Migration + +Mirrors SPL Token's API. Same pattern, same flow. + +## TL;DR + +```typescript +// SPL Token +import { transfer, getOrCreateAssociatedTokenAccount } from '@solana/spl-token'; + +// CToken +import { + transferInterface, + getOrCreateAtaInterface, +} from '@lightprotocol/compressed-token'; +``` + +## Action Level + +### SPL Token + +```typescript +const recipientAta = await getOrCreateAssociatedTokenAccount( + connection, + payer, + mint, + recipient, +); +await transfer( + connection, + payer, + sourceAta, + recipientAta.address, + owner, + amount, +); +``` + +### CToken + +```typescript +const recipientAta = await getOrCreateAtaInterface(rpc, payer, mint, recipient); +await transferInterface( + rpc, + payer, + sourceAta, + recipientAta.address, + owner, + mint, + amount, +); +``` + +Same two-step pattern. `transferInterface` auto-loads sender's unified balance (cold + SPL + T22). + +--- + +## Instruction Level + +### SPL Token + +```typescript +import { + createAssociatedTokenAccountIdempotentInstruction, + createTransferInstruction, + getAssociatedTokenAddressSync, +} from '@solana/spl-token'; + +const sourceAta = getAssociatedTokenAddressSync(mint, sender); +const recipientAta = getAssociatedTokenAddressSync(mint, recipient); + +const tx = new Transaction().add( + createAssociatedTokenAccountIdempotentInstruction( + payer, + recipientAta, + recipient, + mint, + ), + createTransferInstruction(sourceAta, recipientAta, sender, amount), +); +``` + +### CToken (sender already hot) + +```typescript +import { + getAtaAddressInterface, + createAtaInterfaceIdempotentInstruction, + createTransferInterfaceInstruction, + CTOKEN_PROGRAM_ID, +} from '@lightprotocol/compressed-token'; + +const sourceAta = getAtaAddressInterface(mint, sender); +const recipientAta = getAtaAddressInterface(mint, recipient); + +const tx = new Transaction().add( + createAtaInterfaceIdempotentInstruction( + payer, + recipientAta, + recipient, + mint, + CTOKEN_PROGRAM_ID, + ), + createTransferInterfaceInstruction(sourceAta, recipientAta, sender, amount), +); +``` + +### CToken (sender may be cold - needs loading) + +```typescript +import { + createLoadAtaInstructions, + getAtaAddressInterface, + createAtaInterfaceIdempotentInstruction, + createTransferInterfaceInstruction, + CTOKEN_PROGRAM_ID, +} from '@lightprotocol/compressed-token'; + +// 1. Derive addresses +const sourceAta = getAtaAddressInterface(mint, sender); +const recipientAta = getAtaAddressInterface(mint, recipient); + +// 2. Build load instructions (empty if already hot) +const loadIxs = await createLoadAtaInstructions( + rpc, + payer, + sourceAta, + sender, + mint, +); + +// 3. Build transaction +const tx = new Transaction().add( + ...loadIxs, // Load sender if cold (wrap SPL/T22, decompress) + createAtaInterfaceIdempotentInstruction( + payer, + recipientAta, + recipient, + mint, + CTOKEN_PROGRAM_ID, + ), + createTransferInterfaceInstruction(sourceAta, recipientAta, sender, amount), +); +``` + +### CToken (sender pre-fetched) + +```typescript +import { + getAtaInterface, + createLoadAtaInstructionsFromInterface, + getAtaAddressInterface, + createAtaInterfaceIdempotentInstruction, + createTransferInterfaceInstruction, + CTOKEN_PROGRAM_ID, +} from '@lightprotocol/compressed-token'; + +// 1. Pre-fetch sender's unified balance +const senderAtaInfo = await getAtaInterface(rpc, sender, mint); + +// 2. Build load instructions from interface (empty if already hot) +const loadIxs = await createLoadAtaInstructionsFromInterface( + rpc, + payer, + senderAtaInfo, +); + +// 3. Derive addresses +const sourceAta = getAtaAddressInterface(mint, sender); +const recipientAta = getAtaAddressInterface(mint, recipient); + +// 4. Build transaction +const tx = new Transaction().add( + ...loadIxs, + createAtaInterfaceIdempotentInstruction( + payer, + recipientAta, + recipient, + mint, + CTOKEN_PROGRAM_ID, + ), + createTransferInterfaceInstruction(sourceAta, recipientAta, sender, amount), +); +``` + +--- + +## Instruction Mapping + +| SPL Token | CToken | +| --------------------------------------------------- | ----------------------------------------------------------------- | +| `getAssociatedTokenAddressSync` | `getAtaAddressInterface` | +| `createAssociatedTokenAccountIdempotentInstruction` | `createAtaInterfaceIdempotentInstruction` | +| `createTransferInstruction` | `createTransferInterfaceInstruction` | +| N/A | `createLoadAtaInstructions` (fetch + build) | +| N/A | `createLoadAtaInstructionsFromInterface` (build from pre-fetched) | + +--- + +## Key Differences + +| | SPL Token | CToken | +| ------------------- | ---------------------- | --------------------------------------------- | +| Recipient ATA | Create before transfer | Create before transfer | +| Sender balance | Single ATA | Unified (cold + SPL + T22 + hot) | +| Loading | N/A | `createLoadAtaInstructions` or auto in action | +| `destination` param | ATA address | ATA address | + +--- + +## Common Patterns + +### Check if loading needed + +```typescript +const ata = await getAtaInterface(rpc, owner, mint); +if (ata.isCold) { + // Need to include load instructions +} +``` + +### Get unified balance + +```typescript +const ata = await getAtaInterface(rpc, owner, mint); +const totalBalance = ata.parsed.amount; // All sources combined +``` + +### Idempotent recipient ATA + +Always safe to include - no-op if exists: + +```typescript +createAtaInterfaceIdempotentInstruction( + payer, + recipientAta, + recipient, + mint, + CTOKEN_PROGRAM_ID, +); +``` diff --git a/js/compressed-token/package.json b/js/compressed-token/package.json index fdf9075985..db9f186cec 100644 --- a/js/compressed-token/package.json +++ b/js/compressed-token/package.json @@ -29,12 +29,14 @@ ], "license": "Apache-2.0", "peerDependencies": { + "@coral-xyz/borsh": "^0.29.0", "@lightprotocol/stateless.js": "workspace:*", "@solana/spl-token": ">=0.3.9", "@solana/web3.js": ">=1.73.5" }, "dependencies": { - "@coral-xyz/borsh": "^0.29.0", + "@solana/buffer-layout": "^4.0.1", + "@solana/buffer-layout-utils": "^0.2.0", "bn.js": "^5.2.1", "buffer": "6.0.3" }, @@ -77,9 +79,11 @@ "vitest": "^2.1.1" }, "scripts": { - "test": "pnpm test:e2e:all", - "test:v1": "LIGHT_PROTOCOL_VERSION=V1 pnpm test", - "test:v2": "LIGHT_PROTOCOL_VERSION=V2 pnpm test", + "test": "pnpm test:e2e:legacy:all", + "test-ci": "pnpm test:v1 && pnpm test:v2 && LIGHT_PROTOCOL_VERSION=V2 pnpm test:e2e:ctoken:all", + "test:v1": "pnpm build:v1 && LIGHT_PROTOCOL_VERSION=V1 vitest run tests/unit && LIGHT_PROTOCOL_VERSION=V1 pnpm test:e2e:legacy:all", + "test:v2": "pnpm build:v2 && LIGHT_PROTOCOL_VERSION=V2 vitest run tests/unit && LIGHT_PROTOCOL_VERSION=V2 pnpm test:e2e:legacy:all", + "test:v2:ctoken": "pnpm build:v2 && LIGHT_PROTOCOL_VERSION=V2 pnpm test:e2e:ctoken:all", "test-all": "vitest run", "test:unit:all": "EXCLUDE_E2E=true vitest run", "test:unit:all:v1": "LIGHT_PROTOCOL_VERSION=V1 vitest run tests/unit --reporter=verbose", @@ -88,6 +92,14 @@ "test-validator": "./../../cli/test_bin/run test-validator", "test-validator-skip-prover": "./../../cli/test_bin/run test-validator --skip-prover", "test:e2e:create-mint": "pnpm test-validator && NODE_OPTIONS='--trace-deprecation' vitest run tests/e2e/create-mint.test.ts --reporter=verbose", + "test:e2e:create-compressed-mint": "pnpm test-validator && vitest run tests/e2e/create-compressed-mint.test.ts --reporter=verbose", + "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-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", "test:e2e:layout": "vitest run tests/e2e/layout.test.ts --reporter=verbose --bail=1", "test:e2e:select-accounts": "vitest run tests/e2e/select-accounts.test.ts --reporter=verbose", "test:e2e:create-token-pool": "pnpm test-validator && vitest run tests/e2e/create-token-pool.test.ts", @@ -104,7 +116,9 @@ "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:all": "pnpm test-validator && vitest run tests/e2e/create-mint.test.ts && vitest run tests/e2e/mint-to.test.ts && vitest run tests/e2e/transfer.test.ts && vitest run tests/e2e/delegate.test.ts && vitest run tests/e2e/transfer-delegated.test.ts && vitest run tests/e2e/multi-pool.test.ts && vitest run tests/e2e/decompress-delegated.test.ts && pnpm test-validator-skip-prover && vitest run tests/e2e/compress.test.ts && vitest run tests/e2e/compress-spl-token-account.test.ts && vitest run tests/e2e/decompress.test.ts && vitest run tests/e2e/create-token-pool.test.ts && vitest run tests/e2e/approve-and-mint-to.test.ts && vitest run tests/e2e/rpc-token-interop.test.ts && vitest run tests/e2e/rpc-multi-trees.test.ts && vitest run tests/e2e/layout.test.ts && vitest run tests/e2e/select-accounts.test.ts", + "test:e2e:legacy:all": "pnpm test-validator && vitest run tests/e2e/create-mint.test.ts && vitest run tests/e2e/mint-to.test.ts && vitest run tests/e2e/transfer.test.ts && vitest run tests/e2e/delegate.test.ts && vitest run tests/e2e/transfer-delegated.test.ts && vitest run tests/e2e/multi-pool.test.ts && vitest run tests/e2e/decompress-delegated.test.ts && 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: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 && 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", "pull-idl": "../../scripts/push-compressed-token-idl.sh", "build": "if [ \"$LIGHT_PROTOCOL_VERSION\" = \"V2\" ]; then LIGHT_PROTOCOL_VERSION=V2 pnpm build:bundle; else LIGHT_PROTOCOL_VERSION=V1 pnpm build:bundle; fi", "build:bundle": "rimraf dist && rollup -c", @@ -113,7 +127,6 @@ "build:stateless:v1": "cd ../stateless.js && pnpm build:v1", "build:stateless:v2": "cd ../stateless.js && pnpm build:v2", "build-ci": "if [ \"$LIGHT_PROTOCOL_VERSION\" = \"V2\" ]; then LIGHT_PROTOCOL_VERSION=V2 pnpm build:bundle; else LIGHT_PROTOCOL_VERSION=V1 pnpm build:bundle; fi", - "test-ci": "pnpm test", "format": "prettier --write .", "lint": "eslint ." }, diff --git a/js/compressed-token/rollup.config.js b/js/compressed-token/rollup.config.js index c6f64f56d9..5216a7aa99 100644 --- a/js/compressed-token/rollup.config.js +++ b/js/compressed-token/rollup.config.js @@ -18,9 +18,9 @@ const rolls = (fmt, env) => ({ sourcemap: true, }, external: [ + '@coral-xyz/borsh', '@solana/web3.js', '@solana/spl-token', - '@coral-xyz/borsh', '@lightprotocol/stateless.js', ], plugins: [ @@ -85,7 +85,18 @@ const rolls = (fmt, env) => ({ const typesConfig = { input: 'src/index.ts', output: [{ file: 'dist/types/index.d.ts', format: 'es' }], - plugins: [dts()], + external: [ + '@coral-xyz/borsh', + '@solana/web3.js', + '@solana/spl-token', + '@lightprotocol/stateless.js', + ], + plugins: [ + dts({ + respectExternal: true, + tsconfig: './tsconfig.json', + }), + ], }; export default [ diff --git a/js/compressed-token/src/actions/create-mint.ts b/js/compressed-token/src/actions/create-mint.ts index a107dec2be..c08815f62c 100644 --- a/js/compressed-token/src/actions/create-mint.ts +++ b/js/compressed-token/src/actions/create-mint.ts @@ -19,19 +19,20 @@ import { } from '@lightprotocol/stateless.js'; /** - * Create and initialize a new compressed token mint + * Create and initialize a new SPL token mint + * + * @deprecated Use {@link createMintInterface} instead, which supports both SPL and compressed mints. * * @param rpc RPC connection to use * @param payer Fee payer * @param mintAuthority Account that will control minting + * @param freezeAuthority Optional: Account that will control freeze and thaw. * @param decimals Location of the decimal place * @param keypair Optional: Mint keypair. Defaults to a random * keypair. * @param confirmOptions Options for confirming the transaction * @param tokenProgramId Optional: Program ID for the token. Defaults to * TOKEN_PROGRAM_ID. - * @param freezeAuthority Optional: Account that will control freeze and thaw. - * Defaults to none. * * @return Object with mint address and transaction signature */ @@ -39,11 +40,11 @@ export async function createMint( rpc: Rpc, payer: Signer, mintAuthority: PublicKey | Signer, + freezeAuthority: PublicKey | Signer | null, decimals: number, keypair = Keypair.generate(), confirmOptions?: ConfirmOptions, tokenProgramId?: PublicKey | boolean, - freezeAuthority?: PublicKey | Signer, ): Promise<{ mint: PublicKey; transactionSignature: TransactionSignature }> { const rentExemptBalance = await rpc.getMinimumBalanceForRentExemption(MINT_SIZE); diff --git a/js/compressed-token/src/compressible/derivation.ts b/js/compressed-token/src/compressible/derivation.ts new file mode 100644 index 0000000000..06e9f8a920 --- /dev/null +++ b/js/compressed-token/src/compressible/derivation.ts @@ -0,0 +1,62 @@ +import { + CTOKEN_PROGRAM_ID, + deriveAddressV2, + TreeInfo, +} from '@lightprotocol/stateless.js'; +import { PublicKey } from '@solana/web3.js'; +import { Buffer } from 'buffer'; + +/** + * Returns the compressed mint address as a Array (32 bytes). + */ +export function deriveCompressedMintAddress( + mintSeed: PublicKey, + addressTreeInfo: TreeInfo, +) { + // find_spl_mint_address returns [splMint, bump], we want splMint + // In JS, just use the mintSeed directly as the SPL mint address + const address = deriveAddressV2( + findMintAddress(mintSeed)[0].toBytes(), + addressTreeInfo.tree, + CTOKEN_PROGRAM_ID, + ); + return Array.from(address.toBytes()); +} + +/// b"compressed_mint" +export const COMPRESSED_MINT_SEED: Buffer = Buffer.from([ + 99, 111, 109, 112, 114, 101, 115, 115, 101, 100, 95, 109, 105, 110, 116, +]); + +/** + * Finds the SPL mint PDA for a compressed mint. + * @param mintSeed The mint seed public key. + * @returns [PDA, bump] + */ +export function findMintAddress(mintSigner: PublicKey): [PublicKey, number] { + const [address, bump] = PublicKey.findProgramAddressSync( + [COMPRESSED_MINT_SEED, mintSigner.toBuffer()], + CTOKEN_PROGRAM_ID, + ); + return [address, bump]; +} + +/// Same as "getAssociatedTokenAddress" but returns the bump as well. +/// Uses compressed token program ID. +export function getAssociatedCTokenAddressAndBump( + owner: PublicKey, + mint: PublicKey, +) { + return PublicKey.findProgramAddressSync( + [owner.toBuffer(), CTOKEN_PROGRAM_ID.toBuffer(), mint.toBuffer()], + CTOKEN_PROGRAM_ID, + ); +} + +/// Same as "getAssociatedTokenAddress" but implicitly uses compressed token program ID. +export function getAssociatedCTokenAddress(owner: PublicKey, mint: PublicKey) { + return PublicKey.findProgramAddressSync( + [owner.toBuffer(), CTOKEN_PROGRAM_ID.toBuffer(), mint.toBuffer()], + CTOKEN_PROGRAM_ID, + )[0]; +} diff --git a/js/compressed-token/src/compressible/helpers.ts b/js/compressed-token/src/compressible/helpers.ts new file mode 100644 index 0000000000..52232b6581 --- /dev/null +++ b/js/compressed-token/src/compressible/helpers.ts @@ -0,0 +1,204 @@ +import { + Rpc, + MerkleContext, + ValidityProof, + packDecompressAccountsIdempotent, + CTOKEN_PROGRAM_ID, +} from '@lightprotocol/stateless.js'; +import BN from 'bn.js'; +import { + PublicKey, + AccountInfo, + AccountMeta, + Commitment, +} from '@solana/web3.js'; +import { + TOKEN_PROGRAM_ID, + TOKEN_2022_PROGRAM_ID, + getAssociatedTokenAddressSync, + TokenAccountNotFoundError, +} from '@solana/spl-token'; +import { getAssociatedCTokenAddressAndBump } from './derivation'; +import { Account, toAccountInfo } from '../mint/get-account-interface'; +import { Buffer } from 'buffer'; +import { getATAProgramId } from '../utils'; + +function parseTokenData(data: Buffer): { + mint: PublicKey; + owner: PublicKey; + amount: BN; + delegate: PublicKey | null; + state: number; + tlv: Buffer | null; +} | null { + if (!data || data.length === 0) return null; + + try { + let offset = 0; + const mint = new PublicKey(data.slice(offset, offset + 32)); + offset += 32; + const owner = new PublicKey(data.slice(offset, offset + 32)); + offset += 32; + const amount = new BN(data.slice(offset, offset + 8), 'le'); + offset += 8; + const delegateOption = data[offset]; + offset += 1; + const delegate = delegateOption + ? new PublicKey(data.slice(offset, offset + 32)) + : null; + offset += 32; + const state = data[offset]; + offset += 1; + const tlvOption = data[offset]; + offset += 1; + const tlv = tlvOption ? data.slice(offset) : null; + + return { + mint, + owner, + amount, + delegate, + state, + tlv, + }; + } catch (error) { + console.error('Token data parsing error:', error); + return null; + } +} + +function convertTokenDataToAccount( + address: PublicKey, + tokenData: { + mint: PublicKey; + owner: PublicKey; + amount: BN; + delegate: PublicKey | null; + state: number; + tlv: Buffer | null; + }, +): Account { + return { + address, + mint: tokenData.mint, + owner: tokenData.owner, + amount: BigInt(tokenData.amount.toString()), + delegate: tokenData.delegate, + delegatedAmount: BigInt(0), + isInitialized: tokenData.state !== 0, + isFrozen: tokenData.state === 2, + isNative: false, + rentExemptReserve: null, + closeAuthority: null, + tlvData: tokenData.tlv ? Buffer.from(tokenData.tlv) : Buffer.alloc(0), + }; +} + +export interface AccountInput { + address: PublicKey; + info: { + accountInfo?: AccountInfo; + parsed: any; + merkleContext?: MerkleContext; + }; + accountType: string; + tokenVariant?: string; +} + +export interface DecompressInstructionParams { + proofOption: { 0: ValidityProof | null }; + compressedAccounts: any[]; + systemAccountsOffset: number; + remainingAccounts: AccountMeta[]; +} + +/** + * Build decompress params for decompressAccountsIdempotent instruction. + * Automatically handles proof generation and account packing for both + * custom PDAs and cToken accounts. + * + * @param programId The program ID + * @param rpc RPC connection + * @param accounts Array of account inputs with address, parsed data, and merkle context + * @returns Packed params ready for instruction, or null if no compressed accounts + * + * @example + * ```typescript + * const params = await buildDecompressParams(programId, rpc, [ + * { address: poolAddress, info: poolInfo, accountType: "poolState" }, + * { address: vault0, info: vault0Info, accountType: "cTokenData", tokenVariant: "token0Vault" }, + * ]); + * + * if (params) { + * const ix = await program.methods + * .decompressAccountsIdempotent( + * params.proofOption, + * params.compressedAccounts, + * params.systemAccountsOffset + * ) + * .remainingAccounts(params.remainingAccounts) + * .instruction(); + * } + * ``` + */ +export async function buildDecompressParams( + programId: PublicKey, + rpc: Rpc, + accounts: AccountInput[], +): Promise { + const compressedAccounts = accounts.filter( + acc => acc.info.merkleContext !== undefined, + ); + + if (compressedAccounts.length === 0) { + return null; + } + + const proofInputs = compressedAccounts.map(acc => ({ + hash: acc.info.merkleContext!.hash, + tree: acc.info.merkleContext!.treeInfo.tree, + queue: acc.info.merkleContext!.treeInfo.queue, + })); + + const proof = await rpc.getValidityProofV0(proofInputs, []); + + const accountsData = compressedAccounts.map(acc => { + if (acc.accountType === 'cTokenData') { + if (!acc.tokenVariant) { + throw new Error( + `tokenVariant is required when accountType is "cTokenData"`, + ); + } + return { + key: 'cTokenData', + data: { + variant: { [acc.tokenVariant]: {} }, + tokenData: acc.info.parsed, + }, + treeInfo: acc.info.merkleContext!.treeInfo, + }; + } else { + return { + key: acc.accountType, + data: acc.info.parsed, + treeInfo: acc.info.merkleContext!.treeInfo, + }; + } + }); + + const addresses = compressedAccounts.map(acc => acc.address); + + const packed = await packDecompressAccountsIdempotent( + programId, + proof, + accountsData, + addresses, + ); + + return { + proofOption: packed.proofOption, + compressedAccounts: packed.compressedAccounts, + systemAccountsOffset: packed.systemAccountsOffset, + remainingAccounts: packed.remainingAccounts, + }; +} diff --git a/js/compressed-token/src/compressible/index.ts b/js/compressed-token/src/compressible/index.ts new file mode 100644 index 0000000000..eae8e3ae88 --- /dev/null +++ b/js/compressed-token/src/compressible/index.ts @@ -0,0 +1,4 @@ +export * from './derivation'; +export * from './serde'; +export * from './helpers'; +export * from './unified-load'; diff --git a/js/compressed-token/src/compressible/serde.ts b/js/compressed-token/src/compressible/serde.ts new file mode 100644 index 0000000000..80f2737b7e --- /dev/null +++ b/js/compressed-token/src/compressible/serde.ts @@ -0,0 +1,121 @@ +import { + struct, + option, + vec, + bool, + u64, + u8, + u16, + u32, + array, + vecU8, +} from '@coral-xyz/borsh'; +import { Buffer } from 'buffer'; +import { ValidityProof } from '@lightprotocol/stateless.js'; +import { DECOMPRESS_ACCOUNTS_IDEMPOTENT_DISCRIMINATOR } from '../constants'; + +const ValidityProofLayout = struct([ + array(u8(), 32, 'a'), + array(u8(), 64, 'b'), + array(u8(), 32, 'c'), +]); + +const PackedStateTreeInfoLayout = struct([ + u16('rootIndex'), + bool('proveByIndex'), + u8('merkleTreePubkeyIndex'), + u8('queuePubkeyIndex'), + u32('leafIndex'), +]); + +const CompressedAccountMetaLayout = struct([ + PackedStateTreeInfoLayout.replicate('treeInfo'), + option(array(u8(), 32), 'address'), + option(u64(), 'lamports'), + u8('outputStateTreeIndex'), +]); + +export interface PackedStateTreeInfo { + rootIndex: number; + proveByIndex: boolean; + merkleTreePubkeyIndex: number; + queuePubkeyIndex: number; + leafIndex: number; +} + +export interface CompressedAccountMeta { + treeInfo: PackedStateTreeInfo; + address: number[] | null; + lamports: bigint | null; + outputStateTreeIndex: number; +} + +export interface CompressedAccountData { + meta: CompressedAccountMeta; + data: T; + seeds: Uint8Array[]; +} + +export interface DecompressAccountsIdempotentInstructionData { + proof: ValidityProof; + compressedAccounts: CompressedAccountData[]; + systemAccountsOffset: number; +} + +export function createCompressedAccountDataLayout(dataLayout: any): any { + return struct([ + CompressedAccountMetaLayout.replicate('meta'), + dataLayout.replicate('data'), + vec(vecU8(), 'seeds'), + ]); +} + +export function createDecompressAccountsIdempotentLayout( + dataLayout: any, +): any { + return struct([ + ValidityProofLayout.replicate('proof'), + vec( + createCompressedAccountDataLayout(dataLayout), + 'compressedAccounts', + ), + u8('systemAccountsOffset'), + ]); +} + +/** + * Serialize decompress idempotent instruction data + * @param data The decompress idempotent instruction data + * @param dataLayout The data layout + * @returns The serialized decompress idempotent instruction data + */ +export function serializeDecompressIdempotentInstructionData( + data: DecompressAccountsIdempotentInstructionData, + dataLayout: any, +): Buffer { + const layout = createDecompressAccountsIdempotentLayout(dataLayout); + const buffer = Buffer.alloc(1000); + + const len = layout.encode(data, buffer); + + return Buffer.concat([ + DECOMPRESS_ACCOUNTS_IDEMPOTENT_DISCRIMINATOR, + buffer.subarray(0, len), + ]); +} + +/** + * Deserialize decompress idempotent instruction data + * @param buffer The serialized decompress idempotent instruction data + * @param dataLayout The data layout + * @returns The decompress idempotent instruction data + */ +export function deserializeDecompressIdempotentInstructionData( + buffer: Buffer, + dataLayout: any, +): DecompressAccountsIdempotentInstructionData { + const layout = createDecompressAccountsIdempotentLayout(dataLayout); + return layout.decode( + buffer.subarray(DECOMPRESS_ACCOUNTS_IDEMPOTENT_DISCRIMINATOR.length), + ) as DecompressAccountsIdempotentInstructionData; +} diff --git a/js/compressed-token/src/compressible/unified-load.ts b/js/compressed-token/src/compressible/unified-load.ts new file mode 100644 index 0000000000..62144b4577 --- /dev/null +++ b/js/compressed-token/src/compressible/unified-load.ts @@ -0,0 +1,539 @@ +import { + Rpc, + MerkleContext, + ValidityProof, + packDecompressAccountsIdempotent, + CTOKEN_PROGRAM_ID, + buildAndSignTx, + sendAndConfirmTx, + dedupeSigner, +} from '@lightprotocol/stateless.js'; +import { + PublicKey, + AccountMeta, + TransactionInstruction, + Signer, + TransactionSignature, + ConfirmOptions, + ComputeBudgetProgram, +} from '@solana/web3.js'; +import { + TOKEN_PROGRAM_ID, + TOKEN_2022_PROGRAM_ID, + getAssociatedTokenAddressSync, +} from '@solana/spl-token'; +import { + AccountInterface, + getATAInterface, +} from '../mint/get-account-interface'; +import { getATAAddressInterface } from '../mint/actions/create-ata-interface'; +import { createAssociatedTokenAccountInterfaceIdempotentInstruction } from '../mint/instructions/create-associated-ctoken'; +import { createWrapInstruction } from '../mint/instructions/wrap'; +import { createDecompress2Instruction } from '../mint/instructions/decompress2'; +import { + getTokenPoolInfos, + TokenPoolInfo, +} from '../utils/get-token-pool-infos'; +import { getATAProgramId } from '../utils'; +import { InterfaceOptions } from '../mint'; + +/** + * Account info interface for compressible accounts. + * Matches return structure of getAccountInterface/getATAInterface. + * + * Integrating programs provide their own fetch/parse - this is just the data shape. + */ +export interface ParsedAccountInfoInterface { + /** Parsed account data (program-specific) */ + parsed: T; + /** Load context - present if account is compressed (cold), undefined if hot */ + loadContext?: MerkleContext; +} + +/** + * Input for createLoadAccountsParams. + * Supports both program PDAs and CToken vaults. + * + * The integrating program is responsible for fetching and parsing their accounts. + * This helper just packs them for the decompressAccountsIdempotent instruction. + */ +export interface CompressibleAccountInput { + /** Account address */ + address: PublicKey; + /** + * Account type key for packing: + * - For PDAs: program-specific type name (e.g., "poolState", "observationState") + * - For CToken vaults: "cTokenData" + */ + accountType: string; + /** + * Token variant - required when accountType is "cTokenData". + * Examples: "lpVault", "token0Vault", "token1Vault" + */ + tokenVariant?: string; + /** Parsed account info (from program-specific fetch) */ + info: ParsedAccountInfoInterface; +} + +/** + * Packed compressed account for decompressAccountsIdempotent instruction + */ +export interface PackedCompressedAccount { + [key: string]: unknown; + merkleContext: { + merkleTreePubkeyIndex: number; + queuePubkeyIndex: number; + }; +} + +/** + * Result from building load params + */ +export interface CompressibleLoadParams { + /** Validity proof wrapped in option (null if all proveByIndex) */ + proofOption: { 0: ValidityProof | null }; + /** Packed compressed accounts data for instruction */ + compressedAccounts: PackedCompressedAccount[]; + /** Offset to system accounts in remainingAccounts */ + systemAccountsOffset: number; + /** Account metas for remaining accounts */ + remainingAccounts: AccountMeta[]; +} + +/** + * Result from createLoadAccountsParams + */ +export interface LoadResult { + /** Params for decompressAccountsIdempotent (null if no program accounts need decompressing) */ + decompressParams: CompressibleLoadParams | null; + /** Instructions to load ATAs (create ATA, wrap SPL/T22, decompress2) */ + ataInstructions: TransactionInstruction[]; +} + +// ============================================ +// Shared helper: Build load instructions from AccountInterface +// ============================================ + +/** + * Create instructions to load an ATA from its AccountInterface. + * + * This creates instructions to: + * 1. Create CToken ATA if needed (idempotent) + * 2. Wrap SPL tokens to CToken ATA (if SPL balance > 0) + * 3. Wrap T22 tokens to CToken ATA (if T22 balance > 0) + * 4. Decompress2 compressed tokens to CToken ATA (if cold balance > 0) + * + * Use this when you have a pre-fetched AccountInterface to save an RPC call. + * + * @param rpc RPC connection + * @param payer Fee payer + * @param ata AccountInterface from getATAInterface (must have _isAta, _owner, _mint) + * @param options Optional load options + * @returns Array of instructions (empty if nothing to load) + */ +export async function createLoadATAInstructionsFromInterface( + rpc: Rpc, + payer: PublicKey, + ata: AccountInterface, + options?: InterfaceOptions, +): Promise { + if (!ata._isAta || !ata._owner || !ata._mint) { + throw new Error( + 'AccountInterface must be from getATAInterface (requires _isAta, _owner, _mint)', + ); + } + + const instructions: TransactionInstruction[] = []; + const owner = ata._owner; + const mint = ata._mint; + const sources = ata._sources ?? []; + + // Derive addresses + const ctokenAta = getATAAddressInterface(mint, owner); + const splAta = getAssociatedTokenAddressSync( + mint, + owner, + false, + TOKEN_PROGRAM_ID, + getATAProgramId(TOKEN_PROGRAM_ID), + ); + const t22Ata = getAssociatedTokenAddressSync( + mint, + owner, + false, + TOKEN_2022_PROGRAM_ID, + getATAProgramId(TOKEN_2022_PROGRAM_ID), + ); + + // Check sources for balances + const splSource = sources.find(s => s.type === 'spl'); + const t22Source = sources.find(s => s.type === 'token2022'); + const ctokenHotSource = sources.find(s => s.type === 'ctoken-hot'); + const ctokenColdSource = sources.find(s => s.type === 'ctoken-cold'); + + const splBalance = splSource?.amount ?? BigInt(0); + const t22Balance = t22Source?.amount ?? BigInt(0); + const coldBalance = ctokenColdSource?.amount ?? BigInt(0); + + // Nothing to load + if ( + splBalance === BigInt(0) && + t22Balance === BigInt(0) && + coldBalance === BigInt(0) + ) { + return []; + } + + // 1. Create CToken ATA if needed (idempotent) + if (!ctokenHotSource) { + instructions.push( + createAssociatedTokenAccountInterfaceIdempotentInstruction( + payer, + ctokenAta, + owner, + mint, + CTOKEN_PROGRAM_ID, + ), + ); + } + + // Get token pool info for wrap operations + const tokenPoolInfos = + options?.tokenPoolInfos ?? (await getTokenPoolInfos(rpc, mint)); + const tokenPoolInfo = tokenPoolInfos.find( + (info: TokenPoolInfo) => info.isInitialized, + ); + + // 2. Wrap SPL tokens + if (splBalance > BigInt(0) && tokenPoolInfo) { + instructions.push( + createWrapInstruction( + splAta, + ctokenAta, + owner, + mint, + splBalance, + tokenPoolInfo, + payer, + ), + ); + } + + // 3. Wrap T22 tokens + if (t22Balance > BigInt(0) && tokenPoolInfo) { + instructions.push( + createWrapInstruction( + t22Ata, + ctokenAta, + owner, + mint, + t22Balance, + tokenPoolInfo, + payer, + ), + ); + } + + // 4. Decompress2 compressed tokens + if (coldBalance > BigInt(0) && ctokenColdSource) { + // Need to fetch compressed accounts for decompress2 instruction + const compressedResult = await rpc.getCompressedTokenAccountsByOwner( + owner, + { mint }, + ); + const compressedAccounts = compressedResult.items; + + if (compressedAccounts.length > 0) { + const proof = await rpc.getValidityProofV0( + compressedAccounts.map(acc => ({ + hash: acc.compressedAccount.hash, + tree: acc.compressedAccount.treeInfo.tree, + queue: acc.compressedAccount.treeInfo.queue, + })), + ); + + instructions.push( + createDecompress2Instruction( + payer, + compressedAccounts, + ctokenAta, + coldBalance, + proof.compressedProof, + proof.rootIndices, + ), + ); + } + } + + return instructions; +} + +/** + * Create instructions to load an ATA. + * + * Fetches the AccountInterface internally, then builds instructions to: + * 1. Create CToken ATA if needed (idempotent) + * 2. Wrap SPL tokens to CToken ATA (if SPL balance > 0) + * 3. Wrap T22 tokens to CToken ATA (if T22 balance > 0) + * 4. Decompress2 compressed tokens to CToken ATA (if cold balance > 0) + * + * @param rpc RPC connection + * @param payer Fee payer + * @param ata CToken ATA address (from getATAAddressInterface) + * @param owner ATA owner + * @param mint Token mint + * @param options Optional load options + * @returns Array of instructions (empty if nothing to load) + * + * @example + * ```typescript + * const ata = getATAAddressInterface(mint, sender); + * const instructions = await createLoadATAInstructions(rpc, payer, ata, sender, mint); + * ``` + */ +export async function createLoadATAInstructions( + rpc: Rpc, + payer: PublicKey, + ata: PublicKey, + owner: PublicKey, + mint: PublicKey, + options?: InterfaceOptions, +): Promise { + const ataInterface = await getATAInterface(rpc, owner, mint); + return createLoadATAInstructionsFromInterface( + rpc, + payer, + ataInterface, + options, + ); +} + +/** + * Load ALL token balances into a single CToken ATA (ATA-only, full execute). + * + * This loads: + * 1. SPL ATA balance → wrapped to CToken ATA + * 2. Token-2022 ATA balance → wrapped to CToken ATA + * 3. All compressed tokens → decompressed to CToken ATA + * + * Idempotent: returns null if nothing to load. + * + * @param rpc RPC connection + * @param payer Fee payer (signer) + * @param ata CToken ATA address (from getATAAddressInterface) + * @param owner Owner of the tokens (signer) + * @param mint Mint address + * @param confirmOptions Optional confirm options + * @param options Optional interface options + * @returns Transaction signature, or null if nothing to load + * + * @example + * ```typescript + * const ata = getATAAddressInterface(mint, sender); + * const signature = await loadATA(rpc, payer, ata, sender, mint); + * ``` + */ +export async function loadATA( + rpc: Rpc, + payer: Signer, + ata: PublicKey, + owner: Signer, + mint: PublicKey, + confirmOptions?: ConfirmOptions, + options?: InterfaceOptions, +): Promise { + const ixs = await createLoadATAInstructions( + rpc, + payer.publicKey, + ata, + owner.publicKey, + mint, + options, + ); + + if (ixs.length === 0) { + return null; + } + + const { blockhash } = await rpc.getLatestBlockhash(); + const additionalSigners = dedupeSigner(payer, [owner]); + + const tx = buildAndSignTx( + [ComputeBudgetProgram.setComputeUnitLimit({ units: 500_000 }), ...ixs], + payer, + blockhash, + additionalSigners, + ); + + return sendAndConfirmTx(rpc, tx, confirmOptions); +} + +// ============================================ +// Main function: createLoadAccountsParams +// ============================================ + +/** + * Create params for loading program accounts and ATAs. + * + * Returns: + * - decompressParams: for custom program's decompressAccountsIdempotent instruction + * - ataInstructions: for loading user ATAs (create ATA, wrap SPL/T22, decompress2) + * + * @param rpc RPC connection + * @param payer Fee payer (needed for ATA instructions) + * @param programId Program ID for decompressAccountsIdempotent + * @param programAccounts PDAs and vaults (caller pre-fetches) + * @param atas User ATAs (fetched via getATAInterface) + * @param options Optional load options + * @returns LoadResult with decompressParams and ataInstructions + * + * @example + * ```typescript + * const poolInfo = await myProgram.fetchPoolState(rpc, poolAddress); + * const vault0Info = await getATAInterface(rpc, poolAddress, token0Mint, undefined, CTOKEN_PROGRAM_ID); + * const userAta = await getATAInterface(rpc, userWallet, tokenMint); + * + * const result = await createLoadAccountsParams( + * rpc, + * payer.publicKey, + * programId, + * [ + * { address: poolAddress, accountType: 'poolState', info: poolInfo }, + * { address: vault0, accountType: 'cTokenData', tokenVariant: 'token0Vault', info: vault0Info }, + * ], + * [userAta], + * ); + * + * // Build transaction with both program decompress and ATA load + * const instructions = [...result.ataInstructions]; + * if (result.decompressParams) { + * instructions.push(await program.methods + * .decompressAccountsIdempotent( + * result.decompressParams.proofOption, + * result.decompressParams.compressedAccounts, + * result.decompressParams.systemAccountsOffset, + * ) + * .remainingAccounts(result.decompressParams.remainingAccounts) + * .instruction()); + * } + * ``` + */ +export async function createLoadAccountsParams( + rpc: Rpc, + payer: PublicKey, + programId: PublicKey, + programAccounts: CompressibleAccountInput[] = [], + atas: AccountInterface[] = [], + options?: InterfaceOptions, +): Promise { + // ============================================ + // 1. Build decompressParams for program accounts + // ============================================ + let decompressParams: CompressibleLoadParams | null = null; + + const compressedProgramAccounts = programAccounts.filter( + acc => acc.info.loadContext !== undefined, + ); + + if (compressedProgramAccounts.length > 0) { + // Build proof inputs + const proofInputs = compressedProgramAccounts.map(acc => ({ + hash: acc.info.loadContext!.hash, + tree: acc.info.loadContext!.treeInfo.tree, + queue: acc.info.loadContext!.treeInfo.queue, + })); + + // Get validity proof + const proofResult = await rpc.getValidityProofV0(proofInputs, []); + + // Build accounts data for packing + const accountsData = compressedProgramAccounts.map(acc => { + if (acc.accountType === 'cTokenData') { + if (!acc.tokenVariant) { + throw new Error( + 'tokenVariant is required when accountType is "cTokenData"', + ); + } + return { + key: 'cTokenData', + data: { + variant: { [acc.tokenVariant]: {} }, + tokenData: acc.info.parsed, + }, + treeInfo: acc.info.loadContext!.treeInfo, + }; + } + return { + key: acc.accountType, + data: acc.info.parsed, + treeInfo: acc.info.loadContext!.treeInfo, + }; + }); + + const addresses = compressedProgramAccounts.map(acc => acc.address); + const treeInfos = compressedProgramAccounts.map( + acc => acc.info.loadContext!.treeInfo, + ); + + const packed = await packDecompressAccountsIdempotent( + programId, + { + compressedProof: proofResult.compressedProof, + treeInfos, + }, + accountsData, + addresses, + ); + + decompressParams = { + proofOption: packed.proofOption, + compressedAccounts: + packed.compressedAccounts as PackedCompressedAccount[], + systemAccountsOffset: packed.systemAccountsOffset, + remainingAccounts: packed.remainingAccounts, + }; + } + + // ============================================ + // 2. Build ATA load instructions + // ============================================ + const ataInstructions: TransactionInstruction[] = []; + + for (const ata of atas) { + const ixs = await createLoadATAInstructionsFromInterface( + rpc, + payer, + ata, + options, + ); + ataInstructions.push(...ixs); + } + + return { + decompressParams, + ataInstructions, + }; +} + +/** + * Calculate compute units for compressible load operation + */ +export function calculateCompressibleLoadComputeUnits( + compressedAccountCount: number, + hasValidityProof: boolean, +): number { + let cu = 50_000; // Base + + if (hasValidityProof) { + cu += 100_000; // Proof verification + } + + // Per compressed account + cu += compressedAccountCount * 30_000; + + return cu; +} + +// Re-export for backward compatibility +export { buildDecompressParams } from './helpers'; +export type { AccountInput, DecompressInstructionParams } from './helpers'; diff --git a/js/compressed-token/src/constants.ts b/js/compressed-token/src/constants.ts index 496796be14..fd91876eb6 100644 --- a/js/compressed-token/src/constants.ts +++ b/js/compressed-token/src/constants.ts @@ -1,4 +1,18 @@ import { Buffer } from 'buffer'; + +/** + * Token data version enum - mirrors Rust TokenDataVersion + * Used for compressed token account hashing strategy + */ +export enum TokenDataVersion { + /** V1: Poseidon hash with little-endian amount, discriminator [2,0,0,0,0,0,0,0] */ + V1 = 1, + /** V2: Poseidon hash with big-endian amount, discriminator [0,0,0,0,0,0,0,3] */ + V2 = 2, + /** ShaFlat: SHA256 hash of borsh-serialized data, discriminator [0,0,0,0,0,0,0,4] */ + ShaFlat = 3, +} + export const POOL_SEED = Buffer.from('pool'); export const CPI_AUTHORITY_SEED = Buffer.from('cpi_authority'); @@ -30,3 +44,5 @@ export const REVOKE_DISCRIMINATOR = Buffer.from([ export const ADD_TOKEN_POOL_DISCRIMINATOR = Buffer.from([ 114, 143, 210, 73, 96, 115, 1, 228, ]); + +export const DECOMPRESS_ACCOUNTS_IDEMPOTENT_DISCRIMINATOR = Buffer.from([107]); diff --git a/js/compressed-token/src/index.ts b/js/compressed-token/src/index.ts index 4e8896433d..395b74bce2 100644 --- a/js/compressed-token/src/index.ts +++ b/js/compressed-token/src/index.ts @@ -5,3 +5,102 @@ export * from './idl'; export * from './layout'; export * from './program'; export * from './types'; +export * from './compressible'; +export { + createLoadAccountsParams, + createLoadATAInstructionsFromInterface, + createLoadATAInstructions, + loadATA, + calculateCompressibleLoadComputeUnits, + CompressibleAccountInput, + ParsedAccountInfoInterface, + CompressibleLoadParams, + PackedCompressedAccount, + LoadResult, +} from './compressible/unified-load'; + +// Export mint module with explicit naming to avoid conflicts +export { + // Instructions + createMintInstruction, + createTokenMetadata, + createAssociatedCTokenAccountInstruction, + createAssociatedCTokenAccountIdempotentInstruction, + createAssociatedTokenAccountInterfaceInstruction, + createAssociatedTokenAccountInterfaceIdempotentInstruction, + createATAInterfaceIdempotentInstruction, + createMintToInstruction, + createMintToCompressedInstruction, + createMintToInterfaceInstruction, + createUpdateMintAuthorityInstruction, + createUpdateFreezeAuthorityInstruction, + createUpdateMetadataFieldInstruction, + createUpdateMetadataAuthorityInstruction, + createRemoveMetadataKeyInstruction, + createWrapInstruction, + createTransferInterfaceInstruction, + createCTokenTransferInstruction, + // Types + TokenMetadataInstructionData, + CompressibleConfig, + CTokenConfig, + CreateAssociatedCTokenAccountParams, + // Actions + createMintInterface, + createATAInterface, + createATAInterfaceIdempotent, + getATAAddressInterface, + getOrCreateATAInterface, + transferInterface, + decompress2, + wrap, + mintTo as mintToCToken, + mintToCompressed, + mintToInterface, + updateMintAuthority, + updateFreezeAuthority, + updateMetadataField, + updateMetadataAuthority, + removeMetadataKey, + // Action types + CreateATAInterfaceParams, + CreateATAInterfaceResult, + InterfaceOptions, + LoadOptions, + TransferInterfaceOptions, + WrapParams, + WrapResult, + // Helpers + getMintInterface, + unpackMintInterface, + unpackMintData, + MintInterface, + getAccountInterface, + getATAInterface, + Account, + AccountState, + ParsedTokenAccount as ParsedTokenAccountInterface, + parseCTokenHot, + parseCTokenCold, + toAccountInfo, + convertTokenDataToAccount, + // Types + AccountInterface, + TokenAccountSource, + // Serde + BaseMint, + MintContext, + MintExtension, + TokenMetadata, + CompressedMint, + deserializeMint, + serializeMint, + decodeTokenMetadata, + encodeTokenMetadata, + extractTokenMetadata, + ExtensionType, + // Metadata formatting (for use with any uploader) + toOffChainMetadataJson, + OffChainTokenMetadata, + OffChainTokenMetadataJson, +} from './mint'; diff --git a/js/compressed-token/src/layout-transfer2.ts b/js/compressed-token/src/layout-transfer2.ts new file mode 100644 index 0000000000..35baa4e298 --- /dev/null +++ b/js/compressed-token/src/layout-transfer2.ts @@ -0,0 +1,272 @@ +import { + struct, + option, + vec, + bool, + u64, + u8, + u16, + u32, + array, +} from '@coral-xyz/borsh'; +import { Buffer } from 'buffer'; +import { bn } from '@lightprotocol/stateless.js'; + +// Transfer2 discriminator = 101 +export const TRANSFER2_DISCRIMINATOR = Buffer.from([101]); + +// CompressionMode enum values +export const COMPRESSION_MODE_COMPRESS = 0; +export const COMPRESSION_MODE_DECOMPRESS = 1; +export const COMPRESSION_MODE_COMPRESS_AND_CLOSE = 2; + +/** + * Compression struct for Transfer2 instruction + */ +export interface Compression { + mode: number; + amount: bigint; + mint: number; + sourceOrRecipient: number; + authority: number; + poolAccountIndex: number; + poolIndex: number; + bump: number; +} + +/** + * Packed merkle context for compressed accounts + */ +export interface PackedMerkleContext { + merkleTreePubkeyIndex: number; + queuePubkeyIndex: number; + leafIndex: number; + proveByIndex: boolean; +} + +/** + * Input token data with context for Transfer2 + */ +export interface MultiInputTokenDataWithContext { + owner: number; + amount: bigint; + hasDelegate: boolean; + delegate: number; + mint: number; + version: number; + merkleContext: PackedMerkleContext; + rootIndex: number; +} + +/** + * Output token data for Transfer2 + */ +export interface MultiTokenTransferOutputData { + owner: number; + amount: bigint; + hasDelegate: boolean; + delegate: number; + mint: number; + version: number; +} + +/** + * CPI context for Transfer2 + */ +export interface CompressedCpiContext { + setContext: boolean; + firstSetContext: boolean; + cpiContextAccountIndex: number; +} + +/** + * Full Transfer2 instruction data + */ +export interface Transfer2InstructionData { + withTransactionHash: boolean; + withLamportsChangeAccountMerkleTreeIndex: boolean; + lamportsChangeAccountMerkleTreeIndex: number; + lamportsChangeAccountOwnerIndex: number; + outputQueue: number; + cpiContext: CompressedCpiContext | null; + compressions: Compression[] | null; + proof: { a: number[]; b: number[]; c: number[] } | null; + inTokenData: MultiInputTokenDataWithContext[]; + outTokenData: MultiTokenTransferOutputData[]; + inLamports: bigint[] | null; + outLamports: bigint[] | null; + inTlv: number[][] | null; + outTlv: number[][] | null; +} + +// Borsh layouts +const CompressionLayout = struct([ + u8('mode'), + u64('amount'), + u8('mint'), + u8('sourceOrRecipient'), + u8('authority'), + u8('poolAccountIndex'), + u8('poolIndex'), + u8('bump'), +]); + +const PackedMerkleContextLayout = struct([ + u8('merkleTreePubkeyIndex'), + u8('queuePubkeyIndex'), + u32('leafIndex'), + bool('proveByIndex'), +]); + +const MultiInputTokenDataWithContextLayout = struct([ + u8('owner'), + u64('amount'), + bool('hasDelegate'), + u8('delegate'), + u8('mint'), + u8('version'), + PackedMerkleContextLayout.replicate('merkleContext'), + u16('rootIndex'), +]); + +const MultiTokenTransferOutputDataLayout = struct([ + u8('owner'), + u64('amount'), + bool('hasDelegate'), + u8('delegate'), + u8('mint'), + u8('version'), +]); + +const CompressedCpiContextLayout = struct([ + bool('setContext'), + bool('firstSetContext'), + u8('cpiContextAccountIndex'), +]); + +const CompressedProofLayout = struct([ + array(u8(), 32, 'a'), + array(u8(), 64, 'b'), + array(u8(), 32, 'c'), +]); + +const Transfer2InstructionDataLayout = struct([ + bool('withTransactionHash'), + bool('withLamportsChangeAccountMerkleTreeIndex'), + u8('lamportsChangeAccountMerkleTreeIndex'), + u8('lamportsChangeAccountOwnerIndex'), + u8('outputQueue'), + option(CompressedCpiContextLayout, 'cpiContext'), + option(vec(CompressionLayout), 'compressions'), + option(CompressedProofLayout, 'proof'), + vec(MultiInputTokenDataWithContextLayout, 'inTokenData'), + vec(MultiTokenTransferOutputDataLayout, 'outTokenData'), + option(vec(u64()), 'inLamports'), + option(vec(u64()), 'outLamports'), + option(vec(vec(u8())), 'inTlv'), + option(vec(vec(u8())), 'outTlv'), +]); + +/** + * Encode Transfer2 instruction data using Borsh + */ +export function encodeTransfer2InstructionData( + data: Transfer2InstructionData, +): Buffer { + // Convert bigint values to BN for Borsh encoding + const encodableData = { + ...data, + compressions: + data.compressions?.map(c => ({ + ...c, + amount: bn(c.amount.toString()), + })) ?? null, + inTokenData: data.inTokenData.map(t => ({ + ...t, + amount: bn(t.amount.toString()), + })), + outTokenData: data.outTokenData.map(t => ({ + ...t, + amount: bn(t.amount.toString()), + })), + inLamports: data.inLamports?.map(v => bn(v.toString())) ?? null, + outLamports: data.outLamports?.map(v => bn(v.toString())) ?? null, + }; + + const buffer = Buffer.alloc(2000); // Allocate enough space + const len = Transfer2InstructionDataLayout.encode(encodableData, buffer); + return Buffer.concat([TRANSFER2_DISCRIMINATOR, buffer.subarray(0, len)]); +} + +/** + * Create a compression struct for wrapping SPL tokens to CToken + * (compress from SPL ATA) + */ +export function createCompressSpl( + amount: bigint, + mintIndex: number, + sourceIndex: number, + authorityIndex: number, + poolAccountIndex: number, + poolIndex: number, + bump: number, +): Compression { + return { + mode: COMPRESSION_MODE_COMPRESS, + amount, + mint: mintIndex, + sourceOrRecipient: sourceIndex, + authority: authorityIndex, + poolAccountIndex, + poolIndex, + bump, + }; +} + +/** + * Create a compression struct for decompressing to CToken ATA + * @param amount - Amount to decompress + * @param mintIndex - Index of mint in packed accounts + * @param recipientIndex - Index of recipient CToken account in packed accounts + * @param tokenProgramIndex - Index of CToken program in packed accounts (for CPI) + */ +export function createDecompressCtoken( + amount: bigint, + mintIndex: number, + recipientIndex: number, + tokenProgramIndex?: number, +): Compression { + return { + mode: COMPRESSION_MODE_DECOMPRESS, + amount, + mint: mintIndex, + sourceOrRecipient: recipientIndex, + authority: 0, + poolAccountIndex: tokenProgramIndex ?? 0, + poolIndex: 0, + bump: 0, + }; +} + +/** + * Create a compression struct for decompressing SPL tokens + */ +export function createDecompressSpl( + amount: bigint, + mintIndex: number, + recipientIndex: number, + poolAccountIndex: number, + poolIndex: number, + bump: number, +): Compression { + return { + mode: COMPRESSION_MODE_DECOMPRESS, + amount, + mint: mintIndex, + sourceOrRecipient: recipientIndex, + authority: 0, + poolAccountIndex, + poolIndex, + bump, + }; +} diff --git a/js/compressed-token/src/mint/actions/create-associated-ctoken.ts b/js/compressed-token/src/mint/actions/create-associated-ctoken.ts new file mode 100644 index 0000000000..ed64659c1c --- /dev/null +++ b/js/compressed-token/src/mint/actions/create-associated-ctoken.ts @@ -0,0 +1,108 @@ +import { + ComputeBudgetProgram, + ConfirmOptions, + PublicKey, + Signer, + TransactionSignature, +} from '@solana/web3.js'; +import { + Rpc, + buildAndSignTx, + sendAndConfirmTx, +} from '@lightprotocol/stateless.js'; +import { + createAssociatedCTokenAccountInstruction, + createAssociatedCTokenAccountIdempotentInstruction, + CompressibleConfig, +} from '../instructions/create-associated-ctoken'; +import { getAssociatedCTokenAddress } from '../../compressible'; + +/** + * Create an associated compressed token account. + * + * @param rpc RPC connection + * @param payer Fee payer + * @param owner Owner of the associated token account + * @param mint Mint address + * @param compressibleConfig Optional compressible configuration + * @param configAccount Optional config account + * @param rentPayerPda Optional rent payer PDA + * @param confirmOptions Optional confirm options + */ +export async function createAssociatedCTokenAccount( + rpc: Rpc, + payer: Signer, + owner: PublicKey, + mint: PublicKey, + compressibleConfig?: CompressibleConfig, + configAccount?: PublicKey, + rentPayerPda?: PublicKey, + confirmOptions?: ConfirmOptions, +): Promise<{ address: PublicKey; transactionSignature: TransactionSignature }> { + const ix = createAssociatedCTokenAccountInstruction( + payer.publicKey, + owner, + mint, + compressibleConfig, + configAccount, + rentPayerPda, + ); + + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx( + [ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }), ix], + payer, + blockhash, + [], + ); + + const txId = await sendAndConfirmTx(rpc, tx, confirmOptions); + const address = getAssociatedCTokenAddress(owner, mint); + + return { address, transactionSignature: txId }; +} + +/** + * Create an associated compressed token account idempotently. + * + * @param rpc RPC connection + * @param payer Fee payer + * @param owner Owner of the associated token account + * @param mint Mint address + * @param compressibleConfig Optional compressible configuration + * @param configAccount Optional config account + * @param rentPayerPda Optional rent payer PDA + * @param confirmOptions Optional confirm options + */ +export async function createAssociatedCTokenAccountIdempotent( + rpc: Rpc, + payer: Signer, + owner: PublicKey, + mint: PublicKey, + compressibleConfig?: CompressibleConfig, + configAccount?: PublicKey, + rentPayerPda?: PublicKey, + confirmOptions?: ConfirmOptions, +): Promise<{ address: PublicKey; transactionSignature: TransactionSignature }> { + const ix = createAssociatedCTokenAccountIdempotentInstruction( + payer.publicKey, + owner, + mint, + compressibleConfig, + configAccount, + rentPayerPda, + ); + + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx( + [ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }), ix], + payer, + blockhash, + [], + ); + + const txId = await sendAndConfirmTx(rpc, tx, confirmOptions); + const address = getAssociatedCTokenAddress(owner, mint); + + return { address, transactionSignature: txId }; +} diff --git a/js/compressed-token/src/mint/actions/create-ata-interface.ts b/js/compressed-token/src/mint/actions/create-ata-interface.ts new file mode 100644 index 0000000000..82afdd4937 --- /dev/null +++ b/js/compressed-token/src/mint/actions/create-ata-interface.ts @@ -0,0 +1,270 @@ +import { + ComputeBudgetProgram, + ConfirmOptions, + PublicKey, + Signer, + Transaction, + TransactionSignature, + sendAndConfirmTransaction, +} from '@solana/web3.js'; +import { + Rpc, + CTOKEN_PROGRAM_ID, + buildAndSignTx, + sendAndConfirmTx, +} from '@lightprotocol/stateless.js'; +import { + TOKEN_PROGRAM_ID, + TOKEN_2022_PROGRAM_ID, + getAssociatedTokenAddressSync, +} from '@solana/spl-token'; +import { + createAssociatedTokenAccountInterfaceInstruction, + createAssociatedTokenAccountInterfaceIdempotentInstruction, + CTokenConfig, +} from '../instructions/create-associated-ctoken'; +import { getAssociatedCTokenAddress } from '../../compressible'; +import { getATAProgramId } from '../../utils'; + +// Re-export types for backwards compatibility +export type { CTokenConfig }; + +// Keep old interface type for backwards compatibility export +export interface CreateATAInterfaceParams { + rpc: Rpc; + payer: Signer; + owner: PublicKey; + mint: PublicKey; + allowOwnerOffCurve?: boolean; + confirmOptions?: ConfirmOptions; + programId?: PublicKey; + associatedTokenProgramId?: PublicKey; + ctokenConfig?: CTokenConfig; +} + +export interface CreateATAInterfaceResult { + address: PublicKey; + transactionSignature: TransactionSignature; +} + +/** + * Derive the associated token address for any token program. + * Follows SPL Token getAssociatedTokenAddressSync signature. + * Defaults to CToken program. + * + * @param mint - Mint public key + * @param owner - Owner public key + * @param allowOwnerOffCurve - Allow owner to be a PDA (default: false) + * @param programId - Token program ID (default: CTOKEN_PROGRAM_ID) + * @param associatedTokenProgramId - Associated token program ID + */ +export function getATAAddressInterface( + mint: PublicKey, + owner: PublicKey, + allowOwnerOffCurve = false, + programId: PublicKey = CTOKEN_PROGRAM_ID, + associatedTokenProgramId?: PublicKey, +): PublicKey { + const effectiveAtaProgramId = + associatedTokenProgramId ?? getATAProgramId(programId); + + if (programId.equals(CTOKEN_PROGRAM_ID)) { + return getAssociatedCTokenAddress(owner, mint); + } + + return getAssociatedTokenAddressSync( + mint, + owner, + allowOwnerOffCurve, + programId, + effectiveAtaProgramId, + ); +} + +/** + * Create an associated token account for SPL Token, Token-2022, or Compressed Token. + * Follows SPL Token createAssociatedTokenAccount signature. + * Defaults to CToken program. + * + * Dispatches to the appropriate program based on `programId`: + * - `CTOKEN_PROGRAM_ID` -> Compressed Token ATA (default) + * - `TOKEN_PROGRAM_ID` -> SPL Token ATA + * - `TOKEN_2022_PROGRAM_ID` -> Token-2022 ATA + * + * @param rpc RPC connection + * @param payer Fee payer and transaction signer + * @param mint Mint address + * @param owner Owner of the associated token account + * @param allowOwnerOffCurve Allow owner to be a PDA (default: false) + * @param confirmOptions Options for confirming the transaction + * @param programId Token program ID (default: CTOKEN_PROGRAM_ID) + * @param associatedTokenProgramId Associated token program ID (auto-derived if not provided) + * @param ctokenConfig Optional CToken-specific configuration + * + * @example + * // Create Compressed Token ATA (default) + * const { address } = await createATAInterface( + * rpc, + * payer, + * mint, + * wallet.publicKey, + * ); + * + * @example + * // Create SPL Token ATA + * const { address } = await createATAInterface( + * rpc, + * payer, + * splMint, + * wallet.publicKey, + * false, + * undefined, + * TOKEN_PROGRAM_ID, + * ); + */ +export async function createATAInterface( + rpc: Rpc, + payer: Signer, + mint: PublicKey, + owner: PublicKey, + allowOwnerOffCurve = false, + confirmOptions?: ConfirmOptions, + programId: PublicKey = CTOKEN_PROGRAM_ID, + associatedTokenProgramId?: PublicKey, + ctokenConfig?: CTokenConfig, +): Promise { + const effectiveAtaProgramId = + associatedTokenProgramId ?? getATAProgramId(programId); + + const associatedToken = getATAAddressInterface( + mint, + owner, + allowOwnerOffCurve, + programId, + effectiveAtaProgramId, + ); + + const ix = createAssociatedTokenAccountInterfaceInstruction( + payer.publicKey, + associatedToken, + owner, + mint, + programId, + effectiveAtaProgramId, + ctokenConfig, + ); + + let txId: TransactionSignature; + + if (programId.equals(CTOKEN_PROGRAM_ID)) { + // CToken uses Light protocol transaction handling + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx( + [ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }), ix], + payer, + blockhash, + [], + ); + txId = await sendAndConfirmTx(rpc, tx, confirmOptions); + } else { + // SPL Token / Token-2022 use standard transaction + const transaction = new Transaction().add(ix); + txId = await sendAndConfirmTransaction( + rpc, + transaction, + [payer], + confirmOptions, + ); + } + + return { address: associatedToken, transactionSignature: txId }; +} + +/** + * Create an associated token account idempotently for SPL Token, Token-2022, or Compressed Token. + * Follows SPL Token createAssociatedTokenAccountIdempotent signature. + * Defaults to CToken program. + * + * This is idempotent - if the account already exists, the instruction succeeds without error. + * + * Dispatches to the appropriate program based on `programId`: + * - `CTOKEN_PROGRAM_ID` -> Compressed Token ATA (default, idempotent) + * - `TOKEN_PROGRAM_ID` -> SPL Token ATA (idempotent) + * - `TOKEN_2022_PROGRAM_ID` -> Token-2022 ATA (idempotent) + * + * @param rpc RPC connection + * @param payer Fee payer and transaction signer + * @param mint Mint address + * @param owner Owner of the associated token account + * @param allowOwnerOffCurve Allow owner to be a PDA (default: false) + * @param confirmOptions Options for confirming the transaction + * @param programId Token program ID (default: CTOKEN_PROGRAM_ID) + * @param associatedTokenProgramId Associated token program ID (auto-derived if not provided) + * @param ctokenConfig Optional CToken-specific configuration + * + * @example + * // Create or get existing CToken ATA (default) + * const { address } = await createATAInterfaceIdempotent( + * rpc, + * payer, + * mint, + * wallet.publicKey, + * ); + */ +export async function createATAInterfaceIdempotent( + rpc: Rpc, + payer: Signer, + mint: PublicKey, + owner: PublicKey, + allowOwnerOffCurve = false, + confirmOptions?: ConfirmOptions, + programId: PublicKey = CTOKEN_PROGRAM_ID, + associatedTokenProgramId?: PublicKey, + ctokenConfig?: CTokenConfig, +): Promise { + const effectiveAtaProgramId = + associatedTokenProgramId ?? getATAProgramId(programId); + + const associatedToken = getATAAddressInterface( + mint, + owner, + allowOwnerOffCurve, + programId, + effectiveAtaProgramId, + ); + + const ix = createAssociatedTokenAccountInterfaceIdempotentInstruction( + payer.publicKey, + associatedToken, + owner, + mint, + programId, + effectiveAtaProgramId, + ctokenConfig, + ); + + let txId: TransactionSignature; + + if (programId.equals(CTOKEN_PROGRAM_ID)) { + // CToken uses Light protocol transaction handling + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx( + [ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }), ix], + payer, + blockhash, + [], + ); + txId = await sendAndConfirmTx(rpc, tx, confirmOptions); + } else { + // SPL Token / Token-2022 use standard transaction + const transaction = new Transaction().add(ix); + txId = await sendAndConfirmTransaction( + rpc, + transaction, + [payer], + confirmOptions, + ); + } + + return { address: associatedToken, transactionSignature: txId }; +} diff --git a/js/compressed-token/src/mint/actions/create-mint-interface.ts b/js/compressed-token/src/mint/actions/create-mint-interface.ts new file mode 100644 index 0000000000..e1e76a3c78 --- /dev/null +++ b/js/compressed-token/src/mint/actions/create-mint-interface.ts @@ -0,0 +1,138 @@ +import { + ComputeBudgetProgram, + ConfirmOptions, + Keypair, + PublicKey, + Signer, + TransactionSignature, +} from '@solana/web3.js'; +import { + Rpc, + buildAndSignTx, + dedupeSigner, + sendAndConfirmTx, + TreeInfo, + AddressTreeInfo, + selectStateTreeInfo, + getBatchAddressTreeInfo, + DerivationMode, + CTOKEN_PROGRAM_ID, +} from '@lightprotocol/stateless.js'; +import { TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from '@solana/spl-token'; +import { + createMintInstruction, + TokenMetadataInstructionData, +} from '../instructions/create-mint'; +import { findMintAddress } from '../../compressible'; +import { createMint } from '../../actions/create-mint'; + +export { TokenMetadataInstructionData }; + +/** + * Create and initialize a new mint (SPL, Token-2022, or Compressed Token). + * + * This is a unified interface that dispatches to either: + * - SPL/Token-2022 mint creation when `programId` is TOKEN_PROGRAM_ID or TOKEN_2022_PROGRAM_ID + * - Compressed token mint creation when `programId` is CTOKEN_PROGRAM_ID (default) + * + * @param rpc RPC connection to use + * @param payer Fee payer + * @param mintAuthority Account that will control minting (must be Signer for compressed mints) + * @param freezeAuthority Optional: Account that will control freeze and thaw. + * @param decimals Location of the decimal place + * @param keypair Optional: Mint keypair. Defaults to a random keypair. + * @param metadata Optional: Token metadata (only used for compressed mints) + * @param addressTreeInfo Optional: Address tree info (only used for compressed mints) + * @param outputStateTreeInfo Optional: Output state tree info (only used for compressed mints) + * @param confirmOptions Optional: Options for confirming the transaction + * @param programId Optional: Token program ID. Defaults to CTOKEN_PROGRAM_ID (compressed). + * Set to TOKEN_PROGRAM_ID or TOKEN_2022_PROGRAM_ID for SPL mints. + * + * @return Object with mint address and transaction signature + */ +export async function createMintInterface( + rpc: Rpc, + payer: Signer, + mintAuthority: PublicKey | Signer, + freezeAuthority: PublicKey | Signer | null, + decimals: number, + keypair: Keypair = Keypair.generate(), + metadata?: TokenMetadataInstructionData, + addressTreeInfo?: AddressTreeInfo, + outputStateTreeInfo?: TreeInfo, + confirmOptions?: ConfirmOptions, + programId: PublicKey = CTOKEN_PROGRAM_ID, +): Promise<{ mint: PublicKey; transactionSignature: TransactionSignature }> { + // Dispatch to SPL/Token-2022 mint creation + if ( + programId.equals(TOKEN_PROGRAM_ID) || + programId.equals(TOKEN_2022_PROGRAM_ID) + ) { + return createMint( + rpc, + payer, + mintAuthority, + freezeAuthority, + decimals, + keypair, + confirmOptions, + programId, + ); + } + + // Default: compressed token mint creation + if (!('secretKey' in mintAuthority)) { + throw new Error( + 'mintAuthority must be a Signer for compressed token mints', + ); + } + + const resolvedFreezeAuthority = + freezeAuthority && 'secretKey' in freezeAuthority + ? freezeAuthority.publicKey + : (freezeAuthority as PublicKey | null); + + addressTreeInfo = addressTreeInfo ?? getBatchAddressTreeInfo(); + outputStateTreeInfo = + outputStateTreeInfo ?? + selectStateTreeInfo(await rpc.getStateTreeInfos()); + + const validityProof = await rpc.getValidityProofV2( + [], + [ + { + address: findMintAddress(keypair.publicKey)[0].toBytes(), + treeInfo: addressTreeInfo, + }, + ], + DerivationMode.compressible, + ); + + const ix = createMintInstruction( + keypair.publicKey, + decimals, + mintAuthority.publicKey, + resolvedFreezeAuthority, + payer.publicKey, + validityProof, + addressTreeInfo, + outputStateTreeInfo, + metadata, + ); + + const additionalSigners = dedupeSigner(payer, [keypair, mintAuthority]); + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx( + [ComputeBudgetProgram.setComputeUnitLimit({ units: 500_000 }), ix], + payer, + blockhash, + additionalSigners, + ); + const txId = await sendAndConfirmTx(rpc, tx, { + ...confirmOptions, + skipPreflight: true, + }); + + const mint = findMintAddress(keypair.publicKey); + return { mint: mint[0], transactionSignature: txId }; +} diff --git a/js/compressed-token/src/mint/actions/decompress2.ts b/js/compressed-token/src/mint/actions/decompress2.ts new file mode 100644 index 0000000000..76bd8467e4 --- /dev/null +++ b/js/compressed-token/src/mint/actions/decompress2.ts @@ -0,0 +1,168 @@ +import { + ConfirmOptions, + PublicKey, + Signer, + TransactionSignature, + ComputeBudgetProgram, +} from '@solana/web3.js'; +import { + Rpc, + buildAndSignTx, + sendAndConfirmTx, + dedupeSigner, + ParsedTokenAccount, + bn, +} from '@lightprotocol/stateless.js'; +import BN from 'bn.js'; +import { createDecompress2Instruction } from '../instructions/decompress2'; +import { createAssociatedTokenAccountInterfaceIdempotentInstruction } from '../instructions/create-associated-ctoken'; +import { getATAAddressInterface } from './create-ata-interface'; +import { CTOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; + +/** + * Parameters for decompress2 action + */ +export interface Decompress2ActionParams { + /** RPC connection */ + rpc: Rpc; + /** Fee payer (signer) */ + payer: Signer; + /** Owner of the compressed tokens (signer) */ + owner: Signer; + /** Mint address */ + mint: PublicKey; + /** Optional: specific amount to decompress (defaults to all) */ + amount?: number | bigint | BN; + /** Optional: destination CToken ATA (defaults to owner's ATA) */ + destinationAta?: PublicKey; + /** Optional: confirm options */ + confirmOptions?: ConfirmOptions; +} + +/** + * Decompress compressed tokens to a CToken ATA using Transfer2. + * + * This is more efficient than the old decompress for CToken destinations + * as it doesn't require SPL token pool operations. + * + * @param params Decompress2 action parameters + * @returns Transaction signature, or null if no compressed tokens to decompress + */ +export async function decompress2( + params: Decompress2ActionParams, +): Promise { + const { + rpc, + payer, + owner, + mint, + amount: requestedAmount, + destinationAta, + confirmOptions, + } = params; + + // Get compressed token accounts + const compressedResult = await rpc.getCompressedTokenAccountsByOwner( + owner.publicKey, + { mint }, + ); + const compressedAccounts = compressedResult.items; + + if (compressedAccounts.length === 0) { + return null; // Nothing to decompress + } + + // Calculate total and determine amount + const totalBalance = compressedAccounts.reduce( + (sum, acc) => sum + BigInt(acc.parsed.amount.toString()), + BigInt(0), + ); + + const amount = requestedAmount + ? BigInt(requestedAmount.toString()) + : totalBalance; + + if (amount > totalBalance) { + throw new Error( + `Insufficient compressed balance. Requested: ${amount}, Available: ${totalBalance}`, + ); + } + + // Select accounts to use (for now, use all - could optimize later) + const accountsToUse: ParsedTokenAccount[] = []; + let accumulatedAmount = BigInt(0); + for (const acc of compressedAccounts) { + if (accumulatedAmount >= amount) break; + accountsToUse.push(acc); + accumulatedAmount += BigInt(acc.parsed.amount.toString()); + } + + // Get validity proof + const proof = await rpc.getValidityProofV0( + accountsToUse.map(acc => ({ + hash: acc.compressedAccount.hash, + tree: acc.compressedAccount.treeInfo.tree, + queue: acc.compressedAccount.treeInfo.queue, + })), + ); + + // Determine destination ATA + const ctokenAta = + destinationAta ?? getATAAddressInterface(mint, owner.publicKey); + + // Build instructions + const instructions = []; + + // Create CToken ATA if needed (idempotent) + const ctokenAtaInfo = await rpc.getAccountInfo(ctokenAta); + if (!ctokenAtaInfo) { + instructions.push( + createAssociatedTokenAccountInterfaceIdempotentInstruction( + payer.publicKey, + ctokenAta, + owner.publicKey, + mint, + CTOKEN_PROGRAM_ID, + ), + ); + } + + // Calculate compute units + const hasValidityProof = proof.compressedProof !== null; + let computeUnits = 50_000; // Base + if (hasValidityProof) { + computeUnits += 100_000; + } + for (const acc of accountsToUse) { + const proveByIndex = acc.compressedAccount.proveByIndex ?? false; + computeUnits += proveByIndex ? 10_000 : 30_000; + } + + // Add decompress2 instruction + instructions.push( + createDecompress2Instruction( + payer.publicKey, + accountsToUse, + ctokenAta, + amount, + proof.compressedProof, + proof.rootIndices, + ), + ); + + // Build and send + const { blockhash } = await rpc.getLatestBlockhash(); + const additionalSigners = dedupeSigner(payer, [owner]); + + const tx = buildAndSignTx( + [ + ComputeBudgetProgram.setComputeUnitLimit({ units: computeUnits }), + ...instructions, + ], + payer, + blockhash, + additionalSigners, + ); + + return sendAndConfirmTx(rpc, tx, confirmOptions); +} diff --git a/js/compressed-token/src/mint/actions/get-or-create-ata-interface.ts b/js/compressed-token/src/mint/actions/get-or-create-ata-interface.ts new file mode 100644 index 0000000000..f9ab911706 --- /dev/null +++ b/js/compressed-token/src/mint/actions/get-or-create-ata-interface.ts @@ -0,0 +1,120 @@ +import { CTOKEN_PROGRAM_ID, Rpc } from '@lightprotocol/stateless.js'; +import { + Account, + ASSOCIATED_TOKEN_PROGRAM_ID, + getAccount, + getAssociatedTokenAddressSync, + TOKEN_PROGRAM_ID, + TokenAccountNotFoundError, + TokenInvalidAccountOwnerError, + TokenInvalidMintError, + TokenInvalidOwnerError, +} from '@solana/spl-token'; +import type { + Commitment, + ConfirmOptions, + PublicKey, + Signer, +} from '@solana/web3.js'; +import { sendAndConfirmTransaction, Transaction } from '@solana/web3.js'; +import { createAssociatedTokenAccountInterfaceInstruction } from '../instructions/create-associated-ctoken'; +import { getAccountInterface } from '../get-account-interface'; +import { getATAProgramId } from '../../utils'; + +/** + * Retrieve the associated token account, or create it if it doesn't exist. + * Follows SPL Token getOrCreateAssociatedTokenAccount signature. + * + * @param rpc Connection to use + * @param payer Payer of the transaction and initialization fees + * @param mint Mint associated with the account to set or verify + * @param owner Owner of the account to set or verify + * @param allowOwnerOffCurve Allow the owner account to be a PDA (Program Derived Address) + * @param commitment Desired level of commitment for querying the state + * @param confirmOptions Options for confirming the transaction + * @param programId SPL Token program account or C token program account + * @param associatedTokenProgramId SPL Associated Token program account or C token program account + * + * @return Address of the new associated token account + */ +export async function getOrCreateATAInterface( + rpc: Rpc, + payer: Signer, + mint: PublicKey, + owner: PublicKey, + allowOwnerOffCurve = false, + commitment?: Commitment, + confirmOptions?: ConfirmOptions, + programId = TOKEN_PROGRAM_ID, + associatedTokenProgramId = getATAProgramId(programId), +): Promise { + const associatedToken = getAssociatedTokenAddressSync( + mint, + owner, + allowOwnerOffCurve, + programId, + associatedTokenProgramId, + ); + + // This is the optimal logic, considering TX fee, client-side computation, RPC roundtrips and guaranteed idempotent. + // Sadly we can't do this atomically. + let account: Account; + try { + // TODO: dynamically handle compressed or partially compressed TOKENS for user+mint + const accountInterface = await getAccountInterface( + rpc, + associatedToken, + commitment, + programId, + ); + account = accountInterface.parsed; + } catch (error: unknown) { + // TokenAccountNotFoundError can be possible if the associated address has already received some lamports, + // becoming a system account. Assuming program derived addressing is safe, this is the only case for the + // TokenInvalidAccountOwnerError in this code path. + if ( + error instanceof TokenAccountNotFoundError || + error instanceof TokenInvalidAccountOwnerError + ) { + // As this isn't atomic, it's possible others can create associated accounts meanwhile. + try { + const transaction = new Transaction().add( + createAssociatedTokenAccountInterfaceInstruction( + payer.publicKey, + associatedToken, + owner, + mint, + programId, + associatedTokenProgramId, + ), + ); + + await sendAndConfirmTransaction( + rpc, + transaction, + [payer], + confirmOptions, + ); + } catch (error: unknown) { + // Ignore all errors; for now there is no API-compatible way to selectively ignore the expected + // instruction error if the associated account exists already. + } + + // Now this should always succeed + const accountInterface = await getAccountInterface( + rpc, + associatedToken, + commitment, + programId, + ); + account = accountInterface.parsed; + } else { + throw error; + } + } + + if (!account.mint.equals(mint)) throw new TokenInvalidMintError(); + if (!account.owner.equals(owner)) throw new TokenInvalidOwnerError(); + + return account; +} diff --git a/js/compressed-token/src/mint/actions/index.ts b/js/compressed-token/src/mint/actions/index.ts new file mode 100644 index 0000000000..0a24e8c73a --- /dev/null +++ b/js/compressed-token/src/mint/actions/index.ts @@ -0,0 +1,12 @@ +export * from './create-mint-interface'; +export * from './update-mint'; +export * from './update-metadata'; +export * from './create-associated-ctoken'; +export * from './create-ata-interface'; +export * from './mint-to'; +export * from './mint-to-compressed'; +export * from './mint-to-interface'; +export * from './get-or-create-ata-interface'; +export * from './transfer-interface'; +export * from './decompress2'; +export * from './wrap'; diff --git a/js/compressed-token/src/mint/actions/mint-to-compressed.ts b/js/compressed-token/src/mint/actions/mint-to-compressed.ts new file mode 100644 index 0000000000..b34553199c --- /dev/null +++ b/js/compressed-token/src/mint/actions/mint-to-compressed.ts @@ -0,0 +1,107 @@ +import { + ComputeBudgetProgram, + ConfirmOptions, + PublicKey, + Signer, + TransactionSignature, +} from '@solana/web3.js'; +import { + Rpc, + buildAndSignTx, + sendAndConfirmTx, + DerivationMode, + bn, + CTOKEN_PROGRAM_ID, + selectStateTreeInfo, +} from '@lightprotocol/stateless.js'; +import { createMintToCompressedInstruction } from '../instructions/mint-to-compressed'; +import { getMintInterface } from '../helpers'; + +export async function mintToCompressed( + rpc: Rpc, + payer: Signer, + mint: PublicKey, + authority: Signer, + recipients: Array<{ recipient: PublicKey; amount: number | bigint }>, + outputQueue?: PublicKey, + tokensOutQueue?: PublicKey, + tokenAccountVersion: number = 3, + confirmOptions?: ConfirmOptions, +): Promise { + const mintInfo = await getMintInterface( + rpc, + mint, + confirmOptions?.commitment, + CTOKEN_PROGRAM_ID, + ); + + if (!mintInfo.merkleContext) { + throw new Error('Mint does not have MerkleContext'); + } + + if (!outputQueue) { + const trees = await rpc.getStateTreeInfos(); + const tree = selectStateTreeInfo(trees); + outputQueue = tree.queue; + } + + if (!tokensOutQueue) { + tokensOutQueue = outputQueue; + } + + const validityProof = await rpc.getValidityProofV2( + [ + { + hash: bn(mintInfo.merkleContext.hash), + leafIndex: mintInfo.merkleContext.leafIndex, + treeInfo: mintInfo.merkleContext.treeInfo, + proveByIndex: mintInfo.merkleContext.proveByIndex, + }, + ], + [], + DerivationMode.compressible, + ); + + const ix = createMintToCompressedInstruction( + authority.publicKey, + payer.publicKey, + validityProof, + mintInfo.merkleContext, + { + supply: mintInfo.mint.supply, + decimals: mintInfo.mint.decimals, + mintAuthority: mintInfo.mint.mintAuthority, + freezeAuthority: mintInfo.mint.freezeAuthority, + splMint: mintInfo.mintContext!.splMint, + splMintInitialized: mintInfo.mintContext!.splMintInitialized, + version: mintInfo.mintContext!.version, + metadata: mintInfo.tokenMetadata + ? { + updateAuthority: + mintInfo.tokenMetadata.updateAuthority || null, + name: mintInfo.tokenMetadata.name, + symbol: mintInfo.tokenMetadata.symbol, + uri: mintInfo.tokenMetadata.uri, + } + : undefined, + }, + outputQueue, + tokensOutQueue, + recipients, + tokenAccountVersion, + ); + + const additionalSigners = authority.publicKey.equals(payer.publicKey) + ? [] + : [authority]; + + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx( + [ComputeBudgetProgram.setComputeUnitLimit({ units: 500_000 }), ix], + payer, + blockhash, + additionalSigners, + ); + + return await sendAndConfirmTx(rpc, tx, confirmOptions); +} diff --git a/js/compressed-token/src/mint/actions/mint-to-interface.ts b/js/compressed-token/src/mint/actions/mint-to-interface.ts new file mode 100644 index 0000000000..b56b048f0d --- /dev/null +++ b/js/compressed-token/src/mint/actions/mint-to-interface.ts @@ -0,0 +1,110 @@ +import { + ComputeBudgetProgram, + ConfirmOptions, + PublicKey, + Signer, + TransactionSignature, +} from '@solana/web3.js'; +import { + Rpc, + buildAndSignTx, + sendAndConfirmTx, + DerivationMode, + bn, +} from '@lightprotocol/stateless.js'; +import { createMintToInterfaceInstruction } from '../instructions/mint-to-interface'; +import { getMintInterface } from '../helpers'; + +/** + * Mint tokens to a decompressed/onchain token account. + * Works with SPL, Token-2022, and compressed token (CToken) mints. + * + * This function ONLY mints to decompressed onchain token accounts, never to compressed accounts. + * The signature matches the standard SPL mintTo for simplicity and consistency. + * + * @param rpc - RPC connection to use + * @param payer - Transaction fee payer + * @param mint - Mint address (SPL, Token-2022, or compressed mint) + * @param destination - Destination token account address (must be an existing onchain token account) + * @param authority - Mint authority (can be Signer or PublicKey if multiSigners provided) + * @param amount - Amount to mint + * @param multiSigners - Optional: Multi-signature signers (default: []) + * @param confirmOptions - Optional: Transaction confirmation options + * @param programId - Optional: Token program ID (TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, or CTOKEN_PROGRAM_ID). If undefined, auto-detects. + * + * @returns Transaction signature + */ +export async function mintToInterface( + rpc: Rpc, + payer: Signer, + mint: PublicKey, + destination: PublicKey, + authority: Signer | PublicKey, + amount: number | bigint, + multiSigners: Signer[] = [], + confirmOptions?: ConfirmOptions, + programId?: PublicKey, +): Promise { + // Fetch mint interface (auto-detects program type if not provided) + const mintInterface = await getMintInterface( + rpc, + mint, + confirmOptions?.commitment, + programId, + ); + + // Fetch validity proof if this is a compressed mint (has merkleContext) + let validityProof; + if (mintInterface.merkleContext) { + validityProof = await rpc.getValidityProofV2( + [ + { + hash: bn(mintInterface.merkleContext.hash), + leafIndex: mintInterface.merkleContext.leafIndex, + treeInfo: mintInterface.merkleContext.treeInfo, + proveByIndex: mintInterface.merkleContext.proveByIndex, + }, + ], + [], + DerivationMode.compressible, + ); + } + + // Create instruction + const authorityPubkey = + authority instanceof PublicKey ? authority : authority.publicKey; + const multiSignerPubkeys = multiSigners.map(s => s.publicKey); + + const ix = createMintToInterfaceInstruction( + mintInterface, + destination, + authorityPubkey, + payer.publicKey, + amount, + validityProof, + multiSignerPubkeys, + ); + + // Build signers list + const signers: Signer[] = []; + if (authority instanceof PublicKey) { + // Authority is a pubkey, so multiSigners must be provided + signers.push(...multiSigners); + } else { + // Authority is a signer + if (!authority.publicKey.equals(payer.publicKey)) { + signers.push(authority); + } + signers.push(...multiSigners); + } + + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx( + [ComputeBudgetProgram.setComputeUnitLimit({ units: 500_000 }), ix], + payer, + blockhash, + signers, + ); + + return await sendAndConfirmTx(rpc, tx, confirmOptions); +} diff --git a/js/compressed-token/src/mint/actions/mint-to.ts b/js/compressed-token/src/mint/actions/mint-to.ts new file mode 100644 index 0000000000..0e3f78c9ee --- /dev/null +++ b/js/compressed-token/src/mint/actions/mint-to.ts @@ -0,0 +1,110 @@ +import { + ComputeBudgetProgram, + ConfirmOptions, + PublicKey, + Signer, + TransactionSignature, +} from '@solana/web3.js'; +import { + Rpc, + buildAndSignTx, + sendAndConfirmTx, + DerivationMode, + bn, + CTOKEN_PROGRAM_ID, + selectStateTreeInfo, + TreeInfo, +} from '@lightprotocol/stateless.js'; +import { createMintToInstruction } from '../instructions/mint-to'; +import { getMintInterface } from '../helpers'; + +export async function mintTo( + rpc: Rpc, + payer: Signer, + mint: PublicKey, + recipientAccount: PublicKey, + authority: Signer, + amount: number | bigint, + outputQueue?: PublicKey, + confirmOptions?: ConfirmOptions, +): Promise { + const mintInfo = await getMintInterface( + rpc, + mint, + confirmOptions?.commitment, + CTOKEN_PROGRAM_ID, + ); + + if (!mintInfo.merkleContext) { + throw new Error('Mint does not have MerkleContext'); + } + + let outputStateTreeInfo: TreeInfo; + if (!outputQueue) { + const trees = await rpc.getStateTreeInfos(); + outputStateTreeInfo = selectStateTreeInfo(trees); + } else { + const trees = await rpc.getStateTreeInfos(); + outputStateTreeInfo = trees.find( + t => t.queue.equals(outputQueue!) || t.tree.equals(outputQueue!), + )!; + if (!outputStateTreeInfo) { + throw new Error('Could not find TreeInfo for provided outputQueue'); + } + } + + const validityProof = await rpc.getValidityProofV2( + [ + { + hash: bn(mintInfo.merkleContext.hash), + leafIndex: mintInfo.merkleContext.leafIndex, + treeInfo: mintInfo.merkleContext.treeInfo, + proveByIndex: mintInfo.merkleContext.proveByIndex, + }, + ], + [], + DerivationMode.compressible, + ); + + const ix = createMintToInstruction( + authority.publicKey, + payer.publicKey, + validityProof, + mintInfo.merkleContext, + { + supply: mintInfo.mint.supply, + decimals: mintInfo.mint.decimals, + mintAuthority: mintInfo.mint.mintAuthority, + freezeAuthority: mintInfo.mint.freezeAuthority, + splMint: mintInfo.mintContext!.splMint, + splMintInitialized: mintInfo.mintContext!.splMintInitialized, + version: mintInfo.mintContext!.version, + metadata: mintInfo.tokenMetadata + ? { + updateAuthority: + mintInfo.tokenMetadata.updateAuthority || null, + name: mintInfo.tokenMetadata.name, + symbol: mintInfo.tokenMetadata.symbol, + uri: mintInfo.tokenMetadata.uri, + } + : undefined, + }, + outputStateTreeInfo, + recipientAccount, + amount, + ); + + const additionalSigners = authority.publicKey.equals(payer.publicKey) + ? [] + : [authority]; + + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx( + [ComputeBudgetProgram.setComputeUnitLimit({ units: 500_000 }), ix], + payer, + blockhash, + additionalSigners, + ); + + return await sendAndConfirmTx(rpc, tx, confirmOptions); +} diff --git a/js/compressed-token/src/mint/actions/transfer-interface.ts b/js/compressed-token/src/mint/actions/transfer-interface.ts new file mode 100644 index 0000000000..6c64f0986d --- /dev/null +++ b/js/compressed-token/src/mint/actions/transfer-interface.ts @@ -0,0 +1,341 @@ +import { + ComputeBudgetProgram, + ConfirmOptions, + PublicKey, + Signer, + TransactionInstruction, + TransactionSignature, +} from '@solana/web3.js'; +import { + Rpc, + buildAndSignTx, + sendAndConfirmTx, + CTOKEN_PROGRAM_ID, + dedupeSigner, + ParsedTokenAccount, +} from '@lightprotocol/stateless.js'; +import { + TOKEN_PROGRAM_ID, + TOKEN_2022_PROGRAM_ID, + getAssociatedTokenAddressSync, +} from '@solana/spl-token'; +import BN from 'bn.js'; +import { getATAProgramId } from '../../utils'; +import { + createTransferInterfaceInstruction, + createCTokenTransferInstruction, +} from '../instructions/transfer-interface'; +import { createAssociatedTokenAccountInterfaceIdempotentInstruction } from '../instructions/create-associated-ctoken'; +import { getATAAddressInterface } from './create-ata-interface'; +import { + getTokenPoolInfos, + TokenPoolInfo, +} from '../../utils/get-token-pool-infos'; +import { createWrapInstruction } from '../instructions/wrap'; +import { createDecompress2Instruction } from '../instructions/decompress2'; +import { getATAInterface } from '../get-account-interface'; + +/** + * Options for interface operations (load, transfer) + */ +export interface InterfaceOptions { + /** Token pool infos (fetched if not provided) */ + tokenPoolInfos?: TokenPoolInfo[]; +} + +/** + * Calculate compute units needed for the operation + */ +function calculateComputeUnits( + compressedAccounts: ParsedTokenAccount[], + hasValidityProof: boolean, + splWrapCount: number, +): number { + // Base CU for hot CToken transfer + let cu = 5_000; + + // Compressed token decompression + if (compressedAccounts.length > 0) { + if (hasValidityProof) { + cu += 100_000; // Validity proof verification + } + // Per compressed account + for (const acc of compressedAccounts) { + const proveByIndex = acc.compressedAccount.proveByIndex ?? false; + cu += proveByIndex ? 10_000 : 30_000; + } + } + + // SPL/T22 wrap operations + cu += splWrapCount * 5_000; + + return cu; +} + +/** + * Transfer tokens using the CToken interface. + * Mirrors SPL Token's transfer() - destination must exist. + * + * This action: + * 1. Validates source matches derived ATA from owner + mint + * 2. Loads ALL sender balances to CToken ATA (SPL, T22, compressed) + * 3. Executes hot-to-hot transfer + * + * Note: Like SPL Token, this does NOT create the destination ATA. + * Use getOrCreateATAInterface() first if destination may not exist. + * + * @param rpc RPC connection + * @param payer Fee payer (signer) + * @param source Source CToken ATA address + * @param destination Destination CToken ATA address (must exist) + * @param owner Source owner (signer) + * @param mint Mint address + * @param amount Amount to transfer + * @param programId Token program ID (default: CTOKEN_PROGRAM_ID) + * @param confirmOptions Optional confirm options + * @param options Optional interface options + * @returns Transaction signature + */ +export async function transferInterface( + rpc: Rpc, + payer: Signer, + source: PublicKey, + destination: PublicKey, + owner: Signer, + mint: PublicKey, + amount: number | bigint | BN, + programId: PublicKey = CTOKEN_PROGRAM_ID, + confirmOptions?: ConfirmOptions, + options?: InterfaceOptions, +): Promise { + const amountBigInt = BigInt(amount.toString()); + const { tokenPoolInfos: providedTokenPoolInfos } = options ?? {}; + + const instructions: TransactionInstruction[] = []; + + // For non-CToken programs, use simple SPL transfer (no load) + if (!programId.equals(CTOKEN_PROGRAM_ID)) { + const expectedSource = getAssociatedTokenAddressSync( + mint, + owner.publicKey, + false, + programId, + getATAProgramId(programId), + ); + if (!source.equals(expectedSource)) { + throw new Error( + `Source mismatch. Expected ${expectedSource.toBase58()}, got ${source.toBase58()}`, + ); + } + + instructions.push( + createTransferInterfaceInstruction( + source, + destination, + owner.publicKey, + amountBigInt, + [], + programId, + ), + ); + + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx( + [ + ComputeBudgetProgram.setComputeUnitLimit({ units: 10_000 }), + ...instructions, + ], + payer, + blockhash, + [owner], + ); + return sendAndConfirmTx(rpc, tx, confirmOptions); + } + + // CToken transfer + const expectedSource = getATAAddressInterface(mint, owner.publicKey); + if (!source.equals(expectedSource)) { + throw new Error( + `Source mismatch. Expected ${expectedSource.toBase58()}, got ${source.toBase58()}`, + ); + } + + const ctokenAta = getATAAddressInterface(mint, owner.publicKey); + + // Derive ATAs for all token programs (sender only) + const splAta = getAssociatedTokenAddressSync( + mint, + owner.publicKey, + false, + TOKEN_PROGRAM_ID, + getATAProgramId(TOKEN_PROGRAM_ID), + ); + const t22Ata = getAssociatedTokenAddressSync( + mint, + owner.publicKey, + false, + TOKEN_2022_PROGRAM_ID, + getATAProgramId(TOKEN_2022_PROGRAM_ID), + ); + + // Fetch sender's accounts in parallel + const [ctokenAtaInfo, splAtaInfo, t22AtaInfo, compressedResult] = + await Promise.all([ + rpc.getAccountInfo(ctokenAta), + rpc.getAccountInfo(splAta), + rpc.getAccountInfo(t22Ata), + rpc.getCompressedTokenAccountsByOwner(owner.publicKey, { mint }), + ]); + + const compressedAccounts = compressedResult.items; + + // Parse balances + const hotBalance = + ctokenAtaInfo && ctokenAtaInfo.data.length >= 72 + ? ctokenAtaInfo.data.readBigUInt64LE(64) + : BigInt(0); + const splBalance = + splAtaInfo && splAtaInfo.data.length >= 72 + ? splAtaInfo.data.readBigUInt64LE(64) + : BigInt(0); + const t22Balance = + t22AtaInfo && t22AtaInfo.data.length >= 72 + ? t22AtaInfo.data.readBigUInt64LE(64) + : BigInt(0); + const compressedBalance = compressedAccounts.reduce( + (sum, acc) => sum + BigInt(acc.parsed.amount.toString()), + BigInt(0), + ); + + const totalBalance = + hotBalance + splBalance + t22Balance + compressedBalance; + + if (totalBalance < amountBigInt) { + throw new Error( + `Insufficient balance. Required: ${amountBigInt}, Available: ${totalBalance}`, + ); + } + + // Track what we're doing for CU calculation + let splWrapCount = 0; + let hasValidityProof = false; + let compressedToLoad: ParsedTokenAccount[] = []; + + // Create sender's CToken ATA if needed (idempotent) + if (!ctokenAtaInfo) { + instructions.push( + createAssociatedTokenAccountInterfaceIdempotentInstruction( + payer.publicKey, + ctokenAta, + owner.publicKey, + mint, + CTOKEN_PROGRAM_ID, + ), + ); + } + + // Get token pool infos if we need to load + const needsLoad = + splBalance > BigInt(0) || + t22Balance > BigInt(0) || + compressedBalance > BigInt(0); + const tokenPoolInfos = needsLoad + ? (providedTokenPoolInfos ?? (await getTokenPoolInfos(rpc, mint))) + : []; + const tokenPoolInfo = tokenPoolInfos.find(info => info.isInitialized); + + // Wrap SPL tokens if balance exists + if (splBalance > BigInt(0) && tokenPoolInfo) { + instructions.push( + createWrapInstruction( + splAta, + ctokenAta, + owner.publicKey, + mint, + splBalance, + tokenPoolInfo, + payer.publicKey, + ), + ); + splWrapCount++; + } + + // Wrap T22 tokens if balance exists + if (t22Balance > BigInt(0) && tokenPoolInfo) { + instructions.push( + createWrapInstruction( + t22Ata, + ctokenAta, + owner.publicKey, + mint, + t22Balance, + tokenPoolInfo, + payer.publicKey, + ), + ); + splWrapCount++; + } + + // Decompress compressed tokens if they exist + if (compressedBalance > BigInt(0) && compressedAccounts.length > 0) { + const proof = await rpc.getValidityProofV0( + compressedAccounts.map(acc => ({ + hash: acc.compressedAccount.hash, + tree: acc.compressedAccount.treeInfo.tree, + queue: acc.compressedAccount.treeInfo.queue, + })), + ); + + hasValidityProof = proof.compressedProof !== null; + compressedToLoad = compressedAccounts; + + instructions.push( + createDecompress2Instruction( + payer.publicKey, + compressedAccounts, + ctokenAta, + compressedBalance, + proof.compressedProof, + proof.rootIndices, + ), + ); + } + + // Transfer (destination must already exist - like SPL Token) + instructions.push( + createCTokenTransferInstruction( + source, + destination, + owner.publicKey, + amountBigInt, + payer.publicKey, + ), + ); + + // Calculate compute units + const computeUnits = calculateComputeUnits( + compressedToLoad, + hasValidityProof, + splWrapCount, + ); + + // Build and send + const { blockhash } = await rpc.getLatestBlockhash(); + const additionalSigners = dedupeSigner(payer, [owner]); + + const tx = buildAndSignTx( + [ + ComputeBudgetProgram.setComputeUnitLimit({ units: computeUnits }), + ...instructions, + ], + payer, + blockhash, + additionalSigners, + ); + + return sendAndConfirmTx(rpc, tx, confirmOptions); +} + +// Re-export old names for backwards compatibility +export type LoadOptions = InterfaceOptions; +export type TransferInterfaceOptions = InterfaceOptions; diff --git a/js/compressed-token/src/mint/actions/update-metadata.ts b/js/compressed-token/src/mint/actions/update-metadata.ts new file mode 100644 index 0000000000..be4c80674d --- /dev/null +++ b/js/compressed-token/src/mint/actions/update-metadata.ts @@ -0,0 +1,269 @@ +import { + ComputeBudgetProgram, + ConfirmOptions, + PublicKey, + Signer, + TransactionSignature, +} from '@solana/web3.js'; +import { + Rpc, + buildAndSignTx, + sendAndConfirmTx, + TreeInfo, + selectStateTreeInfo, + DerivationMode, + bn, + CTOKEN_PROGRAM_ID, +} from '@lightprotocol/stateless.js'; +import { + createUpdateMetadataFieldInstruction, + createUpdateMetadataAuthorityInstruction, + createRemoveMetadataKeyInstruction, +} from '../instructions/update-metadata'; +import { getMintInterface } from '../helpers'; + +export async function updateMetadataField( + rpc: Rpc, + payer: Signer, + mint: PublicKey, + mintSigner: Signer, + authority: Signer, + fieldType: 'name' | 'symbol' | 'uri' | 'custom', + value: string, + customKey?: string, + extensionIndex: number = 0, + outputStateTreeInfo?: TreeInfo, + confirmOptions?: ConfirmOptions, +): Promise { + outputStateTreeInfo = + outputStateTreeInfo ?? + selectStateTreeInfo(await rpc.getStateTreeInfos()); + + const mintInfo = await getMintInterface( + rpc, + mint, + confirmOptions?.commitment, + CTOKEN_PROGRAM_ID, + ); + + if (!mintInfo.tokenMetadata || !mintInfo.merkleContext) { + throw new Error('Mint does not have TokenMetadata extension'); + } + + const validityProof = await rpc.getValidityProofV2( + [ + { + hash: bn(mintInfo.merkleContext.hash), + leafIndex: mintInfo.merkleContext.leafIndex, + treeInfo: mintInfo.merkleContext.treeInfo, + proveByIndex: mintInfo.merkleContext.proveByIndex, + }, + ], + [], + DerivationMode.compressible, + ); + + const ix = createUpdateMetadataFieldInstruction( + mintSigner.publicKey, + authority.publicKey, + payer.publicKey, + validityProof, + mintInfo.merkleContext, + { + supply: mintInfo.mint.supply, + decimals: mintInfo.mint.decimals, + mintAuthority: mintInfo.mint.mintAuthority, + freezeAuthority: mintInfo.mint.freezeAuthority, + splMint: mintInfo.mintContext!.splMint, + splMintInitialized: mintInfo.mintContext!.splMintInitialized, + version: mintInfo.mintContext!.version, + metadata: { + updateAuthority: mintInfo.tokenMetadata.updateAuthority || null, + name: mintInfo.tokenMetadata.name, + symbol: mintInfo.tokenMetadata.symbol, + uri: mintInfo.tokenMetadata.uri, + }, + }, + outputStateTreeInfo.queue, + fieldType, + value, + customKey, + extensionIndex, + ); + + const additionalSigners = authority.publicKey.equals(payer.publicKey) + ? [] + : [authority]; + + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx( + [ComputeBudgetProgram.setComputeUnitLimit({ units: 500_000 }), ix], + payer, + blockhash, + additionalSigners, + ); + + return await sendAndConfirmTx(rpc, tx, confirmOptions); +} + +export async function updateMetadataAuthority( + rpc: Rpc, + payer: Signer, + mint: PublicKey, + mintSigner: Signer, + currentAuthority: Signer, + newAuthority: PublicKey, + extensionIndex: number = 0, + outputStateTreeInfo?: TreeInfo, + confirmOptions?: ConfirmOptions, +): Promise { + outputStateTreeInfo = + outputStateTreeInfo ?? + selectStateTreeInfo(await rpc.getStateTreeInfos()); + + const mintInfo = await getMintInterface( + rpc, + mint, + confirmOptions?.commitment, + CTOKEN_PROGRAM_ID, + ); + + if (!mintInfo.tokenMetadata || !mintInfo.merkleContext) { + throw new Error('Mint does not have TokenMetadata extension'); + } + + const validityProof = await rpc.getValidityProofV2( + [ + { + hash: bn(mintInfo.merkleContext.hash), + leafIndex: mintInfo.merkleContext.leafIndex, + treeInfo: mintInfo.merkleContext.treeInfo, + proveByIndex: mintInfo.merkleContext.proveByIndex, + }, + ], + [], + DerivationMode.compressible, + ); + + const ix = createUpdateMetadataAuthorityInstruction( + mintSigner.publicKey, + currentAuthority.publicKey, + newAuthority, + payer.publicKey, + validityProof, + mintInfo.merkleContext, + { + supply: mintInfo.mint.supply, + decimals: mintInfo.mint.decimals, + mintAuthority: mintInfo.mint.mintAuthority, + freezeAuthority: mintInfo.mint.freezeAuthority, + splMint: mintInfo.mintContext!.splMint, + splMintInitialized: mintInfo.mintContext!.splMintInitialized, + version: mintInfo.mintContext!.version, + metadata: { + updateAuthority: mintInfo.tokenMetadata.updateAuthority || null, + name: mintInfo.tokenMetadata.name, + symbol: mintInfo.tokenMetadata.symbol, + uri: mintInfo.tokenMetadata.uri, + }, + }, + outputStateTreeInfo.queue, + extensionIndex, + ); + + const additionalSigners = currentAuthority.publicKey.equals(payer.publicKey) + ? [] + : [currentAuthority]; + + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx( + [ComputeBudgetProgram.setComputeUnitLimit({ units: 500_000 }), ix], + payer, + blockhash, + additionalSigners, + ); + + return await sendAndConfirmTx(rpc, tx, confirmOptions); +} + +export async function removeMetadataKey( + rpc: Rpc, + payer: Signer, + mint: PublicKey, + mintSigner: Signer, + authority: Signer, + key: string, + idempotent: boolean = false, + extensionIndex: number = 0, + outputStateTreeInfo?: TreeInfo, + confirmOptions?: ConfirmOptions, +): Promise { + outputStateTreeInfo = + outputStateTreeInfo ?? + selectStateTreeInfo(await rpc.getStateTreeInfos()); + + const mintInfo = await getMintInterface( + rpc, + mint, + confirmOptions?.commitment, + CTOKEN_PROGRAM_ID, + ); + + if (!mintInfo.tokenMetadata || !mintInfo.merkleContext) { + throw new Error('Mint does not have TokenMetadata extension'); + } + + const validityProof = await rpc.getValidityProofV2( + [ + { + hash: bn(mintInfo.merkleContext.hash), + leafIndex: mintInfo.merkleContext.leafIndex, + treeInfo: mintInfo.merkleContext.treeInfo, + proveByIndex: mintInfo.merkleContext.proveByIndex, + }, + ], + [], + DerivationMode.compressible, + ); + + const ix = createRemoveMetadataKeyInstruction( + mintSigner.publicKey, + authority.publicKey, + payer.publicKey, + validityProof, + mintInfo.merkleContext, + { + supply: mintInfo.mint.supply, + decimals: mintInfo.mint.decimals, + mintAuthority: mintInfo.mint.mintAuthority, + freezeAuthority: mintInfo.mint.freezeAuthority, + splMint: mintInfo.mintContext!.splMint, + splMintInitialized: mintInfo.mintContext!.splMintInitialized, + version: mintInfo.mintContext!.version, + metadata: { + updateAuthority: mintInfo.tokenMetadata.updateAuthority || null, + name: mintInfo.tokenMetadata.name, + symbol: mintInfo.tokenMetadata.symbol, + uri: mintInfo.tokenMetadata.uri, + }, + }, + outputStateTreeInfo.queue, + key, + idempotent, + extensionIndex, + ); + + const additionalSigners = authority.publicKey.equals(payer.publicKey) + ? [] + : [authority]; + + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx( + [ComputeBudgetProgram.setComputeUnitLimit({ units: 500_000 }), ix], + payer, + blockhash, + additionalSigners, + ); + + return await sendAndConfirmTx(rpc, tx, confirmOptions); +} diff --git a/js/compressed-token/src/mint/actions/update-mint.ts b/js/compressed-token/src/mint/actions/update-mint.ts new file mode 100644 index 0000000000..d5ad2e8c13 --- /dev/null +++ b/js/compressed-token/src/mint/actions/update-mint.ts @@ -0,0 +1,185 @@ +import { + ComputeBudgetProgram, + ConfirmOptions, + PublicKey, + Signer, + TransactionSignature, +} from '@solana/web3.js'; +import { + Rpc, + buildAndSignTx, + sendAndConfirmTx, + TreeInfo, + selectStateTreeInfo, + DerivationMode, + bn, + CTOKEN_PROGRAM_ID, +} from '@lightprotocol/stateless.js'; +import { + createUpdateMintAuthorityInstruction, + createUpdateFreezeAuthorityInstruction, +} from '../instructions/update-mint'; +import { getMintInterface } from '../helpers'; + +export async function updateMintAuthority( + rpc: Rpc, + payer: Signer, + mint: PublicKey, + mintSigner: Signer, + currentMintAuthority: Signer, + newMintAuthority: PublicKey | null, + outputStateTreeInfo?: TreeInfo, + confirmOptions?: ConfirmOptions, +): Promise { + outputStateTreeInfo = + outputStateTreeInfo ?? + selectStateTreeInfo(await rpc.getStateTreeInfos()); + + const mintInfo = await getMintInterface( + rpc, + mint, + undefined, + CTOKEN_PROGRAM_ID, + ); + + if (!mintInfo.merkleContext) { + throw new Error('Mint does not have MerkleContext'); + } + + const validityProof = await rpc.getValidityProofV2( + [ + { + hash: bn(mintInfo.merkleContext.hash), + leafIndex: mintInfo.merkleContext.leafIndex, + treeInfo: mintInfo.merkleContext.treeInfo, + proveByIndex: mintInfo.merkleContext.proveByIndex, + }, + ], + [], + DerivationMode.compressible, + ); + + const ix = createUpdateMintAuthorityInstruction( + currentMintAuthority.publicKey, + newMintAuthority, + payer.publicKey, + validityProof, + mintInfo.merkleContext, + { + supply: mintInfo.mint.supply, + decimals: mintInfo.mint.decimals, + mintAuthority: mintInfo.mint.mintAuthority, + freezeAuthority: mintInfo.mint.freezeAuthority, + splMint: mintInfo.mintContext!.splMint, + splMintInitialized: mintInfo.mintContext!.splMintInitialized, + version: mintInfo.mintContext!.version, + metadata: mintInfo.tokenMetadata + ? { + updateAuthority: + mintInfo.tokenMetadata.updateAuthority || null, + name: mintInfo.tokenMetadata.name, + symbol: mintInfo.tokenMetadata.symbol, + uri: mintInfo.tokenMetadata.uri, + } + : undefined, + }, + outputStateTreeInfo.queue, + ); + + const additionalSigners = currentMintAuthority.publicKey.equals( + payer.publicKey, + ) + ? [] + : [currentMintAuthority]; + + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx( + [ComputeBudgetProgram.setComputeUnitLimit({ units: 500_000 }), ix], + payer, + blockhash, + additionalSigners, + ); + + return await sendAndConfirmTx(rpc, tx, confirmOptions); +} + +export async function updateFreezeAuthority( + rpc: Rpc, + payer: Signer, + mint: PublicKey, + mintSigner: Signer, + currentFreezeAuthority: Signer, + newFreezeAuthority: PublicKey | null, + outputStateTreeInfo?: TreeInfo, + confirmOptions?: ConfirmOptions, +): Promise { + outputStateTreeInfo = + outputStateTreeInfo ?? + selectStateTreeInfo(await rpc.getStateTreeInfos()); + + const mintInfo = await getMintInterface( + rpc, + mint, + undefined, + CTOKEN_PROGRAM_ID, + ); + if (!mintInfo.merkleContext) { + throw new Error('Mint does not have MerkleContext'); + } + + const validityProof = await rpc.getValidityProofV2( + [ + { + hash: bn(mintInfo.merkleContext.hash), + leafIndex: mintInfo.merkleContext.leafIndex, + treeInfo: mintInfo.merkleContext.treeInfo, + proveByIndex: mintInfo.merkleContext.proveByIndex, + }, + ], + [], + DerivationMode.compressible, + ); + + const ix = createUpdateFreezeAuthorityInstruction( + currentFreezeAuthority.publicKey, + newFreezeAuthority, + payer.publicKey, + validityProof, + mintInfo.merkleContext, + { + supply: mintInfo.mint.supply, + decimals: mintInfo.mint.decimals, + mintAuthority: mintInfo.mint.mintAuthority, + freezeAuthority: mintInfo.mint.freezeAuthority, + splMint: mintInfo.mintContext!.splMint, + splMintInitialized: mintInfo.mintContext!.splMintInitialized, + version: mintInfo.mintContext!.version, + metadata: mintInfo.tokenMetadata + ? { + updateAuthority: + mintInfo.tokenMetadata.updateAuthority || null, + name: mintInfo.tokenMetadata.name, + symbol: mintInfo.tokenMetadata.symbol, + uri: mintInfo.tokenMetadata.uri, + } + : undefined, + }, + outputStateTreeInfo.queue, + ); + + const additionalSigners = currentFreezeAuthority.publicKey.equals( + payer.publicKey, + ) + ? [] + : [currentFreezeAuthority]; + + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx( + [ComputeBudgetProgram.setComputeUnitLimit({ units: 500_000 }), ix], + payer, + blockhash, + additionalSigners, + ); + + return await sendAndConfirmTx(rpc, tx, confirmOptions); +} diff --git a/js/compressed-token/src/mint/actions/wrap.ts b/js/compressed-token/src/mint/actions/wrap.ts new file mode 100644 index 0000000000..5e7c6927bf --- /dev/null +++ b/js/compressed-token/src/mint/actions/wrap.ts @@ -0,0 +1,120 @@ +import { + ComputeBudgetProgram, + ConfirmOptions, + PublicKey, + Signer, + TransactionSignature, +} from '@solana/web3.js'; +import { + Rpc, + buildAndSignTx, + sendAndConfirmTx, + dedupeSigner, +} from '@lightprotocol/stateless.js'; +import { createWrapInstruction } from '../instructions/wrap'; +import { + getTokenPoolInfos, + TokenPoolInfo, +} from '../../utils/get-token-pool-infos'; + +// Keep old interface type for backwards compatibility export +export interface WrapParams { + rpc: Rpc; + payer: Signer; + source: PublicKey; + destination: PublicKey; + owner: Signer; + mint: PublicKey; + amount: bigint; + tokenPoolInfo?: TokenPoolInfo; + confirmOptions?: ConfirmOptions; +} + +export interface WrapResult { + transactionSignature: TransactionSignature; +} + +/** + * Wrap tokens from an SPL/T22 account to a CToken account. + * + * This is an agnostic action that takes explicit account addresses (spl-token style). + * Use getAssociatedTokenAddressSync() to derive ATA addresses if needed. + * + * @param rpc RPC connection + * @param payer Fee payer + * @param source Source SPL/T22 token account (any token account, not just ATA) + * @param destination Destination CToken account (any CToken account, not just ATA) + * @param owner Owner/authority of the source account (must sign) + * @param mint Mint address + * @param amount Amount to wrap + * @param tokenPoolInfo Optional: Token pool info (will be fetched if not provided) + * @param confirmOptions Optional: Confirm options + * + * @example + * const splAta = getAssociatedTokenAddressSync(mint, owner.publicKey, false, TOKEN_PROGRAM_ID); + * const ctokenAta = getATAAddressInterface(mint, owner.publicKey); // defaults to CToken + * + * await wrap( + * rpc, + * payer, + * splAta, + * ctokenAta, + * owner, + * mint, + * 1000n, + * ); + * + * @returns Transaction signature + */ +export async function wrap( + rpc: Rpc, + payer: Signer, + source: PublicKey, + destination: PublicKey, + owner: Signer, + mint: PublicKey, + amount: bigint, + tokenPoolInfo?: TokenPoolInfo, + confirmOptions?: ConfirmOptions, +): Promise { + // Get token pool info if not provided + let resolvedTokenPoolInfo = tokenPoolInfo; + if (!resolvedTokenPoolInfo) { + const tokenPoolInfos = await getTokenPoolInfos(rpc, mint); + resolvedTokenPoolInfo = tokenPoolInfos.find(info => info.isInitialized); + + if (!resolvedTokenPoolInfo) { + throw new Error( + `No initialized token pool found for mint: ${mint.toBase58()}. ` + + `Please create a token pool via createTokenPool().`, + ); + } + } + + // Build wrap instruction + const ix = createWrapInstruction( + source, + destination, + owner.publicKey, + mint, + amount, + resolvedTokenPoolInfo, + payer.publicKey, + ); + + // Build and send transaction + const { blockhash } = await rpc.getLatestBlockhash(); + + const additionalSigners = dedupeSigner(payer, [owner]); + + const tx = buildAndSignTx( + [ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }), ix], + payer, + blockhash, + additionalSigners, + ); + + const txId = await sendAndConfirmTx(rpc, tx, confirmOptions); + + return { transactionSignature: txId }; +} diff --git a/js/compressed-token/src/mint/get-account-interface.ts b/js/compressed-token/src/mint/get-account-interface.ts new file mode 100644 index 0000000000..9b90b857f4 --- /dev/null +++ b/js/compressed-token/src/mint/get-account-interface.ts @@ -0,0 +1,697 @@ +import { AccountInfo, Commitment, PublicKey } from '@solana/web3.js'; +import { + TOKEN_PROGRAM_ID, + TOKEN_2022_PROGRAM_ID, + unpackAccount as unpackAccountSPL, + TokenAccountNotFoundError, + getAssociatedTokenAddressSync, + AccountState, + AccountLayout, + Account, +} from '@solana/spl-token'; +import { + Rpc, + CTOKEN_PROGRAM_ID, + MerkleContext, + CompressedAccountWithMerkleContext, + ParsedTokenAccount, +} from '@lightprotocol/stateless.js'; +import { Buffer } from 'buffer'; +import BN from 'bn.js'; +import { getATAProgramId } from '../utils'; + +// Re-export types that are used in the interface +export { Account, AccountState } from '@solana/spl-token'; +export { ParsedTokenAccount } from '@lightprotocol/stateless.js'; + +export interface TokenAccountSource { + type: 'spl' | 'token2022' | 'ctoken-hot' | 'ctoken-cold'; + address: PublicKey; + amount: bigint; + accountInfo: AccountInfo; + loadContext?: MerkleContext; + parsed: Account; +} + +export interface AccountInterface { + accountInfo: AccountInfo; + parsed: Account; + isCold: boolean; + loadContext?: MerkleContext; + _sources?: TokenAccountSource[]; + _needsConsolidation?: boolean; + _hasDelegate?: boolean; + _anyFrozen?: boolean; + /** True when fetched via getATAInterface */ + _isAta?: boolean; + /** ATA owner - set by getATAInterface */ + _owner?: PublicKey; + /** ATA mint - set by getATAInterface */ + _mint?: PublicKey; +} + +function parseTokenData(data: Buffer): { + mint: PublicKey; + owner: PublicKey; + amount: BN; + delegate: PublicKey | null; + state: number; + tlv: Buffer | null; +} | null { + if (!data || data.length === 0) return null; + + try { + let offset = 0; + const mint = new PublicKey(data.slice(offset, offset + 32)); + offset += 32; + const owner = new PublicKey(data.slice(offset, offset + 32)); + offset += 32; + const amount = new BN(data.slice(offset, offset + 8), 'le'); + offset += 8; + const delegateOption = data[offset]; + offset += 1; + const delegate = delegateOption + ? new PublicKey(data.slice(offset, offset + 32)) + : null; + offset += 32; + const state = data[offset]; + offset += 1; + const tlvOption = data[offset]; + offset += 1; + const tlv = tlvOption ? data.slice(offset) : null; + + return { + mint, + owner, + amount, + delegate, + state, + tlv, + }; + } catch (error) { + console.error('Token data parsing error:', error); + return null; + } +} + +export function convertTokenDataToAccount( + address: PublicKey, + tokenData: { + mint: PublicKey; + owner: PublicKey; + amount: BN; + delegate: PublicKey | null; + state: number; + tlv: Buffer | null; + }, +): Account { + return { + address, + mint: tokenData.mint, + owner: tokenData.owner, + amount: BigInt(tokenData.amount.toString()), + delegate: tokenData.delegate, + delegatedAmount: BigInt(0), + isInitialized: tokenData.state !== AccountState.Uninitialized, + isFrozen: tokenData.state === AccountState.Frozen, + isNative: false, + rentExemptReserve: null, + closeAuthority: null, + tlvData: tokenData.tlv ? Buffer.from(tokenData.tlv) : Buffer.alloc(0), + }; +} + +/** normalize compressed account to account info */ +export function toAccountInfo( + compressedAccount: CompressedAccountWithMerkleContext, +): AccountInfo { + // we must define Buffer type explicitly. + const dataDiscriminatorBuffer: Buffer = Buffer.from( + compressedAccount.data!.discriminator, + ); + const dataBuffer: Buffer = Buffer.from(compressedAccount.data!.data); + const data: Buffer = Buffer.concat([dataDiscriminatorBuffer, dataBuffer]); + + return { + executable: false, + owner: compressedAccount.owner, + lamports: compressedAccount.lamports.toNumber(), + data, + rentEpoch: undefined, + }; +} + +export function parseCTokenHot( + address: PublicKey, + accountInfo: AccountInfo, +): { + accountInfo: AccountInfo; + loadContext: undefined; + parsed: Account; + isCold: false; +} { + const parsed = parseTokenData(accountInfo.data); + if (!parsed) throw new Error('Invalid token data'); + return { + accountInfo, + loadContext: undefined, + parsed: convertTokenDataToAccount(address, parsed), + isCold: false, + }; +} + +export function parseCTokenCold( + address: PublicKey, + compressedAccount: CompressedAccountWithMerkleContext, +): { + accountInfo: AccountInfo; + loadContext: MerkleContext; + parsed: Account; + isCold: true; +} { + const parsed = parseTokenData(compressedAccount.data!.data); + if (!parsed) throw new Error('Invalid token data'); + return { + accountInfo: toAccountInfo(compressedAccount), + loadContext: { + treeInfo: compressedAccount.treeInfo, + hash: compressedAccount.hash, + leafIndex: compressedAccount.leafIndex, + proveByIndex: compressedAccount.proveByIndex, + }, + parsed: convertTokenDataToAccount(address, parsed), + isCold: true, + }; +} +/** + * Retrieve information about a token account (SPL, T22, C-Token) + * + * @param rpc RPC connection to use + * @param address Token account address + * @param commitment Desired level of commitment for querying the state + * @param programId Token program ID. If not provided, tries all programs concurrently to auto-detect + * + * @return Token account information with compression context if applicable + */ +export async function getAccountInterface( + rpc: Rpc, + address: PublicKey, + commitment?: Commitment, + programId?: PublicKey, +): Promise { + return _getAccountInterface(rpc, address, commitment, programId, undefined); +} + +/** Retrieve associated token account for a given owner and mint. */ +export async function getATAInterface( + rpc: Rpc, + owner: PublicKey, + mint: PublicKey, + commitment?: Commitment, + programId?: PublicKey, +): Promise { + const result = await _getAccountInterface( + rpc, + undefined, + commitment, + programId, + { + owner, + mint, + }, + ); + result._isAta = true; + result._owner = owner; + result._mint = mint; + return result; +} + +/** + * Helper: Try to fetch SPL Token account + */ +async function _tryFetchSpl( + rpc: Rpc, + address: PublicKey, + commitment?: Commitment, +): Promise<{ + accountInfo: AccountInfo; + parsed: Account; + isCold: false; + loadContext: undefined; +}> { + const info = await rpc.getAccountInfo(address, commitment); + if (!info || !info.owner.equals(TOKEN_PROGRAM_ID)) { + throw new Error('Not a TOKEN_PROGRAM_ID account'); + } + const account = unpackAccountSPL(address, info, TOKEN_PROGRAM_ID); + return { + accountInfo: info, + parsed: account, + isCold: false, + loadContext: undefined, + }; +} + +/** + * Helper: Try to fetch Token-2022 account + */ +async function _tryFetchToken2022( + rpc: Rpc, + address: PublicKey, + commitment?: Commitment, +): Promise<{ + accountInfo: AccountInfo; + parsed: Account; + isCold: false; + loadContext: undefined; +}> { + const info = await rpc.getAccountInfo(address, commitment); + if (!info || !info.owner.equals(TOKEN_2022_PROGRAM_ID)) { + throw new Error('Not a TOKEN_2022_PROGRAM_ID account'); + } + const account = unpackAccountSPL(address, info, TOKEN_2022_PROGRAM_ID); + return { + accountInfo: info, + parsed: account, + isCold: false, + loadContext: undefined, + }; +} + +/** + * Helper: Try to fetch CToken hot (decompressed) account + */ +async function _tryFetchCTokenHot( + rpc: Rpc, + address: PublicKey, + commitment?: Commitment, +): Promise<{ + accountInfo: AccountInfo; + loadContext: undefined; + parsed: Account; + isCold: false; +}> { + const info = await rpc.getAccountInfo(address, commitment); + if (!info || !info.owner.equals(CTOKEN_PROGRAM_ID)) { + throw new Error('Not a CTOKEN onchain account'); + } + return parseCTokenHot(address, info); +} + +/** + * Helper: Try to fetch CToken cold (compressed) account by owner+mint + */ +async function _tryFetchCTokenColdByOwner( + rpc: Rpc, + owner: PublicKey, + mint: PublicKey, + ataAddress: PublicKey, +): Promise<{ + accountInfo: AccountInfo; + loadContext: MerkleContext; + parsed: Account; + isCold: true; +}> { + const result = await rpc.getCompressedTokenAccountsByOwner(owner, { + mint, + }); + const compressedAccount = + result.items.length > 0 ? result.items[0].compressedAccount : null; + if (!compressedAccount?.data?.data.length) { + throw new Error('Not a compressed token account'); + } + if (!compressedAccount.owner.equals(CTOKEN_PROGRAM_ID)) { + throw new Error('Invalid owner for compressed token'); + } + return parseCTokenCold(ataAddress, compressedAccount); +} + +/** + * Helper: Try to fetch CToken cold (compressed) account by address (for non-ATA ctokens) + */ +async function _tryFetchCTokenColdByAddress( + rpc: Rpc, + address: PublicKey, +): Promise<{ + accountInfo: AccountInfo; + loadContext: MerkleContext; + parsed: Account; + isCold: true; +}> { + const result = await rpc.getCompressedTokenAccountsByOwner(address); + const compressedAccount = + result.items.length > 0 ? result.items[0].compressedAccount : null; + if (!compressedAccount?.data?.data.length) { + throw new Error('Not a compressed token account'); + } + if (!compressedAccount.owner.equals(CTOKEN_PROGRAM_ID)) { + throw new Error('Invalid owner for compressed token'); + } + return parseCTokenCold(address, compressedAccount); +} + +// TODO: add test +// +// TODO: implement actual solution for compressed token accounts for vaults for +// spl/t22 mints. +/** + * @internal + * Retrieve information about a token account (SPL, T22, C-Token) + * + * @param rpc RPC connection to use + * @param address Token account address + * @param commitment Desired level of commitment for querying the state + * @param programId Token program ID. If not provided, tries all programs concurrently to auto-detect + * @param fetchByOwner ATA options. If provided, tries to fetch the compressible side by owner and mint instead of address + * + * @return Token account information with compression context if applicable + */ +async function _getAccountInterface( + rpc: Rpc, + address?: PublicKey, + commitment?: Commitment, + programId?: PublicKey, + fetchByOwner?: { + owner: PublicKey; + mint: PublicKey; + }, +): Promise { + if (!address && !fetchByOwner) { + throw new Error('One of Address or fetchByOwner is required'); + } + if (address && fetchByOwner) { + throw new Error('Only one of Address or fetchByOwner can be provided'); + } + + // Auto-detect: try all programs in parallel + if (!programId) { + // Derive ATA addresses for each program (or use provided address) + const cTokenAta = address + ? address + : getAssociatedTokenAddressSync( + fetchByOwner!.mint, + fetchByOwner!.owner, + false, + CTOKEN_PROGRAM_ID, + getATAProgramId(CTOKEN_PROGRAM_ID), + ); + const splTokenAta = address + ? address + : getAssociatedTokenAddressSync( + fetchByOwner!.mint, + fetchByOwner!.owner, + false, + TOKEN_PROGRAM_ID, + getATAProgramId(TOKEN_PROGRAM_ID), + ); + const token2022Ata = address + ? address + : getAssociatedTokenAddressSync( + fetchByOwner!.mint, + fetchByOwner!.owner, + false, + TOKEN_2022_PROGRAM_ID, + getATAProgramId(TOKEN_2022_PROGRAM_ID), + ); + + const results = await Promise.allSettled([ + // 1. SPL Token + _tryFetchSpl(rpc, splTokenAta, commitment), + // 2. Token-2022 + _tryFetchToken2022(rpc, token2022Ata, commitment), + // 3. CToken hot (decompressed) + _tryFetchCTokenHot(rpc, cTokenAta, commitment), + // 4. CToken cold (compressed) + fetchByOwner + ? _tryFetchCTokenColdByOwner( + rpc, + fetchByOwner.owner, + fetchByOwner.mint, + cTokenAta, + ) + : _tryFetchCTokenColdByAddress(rpc, address!), + ]); + + // Collect all successful results + const sources: TokenAccountSource[] = []; + const successfulResults: Array<{ + accountInfo: AccountInfo; + parsed: Account; + isCold: boolean; + loadContext?: MerkleContext; + }> = []; + + for (let i = 0; i < results.length; i++) { + const result = results[i]; + if (result.status === 'fulfilled') { + const value = result.value; + successfulResults.push(value); + + let type: TokenAccountSource['type']; + let addr: PublicKey; + + if (i === 0) { + type = 'spl'; + addr = splTokenAta; + } else if (i === 1) { + type = 'token2022'; + addr = token2022Ata; + } else if (i === 2) { + type = 'ctoken-hot'; + addr = cTokenAta; + } else { + type = 'ctoken-cold'; + addr = cTokenAta; + } + + sources.push({ + type, + address: addr, + amount: value.parsed.amount, + accountInfo: value.accountInfo, + loadContext: value.loadContext, + parsed: value.parsed, + }); + } + } + + // None succeeded - account not found + if (sources.length === 0) { + throw new Error( + `Token account not found. ` + + `Tried TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, and CTOKEN_PROGRAM_ID (both onchain and compressed).`, + ); + } + + // Priority order: CToken hot > CToken cold > SPL/T22 + const priority: TokenAccountSource['type'][] = [ + 'ctoken-hot', + 'ctoken-cold', + 'spl', + 'token2022', + ]; + + sources.sort((a, b) => { + const aIdx = priority.indexOf(a.type); + const bIdx = priority.indexOf(b.type); + return aIdx - bIdx; + }); + + // Aggregate balance from all sources + const totalAmount = sources.reduce( + (sum, src) => sum + src.amount, + BigInt(0), + ); + + // Use the highest priority source as base + const primarySource = sources[0]; + + // Check for concerns + const hasDelegate = sources.some(src => src.parsed.delegate !== null); + const anyFrozen = sources.some(src => src.parsed.isFrozen); + const needsConsolidation = sources.length > 1; + + // Create unified account with aggregated balance + const unifiedAccount: Account = { + ...primarySource.parsed, + address: cTokenAta, + amount: totalAmount, + }; + + const isCold = primarySource.type === 'ctoken-cold'; + + return { + accountInfo: primarySource.accountInfo!, + parsed: unifiedAccount, + isCold, + loadContext: primarySource.loadContext, + _sources: sources, + _needsConsolidation: needsConsolidation, + _hasDelegate: hasDelegate, + _anyFrozen: anyFrozen, + }; + } + + // Handle specific programId - CTOKEN + if (programId.equals(CTOKEN_PROGRAM_ID)) { + // Derive address if not provided + if (!address) { + if (!fetchByOwner) { + throw new Error('fetchByOwner is required'); + } + address = getAssociatedTokenAddressSync( + fetchByOwner.mint, + fetchByOwner.owner, + false, + CTOKEN_PROGRAM_ID, + getATAProgramId(CTOKEN_PROGRAM_ID), + ); + } + + const [onchainResult, compressedResult] = await Promise.allSettled([ + rpc.getAccountInfo(address, commitment), + // Fetch compressed: by owner+mint for ATAs, by address for non-ATAs + fetchByOwner + ? rpc.getCompressedTokenAccountsByOwner(fetchByOwner.owner, { + mint: fetchByOwner.mint, + }) + : rpc.getCompressedTokenAccountsByOwner(address), + ]); + + const onchainAccount = + onchainResult.status === 'fulfilled' ? onchainResult.value : null; + const compressedAccounts = + compressedResult.status === 'fulfilled' + ? compressedResult.value.items.map( + item => item.compressedAccount, + ) + : []; + + const sources: TokenAccountSource[] = []; + + // Collect hot (decompressed) CToken account + if (onchainAccount && onchainAccount.owner.equals(programId)) { + const parsed = parseCTokenHot(address, onchainAccount); + sources.push({ + type: 'ctoken-hot', + address, + amount: parsed.parsed.amount, + accountInfo: onchainAccount, + parsed: parsed.parsed, + }); + } + + // Collect cold (compressed) CToken accounts + for (const compressedAccount of compressedAccounts) { + if ( + compressedAccount && + compressedAccount.data && + compressedAccount.data.data.length > 0 && + compressedAccount.owner.equals(programId) + ) { + const parsed = parseCTokenCold(address, compressedAccount); + sources.push({ + type: 'ctoken-cold', + address, + amount: parsed.parsed.amount, + accountInfo: parsed.accountInfo, + loadContext: parsed.loadContext, + parsed: parsed.parsed, + }); + } + } + + if (sources.length === 0) { + throw new TokenAccountNotFoundError(); + } + + // Priority: hot > cold + sources.sort((a, b) => { + if (a.type === 'ctoken-hot' && b.type === 'ctoken-cold') return -1; + if (a.type === 'ctoken-cold' && b.type === 'ctoken-hot') return 1; + return 0; + }); + + // Aggregate balance + const totalAmount = sources.reduce( + (sum, src) => sum + src.amount, + BigInt(0), + ); + + const primarySource = sources[0]; + const hasDelegate = sources.some(src => src.parsed.delegate !== null); + const anyFrozen = sources.some(src => src.parsed.isFrozen); + const needsConsolidation = sources.length > 1; + + const unifiedAccount: Account = { + ...primarySource.parsed, + address, + amount: totalAmount, + }; + + return { + accountInfo: primarySource.accountInfo!, + parsed: unifiedAccount, + isCold: primarySource.type === 'ctoken-cold', + loadContext: primarySource.loadContext, + _sources: sources, + _needsConsolidation: needsConsolidation, + _hasDelegate: hasDelegate, + _anyFrozen: anyFrozen, + }; + } + + // Handle specific programId - SPL Token or Token-2022 + if ( + programId.equals(TOKEN_PROGRAM_ID) || + programId.equals(TOKEN_2022_PROGRAM_ID) + ) { + // Derive address if not provided + if (!address) { + if (!fetchByOwner) { + throw new Error('fetchByOwner is required'); + } + address = getAssociatedTokenAddressSync( + fetchByOwner.mint, + fetchByOwner.owner, + false, + programId, + getATAProgramId(programId), + ); + } + + const info = await rpc.getAccountInfo(address, commitment); + if (!info) { + throw new TokenAccountNotFoundError(); + } + + const account = unpackAccountSPL(address, info, programId); + + const type: TokenAccountSource['type'] = programId.equals( + TOKEN_PROGRAM_ID, + ) + ? 'spl' + : 'token2022'; + + return { + accountInfo: info, + parsed: account, + isCold: false, + loadContext: undefined, + _sources: [ + { + type, + address, + amount: account.amount, + accountInfo: info, + parsed: account, + }, + ], + _needsConsolidation: false, + _hasDelegate: account.delegate !== null, + _anyFrozen: account.isFrozen, + }; + } + + throw new Error(`Unsupported program ID: ${programId.toBase58()}`); +} diff --git a/js/compressed-token/src/mint/helpers.ts b/js/compressed-token/src/mint/helpers.ts new file mode 100644 index 0000000000..a5eb934358 --- /dev/null +++ b/js/compressed-token/src/mint/helpers.ts @@ -0,0 +1,246 @@ +import { PublicKey, AccountInfo, Commitment } from '@solana/web3.js'; +import { Buffer } from 'buffer'; +import { + Rpc, + bn, + deriveAddressV2, + CTOKEN_PROGRAM_ID, + getDefaultAddressTreeInfo, + MerkleContext, +} from '@lightprotocol/stateless.js'; +import { + Mint, + getMint as getSplMint, + unpackMint as unpackSplMint, + TOKEN_PROGRAM_ID, + TOKEN_2022_PROGRAM_ID, +} from '@solana/spl-token'; +import { + deserializeMint, + CompressedMint, + MintContext, + TokenMetadata, + MintExtension, + extractTokenMetadata, +} from './serde'; + +export interface MintInterface { + mint: Mint; + programId: PublicKey; // Token program that owns this mint (TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, or CTOKEN_PROGRAM_ID) + merkleContext?: MerkleContext; + mintContext?: MintContext; + tokenMetadata?: TokenMetadata; // Parsed metadata (first-class) + extensions?: MintExtension[]; // Raw extensions array (optional) +} + +/** + * Get mint interface - supports both SPL and compressed mints + * Supports TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID (SPL), and CTOKEN_PROGRAM_ID (compressed) + * + * @param rpc - RPC connection + * @param address - The mint address + * @param commitment - Optional commitment level + * @param programId - Token program ID. If not provided, tries all programs to auto-detect + * @returns Object with mint, optional merkleContext, mintContext, and tokenMetadata for compressed mints + */ +export async function getMintInterface( + rpc: Rpc, + address: PublicKey, + commitment?: Commitment, + programId?: PublicKey, +): Promise { + // Auto-detect: try all three programs in parallel + if (!programId) { + const [tokenResult, token2022Result, compressedResult] = + await Promise.allSettled([ + getMintInterface(rpc, address, commitment, TOKEN_PROGRAM_ID), + getMintInterface( + rpc, + address, + commitment, + TOKEN_2022_PROGRAM_ID, + ), + getMintInterface(rpc, address, commitment, CTOKEN_PROGRAM_ID), + ]); + + // Return whichever succeeded + if (tokenResult.status === 'fulfilled') { + return tokenResult.value; + } + if (token2022Result.status === 'fulfilled') { + return token2022Result.value; + } + if (compressedResult.status === 'fulfilled') { + return compressedResult.value; + } + + // None succeeded - mint not found + throw new Error( + `Mint not found: ${address.toString()}. ` + + `Tried TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, and CTOKEN_PROGRAM_ID.`, + ); + } + + // If programId is compressed token program, fetch compressed mint + if (programId.equals(CTOKEN_PROGRAM_ID)) { + const addressTree = getDefaultAddressTreeInfo().tree; + const compressedAddress = deriveAddressV2( + address.toBytes(), + addressTree, + CTOKEN_PROGRAM_ID, + ); + const compressedAccount = await rpc.getCompressedAccount( + bn(compressedAddress.toBytes()), + ); + + if (!compressedAccount?.data?.data) { + throw new Error( + `Compressed mint not found for ${address.toString()}`, + ); + } + + const compressedMintData = deserializeMint( + Buffer.from(compressedAccount.data.data), + ); + + const mint: Mint = { + address, + mintAuthority: compressedMintData.base.mintAuthority, + supply: compressedMintData.base.supply, + decimals: compressedMintData.base.decimals, + isInitialized: compressedMintData.base.isInitialized, + freezeAuthority: compressedMintData.base.freezeAuthority, + tlvData: Buffer.alloc(0), + }; + + const merkleContext: MerkleContext = { + treeInfo: compressedAccount.treeInfo, + hash: compressedAccount.hash, + leafIndex: compressedAccount.leafIndex, + proveByIndex: compressedAccount.proveByIndex, + }; + + // Extract and parse TokenMetadata + const tokenMetadata = extractTokenMetadata( + compressedMintData.extensions, + ); + + const result: MintInterface = { + mint, + programId, + merkleContext, + mintContext: compressedMintData.mintContext, + tokenMetadata: tokenMetadata || undefined, + extensions: compressedMintData.extensions || undefined, + }; + + // Validate: CTOKEN_PROGRAM_ID requires merkleContext and mintContext + if (programId.equals(CTOKEN_PROGRAM_ID)) { + if (!result.merkleContext) { + throw new Error( + `Invalid compressed mint: merkleContext is required for CTOKEN_PROGRAM_ID`, + ); + } + if (!result.mintContext) { + throw new Error( + `Invalid compressed mint: mintContext is required for CTOKEN_PROGRAM_ID`, + ); + } + } + + return result; + } + + // Otherwise, fetch SPL mint (TOKEN_PROGRAM_ID or TOKEN_2022_PROGRAM_ID) + const mint = await getSplMint(rpc, address, commitment, programId); + return { mint, programId }; +} + +/** + * Unpack mint interface from raw account data + * Handles both SPL and compressed mint formats + * Note: merkleContext not available from raw data, use getMintInterface for full context + * + * @param address - The mint pubkey + * @param data - The raw account data or AccountInfo + * @param programId - Token program ID (defaults to TOKEN_PROGRAM_ID) + * @returns Object with mint, optional mintContext and tokenMetadata for compressed mints + */ +export function unpackMintInterface( + address: PublicKey, + data: Buffer | Uint8Array | AccountInfo, + programId: PublicKey = TOKEN_PROGRAM_ID, +): Omit { + const buffer = + data instanceof Buffer + ? data + : data instanceof Uint8Array + ? Buffer.from(data) + : data.data; + + // If compressed token program, deserialize as compressed mint + if (programId.equals(CTOKEN_PROGRAM_ID)) { + const compressedMintData = deserializeMint(buffer); + + const mint: Mint = { + address, + mintAuthority: compressedMintData.base.mintAuthority, + supply: compressedMintData.base.supply, + decimals: compressedMintData.base.decimals, + isInitialized: compressedMintData.base.isInitialized, + freezeAuthority: compressedMintData.base.freezeAuthority, + tlvData: Buffer.alloc(0), + }; + + // Extract and parse TokenMetadata + const tokenMetadata = extractTokenMetadata( + compressedMintData.extensions, + ); + + const result = { + mint, + programId, + mintContext: compressedMintData.mintContext, + tokenMetadata: tokenMetadata || undefined, + extensions: compressedMintData.extensions || undefined, + }; + + // Validate: CTOKEN_PROGRAM_ID requires mintContext + if (programId.equals(CTOKEN_PROGRAM_ID)) { + if (!result.mintContext) { + throw new Error( + `Invalid compressed mint: mintContext is required for CTOKEN_PROGRAM_ID`, + ); + } + } + + return result; + } + + // Otherwise, unpack as SPL mint + const info = data as AccountInfo; + const mint = unpackSplMint(address, info, programId); + return { mint, programId }; +} + +/** + * Unpack compressed mint context and metadata from raw account data + * + * @param data - The raw account data + * @returns Object with mintContext, tokenMetadata, and extensions + */ +export function unpackMintData(data: Buffer | Uint8Array): { + mintContext: MintContext; + tokenMetadata?: TokenMetadata; + extensions?: MintExtension[]; +} { + const buffer = data instanceof Buffer ? data : Buffer.from(data); + const compressedMint = deserializeMint(buffer); + const tokenMetadata = extractTokenMetadata(compressedMint.extensions); + + return { + mintContext: compressedMint.mintContext, + tokenMetadata: tokenMetadata || undefined, + extensions: compressedMint.extensions || undefined, + }; +} diff --git a/js/compressed-token/src/mint/index.ts b/js/compressed-token/src/mint/index.ts new file mode 100644 index 0000000000..c2862f46c3 --- /dev/null +++ b/js/compressed-token/src/mint/index.ts @@ -0,0 +1,6 @@ +export * from './instructions'; +export * from './actions'; +export * from './helpers'; +export * from './serde'; +export * from './upload'; +export * from './get-account-interface'; diff --git a/js/compressed-token/src/mint/instructions/create-associated-ctoken.ts b/js/compressed-token/src/mint/instructions/create-associated-ctoken.ts new file mode 100644 index 0000000000..137b83d3cb --- /dev/null +++ b/js/compressed-token/src/mint/instructions/create-associated-ctoken.ts @@ -0,0 +1,324 @@ +import { + PublicKey, + SystemProgram, + TransactionInstruction, +} from '@solana/web3.js'; +import { Buffer } from 'buffer'; +import { CTOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; +import { + ASSOCIATED_TOKEN_PROGRAM_ID, + TOKEN_PROGRAM_ID, + createAssociatedTokenAccountInstruction as createSplAssociatedTokenAccountInstruction, + createAssociatedTokenAccountIdempotentInstruction as createSplAssociatedTokenAccountIdempotentInstruction, +} from '@solana/spl-token'; +import { struct, u8, publicKey, option, vec } from '@coral-xyz/borsh'; +import { getATAProgramId } from '../../utils'; + +const CREATE_ASSOCIATED_TOKEN_ACCOUNT_DISCRIMINATOR = Buffer.from([100]); +const CREATE_ASSOCIATED_TOKEN_ACCOUNT_IDEMPOTENT_DISCRIMINATOR = Buffer.from([ + 102, +]); + +const CompressibleExtensionInstructionDataLayout = struct([ + u8('rentPayment'), + u8('writeTopUp'), + option(struct([vec(u8(), 'seeds'), u8('bump')]), 'compressToAccountPubkey'), + u8('tokenAccountVersion'), +]); + +const CreateAssociatedTokenAccountInstructionDataLayout = struct([ + publicKey('owner'), + publicKey('mint'), + u8('bump'), + option(CompressibleExtensionInstructionDataLayout, 'compressibleConfig'), +]); + +export interface CompressibleConfig { + rentPayment: number; + writeTopUp: number; + compressToAccountPubkey?: { + seeds: number[]; + bump: number; + }; + tokenAccountVersion: number; +} + +export interface CreateAssociatedCTokenAccountParams { + owner: PublicKey; + mint: PublicKey; + bump: number; + compressibleConfig?: CompressibleConfig; +} + +/** + * CToken-specific config for createAssociatedTokenAccountInterfaceInstruction + */ +export interface CTokenConfig { + compressibleConfig?: CompressibleConfig; + configAccount?: PublicKey; + rentPayerPda?: PublicKey; +} + +function getAssociatedCTokenAddressAndBump( + owner: PublicKey, + mint: PublicKey, +): [PublicKey, number] { + return PublicKey.findProgramAddressSync( + [owner.toBuffer(), CTOKEN_PROGRAM_ID.toBuffer(), mint.toBuffer()], + CTOKEN_PROGRAM_ID, + ); +} + +function encodeCreateAssociatedCTokenAccountData( + params: CreateAssociatedCTokenAccountParams, + idempotent: boolean, +): Buffer { + const buffer = Buffer.alloc(2000); + const len = CreateAssociatedTokenAccountInstructionDataLayout.encode( + { + owner: params.owner, + mint: params.mint, + bump: params.bump, + compressibleConfig: params.compressibleConfig || null, + }, + buffer, + ); + + const discriminator = idempotent + ? CREATE_ASSOCIATED_TOKEN_ACCOUNT_IDEMPOTENT_DISCRIMINATOR + : CREATE_ASSOCIATED_TOKEN_ACCOUNT_DISCRIMINATOR; + + return Buffer.concat([discriminator, buffer.subarray(0, len)]); +} + +export interface CreateAssociatedCTokenAccountInstructionParams { + feePayer: PublicKey; + owner: PublicKey; + mint: PublicKey; + compressibleConfig?: CompressibleConfig; + configAccount?: PublicKey; + rentPayerPda?: PublicKey; +} + +/** + * Create instruction for creating an associated compressed token account. + * + * @param feePayer Fee payer public key. + * @param owner Owner of the associated token account. + * @param mint Mint address. + * @param compressibleConfig Optional compressible configuration. + * @param configAccount Optional config account. + * @param rentPayerPda Optional rent payer PDA. + */ +export function createAssociatedCTokenAccountInstruction( + feePayer: PublicKey, + owner: PublicKey, + mint: PublicKey, + compressibleConfig?: CompressibleConfig, + configAccount?: PublicKey, + rentPayerPda?: PublicKey, +): TransactionInstruction { + const [associatedTokenAccount, bump] = getAssociatedCTokenAddressAndBump( + owner, + mint, + ); + + const data = encodeCreateAssociatedCTokenAccountData( + { + owner, + mint, + bump, + compressibleConfig, + }, + false, + ); + + const keys = [ + { pubkey: feePayer, isSigner: true, isWritable: true }, + { + pubkey: associatedTokenAccount, + isSigner: false, + isWritable: true, + }, + { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, + ]; + + if (compressibleConfig && configAccount && rentPayerPda) { + keys.push( + { pubkey: configAccount, isSigner: false, isWritable: false }, + { pubkey: rentPayerPda, isSigner: false, isWritable: true }, + ); + } + + return new TransactionInstruction({ + programId: CTOKEN_PROGRAM_ID, + keys, + data, + }); +} + +/** + * Create idempotent instruction for creating an associated compressed token account. + * + * @param feePayer Fee payer public key. + * @param owner Owner of the associated token account. + * @param mint Mint address. + * @param compressibleConfig Optional compressible configuration. + * @param configAccount Optional config account. + * @param rentPayerPda Optional rent payer PDA. + */ +export function createAssociatedCTokenAccountIdempotentInstruction( + feePayer: PublicKey, + owner: PublicKey, + mint: PublicKey, + compressibleConfig?: CompressibleConfig, + configAccount?: PublicKey, + rentPayerPda?: PublicKey, +): TransactionInstruction { + const [associatedTokenAccount, bump] = getAssociatedCTokenAddressAndBump( + owner, + mint, + ); + + const data = encodeCreateAssociatedCTokenAccountData( + { + owner, + mint, + bump, + compressibleConfig, + }, + true, + ); + + const keys = [ + { pubkey: feePayer, isSigner: true, isWritable: true }, + { + pubkey: associatedTokenAccount, + isSigner: false, + isWritable: true, + }, + { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, + ]; + + if (compressibleConfig && configAccount && rentPayerPda) { + keys.push( + { pubkey: configAccount, isSigner: false, isWritable: false }, + { pubkey: rentPayerPda, isSigner: false, isWritable: true }, + ); + } + + return new TransactionInstruction({ + programId: CTOKEN_PROGRAM_ID, + keys, + data, + }); +} + +// Keep old interface type for backwards compatibility export +export interface CreateAssociatedTokenAccountInterfaceInstructionParams { + payer: PublicKey; + associatedToken: PublicKey; + owner: PublicKey; + mint: PublicKey; + programId?: PublicKey; + associatedTokenProgramId?: PublicKey; + compressibleConfig?: CompressibleConfig; + configAccount?: PublicKey; + rentPayerPda?: PublicKey; +} + +/** + * Create instruction for creating an associated token account (SPL, Token-2022, or CToken). + * Follows SPL Token API signature with optional CToken config at the end. + * + * @param payer Fee payer public key. + * @param associatedToken Associated token account address. + * @param owner Owner of the associated token account. + * @param mint Mint address. + * @param programId Token program ID (default: TOKEN_PROGRAM_ID). + * @param associatedTokenProgramId Associated token program ID. + * @param ctokenConfig Optional CToken-specific configuration. + */ +export function createAssociatedTokenAccountInterfaceInstruction( + payer: PublicKey, + associatedToken: PublicKey, + owner: PublicKey, + mint: PublicKey, + programId: PublicKey = TOKEN_PROGRAM_ID, + associatedTokenProgramId?: PublicKey, + ctokenConfig?: CTokenConfig, +): TransactionInstruction { + const effectiveAssociatedTokenProgramId = + associatedTokenProgramId ?? getATAProgramId(programId); + + if (programId.equals(CTOKEN_PROGRAM_ID)) { + return createAssociatedCTokenAccountInstruction( + payer, + owner, + mint, + ctokenConfig?.compressibleConfig, + ctokenConfig?.configAccount, + ctokenConfig?.rentPayerPda, + ); + } else { + return createSplAssociatedTokenAccountInstruction( + payer, + associatedToken, + owner, + mint, + programId, + effectiveAssociatedTokenProgramId, + ); + } +} + +/** + * Create idempotent instruction for creating an associated token account (SPL, Token-2022, or CToken). + * Follows SPL Token API signature with optional CToken config at the end. + * + * @param payer Fee payer public key. + * @param associatedToken Associated token account address. + * @param owner Owner of the associated token account. + * @param mint Mint address. + * @param programId Token program ID (default: TOKEN_PROGRAM_ID). + * @param associatedTokenProgramId Associated token program ID. + * @param ctokenConfig Optional CToken-specific configuration. + */ +export function createAssociatedTokenAccountInterfaceIdempotentInstruction( + payer: PublicKey, + associatedToken: PublicKey, + owner: PublicKey, + mint: PublicKey, + programId: PublicKey = TOKEN_PROGRAM_ID, + associatedTokenProgramId?: PublicKey, + ctokenConfig?: CTokenConfig, +): TransactionInstruction { + const effectiveAssociatedTokenProgramId = + associatedTokenProgramId ?? getATAProgramId(programId); + + if (programId.equals(CTOKEN_PROGRAM_ID)) { + return createAssociatedCTokenAccountIdempotentInstruction( + payer, + owner, + mint, + ctokenConfig?.compressibleConfig, + ctokenConfig?.configAccount, + ctokenConfig?.rentPayerPda, + ); + } else { + return createSplAssociatedTokenAccountIdempotentInstruction( + payer, + associatedToken, + owner, + mint, + programId, + effectiveAssociatedTokenProgramId, + ); + } +} + +/** + * Short alias for createAssociatedTokenAccountInterfaceIdempotentInstruction. + */ +export const createATAInterfaceIdempotentInstruction = + createAssociatedTokenAccountInterfaceIdempotentInstruction; diff --git a/js/compressed-token/src/mint/instructions/create-mint.ts b/js/compressed-token/src/mint/instructions/create-mint.ts new file mode 100644 index 0000000000..caeb1b529b --- /dev/null +++ b/js/compressed-token/src/mint/instructions/create-mint.ts @@ -0,0 +1,223 @@ +import { + PublicKey, + SystemProgram, + TransactionInstruction, +} from '@solana/web3.js'; +import { Buffer } from 'buffer'; +import { + ValidityProofWithContext, + CTOKEN_PROGRAM_ID, + LightSystemProgram, + defaultStaticAccountsStruct, + deriveAddressV2, + TreeInfo, + AddressTreeInfo, +} from '@lightprotocol/stateless.js'; +import { CompressedTokenProgram } from '../../program'; +import { findMintAddress } from '../../compressible/derivation'; +import { + encodeMintActionInstructionData, + MintActionCompressedInstructionData, + TokenMetadataInstructionData as TokenMetadataBorshData, +} from './mint-action-layout'; +import { TokenDataVersion } from '../../constants'; + +/** + * Token metadata for creating a compressed mint + * Uses strings for user-friendly input + */ +export interface TokenMetadataInstructionData { + name: string; + symbol: string; + uri: string; + updateAuthority?: PublicKey | null; + additionalMetadata?: { + key: string; + value: string; + }[]; +} + +/** @deprecated Use TokenMetadataInstructionData instead */ +export type TokenMetadataInstructionDataInput = TokenMetadataInstructionData; + +interface EncodeCreateMintInstructionParams { + mintSigner: PublicKey; + mintAuthority: PublicKey; + freezeAuthority: PublicKey | null; + decimals: number; + addressTree: PublicKey; + outputQueue: PublicKey; + rootIndex: number; + proof: { a: number[]; b: number[]; c: number[] } | null; + metadata?: TokenMetadataInstructionData; +} + +export function createTokenMetadata( + name: string, + symbol: string, + uri: string, + updateAuthority?: PublicKey | null, +): TokenMetadataInstructionData { + return { + name, + symbol, + uri, + updateAuthority: updateAuthority ?? null, + }; +} + +function encodeCreateMintInstructionData( + params: EncodeCreateMintInstructionParams, +): Buffer { + const [splMintPda] = findMintAddress(params.mintSigner); + const compressedAddress = deriveAddressV2( + splMintPda.toBytes(), + params.addressTree, + CTOKEN_PROGRAM_ID, + ); + + // Build extensions if metadata present + let extensions: { tokenMetadata: TokenMetadataBorshData }[] | null = null; + if (params.metadata) { + extensions = [ + { + tokenMetadata: { + updateAuthority: params.metadata.updateAuthority ?? null, + name: Buffer.from(params.metadata.name), + symbol: Buffer.from(params.metadata.symbol), + uri: Buffer.from(params.metadata.uri), + additionalMetadata: null, + }, + }, + ]; + } + + const instructionData: MintActionCompressedInstructionData = { + leafIndex: 0, + proveByIndex: false, + rootIndex: params.rootIndex, + compressedAddress: Array.from(compressedAddress.toBytes()), + tokenPoolBump: 0, + tokenPoolIndex: 0, + createMint: { + readOnlyAddressTrees: [0, 0, 0, 0], + readOnlyAddressTreeRootIndices: [0, 0, 0, 0], + }, + actions: [], // No actions for create mint + proof: params.proof, + cpiContext: null, + mint: { + supply: BigInt(0), + decimals: params.decimals, + metadata: { + version: TokenDataVersion.ShaFlat, + splMintInitialized: false, + mint: splMintPda, + }, + mintAuthority: params.mintAuthority, + freezeAuthority: params.freezeAuthority, + extensions, + }, + }; + + return encodeMintActionInstructionData(instructionData); +} + +// Keep old interface type for backwards compatibility export +export interface CreateMintInstructionParams { + mintSigner: PublicKey; + decimals: number; + mintAuthority: PublicKey; + freezeAuthority: PublicKey | null; + payer: PublicKey; + validityProof: ValidityProofWithContext; + metadata?: TokenMetadataInstructionData; + addressTreeInfo: AddressTreeInfo; + outputStateTreeInfo: TreeInfo; +} + +/** + * Create instruction for initializing a compressed token mint. + * + * @param mintSigner Mint signer keypair public key. + * @param decimals Number of decimals for the mint. + * @param mintAuthority Mint authority public key. + * @param freezeAuthority Optional freeze authority public key. + * @param payer Fee payer public key. + * @param validityProof Validity proof for the compressed account. + * @param addressTreeInfo Address tree info for the mint. + * @param outputStateTreeInfo Output state tree info. + * @param metadata Optional token metadata. + */ +export function createMintInstruction( + mintSigner: PublicKey, + decimals: number, + mintAuthority: PublicKey, + freezeAuthority: PublicKey | null, + payer: PublicKey, + validityProof: ValidityProofWithContext, + addressTreeInfo: AddressTreeInfo, + outputStateTreeInfo: TreeInfo, + metadata?: TokenMetadataInstructionData, +): TransactionInstruction { + const data = encodeCreateMintInstructionData({ + mintSigner, + mintAuthority, + freezeAuthority, + decimals, + addressTree: addressTreeInfo.tree, + outputQueue: outputStateTreeInfo.queue, + rootIndex: validityProof.rootIndices[0], + proof: validityProof.compressedProof, + metadata, + }); + + const sys = defaultStaticAccountsStruct(); + const keys = [ + { + pubkey: LightSystemProgram.programId, + isSigner: false, + isWritable: false, + }, + { pubkey: mintSigner, isSigner: true, isWritable: false }, + { pubkey: mintAuthority, isSigner: true, isWritable: false }, + { pubkey: payer, isSigner: true, isWritable: true }, + { + pubkey: CompressedTokenProgram.deriveCpiAuthorityPda, + isSigner: false, + isWritable: false, + }, + { + pubkey: sys.registeredProgramPda, + isSigner: false, + isWritable: false, + }, + { + pubkey: sys.accountCompressionAuthority, + isSigner: false, + isWritable: false, + }, + { + pubkey: sys.accountCompressionProgram, + isSigner: false, + isWritable: false, + }, + { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, + { + pubkey: outputStateTreeInfo.queue, + isSigner: false, + isWritable: true, + }, + { + pubkey: addressTreeInfo.tree, + isSigner: false, + isWritable: true, + }, + ]; + + return new TransactionInstruction({ + programId: CTOKEN_PROGRAM_ID, + keys, + data, + }); +} diff --git a/js/compressed-token/src/mint/instructions/decompress2.ts b/js/compressed-token/src/mint/instructions/decompress2.ts new file mode 100644 index 0000000000..7eaf628984 --- /dev/null +++ b/js/compressed-token/src/mint/instructions/decompress2.ts @@ -0,0 +1,255 @@ +import { + PublicKey, + TransactionInstruction, + SystemProgram, +} from '@solana/web3.js'; +import { + CTOKEN_PROGRAM_ID, + LightSystemProgram, + defaultStaticAccountsStruct, + ParsedTokenAccount, + bn, + CompressedProof, +} from '@lightprotocol/stateless.js'; +import { CompressedTokenProgram } from '../../program'; +import { + encodeTransfer2InstructionData, + Transfer2InstructionData, + MultiInputTokenDataWithContext, + COMPRESSION_MODE_DECOMPRESS, + Compression, +} from '../../layout-transfer2'; +import { TokenDataVersion } from '../../constants'; + +/** + * Build input token data for Transfer2 from parsed token accounts + */ +function buildInputTokenData( + accounts: ParsedTokenAccount[], + rootIndices: number[], + packedAccountIndices: Map, +): MultiInputTokenDataWithContext[] { + return accounts.map((acc, i) => { + const ownerKey = acc.compressedAccount.owner.toBase58(); + const mintKey = acc.parsed.mint.toBase58(); + + return { + owner: packedAccountIndices.get(ownerKey)!, + amount: BigInt(acc.parsed.amount.toString()), + hasDelegate: acc.parsed.delegate !== null, + delegate: acc.parsed.delegate + ? (packedAccountIndices.get(acc.parsed.delegate.toBase58()) ?? + 0) + : 0, + mint: packedAccountIndices.get(mintKey)!, + version: TokenDataVersion.ShaFlat, + merkleContext: { + merkleTreePubkeyIndex: packedAccountIndices.get( + acc.compressedAccount.treeInfo.tree.toBase58(), + )!, + queuePubkeyIndex: packedAccountIndices.get( + acc.compressedAccount.treeInfo.queue.toBase58(), + )!, + leafIndex: acc.compressedAccount.leafIndex, + proveByIndex: acc.compressedAccount.proveByIndex, + }, + rootIndex: rootIndices[i], + }; + }); +} + +/** + * Create decompress2 instruction using Transfer2. + * + * This decompresses compressed tokens to a CToken account using the unified + * Transfer2 instruction. It's more efficient than the old decompress as it + * doesn't require SPL token pool operations for CToken destinations. + * + * @param payer Fee payer public key + * @param inputCompressedTokenAccounts Input compressed token accounts + * @param toAddress Destination CToken account address + * @param amount Amount to decompress + * @param proof Validity proof (null if all accounts are proveByIndex) + * @param rootIndices Root indices for each input account + * @returns TransactionInstruction + */ +export function createDecompress2Instruction( + payer: PublicKey, + inputCompressedTokenAccounts: ParsedTokenAccount[], + toAddress: PublicKey, + amount: bigint, + proof: CompressedProof | null, + rootIndices: number[], +): TransactionInstruction { + if (inputCompressedTokenAccounts.length === 0) { + throw new Error('No input compressed token accounts provided'); + } + + const mint = inputCompressedTokenAccounts[0].parsed.mint; + const owner = inputCompressedTokenAccounts[0].compressedAccount.owner; + + // Build packed accounts map + // Order: trees/queues first, then mint, owner, CToken account, CToken program + const packedAccountIndices = new Map(); + const packedAccounts: PublicKey[] = []; + + // Collect unique trees and queues + const treeSet = new Set(); + const queueSet = new Set(); + for (const acc of inputCompressedTokenAccounts) { + treeSet.add(acc.compressedAccount.treeInfo.tree.toBase58()); + queueSet.add(acc.compressedAccount.treeInfo.queue.toBase58()); + } + + // Add trees first (owned by account compression program) + for (const tree of treeSet) { + packedAccountIndices.set(tree, packedAccounts.length); + packedAccounts.push(new PublicKey(tree)); + } + + // Add queues + for (const queue of queueSet) { + packedAccountIndices.set(queue, packedAccounts.length); + packedAccounts.push(new PublicKey(queue)); + } + + // Add mint + const mintIndex = packedAccounts.length; + packedAccountIndices.set(mint.toBase58(), mintIndex); + packedAccounts.push(mint); + + // Add owner + const ownerIndex = packedAccounts.length; + packedAccountIndices.set(owner.toBase58(), ownerIndex); + packedAccounts.push(owner); + + // Add destination CToken account + const destinationIndex = packedAccounts.length; + packedAccountIndices.set(toAddress.toBase58(), destinationIndex); + packedAccounts.push(toAddress); + + // Add CToken program (for decompress to CToken) + const ctokenProgramIndex = packedAccounts.length; + packedAccounts.push(CTOKEN_PROGRAM_ID); + + // Build input token data + const inTokenData = buildInputTokenData( + inputCompressedTokenAccounts, + rootIndices, + packedAccountIndices, + ); + + // Build decompress compression + const compressions: Compression[] = [ + { + mode: COMPRESSION_MODE_DECOMPRESS, + amount, + mint: mintIndex, + sourceOrRecipient: destinationIndex, + authority: 0, // Not needed for decompress + poolAccountIndex: ctokenProgramIndex, // CToken program + poolIndex: 0, + bump: 0, + }, + ]; + + // Build Transfer2 instruction data + const instructionData: Transfer2InstructionData = { + withTransactionHash: false, + withLamportsChangeAccountMerkleTreeIndex: false, + lamportsChangeAccountMerkleTreeIndex: 0, + lamportsChangeAccountOwnerIndex: 0, + outputQueue: 0, // First queue in packed accounts + cpiContext: null, + compressions, + proof: proof + ? { + a: Array.from(proof.a), + b: Array.from(proof.b), + c: Array.from(proof.c), + } + : null, + inTokenData, + outTokenData: [], // No compressed outputs + inLamports: null, + outLamports: null, + inTlv: null, + outTlv: null, + }; + + const data = encodeTransfer2InstructionData(instructionData); + + // Build accounts for Transfer2 with compressed accounts (full path) + const { + accountCompressionAuthority, + noopProgram, + registeredProgramPda, + accountCompressionProgram, + } = defaultStaticAccountsStruct(); + + const keys = [ + // 0: light_system_program (non-mutable) + { + pubkey: LightSystemProgram.programId, + isSigner: false, + isWritable: false, + }, + // 1: fee_payer (signer, mutable) + { pubkey: payer, isSigner: true, isWritable: true }, + // 2: authority (signer) + { pubkey: owner, isSigner: true, isWritable: false }, + // 3: cpi_authority_pda + { + pubkey: CompressedTokenProgram.deriveCpiAuthorityPda, + isSigner: false, + isWritable: false, + }, + // 4: registered_program_pda + { + pubkey: registeredProgramPda, + isSigner: false, + isWritable: false, + }, + // 5: account_compression_authority + { + pubkey: accountCompressionAuthority, + isSigner: false, + isWritable: false, + }, + // 6: account_compression_program + { + pubkey: accountCompressionProgram, + isSigner: false, + isWritable: false, + }, + // 7: system_program + { + pubkey: SystemProgram.programId, + isSigner: false, + isWritable: false, + }, + // 8: noop_program (for logging) + { + pubkey: noopProgram, + isSigner: false, + isWritable: false, + }, + // Packed accounts (trees/queues come first, identified by ownership) + ...packedAccounts.map((pubkey, i) => { + // Trees and destination CToken account need to be writable + const isTreeOrQueue = i < treeSet.size + queueSet.size; + const isDestination = pubkey.equals(toAddress); + return { + pubkey, + isSigner: false, + isWritable: isTreeOrQueue || isDestination, + }; + }), + ]; + + return new TransactionInstruction({ + programId: CompressedTokenProgram.programId, + keys, + data, + }); +} diff --git a/js/compressed-token/src/mint/instructions/index.ts b/js/compressed-token/src/mint/instructions/index.ts new file mode 100644 index 0000000000..6372bf6677 --- /dev/null +++ b/js/compressed-token/src/mint/instructions/index.ts @@ -0,0 +1,10 @@ +export * from './create-mint'; +export * from './update-mint'; +export * from './update-metadata'; +export * from './create-associated-ctoken'; +export * from './mint-to'; +export * from './mint-to-compressed'; +export * from './mint-to-interface'; +export * from './transfer-interface'; +export * from './decompress2'; +export * from './wrap'; diff --git a/js/compressed-token/src/mint/instructions/mint-action-layout.ts b/js/compressed-token/src/mint/instructions/mint-action-layout.ts new file mode 100644 index 0000000000..09ec15f346 --- /dev/null +++ b/js/compressed-token/src/mint/instructions/mint-action-layout.ts @@ -0,0 +1,348 @@ +/** + * Borsh layouts for MintAction instruction data + * + * These layouts match the Rust structs in: + * program-libs/ctoken-types/src/instructions/mint_action/ + * + * @module mint-action-layout + */ +import { PublicKey } from '@solana/web3.js'; +import { Buffer } from 'buffer'; +import { + struct, + option, + vec, + bool, + u8, + u16, + u32, + u64, + array, + vecU8, + publicKey, + rustEnum, +} from '@coral-xyz/borsh'; +import { bn } from '@lightprotocol/stateless.js'; + +export const MINT_ACTION_DISCRIMINATOR = Buffer.from([103]); + +export const RecipientLayout = struct([publicKey('recipient'), u64('amount')]); + +export const MintToCompressedActionLayout = struct([ + u8('tokenAccountVersion'), + vec(RecipientLayout, 'recipients'), +]); + +export const UpdateAuthorityLayout = struct([ + option(publicKey(), 'newAuthority'), +]); + +export const CreateSplMintActionLayout = struct([u8('mintBump')]); + +export const MintToCTokenActionLayout = struct([ + u8('accountIndex'), + u64('amount'), +]); + +export const UpdateMetadataFieldActionLayout = struct([ + u8('extensionIndex'), + u8('fieldType'), + vecU8('key'), + vecU8('value'), +]); + +export const UpdateMetadataAuthorityActionLayout = struct([ + u8('extensionIndex'), + publicKey('newAuthority'), +]); + +export const RemoveMetadataKeyActionLayout = struct([ + u8('extensionIndex'), + vecU8('key'), + u8('idempotent'), +]); + +export const ActionLayout = rustEnum([ + MintToCompressedActionLayout.replicate('mintToCompressed'), + UpdateAuthorityLayout.replicate('updateMintAuthority'), + UpdateAuthorityLayout.replicate('updateFreezeAuthority'), + CreateSplMintActionLayout.replicate('createSplMint'), + MintToCTokenActionLayout.replicate('mintToCToken'), + UpdateMetadataFieldActionLayout.replicate('updateMetadataField'), + UpdateMetadataAuthorityActionLayout.replicate('updateMetadataAuthority'), + RemoveMetadataKeyActionLayout.replicate('removeMetadataKey'), +]); + +export const CompressedProofLayout = struct([ + array(u8(), 32, 'a'), + array(u8(), 64, 'b'), + array(u8(), 32, 'c'), +]); + +export const CpiContextLayout = struct([ + bool('setContext'), + bool('firstSetContext'), + u8('inTreeIndex'), + u8('inQueueIndex'), + u8('outQueueIndex'), + u8('tokenOutQueueIndex'), + u8('assignedAccountIndex'), + array(u8(), 4, 'readOnlyAddressTrees'), + array(u8(), 32, 'addressTreePubkey'), +]); + +export const CreateMintLayout = struct([ + array(u8(), 4, 'readOnlyAddressTrees'), + array(u16(), 4, 'readOnlyAddressTreeRootIndices'), +]); + +export const AdditionalMetadataLayout = struct([vecU8('key'), vecU8('value')]); + +export const TokenMetadataInstructionDataLayout = struct([ + option(publicKey(), 'updateAuthority'), + vecU8('name'), + vecU8('symbol'), + vecU8('uri'), + option(vec(AdditionalMetadataLayout), 'additionalMetadata'), +]); + +const PlaceholderLayout = struct([]); + +export const ExtensionInstructionDataLayout = rustEnum([ + PlaceholderLayout.replicate('placeholder0'), + PlaceholderLayout.replicate('placeholder1'), + PlaceholderLayout.replicate('placeholder2'), + PlaceholderLayout.replicate('placeholder3'), + PlaceholderLayout.replicate('placeholder4'), + PlaceholderLayout.replicate('placeholder5'), + PlaceholderLayout.replicate('placeholder6'), + PlaceholderLayout.replicate('placeholder7'), + PlaceholderLayout.replicate('placeholder8'), + PlaceholderLayout.replicate('placeholder9'), + PlaceholderLayout.replicate('placeholder10'), + PlaceholderLayout.replicate('placeholder11'), + PlaceholderLayout.replicate('placeholder12'), + PlaceholderLayout.replicate('placeholder13'), + PlaceholderLayout.replicate('placeholder14'), + PlaceholderLayout.replicate('placeholder15'), + PlaceholderLayout.replicate('placeholder16'), + PlaceholderLayout.replicate('placeholder17'), + PlaceholderLayout.replicate('placeholder18'), + TokenMetadataInstructionDataLayout.replicate('tokenMetadata'), +]); + +export const CompressedMintMetadataLayout = struct([ + u8('version'), + bool('splMintInitialized'), + publicKey('mint'), +]); + +export const CompressedMintInstructionDataLayout = struct([ + u64('supply'), + u8('decimals'), + CompressedMintMetadataLayout.replicate('metadata'), + option(publicKey(), 'mintAuthority'), + option(publicKey(), 'freezeAuthority'), + option(vec(ExtensionInstructionDataLayout), 'extensions'), +]); + +export const MintActionCompressedInstructionDataLayout = struct([ + u32('leafIndex'), + bool('proveByIndex'), + u16('rootIndex'), + array(u8(), 32, 'compressedAddress'), + u8('tokenPoolBump'), + u8('tokenPoolIndex'), + option(CreateMintLayout, 'createMint'), + vec(ActionLayout, 'actions'), + option(CompressedProofLayout, 'proof'), + option(CpiContextLayout, 'cpiContext'), + CompressedMintInstructionDataLayout.replicate('mint'), +]); + +export interface ValidityProof { + a: number[]; + b: number[]; + c: number[]; +} + +export interface Recipient { + recipient: PublicKey; + amount: bigint; +} + +export interface MintToCompressedAction { + tokenAccountVersion: number; + recipients: Recipient[]; +} + +export interface UpdateAuthority { + newAuthority: PublicKey | null; +} + +export interface CreateSplMintAction { + mintBump: number; +} + +export interface MintToCTokenAction { + accountIndex: number; + amount: bigint; +} + +export interface UpdateMetadataFieldAction { + extensionIndex: number; + fieldType: number; + key: Buffer; + value: Buffer; +} + +export interface UpdateMetadataAuthorityAction { + extensionIndex: number; + newAuthority: PublicKey; +} + +export interface RemoveMetadataKeyAction { + extensionIndex: number; + key: Buffer; + idempotent: number; +} + +export type Action = + | { mintToCompressed: MintToCompressedAction } + | { updateMintAuthority: UpdateAuthority } + | { updateFreezeAuthority: UpdateAuthority } + | { createSplMint: CreateSplMintAction } + | { mintToCToken: MintToCTokenAction } + | { updateMetadataField: UpdateMetadataFieldAction } + | { updateMetadataAuthority: UpdateMetadataAuthorityAction } + | { removeMetadataKey: RemoveMetadataKeyAction }; + +export interface CpiContext { + setContext: boolean; + firstSetContext: boolean; + inTreeIndex: number; + inQueueIndex: number; + outQueueIndex: number; + tokenOutQueueIndex: number; + assignedAccountIndex: number; + readOnlyAddressTrees: number[]; + addressTreePubkey: number[]; +} + +export interface CreateMint { + readOnlyAddressTrees: number[]; + readOnlyAddressTreeRootIndices: number[]; +} + +export interface AdditionalMetadata { + key: Buffer; + value: Buffer; +} + +export interface TokenMetadataInstructionData { + updateAuthority: PublicKey | null; + name: Buffer; + symbol: Buffer; + uri: Buffer; + additionalMetadata: AdditionalMetadata[] | null; +} + +export type ExtensionInstructionData = { + tokenMetadata: TokenMetadataInstructionData; +}; + +export interface CompressedMintMetadata { + version: number; + splMintInitialized: boolean; + mint: PublicKey; +} + +export interface CompressedMintInstructionData { + supply: bigint; + decimals: number; + metadata: CompressedMintMetadata; + mintAuthority: PublicKey | null; + freezeAuthority: PublicKey | null; + extensions: ExtensionInstructionData[] | null; +} + +export interface MintActionCompressedInstructionData { + leafIndex: number; + proveByIndex: boolean; + rootIndex: number; + compressedAddress: number[]; + tokenPoolBump: number; + tokenPoolIndex: number; + createMint: CreateMint | null; + actions: Action[]; + proof: ValidityProof | null; + cpiContext: CpiContext | null; + mint: CompressedMintInstructionData; +} + +/** + * Encode MintActionCompressedInstructionData to buffer + * + * @param data - The instruction data to encode + * @returns Encoded buffer with discriminator prepended + */ +export function encodeMintActionInstructionData( + data: MintActionCompressedInstructionData, +): Buffer { + // Convert bigint fields to BN for Borsh encoding + const encodableData = { + ...data, + mint: { + ...data.mint, + supply: bn(data.mint.supply.toString()), + }, + actions: data.actions.map(action => { + // Handle MintToCompressed action with recipients + if ('mintToCompressed' in action && action.mintToCompressed) { + return { + mintToCompressed: { + ...action.mintToCompressed, + recipients: action.mintToCompressed.recipients.map( + r => ({ + ...r, + amount: bn(r.amount.toString()), + }), + ), + }, + }; + } + // Handle MintToCToken action + if ('mintToCToken' in action && action.mintToCToken) { + return { + mintToCToken: { + ...action.mintToCToken, + amount: bn(action.mintToCToken.amount.toString()), + }, + }; + } + return action; + }), + }; + + const buffer = Buffer.alloc(10000); // Generous allocation + const len = MintActionCompressedInstructionDataLayout.encode( + encodableData, + buffer, + ); + + return Buffer.concat([MINT_ACTION_DISCRIMINATOR, buffer.subarray(0, len)]); +} + +/** + * Decode MintActionCompressedInstructionData from buffer + * + * @param buffer - The buffer to decode (including discriminator) + * @returns Decoded instruction data + */ +export function decodeMintActionInstructionData( + buffer: Buffer, +): MintActionCompressedInstructionData { + return MintActionCompressedInstructionDataLayout.decode( + buffer.subarray(MINT_ACTION_DISCRIMINATOR.length), + ) as MintActionCompressedInstructionData; +} diff --git a/js/compressed-token/src/mint/instructions/mint-to-compressed.ts b/js/compressed-token/src/mint/instructions/mint-to-compressed.ts new file mode 100644 index 0000000000..20dcf6353d --- /dev/null +++ b/js/compressed-token/src/mint/instructions/mint-to-compressed.ts @@ -0,0 +1,186 @@ +import { + PublicKey, + SystemProgram, + TransactionInstruction, +} from '@solana/web3.js'; +import { Buffer } from 'buffer'; +import { + ValidityProofWithContext, + CTOKEN_PROGRAM_ID, + LightSystemProgram, + defaultStaticAccountsStruct, + deriveAddressV2, + getDefaultAddressTreeInfo, + MerkleContext, +} from '@lightprotocol/stateless.js'; +import { CompressedTokenProgram } from '../../program'; +import { MintInstructionData } from '../serde'; +import { + encodeMintActionInstructionData, + MintActionCompressedInstructionData, +} from './mint-action-layout'; +import { TokenDataVersion } from '../../constants'; + +interface EncodeCompressedMintToInstructionParams { + addressTree: PublicKey; + leafIndex: number; + rootIndex: number; + proof: { a: number[]; b: number[]; c: number[] } | null; + mintData: MintInstructionData; + recipients: Array<{ recipient: PublicKey; amount: number | bigint }>; + tokenAccountVersion: number; +} + +function encodeCompressedMintToInstructionData( + params: EncodeCompressedMintToInstructionParams, +): Buffer { + const compressedAddress = deriveAddressV2( + params.mintData.splMint.toBytes(), + params.addressTree, + CTOKEN_PROGRAM_ID, + ); + + // TokenMetadata extension not supported in mintTo instruction + if (params.mintData.metadata) { + throw new Error( + 'TokenMetadata extension not supported in mintTo instruction', + ); + } + + const instructionData: MintActionCompressedInstructionData = { + leafIndex: params.leafIndex, + proveByIndex: true, + rootIndex: params.rootIndex, + compressedAddress: Array.from(compressedAddress.toBytes()), + tokenPoolBump: 0, + tokenPoolIndex: 0, + createMint: null, + actions: [ + { + mintToCompressed: { + tokenAccountVersion: params.tokenAccountVersion, + recipients: params.recipients.map(r => ({ + recipient: r.recipient, + amount: BigInt(r.amount.toString()), + })), + }, + }, + ], + proof: params.proof, + cpiContext: null, + mint: { + supply: params.mintData.supply, + decimals: params.mintData.decimals, + metadata: { + version: params.mintData.version, + splMintInitialized: params.mintData.splMintInitialized, + mint: params.mintData.splMint, + }, + mintAuthority: params.mintData.mintAuthority, + freezeAuthority: params.mintData.freezeAuthority, + extensions: null, + }, + }; + + return encodeMintActionInstructionData(instructionData); +} + +// Keep old interface type for backwards compatibility export +export interface CreateMintToCompressedInstructionParams { + mintSigner: PublicKey; + authority: PublicKey; + payer: PublicKey; + validityProof: ValidityProofWithContext; + merkleContext: MerkleContext; + mintData: MintInstructionData; + outputQueue: PublicKey; + tokensOutQueue: PublicKey; + recipients: Array<{ recipient: PublicKey; amount: number | bigint }>; + tokenAccountVersion?: TokenDataVersion; +} + +/** + * Create instruction for minting compressed tokens to compressed accounts. + * + * @param authority Mint authority public key. + * @param payer Fee payer public key. + * @param validityProof Validity proof for the compressed mint. + * @param merkleContext Merkle context of the compressed mint. + * @param mintData Mint instruction data. + * @param outputQueue Output queue for state changes. + * @param tokensOutQueue Queue for token outputs. + * @param recipients Array of recipients with amounts. + * @param tokenAccountVersion Token account version (default: TokenDataVersion.ShaFlat). + */ +export function createMintToCompressedInstruction( + authority: PublicKey, + payer: PublicKey, + validityProof: ValidityProofWithContext, + merkleContext: MerkleContext, + mintData: MintInstructionData, + outputQueue: PublicKey, + tokensOutQueue: PublicKey, + recipients: Array<{ recipient: PublicKey; amount: number | bigint }>, + tokenAccountVersion: TokenDataVersion = TokenDataVersion.ShaFlat, +): TransactionInstruction { + const addressTreeInfo = getDefaultAddressTreeInfo(); + const data = encodeCompressedMintToInstructionData({ + addressTree: addressTreeInfo.tree, + leafIndex: merkleContext.leafIndex, + rootIndex: validityProof.rootIndices[0], + proof: validityProof.compressedProof, + mintData, + recipients, + tokenAccountVersion, + }); + + const sys = defaultStaticAccountsStruct(); + const keys = [ + { + pubkey: LightSystemProgram.programId, + isSigner: false, + isWritable: false, + }, + { pubkey: authority, isSigner: true, isWritable: false }, + { pubkey: payer, isSigner: true, isWritable: true }, + { + pubkey: CompressedTokenProgram.deriveCpiAuthorityPda, + isSigner: false, + isWritable: false, + }, + { + pubkey: sys.registeredProgramPda, + isSigner: false, + isWritable: false, + }, + { + pubkey: sys.accountCompressionAuthority, + isSigner: false, + isWritable: false, + }, + { + pubkey: sys.accountCompressionProgram, + isSigner: false, + isWritable: false, + }, + { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, + { pubkey: outputQueue, isSigner: false, isWritable: true }, + { + pubkey: merkleContext.treeInfo.tree, + isSigner: false, + isWritable: true, + }, + { + pubkey: merkleContext.treeInfo.queue, + isSigner: false, + isWritable: true, + }, + { pubkey: tokensOutQueue, isSigner: false, isWritable: true }, + ]; + + return new TransactionInstruction({ + programId: CTOKEN_PROGRAM_ID, + keys, + data, + }); +} diff --git a/js/compressed-token/src/mint/instructions/mint-to-interface.ts b/js/compressed-token/src/mint/instructions/mint-to-interface.ts new file mode 100644 index 0000000000..c155634cb7 --- /dev/null +++ b/js/compressed-token/src/mint/instructions/mint-to-interface.ts @@ -0,0 +1,99 @@ +import { PublicKey, TransactionInstruction } from '@solana/web3.js'; +import { ValidityProofWithContext } from '@lightprotocol/stateless.js'; +import { createMintToInstruction as createSplMintToInstruction } from '@solana/spl-token'; +import { createMintToInstruction as createCtokenMintToInstruction } from './mint-to'; +import { MintInterface } from '../helpers'; + +// Keep old interface type for backwards compatibility export +export interface CreateMintToInterfaceInstructionParams { + mintInterface: MintInterface; + destination: PublicKey; + authority: PublicKey; + payer: PublicKey; + amount: number | bigint; + validityProof?: ValidityProofWithContext; + multiSigners?: PublicKey[]; +} + +/** + * Create mint-to instruction for SPL, Token-2022, or compressed token mints. + * This instruction ONLY mints to decompressed/onchain token accounts. + * + * @param mintInterface Mint interface (SPL, Token-2022, or compressed). + * @param destination Destination onchain token account address. + * @param authority Mint authority public key. + * @param payer Fee payer public key. + * @param amount Amount to mint. + * @param validityProof Validity proof (required for compressed mints). + * @param multiSigners Multi-signature signer public keys. + */ +export function createMintToInterfaceInstruction( + mintInterface: MintInterface, + destination: PublicKey, + authority: PublicKey, + payer: PublicKey, + amount: number | bigint, + validityProof?: ValidityProofWithContext, + multiSigners: PublicKey[] = [], +): TransactionInstruction { + const mint = mintInterface.mint.address; + const programId = mintInterface.programId; + + // For SPL and Token-2022 mints (no merkleContext) + if (!mintInterface.merkleContext) { + return createSplMintToInstruction( + mint, + destination, + authority, + BigInt(amount.toString()), + multiSigners, + programId, + ); + } + + // For compressed mints (has merkleContext) - mint to decompressed CToken account + if (!validityProof) { + throw new Error( + 'Validity proof required for compressed mint operations', + ); + } + + if (!mintInterface.mintContext) { + throw new Error('mintContext required for compressed mint operations'); + } + + // ensure we rollover if needed. + const outputStateTreeInfo = + mintInterface.merkleContext.treeInfo.nextTreeInfo ?? + mintInterface.merkleContext.treeInfo; + + const mintData = { + supply: mintInterface.mint.supply, + decimals: mintInterface.mint.decimals, + mintAuthority: mintInterface.mint.mintAuthority, + freezeAuthority: mintInterface.mint.freezeAuthority, + splMint: mintInterface.mintContext.splMint, + splMintInitialized: mintInterface.mintContext.splMintInitialized, + version: mintInterface.mintContext.version, + metadata: mintInterface.tokenMetadata + ? { + updateAuthority: + mintInterface.tokenMetadata.updateAuthority || null, + name: mintInterface.tokenMetadata.name, + symbol: mintInterface.tokenMetadata.symbol, + uri: mintInterface.tokenMetadata.uri, + } + : undefined, + }; + + return createCtokenMintToInstruction( + authority, + payer, + validityProof, + mintInterface.merkleContext, + mintData, + outputStateTreeInfo, + destination, + amount, + ); +} diff --git a/js/compressed-token/src/mint/instructions/mint-to.ts b/js/compressed-token/src/mint/instructions/mint-to.ts new file mode 100644 index 0000000000..3d0f8eff33 --- /dev/null +++ b/js/compressed-token/src/mint/instructions/mint-to.ts @@ -0,0 +1,189 @@ +import { + PublicKey, + SystemProgram, + TransactionInstruction, +} from '@solana/web3.js'; +import { Buffer } from 'buffer'; +import { + ValidityProofWithContext, + CTOKEN_PROGRAM_ID, + LightSystemProgram, + defaultStaticAccountsStruct, + deriveAddressV2, + getDefaultAddressTreeInfo, + MerkleContext, + TreeInfo, +} from '@lightprotocol/stateless.js'; +import { CompressedTokenProgram } from '../../program'; +import { MintInstructionData } from '../serde'; +import { + encodeMintActionInstructionData, + MintActionCompressedInstructionData, +} from './mint-action-layout'; + +interface EncodeMintToCTokenInstructionParams { + addressTree: PublicKey; + leafIndex: number; + rootIndex: number; + proof: { a: number[]; b: number[]; c: number[] } | null; + mintData: MintInstructionData; + recipientAccountIndex: number; + amount: number | bigint; +} + +function encodeMintToCTokenInstructionData( + params: EncodeMintToCTokenInstructionParams, +): Buffer { + const compressedAddress = deriveAddressV2( + params.mintData.splMint.toBytes(), + params.addressTree, + CTOKEN_PROGRAM_ID, + ); + + // TokenMetadata extension not supported in mintTo instruction + if (params.mintData.metadata) { + throw new Error( + 'TokenMetadata extension not supported in mintTo instruction', + ); + } + + const instructionData: MintActionCompressedInstructionData = { + leafIndex: params.leafIndex, + proveByIndex: true, + rootIndex: params.rootIndex, + compressedAddress: Array.from(compressedAddress.toBytes()), + tokenPoolBump: 0, + tokenPoolIndex: 0, + createMint: null, + actions: [ + { + mintToCToken: { + accountIndex: params.recipientAccountIndex, + amount: BigInt(params.amount.toString()), + }, + }, + ], + proof: params.proof, + cpiContext: null, + mint: { + supply: params.mintData.supply, + decimals: params.mintData.decimals, + metadata: { + version: params.mintData.version, + splMintInitialized: params.mintData.splMintInitialized, + mint: params.mintData.splMint, + }, + mintAuthority: params.mintData.mintAuthority, + freezeAuthority: params.mintData.freezeAuthority, + extensions: null, + }, + }; + + return encodeMintActionInstructionData(instructionData); +} + +// Keep old interface type for backwards compatibility export +export interface CreateMintToInstructionParams { + mintSigner: PublicKey; + authority: PublicKey; + payer: PublicKey; + validityProof: ValidityProofWithContext; + merkleContext: MerkleContext; + mintData: MintInstructionData; + outputStateTreeInfo: TreeInfo; + tokensOutQueue: PublicKey; + recipientAccount: PublicKey; + amount: number | bigint; +} + +/** + * Create instruction for minting compressed tokens to an onchain token account. + * + * @param authority Mint authority public key. + * @param payer Fee payer public key. + * @param validityProof Validity proof for the compressed mint. + * @param merkleContext Merkle context of the compressed mint. + * @param mintData Mint instruction data. + * @param outputStateTreeInfo Output state tree info. + * @param recipientAccount Recipient onchain token account address. + * @param amount Amount to mint. + */ +export function createMintToInstruction( + authority: PublicKey, + payer: PublicKey, + validityProof: ValidityProofWithContext, + merkleContext: MerkleContext, + mintData: MintInstructionData, + outputStateTreeInfo: TreeInfo, + recipientAccount: PublicKey, + amount: number | bigint, +): TransactionInstruction { + const addressTreeInfo = getDefaultAddressTreeInfo(); + const data = encodeMintToCTokenInstructionData({ + addressTree: addressTreeInfo.tree, + leafIndex: merkleContext.leafIndex, + rootIndex: validityProof.rootIndices[0], + proof: validityProof.compressedProof, + mintData, + recipientAccountIndex: 0, + amount, + }); + + const sys = defaultStaticAccountsStruct(); + const keys = [ + { + pubkey: LightSystemProgram.programId, + isSigner: false, + isWritable: false, + }, + { pubkey: authority, isSigner: true, isWritable: false }, + { pubkey: payer, isSigner: true, isWritable: true }, + { + pubkey: CompressedTokenProgram.deriveCpiAuthorityPda, + isSigner: false, + isWritable: false, + }, + { + pubkey: sys.registeredProgramPda, + isSigner: false, + isWritable: false, + }, + { + pubkey: sys.accountCompressionAuthority, + isSigner: false, + isWritable: false, + }, + { + pubkey: sys.accountCompressionProgram, + isSigner: false, + isWritable: false, + }, + { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, + { + pubkey: outputStateTreeInfo.queue, + isSigner: false, + isWritable: true, + }, + { + pubkey: merkleContext.treeInfo.tree, + isSigner: false, + isWritable: true, + }, + { + pubkey: merkleContext.treeInfo.queue, + isSigner: false, + isWritable: true, + }, + // Note: tokensOutQueue is NOT included for MintToCToken-only actions. + // MintToCToken mints to existing decompressed accounts, doesn't create + // new compressed outputs so Rust expects no tokens_out_queue account. + ]; + + keys.push({ pubkey: recipientAccount, isSigner: false, isWritable: true }); + + return new TransactionInstruction({ + programId: CTOKEN_PROGRAM_ID, + keys, + data, + }); +} diff --git a/js/compressed-token/src/mint/instructions/transfer-interface.ts b/js/compressed-token/src/mint/instructions/transfer-interface.ts new file mode 100644 index 0000000000..ecf89110f7 --- /dev/null +++ b/js/compressed-token/src/mint/instructions/transfer-interface.ts @@ -0,0 +1,150 @@ +import { PublicKey, Signer, TransactionInstruction } from '@solana/web3.js'; +import { CTOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; +import { + TOKEN_2022_PROGRAM_ID, + TOKEN_PROGRAM_ID, + createTransferInstruction as createSplTransferInstruction, +} from '@solana/spl-token'; + +/** + * CToken Transfer discriminator (matches InstructionType::CTokenTransfer = 3) + */ +const CTOKEN_TRANSFER_DISCRIMINATOR = 3; + +/** + * Create a CToken transfer instruction for hot (on-chain) accounts. + * Uses CTokenTransfer instruction (discriminator 3) which wraps SPL Token transfer. + * + * Accounts: + * 1. source (mutable) - Source CToken account + * 2. destination (mutable) - Destination CToken account + * 3. authority (signer) - Owner of source account + * 4. payer (optional, signer, mutable) - For compressible extension top-up + * + * @param source Source CToken account + * @param destination Destination CToken account + * @param owner Owner of the source account (signer) + * @param amount Amount to transfer + * @param payer Optional payer for compressible extension top-up + * @returns TransactionInstruction for CToken transfer + */ +export function createCTokenTransferInstruction( + source: PublicKey, + destination: PublicKey, + owner: PublicKey, + amount: number | bigint, + payer?: PublicKey, +): TransactionInstruction { + // Instruction data format (from CTOKEN_TRANSFER.md): + // byte 0: discriminator (3) + // byte 1: padding (0) + // bytes 2-9: amount (u64 LE) - SPL TokenInstruction::Transfer format + const data = Buffer.alloc(10); + data.writeUInt8(CTOKEN_TRANSFER_DISCRIMINATOR, 0); + data.writeUInt8(0, 1); // padding + data.writeBigUInt64LE(BigInt(amount), 2); + + const keys = [ + { pubkey: source, isSigner: false, isWritable: true }, + { pubkey: destination, isSigner: false, isWritable: true }, + { pubkey: owner, isSigner: true, isWritable: false }, + ]; + + // Add payer as 4th account if provided and different from owner + // (for compressible extension top-up) + if (payer && !payer.equals(owner)) { + keys.push({ pubkey: payer, isSigner: true, isWritable: true }); + } + + return new TransactionInstruction({ + programId: CTOKEN_PROGRAM_ID, + keys, + data, + }); +} + +/** + * Construct a transfer instruction for SPL Token, Token-2022, or CToken (hot accounts). + * Matches SPL Token createTransferInstruction signature exactly. + * Defaults to CToken program. + * + * Dispatches to the appropriate program based on `programId`: + * - `CTOKEN_PROGRAM_ID` -> CToken hot-to-hot transfer (default) + * - `TOKEN_PROGRAM_ID` -> SPL Token transfer + * - `TOKEN_2022_PROGRAM_ID` -> Token-2022 transfer + * + * Note: This is for on-chain (hot) token accounts only. + * For compressed (cold) token transfers, use the `transfer` action. + * For cross-program transfers (SPL <> CToken), use `wrap`/`unwrap`. + * + * @param source Source token account + * @param destination Destination token account + * @param owner Owner of the source account + * @param amount Amount to transfer + * @param multiSigners Signing accounts if `owner` is a multisig (SPL only) + * @param programId Token program ID (default: CTOKEN_PROGRAM_ID) + * @param payer Fee payer for compressible top-up (CToken only) + * + * @example + * // CToken hot transfer (default) - same signature as SPL! + * const ix = createTransferInterfaceInstruction( + * sourceCtokenAccount, + * destCtokenAccount, + * owner, + * 1000000n, + * ); + * + * @example + * // SPL Token transfer - identical call, just change programId + * import { TOKEN_PROGRAM_ID } from '@solana/spl-token'; + * const ix = createTransferInterfaceInstruction( + * sourceAta, + * destAta, + * owner, + * 1000000n, + * [], + * TOKEN_PROGRAM_ID, + * ); + */ +export function createTransferInterfaceInstruction( + source: PublicKey, + destination: PublicKey, + owner: PublicKey, + amount: number | bigint, + multiSigners: (Signer | PublicKey)[] = [], + programId: PublicKey = CTOKEN_PROGRAM_ID, + payer?: PublicKey, +): TransactionInstruction { + if (programId.equals(CTOKEN_PROGRAM_ID)) { + if (multiSigners.length > 0) { + throw new Error( + 'CToken transfer does not support multi-signers. Use a single owner.', + ); + } + return createCTokenTransferInstruction( + source, + destination, + owner, + amount, + payer, + ); + } + + if ( + programId.equals(TOKEN_PROGRAM_ID) || + programId.equals(TOKEN_2022_PROGRAM_ID) + ) { + return createSplTransferInstruction( + source, + destination, + owner, + amount, + multiSigners.map(pk => + pk instanceof PublicKey ? pk : pk.publicKey, + ), + programId, + ); + } + + throw new Error(`Unsupported program ID: ${programId.toBase58()}`); +} diff --git a/js/compressed-token/src/mint/instructions/update-metadata.ts b/js/compressed-token/src/mint/instructions/update-metadata.ts new file mode 100644 index 0000000000..1bdef7e591 --- /dev/null +++ b/js/compressed-token/src/mint/instructions/update-metadata.ts @@ -0,0 +1,385 @@ +import { + PublicKey, + SystemProgram, + TransactionInstruction, +} from '@solana/web3.js'; +import { Buffer } from 'buffer'; +import { + ValidityProofWithContext, + CTOKEN_PROGRAM_ID, + LightSystemProgram, + defaultStaticAccountsStruct, + deriveAddressV2, + getDefaultAddressTreeInfo, + MerkleContext, +} from '@lightprotocol/stateless.js'; +import { CompressedTokenProgram } from '../../program'; +import { findMintAddress } from '../../compressible/derivation'; +import { MintInstructionDataWithMetadata } from '../serde'; +import { + encodeMintActionInstructionData, + MintActionCompressedInstructionData, + Action, +} from './mint-action-layout'; + +type UpdateMetadataAction = + | { + type: 'updateField'; + extensionIndex: number; + fieldType: number; + key: string; + value: string; + } + | { + type: 'updateAuthority'; + extensionIndex: number; + newAuthority: PublicKey; + } + | { + type: 'removeKey'; + extensionIndex: number; + key: string; + idempotent: boolean; + }; + +interface EncodeUpdateMetadataInstructionParams { + mintSigner: PublicKey; + addressTree: PublicKey; + leafIndex: number; + rootIndex: number; + proof: { a: number[]; b: number[]; c: number[] } | null; + mintData: MintInstructionDataWithMetadata; + action: UpdateMetadataAction; +} + +function convertActionToBorsh(action: UpdateMetadataAction): Action { + if (action.type === 'updateField') { + return { + updateMetadataField: { + extensionIndex: action.extensionIndex, + fieldType: action.fieldType, + key: Buffer.from(action.key), + value: Buffer.from(action.value), + }, + }; + } else if (action.type === 'updateAuthority') { + return { + updateMetadataAuthority: { + extensionIndex: action.extensionIndex, + newAuthority: action.newAuthority, + }, + }; + } else { + return { + removeMetadataKey: { + extensionIndex: action.extensionIndex, + key: Buffer.from(action.key), + idempotent: action.idempotent ? 1 : 0, + }, + }; + } +} + +function encodeUpdateMetadataInstructionData( + params: EncodeUpdateMetadataInstructionParams, +): Buffer { + const [splMintPda] = findMintAddress(params.mintSigner); + const compressedAddress = deriveAddressV2( + splMintPda.toBytes(), + params.addressTree, + CTOKEN_PROGRAM_ID, + ); + + const instructionData: MintActionCompressedInstructionData = { + leafIndex: params.leafIndex, + proveByIndex: params.proof === null, + rootIndex: params.rootIndex, + compressedAddress: Array.from(compressedAddress.toBytes()), + tokenPoolBump: 0, + tokenPoolIndex: 0, + createMint: null, + actions: [convertActionToBorsh(params.action)], + proof: params.proof, + cpiContext: null, + mint: { + supply: params.mintData.supply, + decimals: params.mintData.decimals, + metadata: { + version: params.mintData.version, + splMintInitialized: params.mintData.splMintInitialized, + mint: params.mintData.splMint, + }, + mintAuthority: params.mintData.mintAuthority, + freezeAuthority: params.mintData.freezeAuthority, + extensions: [ + { + tokenMetadata: { + updateAuthority: + params.mintData.metadata.updateAuthority ?? null, + name: Buffer.from(params.mintData.metadata.name), + symbol: Buffer.from(params.mintData.metadata.symbol), + uri: Buffer.from(params.mintData.metadata.uri), + additionalMetadata: null, + }, + }, + ], + }, + }; + + return encodeMintActionInstructionData(instructionData); +} + +function createUpdateMetadataInstruction( + mintSigner: PublicKey, + authority: PublicKey, + payer: PublicKey, + validityProof: ValidityProofWithContext, + merkleContext: MerkleContext, + mintData: MintInstructionDataWithMetadata, + outputQueue: PublicKey, + action: UpdateMetadataAction, +): TransactionInstruction { + const addressTreeInfo = getDefaultAddressTreeInfo(); + const data = encodeUpdateMetadataInstructionData({ + mintSigner, + addressTree: addressTreeInfo.tree, + leafIndex: merkleContext.leafIndex, + rootIndex: validityProof.rootIndices[0], + proof: validityProof.compressedProof, + mintData, + action, + }); + + const sys = defaultStaticAccountsStruct(); + const keys = [ + { + pubkey: LightSystemProgram.programId, + isSigner: false, + isWritable: false, + }, + { pubkey: authority, isSigner: true, isWritable: false }, + { pubkey: payer, isSigner: true, isWritable: true }, + { + pubkey: CompressedTokenProgram.deriveCpiAuthorityPda, + isSigner: false, + isWritable: false, + }, + { + pubkey: sys.registeredProgramPda, + isSigner: false, + isWritable: false, + }, + { + pubkey: sys.accountCompressionAuthority, + isSigner: false, + isWritable: false, + }, + { + pubkey: sys.accountCompressionProgram, + isSigner: false, + isWritable: false, + }, + { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, + { pubkey: outputQueue, isSigner: false, isWritable: true }, + { + pubkey: merkleContext.treeInfo.tree, + isSigner: false, + isWritable: true, + }, + { + pubkey: merkleContext.treeInfo.queue, + isSigner: false, + isWritable: true, + }, + ]; + + return new TransactionInstruction({ + programId: CTOKEN_PROGRAM_ID, + keys, + data, + }); +} + +// Keep old interface type for backwards compatibility export +export interface CreateUpdateMetadataFieldInstructionParams { + mintSigner: PublicKey; + authority: PublicKey; + payer: PublicKey; + validityProof: ValidityProofWithContext; + merkleContext: MerkleContext; + mintData: MintInstructionDataWithMetadata; + outputQueue: PublicKey; + fieldType: 'name' | 'symbol' | 'uri' | 'custom'; + value: string; + customKey?: string; + extensionIndex?: number; +} + +/** + * Create instruction for updating a compressed mint's metadata field. + * + * @param mintSigner Mint signer public key. + * @param authority Metadata update authority public key. + * @param payer Fee payer public key. + * @param validityProof Validity proof for the compressed mint. + * @param merkleContext Merkle context of the compressed mint. + * @param mintData Mint instruction data with metadata. + * @param outputQueue Output queue for state changes. + * @param fieldType Field to update: 'name', 'symbol', 'uri', or 'custom'. + * @param value New value for the field. + * @param customKey Custom key name (required if fieldType is 'custom'). + * @param extensionIndex Extension index (default: 0). + */ +export function createUpdateMetadataFieldInstruction( + mintSigner: PublicKey, + authority: PublicKey, + payer: PublicKey, + validityProof: ValidityProofWithContext, + merkleContext: MerkleContext, + mintData: MintInstructionDataWithMetadata, + outputQueue: PublicKey, + fieldType: 'name' | 'symbol' | 'uri' | 'custom', + value: string, + customKey?: string, + extensionIndex: number = 0, +): TransactionInstruction { + const action: UpdateMetadataAction = { + type: 'updateField', + extensionIndex, + fieldType: + fieldType === 'name' + ? 0 + : fieldType === 'symbol' + ? 1 + : fieldType === 'uri' + ? 2 + : 3, + key: customKey || '', + value, + }; + + return createUpdateMetadataInstruction( + mintSigner, + authority, + payer, + validityProof, + merkleContext, + mintData, + outputQueue, + action, + ); +} + +// Keep old interface type for backwards compatibility export +export interface CreateUpdateMetadataAuthorityInstructionParams { + mintSigner: PublicKey; + currentAuthority: PublicKey; + newAuthority: PublicKey; + payer: PublicKey; + validityProof: ValidityProofWithContext; + merkleContext: MerkleContext; + mintData: MintInstructionDataWithMetadata; + outputQueue: PublicKey; + extensionIndex?: number; +} + +/** + * Create instruction for updating a compressed mint's metadata authority. + * + * @param mintSigner Mint signer public key. + * @param currentAuthority Current metadata update authority public key. + * @param newAuthority New metadata update authority public key. + * @param payer Fee payer public key. + * @param validityProof Validity proof for the compressed mint. + * @param merkleContext Merkle context of the compressed mint. + * @param mintData Mint instruction data with metadata. + * @param outputQueue Output queue for state changes. + * @param extensionIndex Extension index (default: 0). + */ +export function createUpdateMetadataAuthorityInstruction( + mintSigner: PublicKey, + currentAuthority: PublicKey, + newAuthority: PublicKey, + payer: PublicKey, + validityProof: ValidityProofWithContext, + merkleContext: MerkleContext, + mintData: MintInstructionDataWithMetadata, + outputQueue: PublicKey, + extensionIndex: number = 0, +): TransactionInstruction { + const action: UpdateMetadataAction = { + type: 'updateAuthority', + extensionIndex, + newAuthority, + }; + + return createUpdateMetadataInstruction( + mintSigner, + currentAuthority, + payer, + validityProof, + merkleContext, + mintData, + outputQueue, + action, + ); +} + +// Keep old interface type for backwards compatibility export +export interface CreateRemoveMetadataKeyInstructionParams { + mintSigner: PublicKey; + authority: PublicKey; + payer: PublicKey; + validityProof: ValidityProofWithContext; + merkleContext: MerkleContext; + mintData: MintInstructionDataWithMetadata; + outputQueue: PublicKey; + key: string; + idempotent?: boolean; + extensionIndex?: number; +} + +/** + * Create instruction for removing a metadata key from a compressed mint. + * + * @param mintSigner Mint signer public key. + * @param authority Metadata update authority public key. + * @param payer Fee payer public key. + * @param validityProof Validity proof for the compressed mint. + * @param merkleContext Merkle context of the compressed mint. + * @param mintData Mint instruction data with metadata. + * @param outputQueue Output queue for state changes. + * @param key Metadata key to remove. + * @param idempotent If true, don't error if key doesn't exist (default: false). + * @param extensionIndex Extension index (default: 0). + */ +export function createRemoveMetadataKeyInstruction( + mintSigner: PublicKey, + authority: PublicKey, + payer: PublicKey, + validityProof: ValidityProofWithContext, + merkleContext: MerkleContext, + mintData: MintInstructionDataWithMetadata, + outputQueue: PublicKey, + key: string, + idempotent: boolean = false, + extensionIndex: number = 0, +): TransactionInstruction { + const action: UpdateMetadataAction = { + type: 'removeKey', + extensionIndex, + key, + idempotent, + }; + + return createUpdateMetadataInstruction( + mintSigner, + authority, + payer, + validityProof, + merkleContext, + mintData, + outputQueue, + action, + ); +} diff --git a/js/compressed-token/src/mint/instructions/update-mint.ts b/js/compressed-token/src/mint/instructions/update-mint.ts new file mode 100644 index 0000000000..b46fb85d69 --- /dev/null +++ b/js/compressed-token/src/mint/instructions/update-mint.ts @@ -0,0 +1,282 @@ +import { + PublicKey, + SystemProgram, + TransactionInstruction, +} from '@solana/web3.js'; +import { Buffer } from 'buffer'; +import { + ValidityProofWithContext, + CTOKEN_PROGRAM_ID, + LightSystemProgram, + defaultStaticAccountsStruct, + deriveAddressV2, + getDefaultAddressTreeInfo, + MerkleContext, +} from '@lightprotocol/stateless.js'; +import { CompressedTokenProgram } from '../../program'; +import { MintInstructionData } from '../serde'; +import { + encodeMintActionInstructionData, + MintActionCompressedInstructionData, + Action, + ExtensionInstructionData, +} from './mint-action-layout'; + +interface EncodeUpdateMintInstructionParams { + addressTree: PublicKey; + leafIndex: number; + proveByIndex: boolean; + rootIndex: number; + proof: { a: number[]; b: number[]; c: number[] } | null; + mintData: MintInstructionData; + newAuthority: PublicKey | null; + actionType: 'mintAuthority' | 'freezeAuthority'; +} + +function encodeUpdateMintInstructionData( + params: EncodeUpdateMintInstructionParams, +): Buffer { + const compressedAddress = deriveAddressV2( + params.mintData.splMint.toBytes(), + params.addressTree, + CTOKEN_PROGRAM_ID, + ); + + // Build action + const action: Action = + params.actionType === 'mintAuthority' + ? { updateMintAuthority: { newAuthority: params.newAuthority } } + : { updateFreezeAuthority: { newAuthority: params.newAuthority } }; + + // Build extensions if metadata present + let extensions: ExtensionInstructionData[] | null = null; + if (params.mintData.metadata) { + extensions = [ + { + tokenMetadata: { + updateAuthority: + params.mintData.metadata.updateAuthority ?? null, + name: Buffer.from(params.mintData.metadata.name), + symbol: Buffer.from(params.mintData.metadata.symbol), + uri: Buffer.from(params.mintData.metadata.uri), + additionalMetadata: null, + }, + }, + ]; + } + + const instructionData: MintActionCompressedInstructionData = { + leafIndex: params.leafIndex, + proveByIndex: params.proveByIndex, + rootIndex: params.rootIndex, + compressedAddress: Array.from(compressedAddress.toBytes()), + tokenPoolBump: 0, + tokenPoolIndex: 0, + createMint: null, + actions: [action], + proof: params.proof, + cpiContext: null, + mint: { + supply: params.mintData.supply, + decimals: params.mintData.decimals, + metadata: { + version: params.mintData.version, + splMintInitialized: params.mintData.splMintInitialized, + mint: params.mintData.splMint, + }, + mintAuthority: params.mintData.mintAuthority, + freezeAuthority: params.mintData.freezeAuthority, + extensions, + }, + }; + + return encodeMintActionInstructionData(instructionData); +} + +// Keep old interface type for backwards compatibility export +export interface CreateUpdateMintAuthorityInstructionParams { + mintSigner: PublicKey; + currentMintAuthority: PublicKey; + newMintAuthority: PublicKey | null; + payer: PublicKey; + validityProof: ValidityProofWithContext; + merkleContext: MerkleContext; + mintData: MintInstructionData; + outputQueue: PublicKey; +} + +/** + * Create instruction for updating a compressed mint's mint authority. + * + * @param currentMintAuthority Current mint authority public key. + * @param newMintAuthority New mint authority (or null to revoke). + * @param payer Fee payer public key. + * @param validityProof Validity proof for the compressed mint. + * @param merkleContext Merkle context of the compressed mint. + * @param mintData Mint instruction data. + * @param outputQueue Output queue for state changes. + */ +export function createUpdateMintAuthorityInstruction( + currentMintAuthority: PublicKey, + newMintAuthority: PublicKey | null, + payer: PublicKey, + validityProof: ValidityProofWithContext, + merkleContext: MerkleContext, + mintData: MintInstructionData, + outputQueue: PublicKey, +): TransactionInstruction { + const addressTreeInfo = getDefaultAddressTreeInfo(); + const data = encodeUpdateMintInstructionData({ + addressTree: addressTreeInfo.tree, + leafIndex: merkleContext.leafIndex, + proveByIndex: true, + rootIndex: validityProof.rootIndices[0], + proof: validityProof.compressedProof, + mintData, + newAuthority: newMintAuthority, + actionType: 'mintAuthority', + }); + + const sys = defaultStaticAccountsStruct(); + const keys = [ + { + pubkey: LightSystemProgram.programId, + isSigner: false, + isWritable: false, + }, + { pubkey: currentMintAuthority, isSigner: true, isWritable: false }, + { pubkey: payer, isSigner: true, isWritable: true }, + { + pubkey: CompressedTokenProgram.deriveCpiAuthorityPda, + isSigner: false, + isWritable: false, + }, + { + pubkey: sys.registeredProgramPda, + isSigner: false, + isWritable: false, + }, + { + pubkey: sys.accountCompressionAuthority, + isSigner: false, + isWritable: false, + }, + { + pubkey: sys.accountCompressionProgram, + isSigner: false, + isWritable: false, + }, + { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, + { pubkey: outputQueue, isSigner: false, isWritable: true }, + { + pubkey: merkleContext.treeInfo.tree, + isSigner: false, + isWritable: true, + }, + { + pubkey: merkleContext.treeInfo.queue, + isSigner: false, + isWritable: true, + }, + ]; + + return new TransactionInstruction({ + programId: CTOKEN_PROGRAM_ID, + keys, + data, + }); +} + +// Keep old interface type for backwards compatibility export +export interface CreateUpdateFreezeAuthorityInstructionParams { + mintSigner: PublicKey; + currentFreezeAuthority: PublicKey; + newFreezeAuthority: PublicKey | null; + payer: PublicKey; + validityProof: ValidityProofWithContext; + merkleContext: MerkleContext; + mintData: MintInstructionData; + outputQueue: PublicKey; +} + +/** + * Create instruction for updating a compressed mint's freeze authority. + * + * @param currentFreezeAuthority Current freeze authority public key. + * @param newFreezeAuthority New freeze authority (or null to revoke). + * @param payer Fee payer public key. + * @param validityProof Validity proof for the compressed mint. + * @param merkleContext Merkle context of the compressed mint. + * @param mintData Mint instruction data. + * @param outputQueue Output queue for state changes. + */ +export function createUpdateFreezeAuthorityInstruction( + currentFreezeAuthority: PublicKey, + newFreezeAuthority: PublicKey | null, + payer: PublicKey, + validityProof: ValidityProofWithContext, + merkleContext: MerkleContext, + mintData: MintInstructionData, + outputQueue: PublicKey, +): TransactionInstruction { + const addressTreeInfo = getDefaultAddressTreeInfo(); + const data = encodeUpdateMintInstructionData({ + addressTree: addressTreeInfo.tree, + leafIndex: merkleContext.leafIndex, + proveByIndex: true, + rootIndex: validityProof.rootIndices[0], + proof: validityProof.compressedProof, + mintData, + newAuthority: newFreezeAuthority, + actionType: 'freezeAuthority', + }); + + const sys = defaultStaticAccountsStruct(); + const keys = [ + { + pubkey: LightSystemProgram.programId, + isSigner: false, + isWritable: false, + }, + { pubkey: currentFreezeAuthority, isSigner: true, isWritable: false }, + { pubkey: payer, isSigner: true, isWritable: true }, + { + pubkey: CompressedTokenProgram.deriveCpiAuthorityPda, + isSigner: false, + isWritable: false, + }, + { + pubkey: sys.registeredProgramPda, + isSigner: false, + isWritable: false, + }, + { + pubkey: sys.accountCompressionAuthority, + isSigner: false, + isWritable: false, + }, + { + pubkey: sys.accountCompressionProgram, + isSigner: false, + isWritable: false, + }, + { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, + { pubkey: outputQueue, isSigner: false, isWritable: true }, + { + pubkey: merkleContext.treeInfo.tree, + isSigner: false, + isWritable: true, + }, + { + pubkey: merkleContext.treeInfo.queue, + isSigner: false, + isWritable: true, + }, + ]; + + return new TransactionInstruction({ + programId: CTOKEN_PROGRAM_ID, + keys, + data, + }); +} diff --git a/js/compressed-token/src/mint/instructions/wrap.ts b/js/compressed-token/src/mint/instructions/wrap.ts new file mode 100644 index 0000000000..e69f0d999e --- /dev/null +++ b/js/compressed-token/src/mint/instructions/wrap.ts @@ -0,0 +1,149 @@ +import { PublicKey, TransactionInstruction } from '@solana/web3.js'; +import { CTOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; +import { CompressedTokenProgram } from '../../program'; +import { TokenPoolInfo } from '../../utils/get-token-pool-infos'; +import { + encodeTransfer2InstructionData, + createCompressSpl, + createDecompressCtoken, + Transfer2InstructionData, + Compression, +} from '../../layout-transfer2'; + +// Keep old interface type for backwards compatibility export +export interface CreateWrapInstructionParams { + source: PublicKey; + destination: PublicKey; + owner: PublicKey; + mint: PublicKey; + amount: bigint; + tokenPoolInfo: TokenPoolInfo; + payer?: PublicKey; +} + +/** + * Create a wrap instruction that moves tokens from an SPL/T22 account to a CToken account. + * + * This is an agnostic, low-level instruction that takes explicit account addresses. + * Use the wrap() action for a higher-level convenience wrapper. + * + * The wrap operation: + * 1. Compresses tokens from the SPL/T22 source account into the token pool + * 2. Decompresses tokens from the pool to the CToken destination account + * + * @param source Source SPL/T22 token account (any token account, not just ATA) + * @param destination Destination CToken account (any CToken account, not just ATA) + * @param owner Owner/authority of the source account (must sign) + * @param mint Mint address + * @param amount Amount to wrap + * @param tokenPoolInfo Token pool info for the compression + * @param payer Fee payer (defaults to owner if not provided) + * @returns TransactionInstruction to wrap tokens + */ +export function createWrapInstruction( + source: PublicKey, + destination: PublicKey, + owner: PublicKey, + mint: PublicKey, + amount: bigint, + tokenPoolInfo: TokenPoolInfo, + payer: PublicKey = owner, +): TransactionInstruction { + // Account indices in packed accounts (after fixed accounts): + // 0 = mint + // 1 = owner/authority + // 2 = source (SPL/T22 token account) + // 3 = destination (CToken account) + // 4 = token pool PDA + // 5 = SPL token program (for compress) + // 6 = CToken program (for decompress to CToken) + const MINT_INDEX = 0; + const OWNER_INDEX = 1; + const SOURCE_INDEX = 2; + const DESTINATION_INDEX = 3; + const POOL_INDEX = 4; + const SPL_TOKEN_PROGRAM_INDEX = 5; + const CTOKEN_PROGRAM_INDEX = 6; + + // Build compressions: + // 1. Compress from source (tokens go to pool) + // 2. Decompress to destination (CToken balance increases) + const compressions: Compression[] = [ + createCompressSpl( + amount, + MINT_INDEX, + SOURCE_INDEX, + OWNER_INDEX, + POOL_INDEX, + tokenPoolInfo.poolIndex, + tokenPoolInfo.bump, + ), + createDecompressCtoken( + amount, + MINT_INDEX, + DESTINATION_INDEX, + CTOKEN_PROGRAM_INDEX, + ), + ]; + + const instructionData: Transfer2InstructionData = { + withTransactionHash: false, + withLamportsChangeAccountMerkleTreeIndex: false, + lamportsChangeAccountMerkleTreeIndex: 0, + lamportsChangeAccountOwnerIndex: 0, + outputQueue: 0, + cpiContext: null, + compressions, + proof: null, + inTokenData: [], + outTokenData: [], + inLamports: null, + outLamports: null, + inTlv: null, + outTlv: null, + }; + + const data = encodeTransfer2InstructionData(instructionData); + + // Accounts for compressions-only path: + // 0: compressions_only_cpi_authority_pda + // 1: compressions_only_fee_payer (signer) + // Then packed accounts: mint, owner, source, destination, pool, spl_program, ctoken_program + const keys = [ + // Fixed accounts for compressions-only + { + pubkey: CompressedTokenProgram.deriveCpiAuthorityPda, + isSigner: false, + isWritable: false, + }, + { pubkey: payer, isSigner: true, isWritable: true }, + // Packed accounts + { pubkey: mint, isSigner: false, isWritable: false }, + { pubkey: owner, isSigner: true, isWritable: false }, + { pubkey: source, isSigner: false, isWritable: true }, + { pubkey: destination, isSigner: false, isWritable: true }, + { + pubkey: tokenPoolInfo.tokenPoolPda, + isSigner: false, + isWritable: true, + }, + // SPL token program for compress + { + pubkey: tokenPoolInfo.tokenProgram, + isSigner: false, + isWritable: false, + }, + // CToken program for decompress to CToken + { + pubkey: CTOKEN_PROGRAM_ID, + isSigner: false, + isWritable: false, + }, + ]; + + return new TransactionInstruction({ + programId: CompressedTokenProgram.programId, + keys, + data, + }); +} diff --git a/js/compressed-token/src/mint/serde.ts b/js/compressed-token/src/mint/serde.ts new file mode 100644 index 0000000000..82e39c4938 --- /dev/null +++ b/js/compressed-token/src/mint/serde.ts @@ -0,0 +1,510 @@ +import { MINT_SIZE, MintLayout } from '@solana/spl-token'; +import { PublicKey } from '@solana/web3.js'; +import { Buffer } from 'buffer'; +import { struct, u8, u32 } from '@solana/buffer-layout'; +import { publicKey } from '@solana/buffer-layout-utils'; +import { + struct as borshStruct, + option, + vec, + vecU8, + publicKey as borshPublicKey, +} from '@coral-xyz/borsh'; + +/** + * SPL-compatible base mint structure + */ +export interface BaseMint { + /** Optional authority used to mint new tokens */ + mintAuthority: PublicKey | null; + /** Total supply of tokens */ + supply: bigint; + /** Number of base 10 digits to the right of the decimal place */ + decimals: number; + /** Is initialized - for SPL compatibility */ + isInitialized: boolean; + /** Optional authority to freeze token accounts */ + freezeAuthority: PublicKey | null; +} + +/** + * Compressed mint context (protocol version, SPL mint reference) + */ +export interface MintContext { + /** Protocol version for upgradability */ + version: number; + /** Whether the associated SPL mint is initialized */ + splMintInitialized: boolean; + /** PDA of the associated SPL mint */ + splMint: PublicKey; +} + +/** + * Raw extension data as stored on-chain + */ +export interface MintExtension { + extensionType: number; + data: Uint8Array; +} + +/** + * Parsed token metadata matching on-chain TokenMetadata extension. + * Fields: updateAuthority, mint, name, symbol, uri, additionalMetadata + */ +export interface TokenMetadata { + /** Authority that can update metadata (None if zero pubkey) */ + updateAuthority?: PublicKey | null; + /** Associated mint pubkey */ + mint: PublicKey; + /** Token name */ + name: string; + /** Token symbol */ + symbol: string; + /** URI pointing to off-chain metadata JSON */ + uri: string; + /** Additional key-value metadata pairs */ + additionalMetadata?: { key: string; value: string }[]; +} + +/** + * Borsh layout for TokenMetadata extension data + * Format: updateAuthority (32) + mint (32) + name + symbol + uri + additional_metadata + */ +export const TokenMetadataLayout = borshStruct([ + borshPublicKey('updateAuthority'), + borshPublicKey('mint'), + vecU8('name'), + vecU8('symbol'), + vecU8('uri'), + vec(borshStruct([vecU8('key'), vecU8('value')]), 'additionalMetadata'), +]); + +/** + * Complete compressed mint structure (raw format) + */ +export interface CompressedMint { + base: BaseMint; + mintContext: MintContext; + extensions: MintExtension[] | null; +} + +/** MintContext as stored by the program */ +export interface RawMintContext { + version: number; + splMintInitialized: number; // bool as u8 + splMint: PublicKey; +} + +/** Buffer layout for de/serializing MintContext */ +export const MintContextLayout = struct([ + u8('version'), + u8('splMintInitialized'), + publicKey('splMint'), +]); + +/** Byte length of MintContext */ +export const MINT_CONTEXT_SIZE = MintContextLayout.span; // 34 bytes + +/** + * Calculate the byte length of a TokenMetadata extension from buffer. + * Format: updateAuthority (32) + mint (32) + name (4+len) + symbol (4+len) + uri (4+len) + additional (4 + items) + */ +function getTokenMetadataByteLength( + buffer: Buffer, + startOffset: number, +): number { + let offset = startOffset; + + // updateAuthority: 32 bytes + offset += 32; + // mint: 32 bytes + offset += 32; + + // name: Vec + const nameLen = buffer.readUInt32LE(offset); + offset += 4 + nameLen; + + // symbol: Vec + const symbolLen = buffer.readUInt32LE(offset); + offset += 4 + symbolLen; + + // uri: Vec + const uriLen = buffer.readUInt32LE(offset); + offset += 4 + uriLen; + + // additional_metadata: Vec + const additionalCount = buffer.readUInt32LE(offset); + offset += 4; + for (let i = 0; i < additionalCount; i++) { + const keyLen = buffer.readUInt32LE(offset); + offset += 4 + keyLen; + const valueLen = buffer.readUInt32LE(offset); + offset += 4 + valueLen; + } + + return offset - startOffset; +} + +/** + * Get the byte length of an extension based on its type. + * Returns the length of the extension data (excluding the 1-byte discriminant). + */ +function getExtensionByteLength( + extensionType: number, + buffer: Buffer, + dataStartOffset: number, +): number { + switch (extensionType) { + case ExtensionType.TokenMetadata: + return getTokenMetadataByteLength(buffer, dataStartOffset); + default: + // For unknown extensions, we can't determine the length + // Return remaining buffer length as fallback + return buffer.length - dataStartOffset; + } +} + +/** + * Deserialize a compressed mint from buffer + * Uses SPL's MintLayout for BaseMint and buffer-layout struct for context + * + * @param data - The raw account data buffer + * @returns The deserialized CompressedMint + */ +export function deserializeMint(data: Buffer | Uint8Array): CompressedMint { + const buffer = data instanceof Buffer ? data : Buffer.from(data); + let offset = 0; + + // 1. Decode BaseMint using SPL's MintLayout (82 bytes) + const rawMint = MintLayout.decode(buffer.slice(offset, offset + MINT_SIZE)); + offset += MINT_SIZE; + + // 2. Decode MintContext using our layout (34 bytes) + const rawContext = MintContextLayout.decode( + buffer.slice(offset, offset + MINT_CONTEXT_SIZE), + ); + offset += MINT_CONTEXT_SIZE; + + // 3. Parse extensions: Option> + // Borsh format: Option byte + Vec length + (discriminant + variant data) for each + const hasExtensions = buffer.readUInt8(offset) === 1; + offset += 1; + + let extensions: MintExtension[] | null = null; + if (hasExtensions) { + const vecLen = buffer.readUInt32LE(offset); + offset += 4; + + extensions = []; + for (let i = 0; i < vecLen; i++) { + const extensionType = buffer.readUInt8(offset); + offset += 1; + + // Calculate extension data length based on type + const dataLength = getExtensionByteLength( + extensionType, + buffer, + offset, + ); + const extensionData = buffer.slice(offset, offset + dataLength); + offset += dataLength; + + extensions.push({ + extensionType, + data: extensionData, + }); + } + } + + // Convert raw types to our interface with proper null handling + const baseMint: BaseMint = { + mintAuthority: + rawMint.mintAuthorityOption === 1 ? rawMint.mintAuthority : null, + supply: rawMint.supply, + decimals: rawMint.decimals, + isInitialized: rawMint.isInitialized, + freezeAuthority: + rawMint.freezeAuthorityOption === 1 + ? rawMint.freezeAuthority + : null, + }; + + const mintContext: MintContext = { + version: rawContext.version, + splMintInitialized: rawContext.splMintInitialized !== 0, + splMint: rawContext.splMint, + }; + + const mint: CompressedMint = { + base: baseMint, + mintContext, + extensions, + }; + + return mint; +} + +/** + * Serialize a CompressedMint to buffer + * Uses SPL's MintLayout for BaseMint, helper functions for context/metadata + * + * @param mint - The CompressedMint to serialize + * @returns The serialized buffer + */ +export function serializeMint(mint: CompressedMint): Buffer { + const buffers: Buffer[] = []; + + // 1. Encode BaseMint using SPL's MintLayout (82 bytes) + const baseMintBuffer = Buffer.alloc(MINT_SIZE); + MintLayout.encode( + { + mintAuthorityOption: mint.base.mintAuthority ? 1 : 0, + mintAuthority: mint.base.mintAuthority || new PublicKey(0), + supply: mint.base.supply, + decimals: mint.base.decimals, + isInitialized: mint.base.isInitialized, + freezeAuthorityOption: mint.base.freezeAuthority ? 1 : 0, + freezeAuthority: mint.base.freezeAuthority || new PublicKey(0), + }, + baseMintBuffer, + ); + buffers.push(baseMintBuffer); + + // 2. Encode MintContext using our layout (34 bytes) + const contextBuffer = Buffer.alloc(MINT_CONTEXT_SIZE); + MintContextLayout.encode( + { + version: mint.mintContext.version, + splMintInitialized: mint.mintContext.splMintInitialized ? 1 : 0, + splMint: mint.mintContext.splMint, + }, + contextBuffer, + ); + buffers.push(contextBuffer); + + // 3. Encode extensions: Option> + // Borsh format: Option byte + Vec length + (discriminant + variant data) for each + // NOTE: No length prefix per extension - Borsh enums are discriminant + data directly + if (mint.extensions && mint.extensions.length > 0) { + buffers.push(Buffer.from([1])); // Some + const vecLenBuf = Buffer.alloc(4); + vecLenBuf.writeUInt32LE(mint.extensions.length); + buffers.push(vecLenBuf); + + for (const ext of mint.extensions) { + // Write discriminant (1 byte) + buffers.push(Buffer.from([ext.extensionType])); + // Write extension data directly (no length prefix - Borsh format) + buffers.push(Buffer.from(ext.data)); + } + } else { + buffers.push(Buffer.from([0])); // None + } + + return Buffer.concat(buffers); +} + +/** + * Extension type constants + */ +export enum ExtensionType { + TokenMetadata = 19, // Name, symbol, uri + // Add more extension types as needed +} + +/** + * Decode TokenMetadata from raw extension data using Borsh layout + * Extension format: updateAuthority (32) + mint (32) + name (Vec) + symbol (Vec) + uri (Vec) + additional (Vec) + */ +export function decodeTokenMetadata(data: Uint8Array): TokenMetadata | null { + try { + const buffer = Buffer.from(data); + // Minimum size: 32 (updateAuthority) + 32 (mint) + 4 (name len) + 4 (symbol len) + 4 (uri len) + 4 (additional len) = 80 + if (buffer.length < 80) { + return null; + } + + // Decode using Borsh layout + const decoded = TokenMetadataLayout.decode(buffer) as { + updateAuthority: PublicKey; + mint: PublicKey; + name: Buffer; + symbol: Buffer; + uri: Buffer; + additionalMetadata: { key: Buffer; value: Buffer }[]; + }; + + // Convert zero pubkey to undefined for updateAuthority + const updateAuthorityBytes = decoded.updateAuthority.toBuffer(); + const isZero = updateAuthorityBytes.every((b: number) => b === 0); + const updateAuthority = isZero ? undefined : decoded.updateAuthority; + + // Convert Buffer fields to strings + const name = Buffer.from(decoded.name).toString('utf-8'); + const symbol = Buffer.from(decoded.symbol).toString('utf-8'); + const uri = Buffer.from(decoded.uri).toString('utf-8'); + + // Convert additional metadata + let additionalMetadata: { key: string; value: string }[] | undefined; + if ( + decoded.additionalMetadata && + decoded.additionalMetadata.length > 0 + ) { + additionalMetadata = decoded.additionalMetadata.map(item => ({ + key: Buffer.from(item.key).toString('utf-8'), + value: Buffer.from(item.value).toString('utf-8'), + })); + } + + return { + updateAuthority, + mint: decoded.mint, + name, + symbol, + uri, + additionalMetadata, + }; + } catch (e) { + console.error('Failed to decode TokenMetadata:', e); + return null; + } +} + +/** + * Encode TokenMetadata to raw bytes using Borsh layout + * @param metadata - TokenMetadata to encode + * @returns Encoded buffer + */ +export function encodeTokenMetadata(metadata: TokenMetadata): Buffer { + const buffer = Buffer.alloc(2000); // Allocate generous buffer + + // Use zero pubkey if updateAuthority is not provided + const updateAuthority = metadata.updateAuthority || new PublicKey(0); + + const len = TokenMetadataLayout.encode( + { + updateAuthority, + mint: metadata.mint, + name: Buffer.from(metadata.name), + symbol: Buffer.from(metadata.symbol), + uri: Buffer.from(metadata.uri), + additionalMetadata: metadata.additionalMetadata + ? metadata.additionalMetadata.map(item => ({ + key: Buffer.from(item.key), + value: Buffer.from(item.value), + })) + : [], + }, + buffer, + ); + return buffer.subarray(0, len); +} + +/** + * @deprecated Use decodeTokenMetadata instead + */ +export const parseTokenMetadata = decodeTokenMetadata; + +/** + * Extract and parse TokenMetadata from extensions array + * @param extensions - Array of raw extensions + * @returns Parsed TokenMetadata or null if not found + */ +export function extractTokenMetadata( + extensions: MintExtension[] | null, +): TokenMetadata | null { + if (!extensions) return null; + const metadataExt = extensions.find( + ext => ext.extensionType === ExtensionType.TokenMetadata, + ); + return metadataExt ? parseTokenMetadata(metadataExt.data) : null; +} + +/** + * Metadata portion of MintInstructionData + * Used for instruction encoding when metadata extension is present + */ +export interface MintMetadataField { + updateAuthority: PublicKey | null; + name: string; + symbol: string; + uri: string; +} + +/** + * Flattened mint data structure for instruction encoding + * This is the format expected by mint action instructions + */ +export interface MintInstructionData { + supply: bigint; + decimals: number; + mintAuthority: PublicKey | null; + freezeAuthority: PublicKey | null; + splMint: PublicKey; + splMintInitialized: boolean; + version: number; + metadata?: MintMetadataField; +} + +/** + * MintInstructionData with required metadata field + * Used for metadata update instructions where metadata must be present + */ +export interface MintInstructionDataWithMetadata extends MintInstructionData { + metadata: MintMetadataField; +} + +/** + * Convert a deserialized CompressedMint to MintInstructionData format + * This extracts and flattens the data structure for instruction encoding + * + * @param compressedMint - Deserialized CompressedMint from account data + * @returns Flattened MintInstructionData for instruction encoding + */ +export function toMintInstructionData( + compressedMint: CompressedMint, +): MintInstructionData { + const { base, mintContext, extensions } = compressedMint; + + // Extract metadata from extensions if present + const tokenMetadata = extractTokenMetadata(extensions); + const metadata: MintMetadataField | undefined = tokenMetadata + ? { + updateAuthority: tokenMetadata.updateAuthority ?? null, + name: tokenMetadata.name, + symbol: tokenMetadata.symbol, + uri: tokenMetadata.uri, + } + : undefined; + + return { + supply: base.supply, + decimals: base.decimals, + mintAuthority: base.mintAuthority, + freezeAuthority: base.freezeAuthority, + splMint: mintContext.splMint, + splMintInitialized: mintContext.splMintInitialized, + version: mintContext.version, + metadata, + }; +} + +/** + * Convert a deserialized CompressedMint to MintInstructionDataWithMetadata + * Throws if the mint doesn't have metadata extension + * + * @param compressedMint - Deserialized CompressedMint from account data + * @returns MintInstructionDataWithMetadata for metadata update instructions + * @throws Error if metadata extension is not present + */ +export function toMintInstructionDataWithMetadata( + compressedMint: CompressedMint, +): MintInstructionDataWithMetadata { + const data = toMintInstructionData(compressedMint); + + if (!data.metadata) { + throw new Error('CompressedMint does not have TokenMetadata extension'); + } + + return data as MintInstructionDataWithMetadata; +} diff --git a/js/compressed-token/src/mint/upload.ts b/js/compressed-token/src/mint/upload.ts new file mode 100644 index 0000000000..59f95fd004 --- /dev/null +++ b/js/compressed-token/src/mint/upload.ts @@ -0,0 +1,75 @@ +/** + * Input for creating off-chain metadata JSON. + * Compatible with Token-2022 and Metaplex standards. + */ +export interface OffChainTokenMetadata { + /** Token name */ + name: string; + /** Token symbol */ + symbol: string; + /** Optional description */ + description?: string; + /** Optional image URI */ + image?: string; + /** Optional additional metadata key-value pairs */ + additionalMetadata?: Array<{ key: string; value: string }>; +} + +/** + * Off-chain JSON format for token metadata. + * Standard format compatible with Token-2022 and Metaplex tooling. + */ +export interface OffChainTokenMetadataJson { + name: string; + symbol: string; + description?: string; + image?: string; + additionalMetadata?: Array<{ key: string; value: string }>; +} + +/** + * Format metadata for off-chain storage. + * + * Returns a plain object ready to be uploaded using any storage provider + * (umi uploader, custom IPFS/Arweave/S3 solution, etc.). + * + * @example + * // With umi uploader + * import { toOffChainMetadataJson } from '@lightprotocol/compressed-token'; + * import { irysUploader } from '@metaplex-foundation/umi-uploader-irys'; + * + * const umi = createUmi(connection).use(irysUploader()); + * const metadataJson = toOffChainMetadataJson({ + * name: 'My Token', + * symbol: 'MTK', + * description: 'A compressed token', + * image: 'https://example.com/image.png', + * }); + * const uri = await umi.uploader.uploadJson(metadataJson); + * + * // Then use uri with createMint + * await createMint(rpc, payer, { ...params, uri }); + */ +export function toOffChainMetadataJson( + meta: OffChainTokenMetadata, +): OffChainTokenMetadataJson { + const json: OffChainTokenMetadataJson = { + name: meta.name, + symbol: meta.symbol, + }; + + if (meta.description !== undefined) { + json.description = meta.description; + } + if (meta.image !== undefined) { + json.image = meta.image; + } + if ( + meta.additionalMetadata !== undefined && + meta.additionalMetadata.length > 0 + ) { + json.additionalMetadata = meta.additionalMetadata; + } + + return json; +} diff --git a/js/compressed-token/src/program.ts b/js/compressed-token/src/program.ts index ac19ee2c1f..391bf11014 100644 --- a/js/compressed-token/src/program.ts +++ b/js/compressed-token/src/program.ts @@ -702,7 +702,7 @@ export class CompressedTokenProgram { } /** - * Construct createMint instruction for compressed tokens. + * Construct createMint instruction for SPL tokens. * * @param feePayer Fee payer. * @param mint SPL Mint address. diff --git a/js/compressed-token/src/utils/ata-utils.ts b/js/compressed-token/src/utils/ata-utils.ts new file mode 100644 index 0000000000..ee7e9b4e12 --- /dev/null +++ b/js/compressed-token/src/utils/ata-utils.ts @@ -0,0 +1,19 @@ +import { + ASSOCIATED_TOKEN_PROGRAM_ID, + TOKEN_PROGRAM_ID, + TOKEN_2022_PROGRAM_ID, +} from '@solana/spl-token'; +import { CTOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; +import { PublicKey } from '@solana/web3.js'; + +/** + * Get the appropriate ATA program ID for a given token program ID + * @param tokenProgramId - The token program ID + * @returns The associated token program ID + */ +export function getATAProgramId(tokenProgramId: PublicKey): PublicKey { + if (tokenProgramId.equals(CTOKEN_PROGRAM_ID)) { + return CTOKEN_PROGRAM_ID; + } + return ASSOCIATED_TOKEN_PROGRAM_ID; +} diff --git a/js/compressed-token/src/utils/index.ts b/js/compressed-token/src/utils/index.ts index 7e280dc27e..8b6ed883af 100644 --- a/js/compressed-token/src/utils/index.ts +++ b/js/compressed-token/src/utils/index.ts @@ -2,3 +2,4 @@ export * from './get-token-pool-infos'; export * from './select-input-accounts'; export * from './pack-compressed-token-accounts'; export * from './validation'; +export * from './ata-utils'; diff --git a/js/compressed-token/tests/e2e/compress-spl-token-account.test.ts b/js/compressed-token/tests/e2e/compress-spl-token-account.test.ts index 34ac9a55ce..6ac06a9d39 100644 --- a/js/compressed-token/tests/e2e/compress-spl-token-account.test.ts +++ b/js/compressed-token/tests/e2e/compress-spl-token-account.test.ts @@ -52,6 +52,7 @@ describe('compressSplTokenAccount', () => { rpc, payer, mintAuthority.publicKey, + null, TEST_TOKEN_DECIMALS, mintKeypair, ) @@ -329,6 +330,7 @@ describe('compressSplTokenAccount', () => { rpc, payer, mintAuthority.publicKey, + null, TEST_TOKEN_DECIMALS, mintKeypair, undefined, diff --git a/js/compressed-token/tests/e2e/compress.test.ts b/js/compressed-token/tests/e2e/compress.test.ts index 329f845063..e3f3023102 100644 --- a/js/compressed-token/tests/e2e/compress.test.ts +++ b/js/compressed-token/tests/e2e/compress.test.ts @@ -23,8 +23,8 @@ import { createMint, createTokenProgramLookupTable, decompress, - mintTo, } from '../../src/actions'; +import { mintTo } from '../../src'; import { createAssociatedTokenAccount, TOKEN_2022_PROGRAM_ID, @@ -120,6 +120,7 @@ describe('compress', () => { rpc, payer, mintAuthority.publicKey, + null, TEST_TOKEN_DECIMALS, mintKeypair, ) @@ -316,6 +317,7 @@ describe('compress', () => { rpc, payer, mintAuthority.publicKey, + null, TEST_TOKEN_DECIMALS, mintKeypair, undefined, diff --git a/js/compressed-token/tests/e2e/compressible-load.test.ts b/js/compressed-token/tests/e2e/compressible-load.test.ts new file mode 100644 index 0000000000..7885f6978a --- /dev/null +++ b/js/compressed-token/tests/e2e/compressible-load.test.ts @@ -0,0 +1,448 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { Keypair, Signer, PublicKey } from '@solana/web3.js'; +import { + Rpc, + bn, + newAccountWithLamports, + getTestRpc, + selectStateTreeInfo, + TreeInfo, + MerkleContext, + VERSION, + featureFlags, + CTOKEN_PROGRAM_ID, +} 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 { + createLoadAccountsParams, + createLoadATAInstructionsFromInterface, + createLoadATAInstructions, + CompressibleAccountInput, + ParsedAccountInfoInterface, + calculateCompressibleLoadComputeUnits, +} from '../../src/compressible/unified-load'; +import { getATAInterface } from '../../src/mint/get-account-interface'; +import { getATAAddressInterface } from '../../src/mint/actions/create-ata-interface'; + +featureFlags.version = VERSION.V2; + +const TEST_TOKEN_DECIMALS = 9; + +describe('compressible-load', () => { + let rpc: Rpc; + let payer: Signer; + let mint: PublicKey; + let mintAuthority: Keypair; + let stateTreeInfo: 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, + null, + TEST_TOKEN_DECIMALS, + mintKeypair, + ) + ).mint; + + stateTreeInfo = selectStateTreeInfo(await rpc.getStateTreeInfos()); + tokenPoolInfos = await getTokenPoolInfos(rpc, mint); + }, 60_000); + + describe('createLoadAccountsParams', () => { + describe('filtering', () => { + it('should return empty result when no accounts provided', async () => { + const result = await createLoadAccountsParams( + rpc, + payer.publicKey, + CTOKEN_PROGRAM_ID, + [], + [], + ); + expect(result.decompressParams).toBeNull(); + expect(result.ataInstructions).toHaveLength(0); + }); + + it('should return null decompressParams when all accounts are hot', async () => { + const hotInfo: ParsedAccountInfoInterface = { + parsed: { dummy: 'data' }, + loadContext: undefined, + }; + + const accounts: CompressibleAccountInput[] = [ + { + address: Keypair.generate().publicKey, + accountType: 'cTokenData', + tokenVariant: 'ata', + info: hotInfo, + }, + ]; + + const result = await createLoadAccountsParams( + rpc, + payer.publicKey, + CTOKEN_PROGRAM_ID, + accounts, + [], + ); + expect(result.decompressParams).toBeNull(); + }); + + it('should filter out hot accounts and only process compressed', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(2000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const coldInfo = await getATAInterface( + rpc, + owner.publicKey, + mint, + undefined, + CTOKEN_PROGRAM_ID, + ); + + const hotInfo: ParsedAccountInfoInterface = { + parsed: { dummy: 'data' }, + loadContext: undefined, + }; + + const accounts: CompressibleAccountInput[] = [ + { + address: Keypair.generate().publicKey, + accountType: 'cTokenData', + tokenVariant: 'vault1', + info: hotInfo, + }, + { + address: getATAAddressInterface(mint, owner.publicKey), + accountType: 'cTokenData', + tokenVariant: 'vault2', + info: coldInfo, + }, + ]; + + const result = await createLoadAccountsParams( + rpc, + payer.publicKey, + CTOKEN_PROGRAM_ID, + accounts, + [], + ); + + expect(result.decompressParams).not.toBeNull(); + expect(result.decompressParams!.compressedAccounts.length).toBe( + 1, + ); + }); + }); + + describe('cTokenData packing', () => { + it('should throw when tokenVariant missing for cTokenData', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(1000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const accountInfo = await getATAInterface( + rpc, + owner.publicKey, + mint, + undefined, + CTOKEN_PROGRAM_ID, + ); + + const accounts: CompressibleAccountInput[] = [ + { + address: getATAAddressInterface(mint, owner.publicKey), + accountType: 'cTokenData', + info: accountInfo, + }, + ]; + + await expect( + createLoadAccountsParams( + rpc, + payer.publicKey, + CTOKEN_PROGRAM_ID, + accounts, + [], + ), + ).rejects.toThrow('tokenVariant is required'); + }); + + it('should pack cTokenData with correct variant structure', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(1000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const accountInfo = await getATAInterface( + rpc, + owner.publicKey, + mint, + undefined, + CTOKEN_PROGRAM_ID, + ); + + const accounts: CompressibleAccountInput[] = [ + { + address: getATAAddressInterface(mint, owner.publicKey), + accountType: 'cTokenData', + tokenVariant: 'token0Vault', + info: accountInfo, + }, + ]; + + const result = await createLoadAccountsParams( + rpc, + payer.publicKey, + CTOKEN_PROGRAM_ID, + accounts, + [], + ); + + expect(result.decompressParams).not.toBeNull(); + expect(result.decompressParams!.compressedAccounts.length).toBe( + 1, + ); + + const packed = result.decompressParams!.compressedAccounts[0]; + expect(packed).toHaveProperty('cTokenData'); + expect(packed).toHaveProperty('merkleContext'); + }); + }); + + describe('ATA loading via atas parameter', () => { + it('should build ATA load instructions for cold ATAs', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(1000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const ata = await getATAInterface( + rpc, + owner.publicKey, + mint, + undefined, + CTOKEN_PROGRAM_ID, + ); + + const result = await createLoadAccountsParams( + rpc, + payer.publicKey, + CTOKEN_PROGRAM_ID, + [], + [ata], + { tokenPoolInfos }, + ); + + expect(result.ataInstructions.length).toBeGreaterThan(0); + }); + + it('should return empty ataInstructions for hot ATAs', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(1000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + // Load first to make it hot + const coldAta = await getATAInterface( + rpc, + owner.publicKey, + mint, + undefined, + CTOKEN_PROGRAM_ID, + ); + + const loadIxs = await createLoadATAInstructionsFromInterface( + rpc, + payer.publicKey, + coldAta, + { tokenPoolInfos }, + ); + + // Execute load (this would need actual tx, simplified here) + // After load, ATA would be hot - for this test we just verify the flow + expect(loadIxs.length).toBeGreaterThan(0); + }); + }); + }); + + describe('createLoadATAInstructionsFromInterface', () => { + it('should throw if AccountInterface not from getATAInterface', async () => { + const fakeInterface = { + accountInfo: { data: Buffer.alloc(0) }, + parsed: {}, + isCold: false, + // Missing _isAta, _owner, _mint + } as any; + + await expect( + createLoadATAInstructionsFromInterface( + rpc, + payer.publicKey, + fakeInterface, + ), + ).rejects.toThrow('must be from getATAInterface'); + }); + + it('should return empty when nothing to load', async () => { + const owner = Keypair.generate(); + + // No balance - getATAInterface will throw, so we test the empty case differently + // For an owner with no tokens, getATAInterface throws TokenAccountNotFoundError + // This is expected behavior + }); + + it('should build instructions for cold ATA', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(1000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const ata = await getATAInterface( + rpc, + owner.publicKey, + mint, + undefined, + CTOKEN_PROGRAM_ID, + ); + + expect(ata._isAta).toBe(true); + expect(ata._owner?.equals(owner.publicKey)).toBe(true); + expect(ata._mint?.equals(mint)).toBe(true); + + const ixs = await createLoadATAInstructionsFromInterface( + rpc, + payer.publicKey, + ata, + { tokenPoolInfos }, + ); + + expect(ixs.length).toBeGreaterThan(0); + }); + }); + + describe('createLoadATAInstructions', () => { + it('should build load instructions by owner and mint', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(1000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const ata = getATAAddressInterface(mint, owner.publicKey); + const ixs = await createLoadATAInstructions( + rpc, + payer.publicKey, + ata, + owner.publicKey, + mint, + { tokenPoolInfos }, + ); + + expect(ixs.length).toBeGreaterThan(0); + }); + + it('should return empty when nothing to load (hot ATA)', async () => { + // For a hot ATA with no cold/SPL/T22 balance, should return empty + // This is tested via createLoadATAInstructionsFromInterface since createLoadATAInstructions + // fetches internally + }); + }); + + describe('calculateCompressibleLoadComputeUnits', () => { + it('should calculate base CU for single account without proof', () => { + const cu = calculateCompressibleLoadComputeUnits(1, false); + expect(cu).toBe(50_000 + 30_000); + }); + + it('should add proof verification CU when hasValidityProof', () => { + const cuWithProof = calculateCompressibleLoadComputeUnits(1, true); + const cuWithoutProof = calculateCompressibleLoadComputeUnits( + 1, + false, + ); + + expect(cuWithProof).toBe(cuWithoutProof + 100_000); + }); + + it('should scale with number of accounts', () => { + const cu1 = calculateCompressibleLoadComputeUnits(1, false); + const cu3 = calculateCompressibleLoadComputeUnits(3, false); + + expect(cu3 - cu1).toBe(2 * 30_000); + }); + }); +}); diff --git a/js/compressed-token/tests/e2e/create-associated-ctoken.test.ts b/js/compressed-token/tests/e2e/create-associated-ctoken.test.ts new file mode 100644 index 0000000000..b7068b5e83 --- /dev/null +++ b/js/compressed-token/tests/e2e/create-associated-ctoken.test.ts @@ -0,0 +1,580 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { Keypair, Signer, PublicKey } from '@solana/web3.js'; +import { + Rpc, + newAccountWithLamports, + createRpc, + VERSION, + featureFlags, + getDefaultAddressTreeInfo, +} from '@lightprotocol/stateless.js'; +import { createMintInterface } from '../../src/mint/actions'; +import { + createAssociatedCTokenAccount, + createAssociatedCTokenAccountIdempotent, +} from '../../src/mint/actions/create-associated-ctoken'; +import { createTokenMetadata } from '../../src/mint/instructions'; +import { getAssociatedCTokenAddress } from '../../src/compressible'; +import { findMintAddress } from '../../src/compressible/derivation'; + +featureFlags.version = VERSION.V2; + +describe('createAssociatedCTokenAccount', () => { + let rpc: Rpc; + let payer: Signer; + + beforeAll(async () => { + rpc = createRpc(); + payer = await newAccountWithLamports(rpc, 10e9); + }); + + it('should create an associated ctoken account', async () => { + const mintSigner = Keypair.generate(); + const mintAuthority = Keypair.generate(); + const owner = Keypair.generate(); + const decimals = 9; + const addressTreeInfo = getDefaultAddressTreeInfo(); + const [mintPda] = findMintAddress(mintSigner.publicKey); + + const { transactionSignature: createMintSig } = + await createMintInterface( + rpc, + payer, + mintAuthority, + null, + decimals, + mintSigner, + undefined, + addressTreeInfo, + undefined, + ); + await rpc.confirmTransaction(createMintSig, 'confirmed'); + + const { address: ataAddress, transactionSignature: createAtaSig } = + await createAssociatedCTokenAccount( + rpc, + payer, + owner.publicKey, + mintPda, + ); + await rpc.confirmTransaction(createAtaSig, 'confirmed'); + + const expectedAddress = getAssociatedCTokenAddress( + owner.publicKey, + mintPda, + ); + expect(ataAddress.toString()).toBe(expectedAddress.toString()); + + const accountInfo = await rpc.getAccountInfo(ataAddress); + expect(accountInfo).not.toBe(null); + expect(accountInfo?.owner.toString()).toBe( + 'cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m', + ); + }); + + it('should fail to create associated ctoken account twice (non-idempotent)', async () => { + const mintSigner = Keypair.generate(); + const mintAuthority = Keypair.generate(); + const owner = Keypair.generate(); + const decimals = 6; + const addressTreeInfo = getDefaultAddressTreeInfo(); + const [mintPda] = findMintAddress(mintSigner.publicKey); + + const { transactionSignature: createMintSig } = + await createMintInterface( + rpc, + payer, + mintAuthority, + null, + decimals, + mintSigner, + undefined, + addressTreeInfo, + undefined, + ); + await rpc.confirmTransaction(createMintSig, 'confirmed'); + + const { transactionSignature: createAtaSig } = + await createAssociatedCTokenAccount( + rpc, + payer, + owner.publicKey, + mintPda, + ); + await rpc.confirmTransaction(createAtaSig, 'confirmed'); + + await expect( + createAssociatedCTokenAccount(rpc, payer, owner.publicKey, mintPda), + ).rejects.toThrow(); + }); + + it('should create associated ctoken account idempotently', async () => { + const mintSigner = Keypair.generate(); + const mintAuthority = Keypair.generate(); + const owner = Keypair.generate(); + const decimals = 9; + const addressTreeInfo = getDefaultAddressTreeInfo(); + const [mintPda] = findMintAddress(mintSigner.publicKey); + + const { transactionSignature: createMintSig } = + await createMintInterface( + rpc, + payer, + mintAuthority, + null, + decimals, + mintSigner, + undefined, + addressTreeInfo, + undefined, + ); + await rpc.confirmTransaction(createMintSig, 'confirmed'); + + const { address: ataAddress1, transactionSignature: createAtaSig1 } = + await createAssociatedCTokenAccountIdempotent( + rpc, + payer, + owner.publicKey, + mintPda, + ); + await rpc.confirmTransaction(createAtaSig1, 'confirmed'); + + const expectedAddress = getAssociatedCTokenAddress( + owner.publicKey, + mintPda, + ); + expect(ataAddress1.toString()).toBe(expectedAddress.toString()); + + const { address: ataAddress2, transactionSignature: createAtaSig2 } = + await createAssociatedCTokenAccountIdempotent( + rpc, + payer, + owner.publicKey, + mintPda, + ); + await rpc.confirmTransaction(createAtaSig2, 'confirmed'); + + expect(ataAddress2.toString()).toBe(ataAddress1.toString()); + + const accountInfo = await rpc.getAccountInfo(ataAddress2); + expect(accountInfo).not.toBe(null); + }); + + it('should create associated accounts for multiple owners for same mint', async () => { + const mintSigner = Keypair.generate(); + const mintAuthority = Keypair.generate(); + const owner1 = Keypair.generate(); + const owner2 = Keypair.generate(); + const owner3 = Keypair.generate(); + const decimals = 9; + const addressTreeInfo = getDefaultAddressTreeInfo(); + const [mintPda] = findMintAddress(mintSigner.publicKey); + + const { transactionSignature: createMintSig } = + await createMintInterface( + rpc, + payer, + mintAuthority, + null, + decimals, + mintSigner, + undefined, + addressTreeInfo, + undefined, + ); + await rpc.confirmTransaction(createMintSig, 'confirmed'); + + const { address: ata1 } = await createAssociatedCTokenAccount( + rpc, + payer, + owner1.publicKey, + mintPda, + ); + + const { address: ata2 } = await createAssociatedCTokenAccount( + rpc, + payer, + owner2.publicKey, + mintPda, + ); + + const { address: ata3 } = await createAssociatedCTokenAccount( + rpc, + payer, + owner3.publicKey, + mintPda, + ); + + expect(ata1.toString()).not.toBe(ata2.toString()); + expect(ata1.toString()).not.toBe(ata3.toString()); + expect(ata2.toString()).not.toBe(ata3.toString()); + + const expectedAta1 = getAssociatedCTokenAddress( + owner1.publicKey, + mintPda, + ); + const expectedAta2 = getAssociatedCTokenAddress( + owner2.publicKey, + mintPda, + ); + const expectedAta3 = getAssociatedCTokenAddress( + owner3.publicKey, + mintPda, + ); + + expect(ata1.toString()).toBe(expectedAta1.toString()); + expect(ata2.toString()).toBe(expectedAta2.toString()); + expect(ata3.toString()).toBe(expectedAta3.toString()); + }); + + it('should handle idempotent creation with concurrent calls', async () => { + const mintSigner = Keypair.generate(); + const mintAuthority = Keypair.generate(); + const owner = Keypair.generate(); + const decimals = 6; + const addressTreeInfo = getDefaultAddressTreeInfo(); + const [mintPda] = findMintAddress(mintSigner.publicKey); + + const { transactionSignature: createMintSig } = + await createMintInterface( + rpc, + payer, + mintAuthority, + null, + decimals, + mintSigner, + undefined, + addressTreeInfo, + undefined, + ); + await rpc.confirmTransaction(createMintSig, 'confirmed'); + + const createPromises = Array(3) + .fill(null) + .map(() => + createAssociatedCTokenAccountIdempotent( + rpc, + payer, + owner.publicKey, + mintPda, + ), + ); + + const results = await Promise.allSettled(createPromises); + + const successfulResults = results.filter(r => r.status === 'fulfilled'); + expect(successfulResults.length).toBeGreaterThan(0); + + if ( + successfulResults.length > 0 && + successfulResults[0].status === 'fulfilled' + ) { + const expectedAddress = getAssociatedCTokenAddress( + owner.publicKey, + mintPda, + ); + expect(successfulResults[0].value.address.toString()).toBe( + expectedAddress.toString(), + ); + } + }); +}); + +describe('createMint -> createAssociatedCTokenAccount flow', () => { + let rpc: Rpc; + let payer: Signer; + + beforeAll(async () => { + rpc = createRpc(); + payer = await newAccountWithLamports(rpc, 10e9); + }); + + it('should create mint then create multiple associated accounts', async () => { + const mintSigner = Keypair.generate(); + const mintAuthority = Keypair.generate(); + const decimals = 9; + const addressTreeInfo = getDefaultAddressTreeInfo(); + const [mintPda] = findMintAddress(mintSigner.publicKey); + + const metadata = createTokenMetadata( + 'Flow Test Token', + 'FLOW', + 'https://flow.com/metadata', + mintAuthority.publicKey, + ); + + const { mint, transactionSignature: createMintSig } = + await createMintInterface( + rpc, + payer, + mintAuthority, + null, + decimals, + mintSigner, + metadata, + addressTreeInfo, + undefined, + ); + await rpc.confirmTransaction(createMintSig, 'confirmed'); + + expect(mint.toString()).toBe(mintPda.toString()); + + const owner1 = Keypair.generate(); + const owner2 = Keypair.generate(); + + const { address: ata1, transactionSignature: createAta1Sig } = + await createAssociatedCTokenAccount( + rpc, + payer, + owner1.publicKey, + mint, + ); + await rpc.confirmTransaction(createAta1Sig, 'confirmed'); + + const { address: ata2, transactionSignature: createAta2Sig } = + await createAssociatedCTokenAccount( + rpc, + payer, + owner2.publicKey, + mint, + ); + await rpc.confirmTransaction(createAta2Sig, 'confirmed'); + + const expectedAta1 = getAssociatedCTokenAddress(owner1.publicKey, mint); + const expectedAta2 = getAssociatedCTokenAddress(owner2.publicKey, mint); + + expect(ata1.toString()).toBe(expectedAta1.toString()); + expect(ata2.toString()).toBe(expectedAta2.toString()); + + const account1Info = await rpc.getAccountInfo(ata1); + const account2Info = await rpc.getAccountInfo(ata2); + + expect(account1Info).not.toBe(null); + expect(account2Info).not.toBe(null); + expect(account1Info?.owner.toString()).toBe( + 'cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m', + ); + expect(account2Info?.owner.toString()).toBe( + 'cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m', + ); + }); + + it('should create mint with freeze authority then create associated account', async () => { + const mintSigner = Keypair.generate(); + const mintAuthority = Keypair.generate(); + const freezeAuthority = Keypair.generate(); + const owner = Keypair.generate(); + const decimals = 6; + const addressTreeInfo = getDefaultAddressTreeInfo(); + const [mintPda] = findMintAddress(mintSigner.publicKey); + + const { mint, transactionSignature: createMintSig } = + await createMintInterface( + rpc, + payer, + mintAuthority, + freezeAuthority.publicKey, + decimals, + mintSigner, + undefined, + addressTreeInfo, + undefined, + ); + await rpc.confirmTransaction(createMintSig, 'confirmed'); + + const { address: ataAddress, transactionSignature: createAtaSig } = + await createAssociatedCTokenAccountIdempotent( + rpc, + payer, + owner.publicKey, + mint, + ); + await rpc.confirmTransaction(createAtaSig, 'confirmed'); + + const expectedAddress = getAssociatedCTokenAddress( + owner.publicKey, + mint, + ); + expect(ataAddress.toString()).toBe(expectedAddress.toString()); + }); + + it('should verify different mints produce different ATAs for same owner', async () => { + const owner = Keypair.generate(); + const decimals = 9; + const addressTreeInfo = getDefaultAddressTreeInfo(); + + const mintSigner1 = Keypair.generate(); + const mintAuthority1 = Keypair.generate(); + const [mintPda1] = findMintAddress(mintSigner1.publicKey); + + const { transactionSignature: createMint1Sig } = + await createMintInterface( + rpc, + payer, + mintAuthority1, + null, + decimals, + mintSigner1, + undefined, + addressTreeInfo, + undefined, + ); + await rpc.confirmTransaction(createMint1Sig, 'confirmed'); + + const mintSigner2 = Keypair.generate(); + const mintAuthority2 = Keypair.generate(); + const [mintPda2] = findMintAddress(mintSigner2.publicKey); + + const { transactionSignature: createMint2Sig } = + await createMintInterface( + rpc, + payer, + mintAuthority2, + null, + decimals, + mintSigner2, + undefined, + addressTreeInfo, + undefined, + ); + await rpc.confirmTransaction(createMint2Sig, 'confirmed'); + + const { address: ata1 } = await createAssociatedCTokenAccount( + rpc, + payer, + owner.publicKey, + mintPda1, + ); + + const { address: ata2 } = await createAssociatedCTokenAccount( + rpc, + payer, + owner.publicKey, + mintPda2, + ); + + expect(ata1.toString()).not.toBe(ata2.toString()); + + const expectedAta1 = getAssociatedCTokenAddress( + owner.publicKey, + mintPda1, + ); + const expectedAta2 = getAssociatedCTokenAddress( + owner.publicKey, + mintPda2, + ); + + expect(ata1.toString()).toBe(expectedAta1.toString()); + expect(ata2.toString()).toBe(expectedAta2.toString()); + }); + + it('should work with pre-existing mint (not created in same test)', async () => { + const mintSigner = Keypair.generate(); + const mintAuthority = Keypair.generate(); + const decimals = 9; + const addressTreeInfo = getDefaultAddressTreeInfo(); + const [mintPda] = findMintAddress(mintSigner.publicKey); + + const { transactionSignature: createMintSig } = + await createMintInterface( + rpc, + payer, + mintAuthority, + null, + decimals, + mintSigner, + undefined, + addressTreeInfo, + undefined, + ); + await rpc.confirmTransaction(createMintSig, 'confirmed'); + + await new Promise(resolve => setTimeout(resolve, 1000)); + + const owner = Keypair.generate(); + const { address: ataAddress } = await createAssociatedCTokenAccount( + rpc, + payer, + owner.publicKey, + mintPda, + ); + + const expectedAddress = getAssociatedCTokenAddress( + owner.publicKey, + mintPda, + ); + expect(ataAddress.toString()).toBe(expectedAddress.toString()); + }); + + it('should verify idempotent behavior with explicit multiple calls', async () => { + const mintSigner = Keypair.generate(); + const mintAuthority = Keypair.generate(); + const owner = Keypair.generate(); + const decimals = 6; + const addressTreeInfo = getDefaultAddressTreeInfo(); + const [mintPda] = findMintAddress(mintSigner.publicKey); + + const { transactionSignature: createMintSig } = + await createMintInterface( + rpc, + payer, + mintAuthority, + null, + decimals, + mintSigner, + undefined, + addressTreeInfo, + undefined, + ); + await rpc.confirmTransaction(createMintSig, 'confirmed'); + + const { address: ataAddress1 } = + await createAssociatedCTokenAccountIdempotent( + rpc, + payer, + owner.publicKey, + mintPda, + ); + + const { address: ataAddress2 } = + await createAssociatedCTokenAccountIdempotent( + rpc, + payer, + owner.publicKey, + mintPda, + ); + + const { address: ataAddress3 } = + await createAssociatedCTokenAccountIdempotent( + rpc, + payer, + owner.publicKey, + mintPda, + ); + + expect(ataAddress1.toString()).toBe(ataAddress2.toString()); + expect(ataAddress2.toString()).toBe(ataAddress3.toString()); + }); + + it('should match SPL-style ATA derivation pattern', async () => { + const owner = PublicKey.unique(); + const mint = PublicKey.unique(); + + const ataAddress = getAssociatedCTokenAddress(owner, mint); + + const [expectedAddress, bump] = PublicKey.findProgramAddressSync( + [ + owner.toBuffer(), + new PublicKey( + 'cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m', + ).toBuffer(), + mint.toBuffer(), + ], + new PublicKey('cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m'), + ); + + expect(ataAddress.toString()).toBe(expectedAddress.toString()); + expect(bump).toBeGreaterThanOrEqual(0); + expect(bump).toBeLessThanOrEqual(255); + }); +}); diff --git a/js/compressed-token/tests/e2e/create-compressed-mint.test.ts b/js/compressed-token/tests/e2e/create-compressed-mint.test.ts new file mode 100644 index 0000000000..07a4468c18 --- /dev/null +++ b/js/compressed-token/tests/e2e/create-compressed-mint.test.ts @@ -0,0 +1,207 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { + PublicKey, + Keypair, + Signer, + ComputeBudgetProgram, +} from '@solana/web3.js'; +import { + Rpc, + newAccountWithLamports, + createRpc, + VERSION, + featureFlags, + getDefaultAddressTreeInfo, + buildAndSignTx, + sendAndConfirmTx, + CTOKEN_PROGRAM_ID, + DerivationMode, + selectStateTreeInfo, +} from '@lightprotocol/stateless.js'; +import { + createMintInstruction, + createTokenMetadata, +} from '../../src/mint/instructions'; +import { createMintInterface } from '../../src/mint/actions'; +import { getMintInterface } from '../../src/mint/helpers'; +import { findMintAddress } from '../../src/compressible/derivation'; + +featureFlags.version = VERSION.V2; + +describe('createMintInterface (compressed)', () => { + let rpc: Rpc; + let payer: Signer; + let mintSigner: Keypair; + let mintAuthority: Keypair; + + beforeAll(async () => { + rpc = createRpc(); + payer = await newAccountWithLamports(rpc, 10e9); + mintSigner = Keypair.generate(); + mintAuthority = Keypair.generate(); + }); + + it('should create a compressed mint with metadata and fetch it', async () => { + const decimals = 9; + const addressTreeInfo = getDefaultAddressTreeInfo(); + const [mintPda] = findMintAddress(mintSigner.publicKey); + + const { transactionSignature: signature } = await createMintInterface( + rpc, + payer, + mintAuthority, + null, + decimals, + mintSigner, + undefined, + addressTreeInfo, + undefined, + ); + + await rpc.confirmTransaction(signature, 'confirmed'); + const { mint, merkleContext, mintContext } = await getMintInterface( + rpc, + mintPda, + undefined, + CTOKEN_PROGRAM_ID, + ); + + expect(mint.address.toString()).toBe(mintPda.toString()); + expect(mint.mintAuthority?.toString()).toBe( + mintAuthority.publicKey.toString(), + ); + expect(mint.supply).toBe(0n); + expect(mint.isInitialized).toBe(true); + expect(mint.freezeAuthority).toBe(null); + expect(merkleContext).toBeDefined(); + expect(mintContext).toBeDefined(); + }); + + it('should create a compressed mint with freeze authority', async () => { + const decimals = 6; + const freezeAuthority = Keypair.generate(); + const mintSigner2 = Keypair.generate(); + + const addressTreeInfo = { + tree: new PublicKey('amt2kaJA14v3urZbZvnc5v2np8jqvc4Z8zDep5wbtzx'), + queue: new PublicKey('amt2kaJA14v3urZbZvnc5v2np8jqvc4Z8zDep5wbtzx'), + cpiContext: undefined, + treeType: 1, + nextTreeInfo: null, + }; + + const [mintPda] = findMintAddress(mintSigner2.publicKey); + + await rpc.getValidityProofV2( + [], + [ + { + address: Uint8Array.from(mintPda.toBytes()), + treeInfo: addressTreeInfo, + }, + ], + DerivationMode.compressible, + ); + + const { transactionSignature: signature } = await createMintInterface( + rpc, + payer, + mintAuthority, + freezeAuthority.publicKey, + decimals, + mintSigner2, + undefined, + addressTreeInfo, + undefined, + ); + + await rpc.confirmTransaction(signature, 'confirmed'); + const { mint, merkleContext, mintContext } = await getMintInterface( + rpc, + mintPda, + undefined, + CTOKEN_PROGRAM_ID, + ); + + expect(mint.address.toString()).toBe(mintPda.toString()); + expect(mint.mintAuthority?.toString()).toBe( + mintAuthority.publicKey.toString(), + ); + expect(mint.freezeAuthority?.toString()).toBe( + freezeAuthority.publicKey.toString(), + ); + expect(mint.isInitialized).toBe(true); + expect(merkleContext).toBeDefined(); + expect(mintContext).toBeDefined(); + }); + + it('should create compressed mint using instruction builder directly', async () => { + const decimals = 2; + const mintSigner3 = Keypair.generate(); + + const addressTreeInfo = { + tree: new PublicKey('amt2kaJA14v3urZbZvnc5v2np8jqvc4Z8zDep5wbtzx'), + queue: new PublicKey('amt2kaJA14v3urZbZvnc5v2np8jqvc4Z8zDep5wbtzx'), + cpiContext: undefined, + treeType: 1, + nextTreeInfo: null, + }; + + const [mintPda] = findMintAddress(mintSigner3.publicKey); + + const validityProof = await rpc.getValidityProofV2( + [], + [ + { + address: Uint8Array.from(mintPda.toBytes()), + treeInfo: addressTreeInfo, + }, + ], + DerivationMode.compressible, + ); + + const outputStateTreeInfo = selectStateTreeInfo( + await rpc.getStateTreeInfos(), + ); + + const instruction = createMintInstruction( + mintSigner3.publicKey, + decimals, + mintAuthority.publicKey, + null, + payer.publicKey, + validityProof, + addressTreeInfo, + outputStateTreeInfo, + createTokenMetadata( + 'Some Name', + 'SOME', + 'https://direct.com/metadata.json', + ), + ); + + const { blockhash } = await rpc.getLatestBlockhash(); + + const transaction = buildAndSignTx( + [ + ComputeBudgetProgram.setComputeUnitLimit({ units: 500_000 }), + instruction, + ], + payer, + blockhash, + [mintSigner3, mintAuthority], + ); + + await sendAndConfirmTx(rpc, transaction); + + const { mint } = await getMintInterface( + rpc, + mintPda, + undefined, + CTOKEN_PROGRAM_ID, + ); + + expect(mint.isInitialized).toBe(true); + expect(mint.address.toString()).toBe(mintPda.toString()); + }); +}); diff --git a/js/compressed-token/tests/e2e/create-mint.test.ts b/js/compressed-token/tests/e2e/create-mint.test.ts index 4489c2b26c..732c074b69 100644 --- a/js/compressed-token/tests/e2e/create-mint.test.ts +++ b/js/compressed-token/tests/e2e/create-mint.test.ts @@ -14,7 +14,7 @@ import { WasmFactory } from '@lightprotocol/hasher.rs'; * Asserts that createMint() creates a new spl mint account + the respective * system pool account */ -async function assertCreateMint( +async function assertCreateMintSPL( mint: PublicKey, authority: PublicKey, rpc: Rpc, @@ -45,7 +45,7 @@ async function assertCreateMint( } const TEST_TOKEN_DECIMALS = 2; -describe('createMint', () => { +describe('createMint (SPL)', () => { let rpc: Rpc; let payer: Signer; let mint: PublicKey; @@ -66,6 +66,7 @@ describe('createMint', () => { rpc, payer, mintAuthority.publicKey, + null, TEST_TOKEN_DECIMALS, mintKeypair, ) @@ -74,7 +75,7 @@ describe('createMint', () => { assert(mint.equals(mintKeypair.publicKey)); - await assertCreateMint( + await assertCreateMintSPL( mint, mintAuthority.publicKey, rpc, @@ -88,6 +89,7 @@ describe('createMint', () => { rpc, payer, mintAuthority.publicKey, + null, TEST_TOKEN_DECIMALS, mintKeypair, ), @@ -96,12 +98,18 @@ describe('createMint', () => { it('should create mint with payer as authority', async () => { mint = ( - await createMint(rpc, payer, payer.publicKey, TEST_TOKEN_DECIMALS) + await createMint( + rpc, + payer, + payer.publicKey, + null, + TEST_TOKEN_DECIMALS, + ) ).mint; const poolAccount = CompressedTokenProgram.deriveTokenPoolPda(mint); - await assertCreateMint( + await assertCreateMintSPL( mint, payer.publicKey, rpc, diff --git a/js/compressed-token/tests/e2e/create-token-pool.test.ts b/js/compressed-token/tests/e2e/create-token-pool.test.ts index a0ff075550..9b728fc2b2 100644 --- a/js/compressed-token/tests/e2e/create-token-pool.test.ts +++ b/js/compressed-token/tests/e2e/create-token-pool.test.ts @@ -126,6 +126,7 @@ describe('createTokenPool', () => { rpc, payer, mintAuthority.publicKey, + null, TEST_TOKEN_DECIMALS, mintKeypair, ), @@ -169,6 +170,7 @@ describe('createTokenPool', () => { rpc, payer, token22MintAuthority.publicKey, + null, TEST_TOKEN_DECIMALS, token22MintKeypair, undefined, diff --git a/js/compressed-token/tests/e2e/decompress-delegated.test.ts b/js/compressed-token/tests/e2e/decompress-delegated.test.ts index f1b62f65e2..b9cccf0d2c 100644 --- a/js/compressed-token/tests/e2e/decompress-delegated.test.ts +++ b/js/compressed-token/tests/e2e/decompress-delegated.test.ts @@ -120,6 +120,7 @@ describe('decompressDelegated', () => { rpc, payer, mintAuthority.publicKey, + null, TEST_TOKEN_DECIMALS, mintKeypair, ) diff --git a/js/compressed-token/tests/e2e/decompress.test.ts b/js/compressed-token/tests/e2e/decompress.test.ts index b3ec1400ca..c431322703 100644 --- a/js/compressed-token/tests/e2e/decompress.test.ts +++ b/js/compressed-token/tests/e2e/decompress.test.ts @@ -90,6 +90,7 @@ describe('decompress', () => { rpc, payer, mintAuthority.publicKey, + null, TEST_TOKEN_DECIMALS, mintKeypair, ) diff --git a/js/compressed-token/tests/e2e/decompress2.test.ts b/js/compressed-token/tests/e2e/decompress2.test.ts new file mode 100644 index 0000000000..a7bfaf5e9a --- /dev/null +++ b/js/compressed-token/tests/e2e/decompress2.test.ts @@ -0,0 +1,569 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { Keypair, Signer, PublicKey } from '@solana/web3.js'; +import { + Rpc, + bn, + newAccountWithLamports, + getTestRpc, + selectStateTreeInfo, + TreeInfo, + VERSION, + featureFlags, +} 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 { getATAAddressInterface } from '../../src/mint/actions/create-ata-interface'; +import { decompress2 } from '../../src/mint/actions/decompress2'; +import { createDecompress2Instruction } from '../../src/mint/instructions/decompress2'; + +featureFlags.version = VERSION.V2; + +const TEST_TOKEN_DECIMALS = 9; + +describe('decompress2', () => { + let rpc: Rpc; + let payer: Signer; + let mint: PublicKey; + let mintAuthority: Keypair; + let stateTreeInfo: 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, + null, + TEST_TOKEN_DECIMALS, + mintKeypair, + ) + ).mint; + + stateTreeInfo = selectStateTreeInfo(await rpc.getStateTreeInfos()); + tokenPoolInfos = await getTokenPoolInfos(rpc, mint); + }, 60_000); + + describe('decompress2 action', () => { + it('should return null when no compressed tokens', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + const signature = await decompress2({ + rpc, + payer, + owner, + mint, + }); + + expect(signature).toBeNull(); + }); + + it('should decompress compressed tokens to CToken ATA', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + // Mint compressed tokens + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(5000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + // Verify compressed balance exists + const compressedBefore = + await rpc.getCompressedTokenAccountsByOwner(owner.publicKey, { + mint, + }); + expect(compressedBefore.items.length).toBeGreaterThan(0); + + // Decompress using decompress2 + const signature = await decompress2({ + rpc, + payer, + owner, + mint, + }); + + expect(signature).not.toBeNull(); + + // Verify CToken ATA has balance + const ctokenAta = getATAAddressInterface(mint, owner.publicKey); + const ataInfo = await rpc.getAccountInfo(ctokenAta); + expect(ataInfo).not.toBeNull(); + const hotBalance = ataInfo!.data.readBigUInt64LE(64); + expect(hotBalance).toBe(BigInt(5000)); + + // Verify compressed balance is gone + const compressedAfter = await rpc.getCompressedTokenAccountsByOwner( + owner.publicKey, + { mint }, + ); + expect(compressedAfter.items.length).toBe(0); + }); + + it('should decompress specific amount when provided', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + // Mint compressed tokens + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(10000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + // Decompress only 3000 + const signature = await decompress2({ + rpc, + payer, + owner, + mint, + amount: BigInt(3000), + }); + + expect(signature).not.toBeNull(); + + // Verify CToken ATA has balance + const ctokenAta = getATAAddressInterface(mint, owner.publicKey); + const ataInfo = await rpc.getAccountInfo(ctokenAta); + expect(ataInfo).not.toBeNull(); + // Note: decompress2 decompresses all from selected accounts, + // so the balance will be 10000 (full account) + const hotBalance = ataInfo!.data.readBigUInt64LE(64); + expect(hotBalance).toBeGreaterThanOrEqual(BigInt(3000)); + }); + + it('should decompress multiple compressed accounts', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + // Mint multiple compressed token accounts + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(1000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(2000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(3000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + // Verify multiple compressed accounts + const compressedBefore = + await rpc.getCompressedTokenAccountsByOwner(owner.publicKey, { + mint, + }); + expect(compressedBefore.items.length).toBe(3); + + // Decompress all + const signature = await decompress2({ + rpc, + payer, + owner, + mint, + }); + + expect(signature).not.toBeNull(); + + // Verify total hot balance = 6000 + const ctokenAta = getATAAddressInterface(mint, owner.publicKey); + const ataInfo = await rpc.getAccountInfo(ctokenAta); + expect(ataInfo).not.toBeNull(); + const hotBalance = ataInfo!.data.readBigUInt64LE(64); + expect(hotBalance).toBe(BigInt(6000)); + + // Verify all compressed accounts are gone + const compressedAfter = await rpc.getCompressedTokenAccountsByOwner( + owner.publicKey, + { mint }, + ); + expect(compressedAfter.items.length).toBe(0); + }); + + it('should throw on insufficient compressed balance', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + // Mint small amount + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(100), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + await expect( + decompress2({ + rpc, + payer, + owner, + mint, + amount: BigInt(99999), + }), + ).rejects.toThrow('Insufficient compressed balance'); + }); + + it('should create CToken ATA if not exists', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + // Verify ATA doesn't exist + const ctokenAta = getATAAddressInterface(mint, owner.publicKey); + const beforeInfo = await rpc.getAccountInfo(ctokenAta); + expect(beforeInfo).toBeNull(); + + // Mint compressed tokens + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(1000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + // Decompress + const signature = await decompress2({ + rpc, + payer, + owner, + mint, + }); + + expect(signature).not.toBeNull(); + + // Verify ATA was created with balance + const afterInfo = await rpc.getAccountInfo(ctokenAta); + expect(afterInfo).not.toBeNull(); + const hotBalance = afterInfo!.data.readBigUInt64LE(64); + expect(hotBalance).toBe(BigInt(1000)); + }); + + it('should decompress to existing CToken ATA with balance', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + // Mint and decompress first batch + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(2000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + await decompress2({ + rpc, + payer, + owner, + mint, + }); + + // Verify initial balance + const ctokenAta = getATAAddressInterface(mint, owner.publicKey); + const midInfo = await rpc.getAccountInfo(ctokenAta); + expect(midInfo!.data.readBigUInt64LE(64)).toBe(BigInt(2000)); + + // Mint more compressed tokens + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(3000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + // Decompress again + await decompress2({ + rpc, + payer, + owner, + mint, + }); + + // Verify total balance = 5000 + const afterInfo = await rpc.getAccountInfo(ctokenAta); + expect(afterInfo!.data.readBigUInt64LE(64)).toBe(BigInt(5000)); + }); + + it('should decompress to custom destination ATA', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + const recipient = await newAccountWithLamports(rpc, 1e9); + + // Mint compressed tokens to owner + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(4000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + // Decompress to recipient's ATA + const recipientAta = getATAAddressInterface( + mint, + recipient.publicKey, + ); + const signature = await decompress2({ + rpc, + payer, + owner, + mint, + destinationAta: recipientAta, + }); + + expect(signature).not.toBeNull(); + + // Verify recipient ATA has balance + const recipientInfo = await rpc.getAccountInfo(recipientAta); + expect(recipientInfo).not.toBeNull(); + expect(recipientInfo!.data.readBigUInt64LE(64)).toBe(BigInt(4000)); + + // Owner's ATA should not exist or have 0 balance + const ownerAta = getATAAddressInterface(mint, owner.publicKey); + const ownerInfo = await rpc.getAccountInfo(ownerAta); + if (ownerInfo) { + expect(ownerInfo.data.readBigUInt64LE(64)).toBe(BigInt(0)); + } + }); + }); + + describe('createDecompress2Instruction', () => { + it('should build instruction with correct accounts', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + // Mint compressed tokens + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(1000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + // Get compressed accounts + const compressedResult = + await rpc.getCompressedTokenAccountsByOwner(owner.publicKey, { + mint, + }); + + const proof = await rpc.getValidityProofV0( + compressedResult.items.map(acc => ({ + hash: acc.compressedAccount.hash, + tree: acc.compressedAccount.treeInfo.tree, + queue: acc.compressedAccount.treeInfo.queue, + })), + ); + + const ctokenAta = getATAAddressInterface(mint, owner.publicKey); + + const ix = createDecompress2Instruction( + payer.publicKey, + compressedResult.items, + ctokenAta, + BigInt(1000), + proof.compressedProof, + proof.rootIndices, + ); + + // Verify instruction structure + expect(ix.programId.toBase58()).toBe( + 'cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m', + ); + expect(ix.keys.length).toBeGreaterThan(0); + + // First account should be light_system_program + expect(ix.keys[0].pubkey.toBase58()).toBe( + 'SySTEM1eSU2p4BGQfQpimFEWWSC1XDFeun3Nqzz3rT7', + ); + + // Second account should be fee payer (signer, mutable) + expect(ix.keys[1].pubkey.equals(payer.publicKey)).toBe(true); + expect(ix.keys[1].isSigner).toBe(true); + expect(ix.keys[1].isWritable).toBe(true); + + // Third account should be authority/owner (signer) + expect(ix.keys[2].pubkey.equals(owner.publicKey)).toBe(true); + expect(ix.keys[2].isSigner).toBe(true); + }); + + it('should throw when no input accounts provided', () => { + const ctokenAta = Keypair.generate().publicKey; + + expect(() => + createDecompress2Instruction( + payer.publicKey, + [], + ctokenAta, + BigInt(1000), + null, + [], + ), + ).toThrow('No input compressed token accounts provided'); + }); + + it('should handle multiple input accounts', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + // Mint multiple compressed token accounts + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(500), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(500), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + // Get compressed accounts + const compressedResult = + await rpc.getCompressedTokenAccountsByOwner(owner.publicKey, { + mint, + }); + expect(compressedResult.items.length).toBe(2); + + const proof = await rpc.getValidityProofV0( + compressedResult.items.map(acc => ({ + hash: acc.compressedAccount.hash, + tree: acc.compressedAccount.treeInfo.tree, + queue: acc.compressedAccount.treeInfo.queue, + })), + ); + + const ctokenAta = getATAAddressInterface(mint, owner.publicKey); + + const ix = createDecompress2Instruction( + payer.publicKey, + compressedResult.items, + ctokenAta, + BigInt(1000), + proof.compressedProof, + proof.rootIndices, + ); + + // Instruction should be valid + expect(ix.programId.toBase58()).toBe( + 'cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m', + ); + // Should have more accounts due to multiple input compressed accounts + expect(ix.keys.length).toBeGreaterThan(10); + }); + + it('should set correct writable flags on accounts', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + // Mint compressed tokens + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(1000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const compressedResult = + await rpc.getCompressedTokenAccountsByOwner(owner.publicKey, { + mint, + }); + + const proof = await rpc.getValidityProofV0( + compressedResult.items.map(acc => ({ + hash: acc.compressedAccount.hash, + tree: acc.compressedAccount.treeInfo.tree, + queue: acc.compressedAccount.treeInfo.queue, + })), + ); + + const ctokenAta = getATAAddressInterface(mint, owner.publicKey); + + const ix = createDecompress2Instruction( + payer.publicKey, + compressedResult.items, + ctokenAta, + BigInt(1000), + proof.compressedProof, + proof.rootIndices, + ); + + // Fee payer should be writable + expect(ix.keys[1].isWritable).toBe(true); + + // Authority should not be writable + expect(ix.keys[2].isWritable).toBe(false); + + // Find destination account and verify it's writable + const destKey = ix.keys.find(k => k.pubkey.equals(ctokenAta)); + expect(destKey).toBeDefined(); + expect(destKey!.isWritable).toBe(true); + }); + }); +}); diff --git a/js/compressed-token/tests/e2e/delegate.test.ts b/js/compressed-token/tests/e2e/delegate.test.ts index 7505b16bc0..c7d310ccf3 100644 --- a/js/compressed-token/tests/e2e/delegate.test.ts +++ b/js/compressed-token/tests/e2e/delegate.test.ts @@ -126,6 +126,7 @@ describe('delegate', () => { rpc, payer, mintAuthority.publicKey, + null, TEST_TOKEN_DECIMALS, mintKeypair, ) diff --git a/js/compressed-token/tests/e2e/layout.test.ts b/js/compressed-token/tests/e2e/layout.test.ts index fc89aa1755..46a9bb32da 100644 --- a/js/compressed-token/tests/e2e/layout.test.ts +++ b/js/compressed-token/tests/e2e/layout.test.ts @@ -12,7 +12,7 @@ import { InputTokenDataWithContext, PackedMerkleContextLegacy, ValidityProof, - COMPRESSED_TOKEN_PROGRAM_ID, + CTOKEN_PROGRAM_ID, defaultStaticAccountsStruct, LightSystemProgram, } from '@lightprotocol/stateless.js'; @@ -50,7 +50,7 @@ const getTestProgram = (): Program => { }, ); setProvider(mockProvider); - return new Program(IDL, COMPRESSED_TOKEN_PROGRAM_ID, mockProvider); + return new Program(IDL, CTOKEN_PROGRAM_ID, mockProvider); }; function deepEqual(ref: any, val: any) { if (ref === null && val === null) return true; diff --git a/js/compressed-token/tests/e2e/merge-token-accounts.test.ts b/js/compressed-token/tests/e2e/merge-token-accounts.test.ts index e63a4a7434..9795c3f5c1 100644 --- a/js/compressed-token/tests/e2e/merge-token-accounts.test.ts +++ b/js/compressed-token/tests/e2e/merge-token-accounts.test.ts @@ -35,6 +35,7 @@ describe('mergeTokenAccounts', () => { rpc, payer, mintAuthority.publicKey, + null, 2, mintKeypair, ) diff --git a/js/compressed-token/tests/e2e/mint-to-compressed.test.ts b/js/compressed-token/tests/e2e/mint-to-compressed.test.ts new file mode 100644 index 0000000000..08da1d78c2 --- /dev/null +++ b/js/compressed-token/tests/e2e/mint-to-compressed.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { + PublicKey, + Keypair, + Signer, + ComputeBudgetProgram, +} from '@solana/web3.js'; +import { + Rpc, + newAccountWithLamports, + createRpc, + VERSION, + featureFlags, + CTOKEN_PROGRAM_ID, + selectStateTreeInfo, +} from '@lightprotocol/stateless.js'; +import { createMintInterface } from '../../src/mint/actions/create-mint-interface'; +import { mintToCompressed } from '../../src/mint/actions/mint-to-compressed'; +import { getMintInterface } from '../../src/mint/helpers'; +import { findMintAddress } from '../../src/compressible/derivation'; + +featureFlags.version = VERSION.V2; + +describe('mintToCompressed', () => { + let rpc: Rpc; + let payer: Signer; + let mintSigner: Keypair; + let mintAuthority: Keypair; + let mint: PublicKey; + let recipient1: Keypair; + let recipient2: Keypair; + + beforeAll(async () => { + rpc = createRpc(); + payer = await newAccountWithLamports(rpc, 10e9); + mintSigner = Keypair.generate(); + mintAuthority = Keypair.generate(); + recipient1 = Keypair.generate(); + recipient2 = Keypair.generate(); + + const decimals = 9; + const result = await createMintInterface( + rpc, + payer, + mintAuthority, + null, + decimals, + mintSigner, + undefined, + undefined, + undefined, + ); + await rpc.confirmTransaction(result.transactionSignature, 'confirmed'); + mint = result.mint; + }); + + it('should mint tokens to a single recipient', async () => { + const amount = 1000; + + const txId = await mintToCompressed(rpc, payer, mint, mintAuthority, [ + { recipient: recipient1.publicKey, amount }, + ]); + + await rpc.confirmTransaction(txId, 'confirmed'); + + const compressedAccounts = await rpc.getCompressedTokenAccountsByOwner( + recipient1.publicKey, + ); + + expect(compressedAccounts.items.length).toBeGreaterThan(0); + + const account = compressedAccounts.items.find(acc => + acc.parsed.mint.equals(mint), + ); + + expect(account).toBeDefined(); + expect(account!.parsed.amount.toNumber()).toBe(amount); + expect(account!.parsed.owner.toString()).toBe( + recipient1.publicKey.toString(), + ); + + const mintInfo = await getMintInterface( + rpc, + mint, + undefined, + CTOKEN_PROGRAM_ID, + ); + expect(mintInfo.mint.supply).toBe(BigInt(amount)); + }); + + it('should mint tokens to multiple recipients', async () => { + const amount1 = 500; + const amount2 = 750; + + const txId = await mintToCompressed(rpc, payer, mint, mintAuthority, [ + { recipient: recipient1.publicKey, amount: amount1 }, + { recipient: recipient2.publicKey, amount: amount2 }, + ]); + + await rpc.confirmTransaction(txId, 'confirmed'); + + const accounts1 = await rpc.getCompressedTokenAccountsByOwner( + recipient1.publicKey, + ); + const account1 = accounts1.items.find(acc => + acc.parsed.mint.equals(mint), + ); + expect(account1).toBeDefined(); + + const accounts2 = await rpc.getCompressedTokenAccountsByOwner( + recipient2.publicKey, + ); + const account2 = accounts2.items.find(acc => + acc.parsed.mint.equals(mint), + ); + expect(account2).toBeDefined(); + expect(account2!.parsed.amount.toNumber()).toBe(amount2); + + const mintInfo = await getMintInterface( + rpc, + mint, + undefined, + CTOKEN_PROGRAM_ID, + ); + expect(mintInfo.mint.supply).toBe(BigInt(1000 + amount1 + amount2)); + }); + + it('should fail with wrong authority', async () => { + const wrongAuthority = Keypair.generate(); + + await expect( + mintToCompressed(rpc, payer, mint, wrongAuthority, [ + { recipient: recipient1.publicKey, amount: 100 }, + ]), + ).rejects.toThrow(); + }); + + it('should support bigint amounts', async () => { + const amount = 1000000000n; + + const txId = await mintToCompressed(rpc, payer, mint, mintAuthority, [ + { recipient: recipient1.publicKey, amount }, + ]); + + await rpc.confirmTransaction(txId, 'confirmed'); + + const mintInfo = await getMintInterface( + rpc, + mint, + undefined, + CTOKEN_PROGRAM_ID, + ); + expect(mintInfo.mint.supply).toBeGreaterThanOrEqual(amount); + }); +}); diff --git a/js/compressed-token/tests/e2e/mint-to-ctoken.test.ts b/js/compressed-token/tests/e2e/mint-to-ctoken.test.ts new file mode 100644 index 0000000000..a2ec6549eb --- /dev/null +++ b/js/compressed-token/tests/e2e/mint-to-ctoken.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { + PublicKey, + Keypair, + Signer, + ComputeBudgetProgram, +} from '@solana/web3.js'; +import { + Rpc, + newAccountWithLamports, + createRpc, + VERSION, + featureFlags, + CTOKEN_PROGRAM_ID, +} from '@lightprotocol/stateless.js'; +import { createMintInterface } from '../../src/mint/actions/create-mint-interface'; +import { mintTo } from '../../src/mint/actions/mint-to'; +import { getMintInterface } from '../../src/mint/helpers'; +import { createAssociatedCTokenAccount } from '../../src/mint/actions/create-associated-ctoken'; +import { + getAssociatedCTokenAddress, + findMintAddress, +} from '../../src/compressible/derivation'; + +featureFlags.version = VERSION.V2; + +describe('mintTo (MintToCToken)', () => { + let rpc: Rpc; + let payer: Signer; + let mintSigner: Keypair; + let mintAuthority: Keypair; + let mint: PublicKey; + let recipient: Keypair; + let recipientCToken: PublicKey; + + beforeAll(async () => { + rpc = createRpc(); + payer = await newAccountWithLamports(rpc, 10e9); + mintSigner = Keypair.generate(); + mintAuthority = Keypair.generate(); + recipient = Keypair.generate(); + + const decimals = 9; + const result = await createMintInterface( + rpc, + payer, + mintAuthority, + null, + decimals, + mintSigner, + undefined, + undefined, + undefined, + ); + await rpc.confirmTransaction(result.transactionSignature, 'confirmed'); + mint = result.mint; + + const { transactionSignature } = await createAssociatedCTokenAccount( + rpc, + payer, + recipient.publicKey, + mint, + ); + await rpc.confirmTransaction(transactionSignature, 'confirmed'); + recipientCToken = getAssociatedCTokenAddress(recipient.publicKey, mint); + }); + + it('should mint tokens to onchain ctoken account', async () => { + const amount = 1000; + + const txId = await mintTo( + rpc, + payer, + mint, + recipientCToken, + mintAuthority, + amount, + ); + + await rpc.confirmTransaction(txId, 'confirmed'); + + const accountInfo = await rpc.getAccountInfo(recipientCToken); + expect(accountInfo).toBeDefined(); + + const mintInfo = await getMintInterface( + rpc, + mint, + undefined, + CTOKEN_PROGRAM_ID, + ); + expect(mintInfo.mint.supply).toBe(BigInt(amount)); + }); + + it('should fail with wrong authority', async () => { + const wrongAuthority = Keypair.generate(); + + await expect( + mintTo(rpc, payer, mint, recipientCToken, wrongAuthority, 100), + ).rejects.toThrow(); + }); + + it('should support bigint amounts', async () => { + const amount = 500n; + + const txId = await mintTo( + rpc, + payer, + mint, + recipientCToken, + mintAuthority, + amount, + ); + + await rpc.confirmTransaction(txId, 'confirmed'); + + const mintInfo = await getMintInterface( + rpc, + mint, + undefined, + CTOKEN_PROGRAM_ID, + ); + expect(mintInfo.mint.supply).toBeGreaterThanOrEqual(1000n + amount); + }); +}); diff --git a/js/compressed-token/tests/e2e/mint-to-interface.test.ts b/js/compressed-token/tests/e2e/mint-to-interface.test.ts new file mode 100644 index 0000000000..a46c654f97 --- /dev/null +++ b/js/compressed-token/tests/e2e/mint-to-interface.test.ts @@ -0,0 +1,454 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { PublicKey, Keypair, Signer } from '@solana/web3.js'; +import { + Rpc, + newAccountWithLamports, + createRpc, + getTestRpc, + VERSION, + featureFlags, + CTOKEN_PROGRAM_ID, +} from '@lightprotocol/stateless.js'; +import { + getOrCreateAssociatedTokenAccount, + getAccount, + TOKEN_PROGRAM_ID, + TOKEN_2022_PROGRAM_ID, +} from '@solana/spl-token'; +import { WasmFactory } from '@lightprotocol/hasher.rs'; +import { createMintInterface } from '../../src/mint/actions/create-mint-interface'; +import { mintToInterface } from '../../src/mint/actions/mint-to-interface'; +import { createMint } from '../../src/actions/create-mint'; +import { createAssociatedCTokenAccount } from '../../src/mint/actions/create-associated-ctoken'; +import { getAssociatedCTokenAddress } from '../../src/compressible/derivation'; +import { getAccountInterface } from '../../src/mint/get-account-interface'; + +featureFlags.version = VERSION.V2; + +const TEST_TOKEN_DECIMALS = 9; + +describe('mintToInterface - SPL Mints', () => { + let rpc: Rpc; + let payer: Signer; + let mint: PublicKey; + let mintAuthority: Keypair; + + 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, + null, + TEST_TOKEN_DECIMALS, + mintKeypair, + ) + ).mint; + }); + + it('should mint SPL tokens to decompressed SPL token account', async () => { + const recipient = Keypair.generate(); + const amount = 2000; + + const ata = await getOrCreateAssociatedTokenAccount( + rpc, + payer as Keypair, + mint, + recipient.publicKey, + false, + 'confirmed', + undefined, + TOKEN_PROGRAM_ID, + ); + + const txId = await mintToInterface( + rpc, + payer, + mint, + ata.address, + mintAuthority, + amount, + ); + + const accountInfo = await getAccount( + rpc, + ata.address, + 'confirmed', + TOKEN_PROGRAM_ID, + ); + expect(accountInfo.amount).toBe(BigInt(amount)); + }); + + it('should mint SPL tokens with bigint amount', async () => { + const recipient = Keypair.generate(); + const amount = 1000000000n; + + const ata = await getOrCreateAssociatedTokenAccount( + rpc, + payer as Keypair, + mint, + recipient.publicKey, + false, + 'confirmed', + undefined, + TOKEN_PROGRAM_ID, + ); + + const txId = await mintToInterface( + rpc, + payer, + mint, + ata.address, + mintAuthority, + amount, + ); + + const accountInfo = await getAccount( + rpc, + ata.address, + 'confirmed', + TOKEN_PROGRAM_ID, + ); + expect(accountInfo.amount).toBe(amount); + }); + + it('should fail with wrong authority for SPL mint', async () => { + const wrongAuthority = Keypair.generate(); + const recipient = Keypair.generate(); + + const ata = await getOrCreateAssociatedTokenAccount( + rpc, + payer as Keypair, + mint, + recipient.publicKey, + false, + 'confirmed', + undefined, + TOKEN_PROGRAM_ID, + ); + + await expect( + mintToInterface(rpc, payer, mint, ata.address, wrongAuthority, 100), + ).rejects.toThrow(); + }); + + it('should auto-detect TOKEN_PROGRAM_ID when programId not provided', async () => { + const recipient = Keypair.generate(); + const amount = 500; + + const ata = await getOrCreateAssociatedTokenAccount( + rpc, + payer as Keypair, + mint, + recipient.publicKey, + false, + 'confirmed', + undefined, + TOKEN_PROGRAM_ID, + ); + + // Don't pass programId - should auto-detect + const txId = await mintToInterface( + rpc, + payer, + mint, + ata.address, + mintAuthority, + amount, + ); + + const accountInfo = await getAccount( + rpc, + ata.address, + 'confirmed', + TOKEN_PROGRAM_ID, + ); + expect(accountInfo.amount).toBe(BigInt(amount)); + }); +}); + +describe('mintToInterface - Compressed Mints', () => { + let rpc: Rpc; + let payer: Signer; + let mintSigner: Keypair; + let mintAuthority: Keypair; + let mint: PublicKey; + + beforeAll(async () => { + rpc = createRpc(); + payer = await newAccountWithLamports(rpc, 10e9); + mintSigner = Keypair.generate(); + mintAuthority = Keypair.generate(); + + const decimals = 9; + const result = await createMintInterface( + rpc, + payer, + mintAuthority, + null, + decimals, + mintSigner, + undefined, + undefined, + undefined, + ); + await rpc.confirmTransaction(result.transactionSignature, 'confirmed'); + mint = result.mint; + }); + + it('should mint compressed tokens to onchain ctoken account', async () => { + const recipient = Keypair.generate(); + const { transactionSignature } = await createAssociatedCTokenAccount( + rpc, + payer, + recipient.publicKey, + mint, + ); + await rpc.confirmTransaction(transactionSignature, 'confirmed'); + + const recipientCToken = getAssociatedCTokenAddress( + recipient.publicKey, + mint, + ); + const amount = 1000; + + const txId = await mintToInterface( + rpc, + payer, + mint, + recipientCToken, + mintAuthority, + amount, + ); + + await rpc.confirmTransaction(txId, 'confirmed'); + + // Verify the account exists and is owned by CToken program + const accountInterface = await getAccountInterface( + rpc, + recipientCToken, + 'confirmed', + ); + expect(accountInterface).toBeDefined(); + expect(accountInterface.accountInfo.owner.toString()).toBe( + CTOKEN_PROGRAM_ID.toBase58(), + ); + expect(accountInterface.parsed.amount).toBe(BigInt(amount)); + }); + + it('should mint compressed tokens with bigint amount', async () => { + const recipient = Keypair.generate(); + const { transactionSignature } = await createAssociatedCTokenAccount( + rpc, + payer, + recipient.publicKey, + mint, + ); + await rpc.confirmTransaction(transactionSignature, 'confirmed'); + + const recipientCToken = getAssociatedCTokenAddress( + recipient.publicKey, + mint, + ); + const amount = 1000000000n; + + const txId = await mintToInterface( + rpc, + payer, + mint, + recipientCToken, + mintAuthority, + amount, + ); + + await rpc.confirmTransaction(txId, 'confirmed'); + + const accountInterface = await getAccountInterface( + rpc, + recipientCToken, + 'confirmed', + ); + expect(accountInterface.parsed.amount).toBe(amount); + }); + + it('should fail with wrong authority for compressed mint', async () => { + const wrongAuthority = Keypair.generate(); + const recipient = Keypair.generate(); + const { transactionSignature } = await createAssociatedCTokenAccount( + rpc, + payer, + recipient.publicKey, + mint, + ); + await rpc.confirmTransaction(transactionSignature, 'confirmed'); + + const recipientCToken = getAssociatedCTokenAddress( + recipient.publicKey, + mint, + ); + + await expect( + mintToInterface( + rpc, + payer, + mint, + recipientCToken, + wrongAuthority, + 100, + ), + ).rejects.toThrow(); + }); + + it('should auto-detect CTOKEN_PROGRAM_ID when programId not provided', async () => { + const recipient = Keypair.generate(); + const { transactionSignature } = await createAssociatedCTokenAccount( + rpc, + payer, + recipient.publicKey, + mint, + ); + await rpc.confirmTransaction(transactionSignature, 'confirmed'); + + const recipientCToken = getAssociatedCTokenAddress( + recipient.publicKey, + mint, + ); + const amount = 500; + + // Don't pass programId - should auto-detect + const txId = await mintToInterface( + rpc, + payer, + mint, + recipientCToken, + mintAuthority, + amount, + ); + + await rpc.confirmTransaction(txId, 'confirmed'); + + const accountInterface = await getAccountInterface( + rpc, + recipientCToken, + 'confirmed', + ); + expect(accountInterface.parsed.amount).toBe(BigInt(amount)); + }); +}); + +describe('mintToInterface - Edge Cases', () => { + let rpc: Rpc; + let payer: Signer; + let compressedMint: PublicKey; + let mintAuthority: Keypair; + + beforeAll(async () => { + rpc = createRpc(); + payer = await newAccountWithLamports(rpc, 10e9); + mintAuthority = Keypair.generate(); + + const mintSigner = Keypair.generate(); + const result = await createMintInterface( + rpc, + payer, + mintAuthority, + null, + 6, + mintSigner, + undefined, + undefined, + undefined, + ); + await rpc.confirmTransaction(result.transactionSignature, 'confirmed'); + compressedMint = result.mint; + }); + + it('should handle zero amount minting', async () => { + const recipient = Keypair.generate(); + const { transactionSignature } = await createAssociatedCTokenAccount( + rpc, + payer, + recipient.publicKey, + compressedMint, + ); + await rpc.confirmTransaction(transactionSignature, 'confirmed'); + + const recipientCToken = getAssociatedCTokenAddress( + recipient.publicKey, + compressedMint, + ); + + const txId = await mintToInterface( + rpc, + payer, + compressedMint, + recipientCToken, + mintAuthority, + 0, + ); + + await rpc.confirmTransaction(txId, 'confirmed'); + + const accountInterface = await getAccountInterface( + rpc, + recipientCToken, + 'confirmed', + ); + expect(accountInterface.parsed.amount).toBe(BigInt(0)); + }); + + it('should handle payer as authority', async () => { + const mintSigner = Keypair.generate(); + const result = await createMintInterface( + rpc, + payer, + payer as Keypair, + null, + 9, + mintSigner, + undefined, + undefined, + undefined, + ); + await rpc.confirmTransaction(result.transactionSignature, 'confirmed'); + + const recipient = Keypair.generate(); + const { transactionSignature } = await createAssociatedCTokenAccount( + rpc, + payer, + recipient.publicKey, + result.mint, + ); + await rpc.confirmTransaction(transactionSignature, 'confirmed'); + + const recipientCToken = getAssociatedCTokenAddress( + recipient.publicKey, + result.mint, + ); + const amount = 1000; + + const txId = await mintToInterface( + rpc, + payer, + result.mint, + recipientCToken, + payer as Keypair, + amount, + ); + + await rpc.confirmTransaction(txId, 'confirmed'); + + const accountInterface = await getAccountInterface( + rpc, + recipientCToken, + 'confirmed', + ); + expect(accountInterface.parsed.amount).toBe(BigInt(amount)); + }); +}); diff --git a/js/compressed-token/tests/e2e/mint-to.test.ts b/js/compressed-token/tests/e2e/mint-to.test.ts index 62c9adc3ed..ad9a73edc8 100644 --- a/js/compressed-token/tests/e2e/mint-to.test.ts +++ b/js/compressed-token/tests/e2e/mint-to.test.ts @@ -83,6 +83,7 @@ describe('mintTo', () => { rpc, payer, mintAuthority.publicKey, + null, TEST_TOKEN_DECIMALS, mintKeypair, ) diff --git a/js/compressed-token/tests/e2e/mint-workflow.test.ts b/js/compressed-token/tests/e2e/mint-workflow.test.ts new file mode 100644 index 0000000000..c60b1aadf7 --- /dev/null +++ b/js/compressed-token/tests/e2e/mint-workflow.test.ts @@ -0,0 +1,677 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { Keypair, Signer } from '@solana/web3.js'; +import { + Rpc, + newAccountWithLamports, + createRpc, + VERSION, + featureFlags, + getDefaultAddressTreeInfo, + CTOKEN_PROGRAM_ID, +} from '@lightprotocol/stateless.js'; +import { createMintInterface } from '../../src/mint/actions'; +import { createTokenMetadata } from '../../src/mint/instructions'; +import { + updateMintAuthority, + updateFreezeAuthority, +} from '../../src/mint/actions/update-mint'; +import { + updateMetadataField, + updateMetadataAuthority, +} from '../../src/mint/actions/update-metadata'; +import { + createATAInterfaceIdempotent, + getATAAddressInterface, +} from '../../src/mint/actions/create-ata-interface'; +import { getMintInterface } from '../../src/mint/helpers'; +import { findMintAddress } from '../../src/compressible/derivation'; + +featureFlags.version = VERSION.V2; + +describe('Complete Mint Workflow', () => { + let rpc: Rpc; + let payer: Signer; + + beforeAll(async () => { + rpc = createRpc(); + payer = await newAccountWithLamports(rpc, 10e9); + }); + + it('should execute complete workflow: create mint -> update metadata -> update authorities -> create ATAs', async () => { + const mintSigner = Keypair.generate(); + const initialMintAuthority = Keypair.generate(); + const initialFreezeAuthority = Keypair.generate(); + const decimals = 9; + const addressTreeInfo = getDefaultAddressTreeInfo(); + const [mintPda] = findMintAddress(mintSigner.publicKey); + + const initialMetadata = createTokenMetadata( + 'Workflow Token', + 'WORK', + 'https://workflow.com/initial', + initialMintAuthority.publicKey, + ); + + const { mint, transactionSignature: createSig } = + await createMintInterface( + rpc, + payer, + initialMintAuthority, + initialFreezeAuthority.publicKey, + decimals, + mintSigner, + initialMetadata, + addressTreeInfo, + undefined, + ); + await rpc.confirmTransaction(createSig, 'confirmed'); + + expect(mint.toString()).toBe(mintPda.toString()); + + let mintInfo = await getMintInterface( + rpc, + mintPda, + undefined, + CTOKEN_PROGRAM_ID, + ); + expect(mintInfo.mint.mintAuthority?.toString()).toBe( + initialMintAuthority.publicKey.toString(), + ); + expect(mintInfo.mint.freezeAuthority?.toString()).toBe( + initialFreezeAuthority.publicKey.toString(), + ); + expect(mintInfo.tokenMetadata?.name).toBe('Workflow Token'); + expect(mintInfo.tokenMetadata?.symbol).toBe('WORK'); + + const updateNameSig = await updateMetadataField( + rpc, + payer, + mintPda, + mintSigner, + initialMintAuthority, + 'name', + 'Workflow Token V2', + ); + await rpc.confirmTransaction(updateNameSig, 'confirmed'); + + mintInfo = await getMintInterface( + rpc, + mintPda, + undefined, + CTOKEN_PROGRAM_ID, + ); + expect(mintInfo.tokenMetadata?.name).toBe('Workflow Token V2'); + + const updateUriSig = await updateMetadataField( + rpc, + payer, + mintPda, + mintSigner, + initialMintAuthority, + 'uri', + 'https://workflow.com/updated', + ); + await rpc.confirmTransaction(updateUriSig, 'confirmed'); + + mintInfo = await getMintInterface( + rpc, + mintPda, + undefined, + CTOKEN_PROGRAM_ID, + ); + expect(mintInfo.tokenMetadata?.uri).toBe( + 'https://workflow.com/updated', + ); + + const newMetadataAuthority = Keypair.generate(); + const updateMetadataAuthSig = await updateMetadataAuthority( + rpc, + payer, + mintPda, + mintSigner, + initialMintAuthority, + newMetadataAuthority.publicKey, + ); + await rpc.confirmTransaction(updateMetadataAuthSig, 'confirmed'); + + mintInfo = await getMintInterface( + rpc, + mintPda, + undefined, + CTOKEN_PROGRAM_ID, + ); + expect(mintInfo.tokenMetadata?.updateAuthority?.toString()).toBe( + newMetadataAuthority.publicKey.toString(), + ); + + const newMintAuthority = Keypair.generate(); + const updateMintAuthSig = await updateMintAuthority( + rpc, + payer, + mintPda, + mintSigner, + initialMintAuthority, + newMintAuthority.publicKey, + ); + await rpc.confirmTransaction(updateMintAuthSig, 'confirmed'); + + mintInfo = await getMintInterface( + rpc, + mintPda, + undefined, + CTOKEN_PROGRAM_ID, + ); + expect(mintInfo.mint.mintAuthority?.toString()).toBe( + newMintAuthority.publicKey.toString(), + ); + + const newFreezeAuthority = Keypair.generate(); + const updateFreezeAuthSig = await updateFreezeAuthority( + rpc, + payer, + mintPda, + mintSigner, + initialFreezeAuthority, + newFreezeAuthority.publicKey, + ); + await rpc.confirmTransaction(updateFreezeAuthSig, 'confirmed'); + + mintInfo = await getMintInterface( + rpc, + mintPda, + undefined, + CTOKEN_PROGRAM_ID, + ); + expect(mintInfo.mint.freezeAuthority?.toString()).toBe( + newFreezeAuthority.publicKey.toString(), + ); + + const owner1 = Keypair.generate(); + const owner2 = Keypair.generate(); + const owner3 = Keypair.generate(); + + const { address: ata1 } = await createATAInterfaceIdempotent( + rpc, + payer, + mint, + owner1.publicKey, + ); + + const { address: ata2 } = await createATAInterfaceIdempotent( + rpc, + payer, + mint, + owner2.publicKey, + ); + + const { address: ata3 } = await createATAInterfaceIdempotent( + rpc, + payer, + mint, + owner3.publicKey, + ); + + const expectedAta1 = getATAAddressInterface(mint, owner1.publicKey); + const expectedAta2 = getATAAddressInterface(mint, owner2.publicKey); + const expectedAta3 = getATAAddressInterface(mint, owner3.publicKey); + + expect(ata1.toString()).toBe(expectedAta1.toString()); + expect(ata2.toString()).toBe(expectedAta2.toString()); + expect(ata3.toString()).toBe(expectedAta3.toString()); + + const finalMintInfo = await getMintInterface( + rpc, + mintPda, + undefined, + CTOKEN_PROGRAM_ID, + ); + expect(finalMintInfo.mint.mintAuthority?.toString()).toBe( + newMintAuthority.publicKey.toString(), + ); + expect(finalMintInfo.mint.freezeAuthority?.toString()).toBe( + newFreezeAuthority.publicKey.toString(), + ); + expect(finalMintInfo.tokenMetadata?.updateAuthority?.toString()).toBe( + newMetadataAuthority.publicKey.toString(), + ); + expect(finalMintInfo.tokenMetadata?.name).toBe('Workflow Token V2'); + expect(finalMintInfo.tokenMetadata?.uri).toBe( + 'https://workflow.com/updated', + ); + }); + + it('should handle authority revocations in workflow', async () => { + const mintSigner = Keypair.generate(); + const mintAuthority = Keypair.generate(); + const freezeAuthority = Keypair.generate(); + const decimals = 6; + const addressTreeInfo = getDefaultAddressTreeInfo(); + const [mintPda] = findMintAddress(mintSigner.publicKey); + + const metadata = createTokenMetadata( + 'Revoke Test', + 'RVKE', + 'https://revoke.com', + mintAuthority.publicKey, + ); + + const { transactionSignature: createSig } = await createMintInterface( + rpc, + payer, + mintAuthority, + freezeAuthority.publicKey, + decimals, + mintSigner, + metadata, + addressTreeInfo, + undefined, + ); + await rpc.confirmTransaction(createSig, 'confirmed'); + + let mintInfo = await getMintInterface( + rpc, + mintPda, + undefined, + CTOKEN_PROGRAM_ID, + ); + expect(mintInfo.mint.freezeAuthority).not.toBe(null); + + const revokeFreezeAuthSig = await updateFreezeAuthority( + rpc, + payer, + mintPda, + mintSigner, + freezeAuthority, + null, + ); + await rpc.confirmTransaction(revokeFreezeAuthSig, 'confirmed'); + + mintInfo = await getMintInterface( + rpc, + mintPda, + undefined, + CTOKEN_PROGRAM_ID, + ); + expect(mintInfo.mint.freezeAuthority).toBe(null); + expect(mintInfo.mint.mintAuthority?.toString()).toBe( + mintAuthority.publicKey.toString(), + ); + + const owner = Keypair.generate(); + const { address: ataAddress } = await createATAInterfaceIdempotent( + rpc, + payer, + mintPda, + owner.publicKey, + ); + + const expectedAddress = getATAAddressInterface( + mintPda, + owner.publicKey, + ); + expect(ataAddress.toString()).toBe(expectedAddress.toString()); + + const accountInfo = await rpc.getAccountInfo(ataAddress); + expect(accountInfo).not.toBe(null); + }); + + it('should create mint without metadata then create ATAs', async () => { + const mintSigner = Keypair.generate(); + const mintAuthority = Keypair.generate(); + const decimals = 9; + const addressTreeInfo = getDefaultAddressTreeInfo(); + const [mintPda] = findMintAddress(mintSigner.publicKey); + + const { mint, transactionSignature: createSig } = + await createMintInterface( + rpc, + payer, + mintAuthority, + null, + decimals, + mintSigner, + undefined, + addressTreeInfo, + undefined, + ); + await rpc.confirmTransaction(createSig, 'confirmed'); + + const mintInfo = await getMintInterface( + rpc, + mintPda, + undefined, + CTOKEN_PROGRAM_ID, + ); + expect(mintInfo.tokenMetadata).toBeUndefined(); + + const owners = [ + Keypair.generate(), + Keypair.generate(), + Keypair.generate(), + ]; + + for (const owner of owners) { + const { address: ataAddress } = await createATAInterfaceIdempotent( + rpc, + payer, + mint, + owner.publicKey, + ); + + const expectedAddress = getATAAddressInterface( + mint, + owner.publicKey, + ); + expect(ataAddress.toString()).toBe(expectedAddress.toString()); + + const accountInfo = await rpc.getAccountInfo(ataAddress); + expect(accountInfo).not.toBe(null); + } + }); + + it('should update metadata after creating ATAs', async () => { + const mintSigner = Keypair.generate(); + const mintAuthority = Keypair.generate(); + const decimals = 9; + const addressTreeInfo = getDefaultAddressTreeInfo(); + const [mintPda] = findMintAddress(mintSigner.publicKey); + + const metadata = createTokenMetadata( + 'Before ATA', + 'BATA', + 'https://before.com', + mintAuthority.publicKey, + ); + + const { transactionSignature: createSig } = await createMintInterface( + rpc, + payer, + mintAuthority, + null, + decimals, + mintSigner, + metadata, + addressTreeInfo, + undefined, + ); + await rpc.confirmTransaction(createSig, 'confirmed'); + + const owner = Keypair.generate(); + const { address: ataAddress } = await createATAInterfaceIdempotent( + rpc, + payer, + mintPda, + owner.publicKey, + ); + + const expectedAddress = getATAAddressInterface( + mintPda, + owner.publicKey, + ); + expect(ataAddress.toString()).toBe(expectedAddress.toString()); + + const updateNameSig = await updateMetadataField( + rpc, + payer, + mintPda, + mintSigner, + mintAuthority, + 'name', + 'After ATA', + ); + await rpc.confirmTransaction(updateNameSig, 'confirmed'); + + const mintInfo = await getMintInterface( + rpc, + mintPda, + undefined, + CTOKEN_PROGRAM_ID, + ); + expect(mintInfo.tokenMetadata?.name).toBe('After ATA'); + + const accountInfo = await rpc.getAccountInfo(ataAddress); + expect(accountInfo).not.toBe(null); + }); + + it('should create mint with all features then verify state consistency', async () => { + const mintSigner = Keypair.generate(); + const mintAuthority = Keypair.generate(); + const freezeAuthority = Keypair.generate(); + const decimals = 9; + const addressTreeInfo = getDefaultAddressTreeInfo(); + const [mintPda] = findMintAddress(mintSigner.publicKey); + + const metadata = createTokenMetadata( + 'Full Feature Token', + 'FULL', + 'https://full.com', + mintAuthority.publicKey, + ); + + const { mint, transactionSignature: createSig } = + await createMintInterface( + rpc, + payer, + mintAuthority, + freezeAuthority.publicKey, + decimals, + mintSigner, + metadata, + addressTreeInfo, + undefined, + ); + await rpc.confirmTransaction(createSig, 'confirmed'); + + let mintInfo = await getMintInterface( + rpc, + mintPda, + undefined, + CTOKEN_PROGRAM_ID, + ); + expect(mintInfo.mint.supply).toBe(0n); + expect(mintInfo.mint.decimals).toBe(decimals); + expect(mintInfo.mint.isInitialized).toBe(true); + expect(mintInfo.mint.mintAuthority?.toString()).toBe( + mintAuthority.publicKey.toString(), + ); + expect(mintInfo.mint.freezeAuthority?.toString()).toBe( + freezeAuthority.publicKey.toString(), + ); + expect(mintInfo.tokenMetadata?.name).toBe('Full Feature Token'); + expect(mintInfo.tokenMetadata?.symbol).toBe('FULL'); + expect(mintInfo.tokenMetadata?.uri).toBe('https://full.com'); + expect(mintInfo.tokenMetadata?.updateAuthority?.toString()).toBe( + mintAuthority.publicKey.toString(), + ); + expect(mintInfo.merkleContext).toBeDefined(); + expect(mintInfo.mintContext).toBeDefined(); + expect(mintInfo.mintContext?.version).toBeGreaterThan(0); + + const owner1 = Keypair.generate(); + const owner2 = Keypair.generate(); + + const { address: ata1 } = await createATAInterfaceIdempotent( + rpc, + payer, + mint, + owner1.publicKey, + ); + + const { address: ata2 } = await createATAInterfaceIdempotent( + rpc, + payer, + mint, + owner2.publicKey, + ); + + const account1 = await rpc.getAccountInfo(ata1); + const account2 = await rpc.getAccountInfo(ata2); + expect(account1).not.toBe(null); + expect(account2).not.toBe(null); + + const newMintAuthority = Keypair.generate(); + const updateMintAuthSig = await updateMintAuthority( + rpc, + payer, + mintPda, + mintSigner, + mintAuthority, + newMintAuthority.publicKey, + ); + await rpc.confirmTransaction(updateMintAuthSig, 'confirmed'); + + mintInfo = await getMintInterface( + rpc, + mintPda, + undefined, + CTOKEN_PROGRAM_ID, + ); + expect(mintInfo.mint.mintAuthority?.toString()).toBe( + newMintAuthority.publicKey.toString(), + ); + + const updateSymbolSig = await updateMetadataField( + rpc, + payer, + mintPda, + mintSigner, + mintAuthority, + 'symbol', + 'FULL2', + ); + await rpc.confirmTransaction(updateSymbolSig, 'confirmed'); + + const finalMintInfo = await getMintInterface( + rpc, + mintPda, + undefined, + CTOKEN_PROGRAM_ID, + ); + expect(finalMintInfo.tokenMetadata?.symbol).toBe('FULL2'); + expect(finalMintInfo.mint.mintAuthority?.toString()).toBe( + newMintAuthority.publicKey.toString(), + ); + + const { address: ata1Again } = await createATAInterfaceIdempotent( + rpc, + payer, + mint, + owner1.publicKey, + ); + expect(ata1Again.toString()).toBe(ata1.toString()); + }); + + it('should create minimal mint then progressively add features and accounts', async () => { + const mintSigner = Keypair.generate(); + const mintAuthority = Keypair.generate(); + const decimals = 6; + const addressTreeInfo = getDefaultAddressTreeInfo(); + const [mintPda] = findMintAddress(mintSigner.publicKey); + + const { mint, transactionSignature: createSig } = + await createMintInterface( + rpc, + payer, + mintAuthority, + null, + decimals, + mintSigner, + undefined, + addressTreeInfo, + undefined, + ); + await rpc.confirmTransaction(createSig, 'confirmed'); + + let mintInfo = await getMintInterface( + rpc, + mintPda, + undefined, + CTOKEN_PROGRAM_ID, + ); + expect(mintInfo.mint.freezeAuthority).toBe(null); + expect(mintInfo.tokenMetadata).toBeUndefined(); + + const owner = Keypair.generate(); + const { address: ataAddress } = await createATAInterfaceIdempotent( + rpc, + payer, + mint, + owner.publicKey, + ); + + const expectedAddress = getATAAddressInterface(mint, owner.publicKey); + expect(ataAddress.toString()).toBe(expectedAddress.toString()); + + const accountInfo = await rpc.getAccountInfo(ataAddress); + expect(accountInfo).not.toBe(null); + expect(accountInfo?.data.length).toBeGreaterThan(0); + + const newMintAuthority = Keypair.generate(); + const updateMintAuthSig = await updateMintAuthority( + rpc, + payer, + mintPda, + mintSigner, + mintAuthority, + newMintAuthority.publicKey, + ); + await rpc.confirmTransaction(updateMintAuthSig, 'confirmed'); + + const finalMintInfo = await getMintInterface( + rpc, + mintPda, + undefined, + CTOKEN_PROGRAM_ID, + ); + expect(finalMintInfo.mint.mintAuthority?.toString()).toBe( + newMintAuthority.publicKey.toString(), + ); + expect(finalMintInfo.mint.supply).toBe(0n); + }); + + it('should verify ATA addresses are deterministic', async () => { + const mintSigner = Keypair.generate(); + const mintAuthority = Keypair.generate(); + const owner = Keypair.generate(); + const decimals = 9; + const addressTreeInfo = getDefaultAddressTreeInfo(); + const [mintPda] = findMintAddress(mintSigner.publicKey); + + const { mint, transactionSignature: createSig } = + await createMintInterface( + rpc, + payer, + mintAuthority, + null, + decimals, + mintSigner, + undefined, + addressTreeInfo, + undefined, + ); + await rpc.confirmTransaction(createSig, 'confirmed'); + + const derivedAddressBefore = getATAAddressInterface( + mint, + owner.publicKey, + ); + + const { address: ataAddress } = await createATAInterfaceIdempotent( + rpc, + payer, + mint, + owner.publicKey, + ); + + const derivedAddressAfter = getATAAddressInterface( + mint, + owner.publicKey, + ); + + expect(ataAddress.toString()).toBe(derivedAddressBefore.toString()); + expect(ataAddress.toString()).toBe(derivedAddressAfter.toString()); + expect(derivedAddressBefore.toString()).toBe( + derivedAddressAfter.toString(), + ); + }); +}); diff --git a/js/compressed-token/tests/e2e/multi-pool.test.ts b/js/compressed-token/tests/e2e/multi-pool.test.ts index 2773c3ad14..531e12ba68 100644 --- a/js/compressed-token/tests/e2e/multi-pool.test.ts +++ b/js/compressed-token/tests/e2e/multi-pool.test.ts @@ -123,6 +123,7 @@ describe('multi-pool', () => { rpc, payer, mintAuthority.publicKey, + null, TEST_TOKEN_DECIMALS, mintKeypair, ), diff --git a/js/compressed-token/tests/e2e/payment-flows.test.ts b/js/compressed-token/tests/e2e/payment-flows.test.ts new file mode 100644 index 0000000000..650c315c4f --- /dev/null +++ b/js/compressed-token/tests/e2e/payment-flows.test.ts @@ -0,0 +1,586 @@ +/** + * Payment Flows Test + * + * Demonstrates CToken payment patterns at both action and instruction level. + * Mirrors SPL Token's flow: destination ATA must exist before transfer. + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import { + Keypair, + Signer, + PublicKey, + ComputeBudgetProgram, +} from '@solana/web3.js'; +import { + Rpc, + bn, + newAccountWithLamports, + getTestRpc, + selectStateTreeInfo, + TreeInfo, + VERSION, + featureFlags, + CTOKEN_PROGRAM_ID, + buildAndSignTx, + sendAndConfirmTx, +} 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 { getATAInterface } from '../../src/mint/get-account-interface'; +import { getATAAddressInterface } from '../../src/mint/actions/create-ata-interface'; +import { getOrCreateATAInterface } from '../../src/mint/actions/get-or-create-ata-interface'; +import { transferInterface } from '../../src/mint/actions/transfer-interface'; +import { + createLoadAccountsParams, + loadATA, +} from '../../src/compressible/unified-load'; +import { createTransferInterfaceInstruction } from '../../src/mint/instructions/transfer-interface'; +import { createAssociatedTokenAccountInterfaceIdempotentInstruction } from '../../src/mint/instructions/create-associated-ctoken'; + +featureFlags.version = VERSION.V2; + +const TEST_TOKEN_DECIMALS = 9; + +describe('Payment Flows', () => { + let rpc: Rpc; + let payer: Signer; + let mint: PublicKey; + let mintAuthority: Keypair; + let stateTreeInfo: 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, + null, + TEST_TOKEN_DECIMALS, + mintKeypair, + ) + ).mint; + + stateTreeInfo = selectStateTreeInfo(await rpc.getStateTreeInfos()); + tokenPoolInfos = await getTokenPoolInfos(rpc, mint); + }, 60_000); + + // ================================================================ + // ACTION LEVEL - Mirrors SPL Token pattern + // ================================================================ + + describe('Action Level', () => { + it('SPL Token pattern: getOrCreate + transfer', async () => { + const sender = await newAccountWithLamports(rpc, 1e9); + const recipient = Keypair.generate(); + const amount = BigInt(1000); + + // Setup: mint compressed tokens to sender + await mintTo( + rpc, + payer, + mint, + sender.publicKey, + mintAuthority, + bn(5000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + // STEP 1: getOrCreateATAInterface for recipient (like SPL's getOrCreateAssociatedTokenAccount) + const recipientAta = await getOrCreateATAInterface( + rpc, + payer, + mint, + recipient.publicKey, + ); + + // STEP 2: transfer (auto-loads sender, destination must exist) + const sourceAta = getATAAddressInterface(mint, sender.publicKey); + const signature = await transferInterface( + rpc, + payer, + sourceAta, + recipientAta.address, + sender, + mint, + amount, + CTOKEN_PROGRAM_ID, + undefined, + { tokenPoolInfos }, + ); + + expect(signature).toBeDefined(); + + // Verify + const recipientBalance = (await rpc.getAccountInfo( + recipientAta.address, + ))!.data.readBigUInt64LE(64); + expect(recipientBalance).toBe(amount); + }); + + it('sender cold, recipient no ATA', async () => { + const sender = await newAccountWithLamports(rpc, 1e9); + const recipient = Keypair.generate(); + + // Mint to sender (cold) + await mintTo( + rpc, + payer, + mint, + sender.publicKey, + mintAuthority, + bn(3000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + // Create recipient ATA first + const recipientAta = await getOrCreateATAInterface( + rpc, + payer, + mint, + recipient.publicKey, + ); + + // Transfer - auto-loads sender + const sourceAta = getATAAddressInterface(mint, sender.publicKey); + await transferInterface( + rpc, + payer, + sourceAta, + recipientAta.address, + sender, + mint, + BigInt(2000), + CTOKEN_PROGRAM_ID, + undefined, + { tokenPoolInfos }, + ); + + // Verify + const recipientBalance = (await rpc.getAccountInfo( + recipientAta.address, + ))!.data.readBigUInt64LE(64); + expect(recipientBalance).toBe(BigInt(2000)); + + const senderBalance = (await rpc.getAccountInfo( + sourceAta, + ))!.data.readBigUInt64LE(64); + expect(senderBalance).toBe(BigInt(1000)); + }); + + it('both sender and recipient have existing hot ATAs', async () => { + const sender = await newAccountWithLamports(rpc, 1e9); + const recipient = await newAccountWithLamports(rpc, 1e9); + + // Setup both with hot balances + await mintTo( + rpc, + payer, + mint, + sender.publicKey, + mintAuthority, + bn(5000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + const senderAta = getATAAddressInterface(mint, sender.publicKey); + await loadATA(rpc, payer, senderAta, sender, mint, undefined, { + tokenPoolInfos, + }); + + await mintTo( + rpc, + payer, + mint, + recipient.publicKey, + mintAuthority, + bn(1000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + const recipientAta = getATAAddressInterface( + mint, + recipient.publicKey, + ); + await loadATA( + rpc, + payer, + recipientAta, + recipient, + mint, + undefined, + { + tokenPoolInfos, + }, + ); + + const sourceAta = getATAAddressInterface(mint, sender.publicKey); + const destAta = getATAAddressInterface(mint, recipient.publicKey); + + const recipientBefore = (await rpc.getAccountInfo( + destAta, + ))!.data.readBigUInt64LE(64); + + // Transfer - no loading needed + await transferInterface( + rpc, + payer, + sourceAta, + destAta, + sender, + mint, + BigInt(500), + ); + + const recipientAfter = (await rpc.getAccountInfo( + destAta, + ))!.data.readBigUInt64LE(64); + expect(recipientAfter).toBe(recipientBefore + BigInt(500)); + }); + }); + + // ================================================================ + // INSTRUCTION LEVEL - Full control + // ================================================================ + + describe('Instruction Level', () => { + it('manual: load + create ATA + transfer', async () => { + const sender = await newAccountWithLamports(rpc, 1e9); + const recipient = Keypair.generate(); + const amount = BigInt(1000); + + // Mint to sender (cold) + await mintTo( + rpc, + payer, + mint, + sender.publicKey, + mintAuthority, + bn(5000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + // STEP 1: Fetch sender's ATA for loading + const senderAta = await getATAInterface( + rpc, + sender.publicKey, + mint, + ); + + // STEP 2: Build load params + const result = await createLoadAccountsParams( + rpc, + payer.publicKey, + CTOKEN_PROGRAM_ID, + [], + [senderAta], + { tokenPoolInfos }, + ); + + // STEP 3: Derive addresses + const senderAtaAddress = getATAAddressInterface( + mint, + sender.publicKey, + ); + const recipientAtaAddress = getATAAddressInterface( + mint, + recipient.publicKey, + ); + + // STEP 4: Build instructions + const instructions = [ + ComputeBudgetProgram.setComputeUnitLimit({ units: 500_000 }), + // Load sender + ...result.ataInstructions, + // Create recipient ATA (idempotent) + createAssociatedTokenAccountInterfaceIdempotentInstruction( + payer.publicKey, + recipientAtaAddress, + recipient.publicKey, + mint, + CTOKEN_PROGRAM_ID, + ), + // Transfer + createTransferInterfaceInstruction( + senderAtaAddress, + recipientAtaAddress, + sender.publicKey, + amount, + ), + ]; + + // STEP 5: Send + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx(instructions, payer, blockhash, [sender]); + const signature = await sendAndConfirmTx(rpc, tx); + + expect(signature).toBeDefined(); + + // Verify + const recipientBalance = (await rpc.getAccountInfo( + recipientAtaAddress, + ))!.data.readBigUInt64LE(64); + expect(recipientBalance).toBe(amount); + }); + + it('sender already hot - minimal instructions', async () => { + const sender = await newAccountWithLamports(rpc, 1e9); + const recipient = Keypair.generate(); + + // Setup sender hot + await mintTo( + rpc, + payer, + mint, + sender.publicKey, + mintAuthority, + bn(3000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + const senderAtaAddress = getATAAddressInterface( + mint, + sender.publicKey, + ); + await loadATA( + rpc, + payer, + senderAtaAddress, + sender, + mint, + undefined, + { + tokenPoolInfos, + }, + ); + + // Sender is hot - createLoadAccountsParams returns empty ataInstructions + const senderAta = await getATAInterface( + rpc, + sender.publicKey, + mint, + ); + const result = await createLoadAccountsParams( + rpc, + payer.publicKey, + CTOKEN_PROGRAM_ID, + [], + [senderAta], + ); + expect(result.ataInstructions).toHaveLength(0); + + const recipientAtaAddress = getATAAddressInterface( + mint, + recipient.publicKey, + ); + + const instructions = [ + ComputeBudgetProgram.setComputeUnitLimit({ units: 50_000 }), + createAssociatedTokenAccountInterfaceIdempotentInstruction( + payer.publicKey, + recipientAtaAddress, + recipient.publicKey, + mint, + CTOKEN_PROGRAM_ID, + ), + createTransferInterfaceInstruction( + senderAtaAddress, + recipientAtaAddress, + sender.publicKey, + BigInt(500), + ), + ]; + + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx(instructions, payer, blockhash, [sender]); + await sendAndConfirmTx(rpc, tx); + + // Verify + const balance = (await rpc.getAccountInfo( + recipientAtaAddress, + ))!.data.readBigUInt64LE(64); + expect(balance).toBe(BigInt(500)); + }); + + it('multiple recipients in single tx', async () => { + const sender = await newAccountWithLamports(rpc, 1e9); + const recipient1 = Keypair.generate(); + const recipient2 = Keypair.generate(); + + // Setup sender hot + await mintTo( + rpc, + payer, + mint, + sender.publicKey, + mintAuthority, + bn(10000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + const senderAta = getATAAddressInterface(mint, sender.publicKey); + await loadATA(rpc, payer, senderAta, sender, mint, undefined, { + tokenPoolInfos, + }); + + const senderAtaAddress = getATAAddressInterface( + mint, + sender.publicKey, + ); + const r1AtaAddress = getATAAddressInterface( + mint, + recipient1.publicKey, + ); + const r2AtaAddress = getATAAddressInterface( + mint, + recipient2.publicKey, + ); + + const instructions = [ + ComputeBudgetProgram.setComputeUnitLimit({ units: 100_000 }), + // Create ATAs + createAssociatedTokenAccountInterfaceIdempotentInstruction( + payer.publicKey, + r1AtaAddress, + recipient1.publicKey, + mint, + CTOKEN_PROGRAM_ID, + ), + createAssociatedTokenAccountInterfaceIdempotentInstruction( + payer.publicKey, + r2AtaAddress, + recipient2.publicKey, + mint, + CTOKEN_PROGRAM_ID, + ), + // Transfers + createTransferInterfaceInstruction( + senderAtaAddress, + r1AtaAddress, + sender.publicKey, + BigInt(1000), + ), + createTransferInterfaceInstruction( + senderAtaAddress, + r2AtaAddress, + sender.publicKey, + BigInt(2000), + ), + ]; + + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx(instructions, payer, blockhash, [sender]); + await sendAndConfirmTx(rpc, tx); + + // Verify + const r1Balance = (await rpc.getAccountInfo( + r1AtaAddress, + ))!.data.readBigUInt64LE(64); + const r2Balance = (await rpc.getAccountInfo( + r2AtaAddress, + ))!.data.readBigUInt64LE(64); + expect(r1Balance).toBe(BigInt(1000)); + expect(r2Balance).toBe(BigInt(2000)); + }); + }); + + // ================================================================ + // IDEMPOTENCY + // ================================================================ + + describe('Idempotency', () => { + it('create ATA instruction is idempotent', async () => { + const sender = await newAccountWithLamports(rpc, 1e9); + const recipient = await newAccountWithLamports(rpc, 1e9); + + // Setup both with hot balances + await mintTo( + rpc, + payer, + mint, + sender.publicKey, + mintAuthority, + bn(5000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + const senderAta = getATAAddressInterface(mint, sender.publicKey); + await loadATA(rpc, payer, senderAta, sender, mint, undefined, { + tokenPoolInfos, + }); + + await mintTo( + rpc, + payer, + mint, + recipient.publicKey, + mintAuthority, + bn(1000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + const recipientAta = getATAAddressInterface( + mint, + recipient.publicKey, + ); + await loadATA( + rpc, + payer, + recipientAta, + recipient, + mint, + undefined, + { + tokenPoolInfos, + }, + ); + + const senderAtaAddress = getATAAddressInterface( + mint, + sender.publicKey, + ); + const recipientAtaAddress = getATAAddressInterface( + mint, + recipient.publicKey, + ); + + // Include create ATA even though it exists - should not fail + const instructions = [ + ComputeBudgetProgram.setComputeUnitLimit({ units: 50_000 }), + createAssociatedTokenAccountInterfaceIdempotentInstruction( + payer.publicKey, + recipientAtaAddress, + recipient.publicKey, + mint, + CTOKEN_PROGRAM_ID, + ), + createTransferInterfaceInstruction( + senderAtaAddress, + recipientAtaAddress, + sender.publicKey, + BigInt(100), + ), + ]; + + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx(instructions, payer, blockhash, [sender]); + + // Should not throw + await expect(sendAndConfirmTx(rpc, tx)).resolves.toBeDefined(); + }); + }); +}); diff --git a/js/compressed-token/tests/e2e/rpc-multi-trees.test.ts b/js/compressed-token/tests/e2e/rpc-multi-trees.test.ts index 254b777cfd..2ebcf4702e 100644 --- a/js/compressed-token/tests/e2e/rpc-multi-trees.test.ts +++ b/js/compressed-token/tests/e2e/rpc-multi-trees.test.ts @@ -43,6 +43,7 @@ describe('rpc-multi-trees', () => { rpc, payer, mintAuthority.publicKey, + null, TEST_TOKEN_DECIMALS, mintKeypair, ) diff --git a/js/compressed-token/tests/e2e/rpc-token-interop.test.ts b/js/compressed-token/tests/e2e/rpc-token-interop.test.ts index 6bdcdfc7c1..d7eb5665e8 100644 --- a/js/compressed-token/tests/e2e/rpc-token-interop.test.ts +++ b/js/compressed-token/tests/e2e/rpc-token-interop.test.ts @@ -46,6 +46,7 @@ describe('rpc-interop token', () => { rpc, payer, mintAuthority.publicKey, + null, TEST_TOKEN_DECIMALS, mintKeypair, ) @@ -259,6 +260,7 @@ describe('rpc-interop token', () => { rpc, payer, mintAuthority.publicKey, + null, TEST_TOKEN_DECIMALS, ) ).mint; diff --git a/js/compressed-token/tests/e2e/transfer-delegated.test.ts b/js/compressed-token/tests/e2e/transfer-delegated.test.ts index be2d66d2da..518fa3dfa3 100644 --- a/js/compressed-token/tests/e2e/transfer-delegated.test.ts +++ b/js/compressed-token/tests/e2e/transfer-delegated.test.ts @@ -190,6 +190,7 @@ describe('transferDelegated', () => { rpc, payer, mintAuthority.publicKey, + null, TEST_TOKEN_DECIMALS, mintKeypair, ) @@ -251,6 +252,7 @@ describe('transferDelegated', () => { rpc, payer, mintAuthority.publicKey, + null, TEST_TOKEN_DECIMALS, newMintKeypair, ) @@ -325,6 +327,7 @@ describe('transferDelegated', () => { rpc, payer, mintAuthority.publicKey, + null, TEST_TOKEN_DECIMALS, newMintKeypair, ) diff --git a/js/compressed-token/tests/e2e/transfer-interface.test.ts b/js/compressed-token/tests/e2e/transfer-interface.test.ts new file mode 100644 index 0000000000..193ea33f37 --- /dev/null +++ b/js/compressed-token/tests/e2e/transfer-interface.test.ts @@ -0,0 +1,509 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { Keypair, Signer, PublicKey } from '@solana/web3.js'; +import { + Rpc, + bn, + newAccountWithLamports, + getTestRpc, + selectStateTreeInfo, + TreeInfo, + CTOKEN_PROGRAM_ID, + VERSION, + featureFlags, +} 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 { getATAAddressInterface } from '../../src/mint/actions/create-ata-interface'; +import { getOrCreateATAInterface } from '../../src/mint/actions/get-or-create-ata-interface'; +import { transferInterface } from '../../src/mint/actions/transfer-interface'; +import { + loadATA, + createLoadATAInstructions, +} from '../../src/compressible/unified-load'; +import { + createTransferInterfaceInstruction, + createCTokenTransferInstruction, +} from '../../src/mint/instructions/transfer-interface'; + +featureFlags.version = VERSION.V2; + +const TEST_TOKEN_DECIMALS = 9; + +describe('transfer-interface', () => { + let rpc: Rpc; + let payer: Signer; + let mint: PublicKey; + let mintAuthority: Keypair; + let stateTreeInfo: 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, + null, + TEST_TOKEN_DECIMALS, + mintKeypair, + ) + ).mint; + + stateTreeInfo = selectStateTreeInfo(await rpc.getStateTreeInfos()); + tokenPoolInfos = await getTokenPoolInfos(rpc, mint); + }, 60_000); + + describe('createTransferInterfaceInstruction', () => { + it('should create CToken transfer instruction with correct accounts', () => { + const source = Keypair.generate().publicKey; + const destination = Keypair.generate().publicKey; + const owner = Keypair.generate().publicKey; + const amount = BigInt(1000); + + const ix = createTransferInterfaceInstruction( + source, + destination, + owner, + amount, + ); + + expect(ix.programId.equals(CTOKEN_PROGRAM_ID)).toBe(true); + expect(ix.keys.length).toBe(3); + expect(ix.keys[0].pubkey.equals(source)).toBe(true); + expect(ix.keys[1].pubkey.equals(destination)).toBe(true); + expect(ix.keys[2].pubkey.equals(owner)).toBe(true); + }); + + it('should add payer as 4th account when different from owner', () => { + const source = Keypair.generate().publicKey; + const destination = Keypair.generate().publicKey; + const owner = Keypair.generate().publicKey; + const payerPk = Keypair.generate().publicKey; + const amount = BigInt(1000); + + const ix = createCTokenTransferInstruction( + source, + destination, + owner, + amount, + payerPk, + ); + + expect(ix.keys.length).toBe(4); + expect(ix.keys[3].pubkey.equals(payerPk)).toBe(true); + }); + + it('should not add payer when same as owner', () => { + const source = Keypair.generate().publicKey; + const destination = Keypair.generate().publicKey; + const owner = Keypair.generate().publicKey; + const amount = BigInt(1000); + + const ix = createCTokenTransferInstruction( + source, + destination, + owner, + amount, + owner, // payer same as owner + ); + + expect(ix.keys.length).toBe(3); + }); + }); + + describe('createLoadATAInstructions', () => { + it('should return empty when no balances to load (idempotent)', async () => { + const owner = Keypair.generate(); + const ata = getATAAddressInterface(mint, owner.publicKey); + + const ixs = await createLoadATAInstructions( + rpc, + payer.publicKey, + ata, + owner.publicKey, + mint, + ); + + expect(ixs.length).toBe(0); + }); + + it('should build load instructions for compressed balance', async () => { + const owner = Keypair.generate(); + + // Mint compressed tokens + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(1000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const ata = getATAAddressInterface(mint, owner.publicKey); + const ixs = await createLoadATAInstructions( + rpc, + payer.publicKey, + ata, + owner.publicKey, + mint, + { tokenPoolInfos }, + ); + + expect(ixs.length).toBeGreaterThan(0); + }); + + it('should load ALL compressed accounts', async () => { + const owner = Keypair.generate(); + + // Mint multiple compressed token accounts + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(500), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(300), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const ata = getATAAddressInterface(mint, owner.publicKey); + const ixs = await createLoadATAInstructions( + rpc, + payer.publicKey, + ata, + owner.publicKey, + mint, + { tokenPoolInfos }, + ); + + expect(ixs.length).toBeGreaterThan(0); + }); + }); + + describe('loadATA action', () => { + it('should return null when nothing to load (idempotent)', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + const ata = getATAAddressInterface(mint, owner.publicKey); + + const signature = await loadATA(rpc, payer, ata, owner, mint); + + expect(signature).toBeNull(); + }); + + it('should execute load and return signature', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + // Mint compressed tokens + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(2000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const ata = getATAAddressInterface(mint, owner.publicKey); + const signature = await loadATA( + rpc, + payer, + ata, + owner, + mint, + undefined, + { + tokenPoolInfos, + }, + ); + + expect(signature).not.toBeNull(); + expect(typeof signature).toBe('string'); + + // Verify hot balance increased + const ctokenAta = getATAAddressInterface(mint, owner.publicKey); + const ataInfo = await rpc.getAccountInfo(ctokenAta); + expect(ataInfo).not.toBeNull(); + const hotBalance = ataInfo!.data.readBigUInt64LE(64); + expect(hotBalance).toBe(BigInt(2000)); + }); + }); + + describe('transferInterface action', () => { + it('should transfer from hot balance (destination exists)', async () => { + const sender = await newAccountWithLamports(rpc, 1e9); + const recipient = Keypair.generate(); + + // Mint and load sender + await mintTo( + rpc, + payer, + mint, + sender.publicKey, + mintAuthority, + bn(5000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + const senderAta = getATAAddressInterface(mint, sender.publicKey); + await loadATA(rpc, payer, senderAta, sender, mint, undefined, { + tokenPoolInfos, + }); + + // Create recipient ATA first (like SPL Token flow) + const recipientAta = await getOrCreateATAInterface( + rpc, + payer, + mint, + recipient.publicKey, + ); + + const sourceAta = getATAAddressInterface(mint, sender.publicKey); + + // Transfer - destination is ATA address + const signature = await transferInterface( + rpc, + payer, + sourceAta, + recipientAta.address, + sender, + mint, + BigInt(1000), + ); + + expect(signature).toBeDefined(); + + // Verify balances + const senderAtaInfo = await rpc.getAccountInfo(sourceAta); + const senderBalance = senderAtaInfo!.data.readBigUInt64LE(64); + expect(senderBalance).toBe(BigInt(4000)); + + const recipientAtaInfo = await rpc.getAccountInfo( + recipientAta.address, + ); + const recipientBalance = recipientAtaInfo!.data.readBigUInt64LE(64); + expect(recipientBalance).toBe(BigInt(1000)); + }); + + it('should auto-load sender when transferring from cold', async () => { + const sender = await newAccountWithLamports(rpc, 1e9); + const recipient = Keypair.generate(); + + // Mint compressed tokens (cold) - don't load + await mintTo( + rpc, + payer, + mint, + sender.publicKey, + mintAuthority, + bn(3000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + // Create recipient ATA first + const recipientAta = await getOrCreateATAInterface( + rpc, + payer, + mint, + recipient.publicKey, + ); + + const sourceAta = getATAAddressInterface(mint, sender.publicKey); + + // Transfer should auto-load sender's cold balance + const signature = await transferInterface( + rpc, + payer, + sourceAta, + recipientAta.address, + sender, + mint, + BigInt(2000), + CTOKEN_PROGRAM_ID, + undefined, + { tokenPoolInfos }, + ); + + expect(signature).toBeDefined(); + + // Verify recipient received tokens + const recipientAtaInfo = await rpc.getAccountInfo( + recipientAta.address, + ); + const recipientBalance = recipientAtaInfo!.data.readBigUInt64LE(64); + expect(recipientBalance).toBe(BigInt(2000)); + + // Sender should have change (loaded all 3000, sent 2000) + const senderAtaInfo = await rpc.getAccountInfo(sourceAta); + const senderBalance = senderAtaInfo!.data.readBigUInt64LE(64); + expect(senderBalance).toBe(BigInt(1000)); + }); + + it('should throw on source mismatch', async () => { + const sender = await newAccountWithLamports(rpc, 1e9); + const recipient = Keypair.generate(); + const wrongSource = Keypair.generate().publicKey; + + const recipientAta = await getOrCreateATAInterface( + rpc, + payer, + mint, + recipient.publicKey, + ); + + await expect( + transferInterface( + rpc, + payer, + wrongSource, + recipientAta.address, + sender, + mint, + BigInt(100), + ), + ).rejects.toThrow('Source mismatch'); + }); + + it('should throw on insufficient balance', async () => { + const sender = await newAccountWithLamports(rpc, 1e9); + const recipient = Keypair.generate(); + + // Mint small amount + await mintTo( + rpc, + payer, + mint, + sender.publicKey, + mintAuthority, + bn(100), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const recipientAta = await getOrCreateATAInterface( + rpc, + payer, + mint, + recipient.publicKey, + ); + + const sourceAta = getATAAddressInterface(mint, sender.publicKey); + + await expect( + transferInterface( + rpc, + payer, + sourceAta, + recipientAta.address, + sender, + mint, + BigInt(99999), + CTOKEN_PROGRAM_ID, + undefined, + { tokenPoolInfos }, + ), + ).rejects.toThrow('Insufficient balance'); + }); + + it('should work when both sender and recipient have existing ATAs', async () => { + const sender = await newAccountWithLamports(rpc, 1e9); + const recipient = await newAccountWithLamports(rpc, 1e9); + + // Setup sender with hot balance + await mintTo( + rpc, + payer, + mint, + sender.publicKey, + mintAuthority, + bn(5000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + const senderAta2 = getATAAddressInterface(mint, sender.publicKey); + await loadATA(rpc, payer, senderAta2, sender, mint, undefined, { + tokenPoolInfos, + }); + + // Setup recipient with existing ATA and balance + await mintTo( + rpc, + payer, + mint, + recipient.publicKey, + mintAuthority, + bn(1000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + const recipientAta2 = getATAAddressInterface( + mint, + recipient.publicKey, + ); + await loadATA( + rpc, + payer, + recipientAta2, + recipient, + mint, + undefined, + { + tokenPoolInfos, + }, + ); + + const sourceAta = getATAAddressInterface(mint, sender.publicKey); + const destAta = getATAAddressInterface(mint, recipient.publicKey); + + const recipientBalanceBefore = (await rpc.getAccountInfo( + destAta, + ))!.data.readBigUInt64LE(64); + + // Transfer + await transferInterface( + rpc, + payer, + sourceAta, + destAta, + sender, + mint, + BigInt(500), + ); + + // Verify recipient balance increased + const recipientBalanceAfter = (await rpc.getAccountInfo( + destAta, + ))!.data.readBigUInt64LE(64); + expect(recipientBalanceAfter).toBe( + recipientBalanceBefore + BigInt(500), + ); + }); + }); +}); diff --git a/js/compressed-token/tests/e2e/transfer.test.ts b/js/compressed-token/tests/e2e/transfer.test.ts index 92aa5c4773..69386d2b8f 100644 --- a/js/compressed-token/tests/e2e/transfer.test.ts +++ b/js/compressed-token/tests/e2e/transfer.test.ts @@ -114,6 +114,7 @@ describe('transfer', () => { rpc, payer, mintAuthority.publicKey, + null, TEST_TOKEN_DECIMALS, mintKeypair, ) @@ -243,6 +244,7 @@ describe('transfer', () => { rpc, payer, mintAuthority.publicKey, + null, TEST_TOKEN_DECIMALS, mintKeypair, undefined, @@ -310,6 +312,7 @@ describe('e2e transfer with multiple accounts', () => { rpc, payer, mintAuthority.publicKey, + null, 9, mintKeypair, ) diff --git a/js/compressed-token/tests/e2e/update-metadata.test.ts b/js/compressed-token/tests/e2e/update-metadata.test.ts new file mode 100644 index 0000000000..2c527cf6db --- /dev/null +++ b/js/compressed-token/tests/e2e/update-metadata.test.ts @@ -0,0 +1,511 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { Keypair, Signer } from '@solana/web3.js'; +import { + Rpc, + newAccountWithLamports, + createRpc, + VERSION, + featureFlags, + getDefaultAddressTreeInfo, + CTOKEN_PROGRAM_ID, +} from '@lightprotocol/stateless.js'; +import { + createMintInterface, + updateMintAuthority, +} from '../../src/mint/actions'; +import { createTokenMetadata } from '../../src/mint/instructions'; +import { + updateMetadataField, + updateMetadataAuthority, + removeMetadataKey, +} from '../../src/mint/actions/update-metadata'; +import { getMintInterface } from '../../src/mint/helpers'; +import { findMintAddress } from '../../src/compressible/derivation'; + +featureFlags.version = VERSION.V2; + +describe('updateMetadata', () => { + let rpc: Rpc; + let payer: Signer; + + beforeAll(async () => { + rpc = createRpc(); + payer = await newAccountWithLamports(rpc, 10e9); + }); + + it('should update metadata name field', async () => { + const mintSigner = Keypair.generate(); + const mintAuthority = Keypair.generate(); + const decimals = 9; + const addressTreeInfo = getDefaultAddressTreeInfo(); + const [mintPda] = findMintAddress(mintSigner.publicKey); + + const initialMetadata = createTokenMetadata( + 'Initial Token', + 'INIT', + 'https://example.com/initial', + mintAuthority.publicKey, + ); + + const { transactionSignature: createSig } = await createMintInterface( + rpc, + payer, + mintAuthority, + null, + decimals, + mintSigner, + initialMetadata, + addressTreeInfo, + undefined, + ); + await rpc.confirmTransaction(createSig, 'confirmed'); + + const mintInfoBefore = await getMintInterface( + rpc, + mintPda, + undefined, + CTOKEN_PROGRAM_ID, + ); + expect(mintInfoBefore.tokenMetadata?.name).toBe('Initial Token'); + + const updateSig = await updateMetadataField( + rpc, + payer, + mintPda, + mintSigner, + mintAuthority, + 'name', + 'Updated Token', + ); + await rpc.confirmTransaction(updateSig, 'confirmed'); + + const mintInfoAfter = await getMintInterface( + rpc, + mintPda, + undefined, + CTOKEN_PROGRAM_ID, + ); + expect(mintInfoAfter.tokenMetadata?.name).toBe('Updated Token'); + expect(mintInfoAfter.tokenMetadata?.symbol).toBe('INIT'); + expect(mintInfoAfter.tokenMetadata?.uri).toBe( + 'https://example.com/initial', + ); + }); + + it('should update metadata symbol field', async () => { + const mintSigner = Keypair.generate(); + const mintAuthority = Keypair.generate(); + const decimals = 6; + const addressTreeInfo = getDefaultAddressTreeInfo(); + const [mintPda] = findMintAddress(mintSigner.publicKey); + + const initialMetadata = createTokenMetadata( + 'Test Token', + 'TEST', + 'https://example.com/test', + mintAuthority.publicKey, + ); + + const { transactionSignature: createSig } = await createMintInterface( + rpc, + payer, + mintAuthority, + null, + decimals, + mintSigner, + initialMetadata, + addressTreeInfo, + undefined, + ); + await rpc.confirmTransaction(createSig, 'confirmed'); + + const updateSig = await updateMetadataField( + rpc, + payer, + mintPda, + mintSigner, + mintAuthority, + 'symbol', + 'UPDATED', + ); + await rpc.confirmTransaction(updateSig, 'confirmed'); + + const mintInfoAfter = await getMintInterface( + rpc, + mintPda, + undefined, + CTOKEN_PROGRAM_ID, + ); + expect(mintInfoAfter.tokenMetadata?.symbol).toBe('UPDATED'); + expect(mintInfoAfter.tokenMetadata?.name).toBe('Test Token'); + }); + + it('should update metadata uri field', async () => { + const mintSigner = Keypair.generate(); + const mintAuthority = Keypair.generate(); + const decimals = 9; + const addressTreeInfo = getDefaultAddressTreeInfo(); + const [mintPda] = findMintAddress(mintSigner.publicKey); + + const initialMetadata = createTokenMetadata( + 'Token', + 'TKN', + 'https://old.com/metadata', + mintAuthority.publicKey, + ); + + const { transactionSignature: createSig } = await createMintInterface( + rpc, + payer, + mintAuthority, + null, + decimals, + mintSigner, + initialMetadata, + addressTreeInfo, + undefined, + ); + await rpc.confirmTransaction(createSig, 'confirmed'); + + const updateSig = await updateMetadataField( + rpc, + payer, + mintPda, + mintSigner, + mintAuthority, + 'uri', + 'https://new.com/metadata', + ); + await rpc.confirmTransaction(updateSig, 'confirmed'); + + const mintInfoAfter = await getMintInterface( + rpc, + mintPda, + undefined, + CTOKEN_PROGRAM_ID, + ); + expect(mintInfoAfter.tokenMetadata?.uri).toBe( + 'https://new.com/metadata', + ); + }); + + it('should update metadata authority', async () => { + const mintSigner = Keypair.generate(); + const mintAuthority = Keypair.generate(); + const initialMetadataAuthority = Keypair.generate(); + const newMetadataAuthority = Keypair.generate(); + const decimals = 9; + const addressTreeInfo = getDefaultAddressTreeInfo(); + const [mintPda] = findMintAddress(mintSigner.publicKey); + + const initialMetadata = createTokenMetadata( + 'Authority Test', + 'AUTH', + 'https://example.com/auth', + initialMetadataAuthority.publicKey, + ); + + const { transactionSignature: createSig } = await createMintInterface( + rpc, + payer, + mintAuthority, + null, + decimals, + mintSigner, + initialMetadata, + addressTreeInfo, + undefined, + ); + await rpc.confirmTransaction(createSig, 'confirmed'); + + const mintInfoBefore = await getMintInterface( + rpc, + mintPda, + undefined, + CTOKEN_PROGRAM_ID, + ); + expect(mintInfoBefore.tokenMetadata?.updateAuthority?.toString()).toBe( + initialMetadataAuthority.publicKey.toString(), + ); + + const updateSig = await updateMetadataAuthority( + rpc, + payer, + mintPda, + mintSigner, + initialMetadataAuthority, + newMetadataAuthority.publicKey, + ); + await rpc.confirmTransaction(updateSig, 'confirmed'); + + const mintInfoAfter = await getMintInterface( + rpc, + mintPda, + undefined, + CTOKEN_PROGRAM_ID, + ); + expect(mintInfoAfter.tokenMetadata?.updateAuthority?.toString()).toBe( + newMetadataAuthority.publicKey.toString(), + ); + }); + + it('should update multiple metadata fields sequentially', async () => { + const mintSigner = Keypair.generate(); + const mintAuthority = Keypair.generate(); + const decimals = 9; + const addressTreeInfo = getDefaultAddressTreeInfo(); + const [mintPda] = findMintAddress(mintSigner.publicKey); + + const initialMetadata = createTokenMetadata( + 'Original Name', + 'ORIG', + 'https://original.com', + mintAuthority.publicKey, + ); + + const { transactionSignature: createSig } = await createMintInterface( + rpc, + payer, + mintAuthority, + null, + decimals, + mintSigner, + initialMetadata, + addressTreeInfo, + undefined, + ); + await rpc.confirmTransaction(createSig, 'confirmed'); + + const updateNameSig = await updateMetadataField( + rpc, + payer, + mintPda, + mintSigner, + mintAuthority, + 'name', + 'New Name', + ); + await rpc.confirmTransaction(updateNameSig, 'confirmed'); + + const mintInfoAfterName = await getMintInterface( + rpc, + mintPda, + undefined, + CTOKEN_PROGRAM_ID, + ); + expect(mintInfoAfterName.tokenMetadata?.name).toBe('New Name'); + + const updateSymbolSig = await updateMetadataField( + rpc, + payer, + mintPda, + mintSigner, + mintAuthority, + 'symbol', + 'NEW', + ); + await rpc.confirmTransaction(updateSymbolSig, 'confirmed'); + + const mintInfoAfterSymbol = await getMintInterface( + rpc, + mintPda, + undefined, + CTOKEN_PROGRAM_ID, + ); + expect(mintInfoAfterSymbol.tokenMetadata?.name).toBe('New Name'); + expect(mintInfoAfterSymbol.tokenMetadata?.symbol).toBe('NEW'); + + const updateUriSig = await updateMetadataField( + rpc, + payer, + mintPda, + mintSigner, + mintAuthority, + 'uri', + 'https://updated.com', + ); + await rpc.confirmTransaction(updateUriSig, 'confirmed'); + + const mintInfoFinal = await getMintInterface( + rpc, + mintPda, + undefined, + CTOKEN_PROGRAM_ID, + ); + expect(mintInfoFinal.tokenMetadata?.name).toBe('New Name'); + expect(mintInfoFinal.tokenMetadata?.symbol).toBe('NEW'); + expect(mintInfoFinal.tokenMetadata?.uri).toBe('https://updated.com'); + }); + + it('should fail to update metadata without proper authority', async () => { + const mintSigner = Keypair.generate(); + const mintAuthority = Keypair.generate(); + const wrongAuthority = Keypair.generate(); + const decimals = 9; + const addressTreeInfo = getDefaultAddressTreeInfo(); + const [mintPda] = findMintAddress(mintSigner.publicKey); + + const initialMetadata = createTokenMetadata( + 'Token', + 'TKN', + 'https://example.com', + mintAuthority.publicKey, + ); + + const { transactionSignature: createSig } = await createMintInterface( + rpc, + payer, + mintAuthority, + null, + decimals, + mintSigner, + initialMetadata, + addressTreeInfo, + undefined, + ); + await rpc.confirmTransaction(createSig, 'confirmed'); + + await expect( + updateMetadataField( + rpc, + payer, + mintPda, + mintSigner, + wrongAuthority, + 'name', + 'Hacked Name', + ), + ).rejects.toThrow(); + }); + + it('should fail to update mint authority with wrong current authority', async () => { + const mintSigner = Keypair.generate(); + const mintAuthority = Keypair.generate(); + const wrongAuthority = Keypair.generate(); + const newAuthority = Keypair.generate(); + const decimals = 9; + const addressTreeInfo = getDefaultAddressTreeInfo(); + const [mintPda] = findMintAddress(mintSigner.publicKey); + + const { transactionSignature: createSig } = await createMintInterface( + rpc, + payer, + mintAuthority, + null, + decimals, + mintSigner, + undefined, + addressTreeInfo, + undefined, + ); + await rpc.confirmTransaction(createSig, 'confirmed'); + + await expect( + updateMintAuthority( + rpc, + payer, + mintPda, + mintSigner, + wrongAuthority, + newAuthority.publicKey, + ), + ).rejects.toThrow(); + }); + + it('should remove metadata key (idempotent)', async () => { + const mintSigner = Keypair.generate(); + const mintAuthority = Keypair.generate(); + const decimals = 9; + const addressTreeInfo = getDefaultAddressTreeInfo(); + const [mintPda] = findMintAddress(mintSigner.publicKey); + + const metadata = createTokenMetadata( + 'Token with Keys', + 'KEYS', + 'https://keys.com', + mintAuthority.publicKey, + ); + + const { transactionSignature: createSig } = await createMintInterface( + rpc, + payer, + mintAuthority, + null, + decimals, + mintSigner, + metadata, + addressTreeInfo, + undefined, + ); + await rpc.confirmTransaction(createSig, 'confirmed'); + + const removeSig = await removeMetadataKey( + rpc, + payer, + mintPda, + mintSigner, + mintAuthority, + 'custom_key', + true, + ); + await rpc.confirmTransaction(removeSig, 'confirmed'); + + const mintInfo = await getMintInterface( + rpc, + mintPda, + undefined, + CTOKEN_PROGRAM_ID, + ); + expect(mintInfo.tokenMetadata).toBeDefined(); + }); + + it('should update metadata fields with same authority as mint authority', async () => { + const mintSigner = Keypair.generate(); + const mintAuthority = Keypair.generate(); + const decimals = 9; + const addressTreeInfo = getDefaultAddressTreeInfo(); + const [mintPda] = findMintAddress(mintSigner.publicKey); + + const metadata = createTokenMetadata( + 'Same Auth Token', + 'SAME', + 'https://same.com', + mintAuthority.publicKey, + ); + + const { transactionSignature: createSig } = await createMintInterface( + rpc, + payer, + mintAuthority, + null, + decimals, + mintSigner, + metadata, + addressTreeInfo, + undefined, + ); + await rpc.confirmTransaction(createSig, 'confirmed'); + + const updateSig = await updateMetadataField( + rpc, + payer, + mintPda, + mintSigner, + mintAuthority, + 'name', + 'Updated by Mint Authority', + ); + await rpc.confirmTransaction(updateSig, 'confirmed'); + + const mintInfo = await getMintInterface( + rpc, + mintPda, + undefined, + CTOKEN_PROGRAM_ID, + ); + expect(mintInfo.tokenMetadata?.name).toBe('Updated by Mint Authority'); + expect(mintInfo.tokenMetadata?.updateAuthority?.toString()).toBe( + mintAuthority.publicKey.toString(), + ); + }); +}); diff --git a/js/compressed-token/tests/e2e/update-mint.test.ts b/js/compressed-token/tests/e2e/update-mint.test.ts new file mode 100644 index 0000000000..e354f69389 --- /dev/null +++ b/js/compressed-token/tests/e2e/update-mint.test.ts @@ -0,0 +1,290 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { Keypair, Signer } from '@solana/web3.js'; +import { + Rpc, + newAccountWithLamports, + createRpc, + VERSION, + featureFlags, + getDefaultAddressTreeInfo, + CTOKEN_PROGRAM_ID, +} from '@lightprotocol/stateless.js'; +import { createMintInterface } from '../../src/mint/actions'; +import { + updateMintAuthority, + updateFreezeAuthority, +} from '../../src/mint/actions/update-mint'; +import { getMintInterface } from '../../src/mint/helpers'; +import { findMintAddress } from '../../src/compressible/derivation'; + +featureFlags.version = VERSION.V2; + +describe('updateMint', () => { + let rpc: Rpc; + let payer: Signer; + + beforeAll(async () => { + rpc = createRpc(); + payer = await newAccountWithLamports(rpc, 10e9); + }); + + it('should update mint authority', async () => { + const mintSigner = Keypair.generate(); + const initialMintAuthority = Keypair.generate(); + const newMintAuthority = Keypair.generate(); + const decimals = 9; + const addressTreeInfo = getDefaultAddressTreeInfo(); + const [mintPda] = findMintAddress(mintSigner.publicKey); + + const { transactionSignature: createSig } = await createMintInterface( + rpc, + payer, + initialMintAuthority, + null, + decimals, + mintSigner, + undefined, + addressTreeInfo, + undefined, + ); + await rpc.confirmTransaction(createSig, 'confirmed'); + + const mintInfoBefore = await getMintInterface( + rpc, + mintPda, + undefined, + CTOKEN_PROGRAM_ID, + ); + expect(mintInfoBefore.mint.mintAuthority?.toString()).toBe( + initialMintAuthority.publicKey.toString(), + ); + + const updateSig = await updateMintAuthority( + rpc, + payer, + mintPda, + mintSigner, + initialMintAuthority, + newMintAuthority.publicKey, + ); + await rpc.confirmTransaction(updateSig, 'confirmed'); + + const mintInfoAfter = await getMintInterface( + rpc, + mintPda, + undefined, + CTOKEN_PROGRAM_ID, + ); + expect(mintInfoAfter.mint.mintAuthority?.toString()).toBe( + newMintAuthority.publicKey.toString(), + ); + expect(mintInfoAfter.mint.supply).toBe(0n); + expect(mintInfoAfter.mint.decimals).toBe(decimals); + }); + + it('should revoke mint authority by setting to null', async () => { + const mintSigner = Keypair.generate(); + const mintAuthority = Keypair.generate(); + const decimals = 6; + const addressTreeInfo = getDefaultAddressTreeInfo(); + const [mintPda] = findMintAddress(mintSigner.publicKey); + + const { transactionSignature: createSig } = await createMintInterface( + rpc, + payer, + mintAuthority, + null, + decimals, + mintSigner, + undefined, + addressTreeInfo, + undefined, + ); + await rpc.confirmTransaction(createSig, 'confirmed'); + + const updateSig = await updateMintAuthority( + rpc, + payer, + mintPda, + mintSigner, + mintAuthority, + null, + ); + await rpc.confirmTransaction(updateSig, 'confirmed'); + + const mintInfoAfter = await getMintInterface( + rpc, + mintPda, + undefined, + CTOKEN_PROGRAM_ID, + ); + expect(mintInfoAfter.mint.mintAuthority).toBe(null); + expect(mintInfoAfter.mint.supply).toBe(0n); + }); + + it('should update freeze authority', async () => { + const mintSigner = Keypair.generate(); + const mintAuthority = Keypair.generate(); + const initialFreezeAuthority = Keypair.generate(); + const newFreezeAuthority = Keypair.generate(); + const decimals = 9; + const addressTreeInfo = getDefaultAddressTreeInfo(); + const [mintPda] = findMintAddress(mintSigner.publicKey); + + const { transactionSignature: createSig } = await createMintInterface( + rpc, + payer, + mintAuthority, + initialFreezeAuthority.publicKey, + decimals, + mintSigner, + undefined, + addressTreeInfo, + undefined, + ); + await rpc.confirmTransaction(createSig, 'confirmed'); + + const mintInfoBefore = await getMintInterface( + rpc, + mintPda, + undefined, + CTOKEN_PROGRAM_ID, + ); + expect(mintInfoBefore.mint.freezeAuthority?.toString()).toBe( + initialFreezeAuthority.publicKey.toString(), + ); + + const updateSig = await updateFreezeAuthority( + rpc, + payer, + mintPda, + mintSigner, + initialFreezeAuthority, + newFreezeAuthority.publicKey, + ); + await rpc.confirmTransaction(updateSig, 'confirmed'); + + const mintInfoAfter = await getMintInterface( + rpc, + mintPda, + undefined, + CTOKEN_PROGRAM_ID, + ); + expect(mintInfoAfter.mint.freezeAuthority?.toString()).toBe( + newFreezeAuthority.publicKey.toString(), + ); + expect(mintInfoAfter.mint.mintAuthority?.toString()).toBe( + mintAuthority.publicKey.toString(), + ); + }); + + it('should revoke freeze authority by setting to null', async () => { + const mintSigner = Keypair.generate(); + const mintAuthority = Keypair.generate(); + const freezeAuthority = Keypair.generate(); + const decimals = 6; + const addressTreeInfo = getDefaultAddressTreeInfo(); + const [mintPda] = findMintAddress(mintSigner.publicKey); + + const { transactionSignature: createSig } = await createMintInterface( + rpc, + payer, + mintAuthority, + freezeAuthority.publicKey, + decimals, + mintSigner, + undefined, + addressTreeInfo, + undefined, + ); + await rpc.confirmTransaction(createSig, 'confirmed'); + + const updateSig = await updateFreezeAuthority( + rpc, + payer, + mintPda, + mintSigner, + freezeAuthority, + null, + ); + await rpc.confirmTransaction(updateSig, 'confirmed'); + + const mintInfoAfter = await getMintInterface( + rpc, + mintPda, + undefined, + CTOKEN_PROGRAM_ID, + ); + expect(mintInfoAfter.mint.freezeAuthority).toBe(null); + expect(mintInfoAfter.mint.mintAuthority?.toString()).toBe( + mintAuthority.publicKey.toString(), + ); + }); + + it('should update both mint and freeze authorities sequentially', async () => { + const mintSigner = Keypair.generate(); + const initialMintAuthority = Keypair.generate(); + const initialFreezeAuthority = Keypair.generate(); + const newMintAuthority = Keypair.generate(); + const newFreezeAuthority = Keypair.generate(); + const decimals = 9; + const addressTreeInfo = getDefaultAddressTreeInfo(); + const [mintPda] = findMintAddress(mintSigner.publicKey); + + const { transactionSignature: createSig } = await createMintInterface( + rpc, + payer, + initialMintAuthority, + initialFreezeAuthority.publicKey, + decimals, + mintSigner, + undefined, + addressTreeInfo, + undefined, + ); + await rpc.confirmTransaction(createSig, 'confirmed'); + + const updateMintAuthSig = await updateMintAuthority( + rpc, + payer, + mintPda, + mintSigner, + initialMintAuthority, + newMintAuthority.publicKey, + ); + await rpc.confirmTransaction(updateMintAuthSig, 'confirmed'); + + const mintInfoAfterMintAuth = await getMintInterface( + rpc, + mintPda, + undefined, + CTOKEN_PROGRAM_ID, + ); + expect(mintInfoAfterMintAuth.mint.mintAuthority?.toString()).toBe( + newMintAuthority.publicKey.toString(), + ); + + const updateFreezeAuthSig = await updateFreezeAuthority( + rpc, + payer, + mintPda, + mintSigner, + initialFreezeAuthority, + newFreezeAuthority.publicKey, + ); + await rpc.confirmTransaction(updateFreezeAuthSig, 'confirmed'); + + const mintInfoAfterBoth = await getMintInterface( + rpc, + mintPda, + undefined, + CTOKEN_PROGRAM_ID, + ); + expect(mintInfoAfterBoth.mint.mintAuthority?.toString()).toBe( + newMintAuthority.publicKey.toString(), + ); + expect(mintInfoAfterBoth.mint.freezeAuthority?.toString()).toBe( + newFreezeAuthority.publicKey.toString(), + ); + }); +}); diff --git a/js/compressed-token/tests/e2e/wrap.test.ts b/js/compressed-token/tests/e2e/wrap.test.ts new file mode 100644 index 0000000000..7d7e592f70 --- /dev/null +++ b/js/compressed-token/tests/e2e/wrap.test.ts @@ -0,0 +1,548 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { Keypair, Signer, PublicKey } from '@solana/web3.js'; +import { + Rpc, + bn, + newAccountWithLamports, + getTestRpc, + selectStateTreeInfo, + TreeInfo, + CTOKEN_PROGRAM_ID, + VERSION, + featureFlags, +} from '@lightprotocol/stateless.js'; +import { WasmFactory } from '@lightprotocol/hasher.rs'; +import { createMint, mintTo, decompress } from '../../src/actions'; +import { + createAssociatedTokenAccount, + getAssociatedTokenAddressSync, + TOKEN_PROGRAM_ID, + getAccount, +} from '@solana/spl-token'; + +// Helper to read CToken account balance (CToken accounts are owned by CTOKEN_PROGRAM_ID) +async function getCTokenBalance(rpc: Rpc, address: PublicKey): Promise { + const accountInfo = await rpc.getAccountInfo(address); + if (!accountInfo) { + throw new Error(`CToken account not found: ${address.toBase58()}`); + } + // CToken account layout: amount is at offset 64-72 (same as SPL token accounts) + return accountInfo.data.readBigUInt64LE(64); +} +import { + getTokenPoolInfos, + selectTokenPoolInfo, + selectTokenPoolInfosForDecompression, + TokenPoolInfo, +} from '../../src/utils/get-token-pool-infos'; +import { createWrapInstruction } from '../../src/mint/instructions/wrap'; +import { wrap } from '../../src/mint/actions/wrap'; +import { + getATAAddressInterface, + createATAInterfaceIdempotent, +} from '../../src/mint/actions/create-ata-interface'; + +// Force V2 for CToken tests +featureFlags.version = VERSION.V2; + +const TEST_TOKEN_DECIMALS = 9; + +describe('createWrapInstruction', () => { + let rpc: Rpc; + let payer: Signer; + let mint: PublicKey; + let mintAuthority: Keypair; + let stateTreeInfo: 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(); + + // Create SPL mint with token pool + mint = ( + await createMint( + rpc, + payer, + mintAuthority.publicKey, + null, + TEST_TOKEN_DECIMALS, + mintKeypair, + ) + ).mint; + + stateTreeInfo = selectStateTreeInfo(await rpc.getStateTreeInfos()); + tokenPoolInfos = await getTokenPoolInfos(rpc, mint); + }, 60_000); + + it('should create valid instruction with all required params', async () => { + const owner = Keypair.generate(); + const source = getAssociatedTokenAddressSync( + mint, + owner.publicKey, + false, + TOKEN_PROGRAM_ID, + ); + const destination = getATAAddressInterface(mint, owner.publicKey); + + const tokenPoolInfo = tokenPoolInfos.find(info => info.isInitialized); + expect(tokenPoolInfo).toBeDefined(); + + const ix = createWrapInstruction( + source, + destination, + owner.publicKey, + mint, + BigInt(1000), + tokenPoolInfo!, + ); + + expect(ix).toBeDefined(); + expect(ix.programId).toBeDefined(); + expect(ix.keys.length).toBeGreaterThan(0); + expect(ix.data.length).toBeGreaterThan(0); + }); + + it('should create instruction with explicit payer', async () => { + const owner = Keypair.generate(); + const feePayer = Keypair.generate(); + const source = getAssociatedTokenAddressSync( + mint, + owner.publicKey, + false, + TOKEN_PROGRAM_ID, + ); + const destination = getATAAddressInterface(mint, owner.publicKey); + + const tokenPoolInfo = tokenPoolInfos.find(info => info.isInitialized); + + const ix = createWrapInstruction( + source, + destination, + owner.publicKey, + mint, + BigInt(500), + tokenPoolInfo!, + feePayer.publicKey, + ); + + expect(ix).toBeDefined(); + // Check that payer is in keys + const payerKey = ix.keys.find( + k => k.pubkey.equals(feePayer.publicKey) && k.isSigner, + ); + expect(payerKey).toBeDefined(); + }); + + it('should use owner as payer when payer not provided', async () => { + const owner = Keypair.generate(); + const source = getAssociatedTokenAddressSync( + mint, + owner.publicKey, + false, + TOKEN_PROGRAM_ID, + ); + const destination = getATAAddressInterface(mint, owner.publicKey); + + const tokenPoolInfo = tokenPoolInfos.find(info => info.isInitialized); + + const ix = createWrapInstruction( + source, + destination, + owner.publicKey, + mint, + BigInt(100), + tokenPoolInfo!, + // payer not provided - defaults to owner + ); + + expect(ix).toBeDefined(); + // Owner should appear as signer (since payer defaults to owner) + const ownerKey = ix.keys.find( + k => k.pubkey.equals(owner.publicKey) && k.isSigner, + ); + expect(ownerKey).toBeDefined(); + }); +}); + +describe('wrap action', () => { + let rpc: Rpc; + let payer: Signer; + let mint: PublicKey; + let mintAuthority: Keypair; + let stateTreeInfo: 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(); + + // Create SPL mint with token pool + mint = ( + await createMint( + rpc, + payer, + mintAuthority.publicKey, + null, + TEST_TOKEN_DECIMALS, + mintKeypair, + ) + ).mint; + + stateTreeInfo = selectStateTreeInfo(await rpc.getStateTreeInfos()); + tokenPoolInfos = await getTokenPoolInfos(rpc, mint); + }, 60_000); + + it('should wrap SPL tokens to CToken ATA', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + // Create SPL ATA and mint tokens + const splAta = await createAssociatedTokenAccount( + rpc, + payer, + mint, + owner.publicKey, + ); + + // Mint compressed then decompress to SPL ATA + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(1000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + tokenPoolInfos = await getTokenPoolInfos(rpc, mint); + await decompress( + rpc, + payer, + mint, + bn(1000), + owner, + splAta, + selectTokenPoolInfosForDecompression(tokenPoolInfos, bn(1000)), + ); + + // Create CToken ATA + const ctokenAta = getATAAddressInterface(mint, owner.publicKey); + await createATAInterfaceIdempotent(rpc, payer, mint, owner.publicKey); + + // Check initial balances + const splBalanceBefore = await getAccount(rpc, splAta); + expect(splBalanceBefore.amount).toBe(BigInt(1000)); + + // Wrap tokens + tokenPoolInfos = await getTokenPoolInfos(rpc, mint); + const tokenPoolInfo = tokenPoolInfos.find(info => info.isInitialized); + + const result = await wrap( + rpc, + payer, + splAta, + ctokenAta, + owner, + mint, + BigInt(500), + tokenPoolInfo, + ); + + expect(result.transactionSignature).toBeDefined(); + + // Check balances after + const splBalanceAfter = await getAccount(rpc, splAta); + expect(splBalanceAfter.amount).toBe(BigInt(500)); + + const ctokenBalanceAfter = await getCTokenBalance(rpc, ctokenAta); + expect(ctokenBalanceAfter).toBe(BigInt(500)); + }, 60_000); + + it('should wrap full balance', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + // Setup: Create SPL ATA with tokens + const splAta = await createAssociatedTokenAccount( + rpc, + payer, + mint, + owner.publicKey, + ); + + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(500), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + tokenPoolInfos = await getTokenPoolInfos(rpc, mint); + await decompress( + rpc, + payer, + mint, + bn(500), + owner, + splAta, + selectTokenPoolInfosForDecompression(tokenPoolInfos, bn(500)), + ); + + // Create CToken ATA + const ctokenAta = getATAAddressInterface(mint, owner.publicKey); + await createATAInterfaceIdempotent(rpc, payer, mint, owner.publicKey); + + // Wrap full balance + tokenPoolInfos = await getTokenPoolInfos(rpc, mint); + const tokenPoolInfo = tokenPoolInfos.find(info => info.isInitialized); + + const result = await wrap( + rpc, + payer, + splAta, + ctokenAta, + owner, + mint, + BigInt(500), // full balance + tokenPoolInfo, + ); + + expect(result.transactionSignature).toBeDefined(); + + // SPL should be empty + const splBalanceAfter = await getAccount(rpc, splAta); + expect(splBalanceAfter.amount).toBe(BigInt(0)); + + // CToken should have full balance + const ctokenBalanceAfter = await getCTokenBalance(rpc, ctokenAta); + expect(ctokenBalanceAfter).toBe(BigInt(500)); + }, 60_000); + + it('should fetch token pool info when not provided', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + // Setup + const splAta = await createAssociatedTokenAccount( + rpc, + payer, + mint, + owner.publicKey, + ); + + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(200), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + tokenPoolInfos = await getTokenPoolInfos(rpc, mint); + await decompress( + rpc, + payer, + mint, + bn(200), + owner, + splAta, + selectTokenPoolInfosForDecompression(tokenPoolInfos, bn(200)), + ); + + const ctokenAta = getATAAddressInterface(mint, owner.publicKey); + await createATAInterfaceIdempotent(rpc, payer, mint, owner.publicKey); + + // Wrap without providing tokenPoolInfo - should fetch automatically + const result = await wrap( + rpc, + payer, + splAta, + ctokenAta, + owner, + mint, + BigInt(100), + // tokenPoolInfo not provided + ); + + expect(result.transactionSignature).toBeDefined(); + + const ctokenBalance = await getCTokenBalance(rpc, ctokenAta); + expect(ctokenBalance).toBe(BigInt(100)); + }, 60_000); + + it('should throw error when token pool not initialized', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + // Create a new mint without token pool + const newMintKeypair = Keypair.generate(); + const newMintAuthority = Keypair.generate(); + + // Note: createMint actually creates a token pool, so this test scenario + // would need a special mint without pool. For now, we'll skip this test + // as it requires a mint without token pool which is hard to set up. + // The error path is tested implicitly through the action's logic. + }); + + it('should work with different owners and payers', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + const separatePayer = await newAccountWithLamports(rpc, 1e9); + + // Setup + const splAta = await createAssociatedTokenAccount( + rpc, + payer, + mint, + owner.publicKey, + ); + + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(300), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + tokenPoolInfos = await getTokenPoolInfos(rpc, mint); + await decompress( + rpc, + payer, + mint, + bn(300), + owner, + splAta, + selectTokenPoolInfosForDecompression(tokenPoolInfos, bn(300)), + ); + + const ctokenAta = getATAAddressInterface(mint, owner.publicKey); + await createATAInterfaceIdempotent(rpc, payer, mint, owner.publicKey); + + // Wrap with separate payer + tokenPoolInfos = await getTokenPoolInfos(rpc, mint); + const tokenPoolInfo = tokenPoolInfos.find(info => info.isInitialized); + + const result = await wrap( + rpc, + separatePayer, // Different from owner + splAta, + ctokenAta, + owner, // Owner still signs for the source account + mint, + BigInt(150), + tokenPoolInfo, + ); + + expect(result.transactionSignature).toBeDefined(); + + const ctokenBalance = await getCTokenBalance(rpc, ctokenAta); + expect(ctokenBalance).toBe(BigInt(150)); + }, 60_000); +}); + +describe('wrap with non-ATA accounts', () => { + let rpc: Rpc; + let payer: Signer; + let mint: PublicKey; + let mintAuthority: Keypair; + let stateTreeInfo: 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, + null, + TEST_TOKEN_DECIMALS, + mintKeypair, + ) + ).mint; + + stateTreeInfo = selectStateTreeInfo(await rpc.getStateTreeInfos()); + tokenPoolInfos = await getTokenPoolInfos(rpc, mint); + }, 60_000); + + it('should work with explicitly derived ATA addresses (spl-token style)', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + // Explicitly derive ATAs + // Note: SPL ATAs use getAssociatedTokenAddressSync + // CToken ATAs use getATAAddressInterface (which defaults to CToken program) + const source = getAssociatedTokenAddressSync( + mint, + owner.publicKey, + false, + TOKEN_PROGRAM_ID, + ); + const destination = getATAAddressInterface(mint, owner.publicKey); + + // Setup: Create both ATAs and fund source + await createAssociatedTokenAccount(rpc, payer, mint, owner.publicKey); + await createATAInterfaceIdempotent(rpc, payer, mint, owner.publicKey); + + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(400), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + tokenPoolInfos = await getTokenPoolInfos(rpc, mint); + await decompress( + rpc, + payer, + mint, + bn(400), + owner, + source, + selectTokenPoolInfosForDecompression(tokenPoolInfos, bn(400)), + ); + + // Wrap using explicit addresses + tokenPoolInfos = await getTokenPoolInfos(rpc, mint); + const tokenPoolInfo = tokenPoolInfos.find(info => info.isInitialized); + + const result = await wrap( + rpc, + payer, + source, + destination, + owner, + mint, + BigInt(200), + tokenPoolInfo, + ); + + expect(result.transactionSignature).toBeDefined(); + + const destBalance = await getCTokenBalance(rpc, destination); + expect(destBalance).toBe(BigInt(200)); + }, 60_000); +}); diff --git a/js/compressed-token/tests/unit/serde.test.ts b/js/compressed-token/tests/unit/serde.test.ts new file mode 100644 index 0000000000..8e97dd208c --- /dev/null +++ b/js/compressed-token/tests/unit/serde.test.ts @@ -0,0 +1,1302 @@ +import { describe, it, expect } from 'vitest'; +import { PublicKey, Keypair } from '@solana/web3.js'; +import { Buffer } from 'buffer'; +import { + deserializeMint, + serializeMint, + decodeTokenMetadata, + encodeTokenMetadata, + extractTokenMetadata, + parseTokenMetadata, + toMintInstructionData, + toMintInstructionDataWithMetadata, + CompressedMint, + BaseMint, + MintContext, + MintExtension, + TokenMetadata, + MintInstructionData, + MintInstructionDataWithMetadata, + MintMetadataField, + ExtensionType, + MINT_CONTEXT_SIZE, + MintContextLayout, +} from '../../src/mint/serde'; +import { MINT_SIZE } from '@solana/spl-token'; + +describe('serde', () => { + describe('MintContextLayout', () => { + it('should have correct size (34 bytes)', () => { + expect(MINT_CONTEXT_SIZE).toBe(34); + expect(MintContextLayout.span).toBe(34); + }); + }); + + describe('deserializeMint / serializeMint roundtrip', () => { + const testCases: { description: string; mint: CompressedMint }[] = [ + { + description: 'minimal mint without extensions', + mint: { + base: { + mintAuthority: null, + supply: BigInt(0), + decimals: 9, + isInitialized: true, + freezeAuthority: null, + }, + mintContext: { + version: 1, + splMintInitialized: false, + splMint: PublicKey.default, + }, + extensions: null, + }, + }, + { + description: 'mint with all authorities set', + mint: { + base: { + mintAuthority: Keypair.generate().publicKey, + supply: BigInt(1_000_000_000), + decimals: 6, + isInitialized: true, + freezeAuthority: Keypair.generate().publicKey, + }, + mintContext: { + version: 1, + splMintInitialized: true, + splMint: Keypair.generate().publicKey, + }, + extensions: null, + }, + }, + { + description: 'mint with only mintAuthority', + mint: { + base: { + mintAuthority: Keypair.generate().publicKey, + supply: BigInt(500), + decimals: 0, + isInitialized: true, + freezeAuthority: null, + }, + mintContext: { + version: 0, + splMintInitialized: false, + splMint: PublicKey.default, + }, + extensions: null, + }, + }, + { + description: 'mint with only freezeAuthority', + mint: { + base: { + mintAuthority: null, + supply: BigInt('18446744073709551615'), // max u64 + decimals: 18, + isInitialized: true, + freezeAuthority: Keypair.generate().publicKey, + }, + mintContext: { + version: 255, + splMintInitialized: true, + splMint: Keypair.generate().publicKey, + }, + extensions: null, + }, + }, + { + description: 'uninitialized mint', + mint: { + base: { + mintAuthority: null, + supply: BigInt(0), + decimals: 0, + isInitialized: false, + freezeAuthority: null, + }, + mintContext: { + version: 0, + splMintInitialized: false, + splMint: PublicKey.default, + }, + extensions: null, + }, + }, + ]; + + testCases.forEach(({ description, mint }) => { + it(`should roundtrip: ${description}`, () => { + const serialized = serializeMint(mint); + const deserialized = deserializeMint(serialized); + + // Compare base mint + if (mint.base.mintAuthority) { + expect(deserialized.base.mintAuthority?.toBase58()).toBe( + mint.base.mintAuthority.toBase58(), + ); + } else { + expect(deserialized.base.mintAuthority).toBeNull(); + } + + expect(deserialized.base.supply).toBe(mint.base.supply); + expect(deserialized.base.decimals).toBe(mint.base.decimals); + expect(deserialized.base.isInitialized).toBe( + mint.base.isInitialized, + ); + + if (mint.base.freezeAuthority) { + expect(deserialized.base.freezeAuthority?.toBase58()).toBe( + mint.base.freezeAuthority.toBase58(), + ); + } else { + expect(deserialized.base.freezeAuthority).toBeNull(); + } + + // Compare mint context + expect(deserialized.mintContext.version).toBe( + mint.mintContext.version, + ); + expect(deserialized.mintContext.splMintInitialized).toBe( + mint.mintContext.splMintInitialized, + ); + expect(deserialized.mintContext.splMint.toBase58()).toBe( + mint.mintContext.splMint.toBase58(), + ); + + // Compare extensions + expect(deserialized.extensions).toEqual(mint.extensions); + }); + }); + + it('should produce expected buffer size for mint without extensions', () => { + const mint: CompressedMint = { + base: { + mintAuthority: null, + supply: BigInt(0), + decimals: 9, + isInitialized: true, + freezeAuthority: null, + }, + mintContext: { + version: 1, + splMintInitialized: false, + splMint: PublicKey.default, + }, + extensions: null, + }; + + const serialized = serializeMint(mint); + // 82 (MINT_SIZE) + 34 (MINT_CONTEXT_SIZE) + 1 (None option byte) + expect(serialized.length).toBe(MINT_SIZE + MINT_CONTEXT_SIZE + 1); + }); + }); + + describe('serializeMint with extensions', () => { + it('should serialize mint with single extension (no length prefix - Borsh format)', () => { + const extensionData = Buffer.from([1, 2, 3, 4, 5]); + const mint: CompressedMint = { + base: { + mintAuthority: null, + supply: BigInt(1000), + decimals: 9, + isInitialized: true, + freezeAuthority: null, + }, + mintContext: { + version: 1, + splMintInitialized: false, + splMint: PublicKey.default, + }, + extensions: [ + { + extensionType: ExtensionType.TokenMetadata, + data: extensionData, + }, + ], + }; + + const serialized = serializeMint(mint); + + // Borsh format: Some(1) + vec_len(4) + discriminant(1) + data (NO length prefix) + const expectedExtensionBytes = 1 + 4 + 1 + extensionData.length; + expect(serialized.length).toBe( + MINT_SIZE + MINT_CONTEXT_SIZE + expectedExtensionBytes, + ); + }); + + it('should serialize mint with multiple extensions (no length prefix)', () => { + const ext1Data = Buffer.from([1, 2, 3]); + const ext2Data = Buffer.from([4, 5, 6, 7, 8]); + const mint: CompressedMint = { + base: { + mintAuthority: null, + supply: BigInt(0), + decimals: 9, + isInitialized: true, + freezeAuthority: null, + }, + mintContext: { + version: 1, + splMintInitialized: false, + splMint: PublicKey.default, + }, + extensions: [ + { extensionType: 1, data: ext1Data }, + { extensionType: 2, data: ext2Data }, + ], + }; + + const serialized = serializeMint(mint); + + // Borsh format: Some(1) + vec_len(4) + (type(1) + data) for each (no length prefix) + const expectedExtensionBytes = 1 + 4 + (1 + 3) + (1 + 5); + expect(serialized.length).toBe( + MINT_SIZE + MINT_CONTEXT_SIZE + expectedExtensionBytes, + ); + }); + + it('should serialize mint with empty extensions array as None', () => { + const mint: CompressedMint = { + base: { + mintAuthority: null, + supply: BigInt(0), + decimals: 9, + isInitialized: true, + freezeAuthority: null, + }, + mintContext: { + version: 1, + splMintInitialized: false, + splMint: PublicKey.default, + }, + extensions: [], + }; + + const serialized = serializeMint(mint); + + // Empty extensions array is treated as None (1 byte) + expect(serialized.length).toBe(MINT_SIZE + MINT_CONTEXT_SIZE + 1); + // The last byte should be 0 (None) + expect(serialized[serialized.length - 1]).toBe(0); + }); + }); + + describe('decodeTokenMetadata / encodeTokenMetadata', () => { + const testCases: { description: string; metadata: TokenMetadata }[] = [ + { + description: 'basic metadata', + metadata: { + mint: Keypair.generate().publicKey, + name: 'Test Token', + symbol: 'TEST', + uri: 'https://example.com/token.json', + }, + }, + { + description: 'metadata with updateAuthority', + metadata: { + updateAuthority: Keypair.generate().publicKey, + mint: Keypair.generate().publicKey, + name: 'My Token', + symbol: 'MTK', + uri: 'ipfs://QmTest123', + }, + }, + { + description: 'metadata with additional metadata', + metadata: { + mint: Keypair.generate().publicKey, + name: 'Rich Token', + symbol: 'RICH', + uri: 'https://arweave.net/xyz', + additionalMetadata: [ + { key: 'description', value: 'A rich token' }, + { key: 'image', value: 'https://example.com/img.png' }, + ], + }, + }, + { + description: 'metadata with all fields', + metadata: { + updateAuthority: Keypair.generate().publicKey, + mint: Keypair.generate().publicKey, + name: 'Full Token', + symbol: 'FULL', + uri: 'https://full.example.com/metadata.json', + additionalMetadata: [ + { key: 'creator', value: 'Alice' }, + { key: 'version', value: '1.0.0' }, + { key: 'category', value: 'utility' }, + ], + }, + }, + { + description: 'metadata with empty strings', + metadata: { + mint: Keypair.generate().publicKey, + name: '', + symbol: '', + uri: '', + }, + }, + { + description: 'metadata with unicode characters', + metadata: { + mint: Keypair.generate().publicKey, + name: 'Token', + symbol: 'TKN', + uri: 'https://example.com', + }, + }, + { + description: 'metadata with long values', + metadata: { + mint: Keypair.generate().publicKey, + name: 'A'.repeat(100), + symbol: 'B'.repeat(10), + uri: 'https://example.com/' + 'c'.repeat(200), + }, + }, + ]; + + testCases.forEach(({ description, metadata }) => { + it(`should roundtrip: ${description}`, () => { + const encoded = encodeTokenMetadata(metadata); + const decoded = decodeTokenMetadata(encoded); + + expect(decoded).not.toBeNull(); + expect(decoded!.name).toBe(metadata.name); + expect(decoded!.symbol).toBe(metadata.symbol); + expect(decoded!.uri).toBe(metadata.uri); + + if (metadata.updateAuthority) { + expect(decoded!.updateAuthority?.toBase58()).toBe( + metadata.updateAuthority.toBase58(), + ); + } + + if ( + metadata.additionalMetadata && + metadata.additionalMetadata.length > 0 + ) { + expect(decoded!.additionalMetadata).toHaveLength( + metadata.additionalMetadata.length, + ); + metadata.additionalMetadata.forEach((item, idx) => { + expect(decoded!.additionalMetadata![idx].key).toBe( + item.key, + ); + expect(decoded!.additionalMetadata![idx].value).toBe( + item.value, + ); + }); + } + }); + }); + + it('should return null for invalid data (too short)', () => { + const shortBuffer = Buffer.alloc(50); // Less than 80 byte minimum + const result = decodeTokenMetadata(shortBuffer); + expect(result).toBeNull(); + }); + + it('should return null for empty data', () => { + const emptyBuffer = Buffer.alloc(0); + const result = decodeTokenMetadata(emptyBuffer); + expect(result).toBeNull(); + }); + }); + + describe('extractTokenMetadata', () => { + it('should return null for null extensions', () => { + const result = extractTokenMetadata(null); + expect(result).toBeNull(); + }); + + it('should return null for empty extensions array', () => { + const result = extractTokenMetadata([]); + expect(result).toBeNull(); + }); + + it('should return null when TokenMetadata extension not found', () => { + const extensions: MintExtension[] = [ + { extensionType: 1, data: Buffer.from([1, 2, 3]) }, + { extensionType: 2, data: Buffer.from([4, 5, 6]) }, + ]; + const result = extractTokenMetadata(extensions); + expect(result).toBeNull(); + }); + + it('should extract and parse TokenMetadata extension', () => { + const metadata: TokenMetadata = { + updateAuthority: Keypair.generate().publicKey, + mint: Keypair.generate().publicKey, + name: 'Extract Test', + symbol: 'EXT', + uri: 'https://extract.test', + }; + + const encodedMetadata = encodeTokenMetadata(metadata); + const extensions: MintExtension[] = [ + { + extensionType: ExtensionType.TokenMetadata, + data: encodedMetadata, + }, + ]; + + const result = extractTokenMetadata(extensions); + + expect(result).not.toBeNull(); + expect(result!.name).toBe(metadata.name); + expect(result!.symbol).toBe(metadata.symbol); + expect(result!.uri).toBe(metadata.uri); + }); + + it('should find TokenMetadata among multiple extensions', () => { + const metadata: TokenMetadata = { + mint: Keypair.generate().publicKey, + name: 'Multi Test', + symbol: 'MLT', + uri: 'https://multi.test', + }; + + const encodedMetadata = encodeTokenMetadata(metadata); + const extensions: MintExtension[] = [ + { extensionType: 1, data: Buffer.from([1, 2, 3]) }, + { + extensionType: ExtensionType.TokenMetadata, + data: encodedMetadata, + }, + { extensionType: 2, data: Buffer.from([4, 5, 6]) }, + ]; + + const result = extractTokenMetadata(extensions); + + expect(result).not.toBeNull(); + expect(result!.name).toBe(metadata.name); + }); + }); + + describe('ExtensionType enum', () => { + it('should have correct value for TokenMetadata', () => { + expect(ExtensionType.TokenMetadata).toBe(19); + }); + }); + + describe('deserializeMint edge cases', () => { + it('should handle Uint8Array input', () => { + const mint: CompressedMint = { + base: { + mintAuthority: null, + supply: BigInt(1000), + decimals: 9, + isInitialized: true, + freezeAuthority: null, + }, + mintContext: { + version: 1, + splMintInitialized: false, + splMint: PublicKey.default, + }, + extensions: null, + }; + + const serialized = serializeMint(mint); + const uint8Array = new Uint8Array(serialized); + const deserialized = deserializeMint(uint8Array); + + expect(deserialized.base.supply).toBe(mint.base.supply); + expect(deserialized.base.decimals).toBe(mint.base.decimals); + }); + + it('should correctly parse version byte', () => { + const testVersions = [0, 1, 127, 255]; + + testVersions.forEach(version => { + const mint: CompressedMint = { + base: { + mintAuthority: null, + supply: BigInt(0), + decimals: 9, + isInitialized: true, + freezeAuthority: null, + }, + mintContext: { + version, + splMintInitialized: false, + splMint: PublicKey.default, + }, + extensions: null, + }; + + const serialized = serializeMint(mint); + const deserialized = deserializeMint(serialized); + + expect(deserialized.mintContext.version).toBe(version); + }); + }); + + it('should correctly parse splMintInitialized boolean', () => { + [true, false].forEach(initialized => { + const mint: CompressedMint = { + base: { + mintAuthority: null, + supply: BigInt(0), + decimals: 9, + isInitialized: true, + freezeAuthority: null, + }, + mintContext: { + version: 1, + splMintInitialized: initialized, + splMint: PublicKey.default, + }, + extensions: null, + }; + + const serialized = serializeMint(mint); + const deserialized = deserializeMint(serialized); + + expect(deserialized.mintContext.splMintInitialized).toBe( + initialized, + ); + }); + }); + }); + + describe('serializeMint / deserializeMint specific pubkey values', () => { + it('should handle specific well-known pubkeys', () => { + const specificPubkeys = [ + PublicKey.default, + new PublicKey('11111111111111111111111111111111'), + new PublicKey('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + new PublicKey('So11111111111111111111111111111111111111112'), + ]; + + specificPubkeys.forEach(pubkey => { + const mint: CompressedMint = { + base: { + mintAuthority: pubkey, + supply: BigInt(0), + decimals: 9, + isInitialized: true, + freezeAuthority: pubkey, + }, + mintContext: { + version: 1, + splMintInitialized: true, + splMint: pubkey, + }, + extensions: null, + }; + + const serialized = serializeMint(mint); + const deserialized = deserializeMint(serialized); + + expect(deserialized.base.mintAuthority?.toBase58()).toBe( + pubkey.toBase58(), + ); + expect(deserialized.base.freezeAuthority?.toBase58()).toBe( + pubkey.toBase58(), + ); + expect(deserialized.mintContext.splMint.toBase58()).toBe( + pubkey.toBase58(), + ); + }); + }); + }); + + describe('supply edge cases', () => { + it('should handle zero supply', () => { + const mint: CompressedMint = { + base: { + mintAuthority: null, + supply: BigInt(0), + decimals: 9, + isInitialized: true, + freezeAuthority: null, + }, + mintContext: { + version: 1, + splMintInitialized: false, + splMint: PublicKey.default, + }, + extensions: null, + }; + + const serialized = serializeMint(mint); + const deserialized = deserializeMint(serialized); + + expect(deserialized.base.supply).toBe(BigInt(0)); + }); + + it('should handle large supply values', () => { + const largeSupplies = [ + BigInt(1_000_000_000), + BigInt('1000000000000000000'), + BigInt('18446744073709551615'), // max u64 + ]; + + largeSupplies.forEach(supply => { + const mint: CompressedMint = { + base: { + mintAuthority: null, + supply, + decimals: 9, + isInitialized: true, + freezeAuthority: null, + }, + mintContext: { + version: 1, + splMintInitialized: false, + splMint: PublicKey.default, + }, + extensions: null, + }; + + const serialized = serializeMint(mint); + const deserialized = deserializeMint(serialized); + + expect(deserialized.base.supply).toBe(supply); + }); + }); + }); + + describe('decimals edge cases', () => { + it('should handle all valid decimal values (0-255)', () => { + [0, 1, 6, 9, 18, 255].forEach(decimals => { + const mint: CompressedMint = { + base: { + mintAuthority: null, + supply: BigInt(0), + decimals, + isInitialized: true, + freezeAuthority: null, + }, + mintContext: { + version: 1, + splMintInitialized: false, + splMint: PublicKey.default, + }, + extensions: null, + }; + + const serialized = serializeMint(mint); + const deserialized = deserializeMint(serialized); + + expect(deserialized.base.decimals).toBe(decimals); + }); + }); + }); + + describe('deserializeMint with extensions', () => { + it('should roundtrip serialize/deserialize with TokenMetadata extension', () => { + const metadata: TokenMetadata = { + updateAuthority: Keypair.generate().publicKey, + mint: Keypair.generate().publicKey, + name: 'Test Token', + symbol: 'TEST', + uri: 'https://example.com/metadata.json', + }; + + const encodedMetadata = encodeTokenMetadata(metadata); + const mint: CompressedMint = { + base: { + mintAuthority: Keypair.generate().publicKey, + supply: BigInt(1_000_000), + decimals: 9, + isInitialized: true, + freezeAuthority: null, + }, + mintContext: { + version: 1, + splMintInitialized: true, + splMint: Keypair.generate().publicKey, + }, + extensions: [ + { + extensionType: ExtensionType.TokenMetadata, + data: encodedMetadata, + }, + ], + }; + + const serialized = serializeMint(mint); + const deserialized = deserializeMint(serialized); + + // Base mint should roundtrip + expect(deserialized.base.supply).toBe(mint.base.supply); + expect(deserialized.base.decimals).toBe(mint.base.decimals); + + // Should have extensions + expect(deserialized.extensions).not.toBeNull(); + expect(deserialized.extensions!.length).toBe(1); + expect(deserialized.extensions![0].extensionType).toBe( + ExtensionType.TokenMetadata, + ); + + // Extension data should be extractable and match original + const extractedMetadata = extractTokenMetadata( + deserialized.extensions, + ); + expect(extractedMetadata).not.toBeNull(); + expect(extractedMetadata!.name).toBe(metadata.name); + expect(extractedMetadata!.symbol).toBe(metadata.symbol); + expect(extractedMetadata!.uri).toBe(metadata.uri); + }); + + it('should handle extension with hasExtensions=false', () => { + const mint: CompressedMint = { + base: { + mintAuthority: null, + supply: BigInt(0), + decimals: 9, + isInitialized: true, + freezeAuthority: null, + }, + mintContext: { + version: 1, + splMintInitialized: false, + splMint: PublicKey.default, + }, + extensions: null, + }; + + const serialized = serializeMint(mint); + const deserialized = deserializeMint(serialized); + + expect(deserialized.extensions).toBeNull(); + }); + + it('should correctly parse Borsh format (discriminant + data, no length prefix)', () => { + const metadata: TokenMetadata = { + updateAuthority: Keypair.generate().publicKey, + mint: Keypair.generate().publicKey, + name: 'Test Token', + symbol: 'TEST', + uri: 'https://example.com/metadata.json', + }; + + const encodedMetadata = encodeTokenMetadata(metadata); + + // Build buffer in Borsh format manually + const baseMintBuffer = Buffer.alloc(MINT_SIZE); + const contextBuffer = Buffer.alloc(MINT_CONTEXT_SIZE); + + // Borsh format: Some(1) + vec_len(4) + discriminant(1) + data (no length prefix) + const extensionsBuffer = Buffer.concat([ + Buffer.from([1]), // Some + Buffer.from([1, 0, 0, 0]), // vec len = 1 + Buffer.from([ExtensionType.TokenMetadata]), // discriminant + encodedMetadata, // data directly (no length prefix) + ]); + + const fullBuffer = Buffer.concat([ + baseMintBuffer, + contextBuffer, + extensionsBuffer, + ]); + + const deserialized = deserializeMint(fullBuffer); + + expect(deserialized.extensions).not.toBeNull(); + expect(deserialized.extensions!.length).toBe(1); + expect(deserialized.extensions![0].extensionType).toBe( + ExtensionType.TokenMetadata, + ); + + // Metadata should be extractable + const extractedMetadata = extractTokenMetadata( + deserialized.extensions, + ); + expect(extractedMetadata).not.toBeNull(); + expect(extractedMetadata!.name).toBe(metadata.name); + expect(extractedMetadata!.symbol).toBe(metadata.symbol); + expect(extractedMetadata!.uri).toBe(metadata.uri); + }); + + it('should handle multiple extensions', () => { + const metadata1: TokenMetadata = { + mint: Keypair.generate().publicKey, + name: 'Token 1', + symbol: 'T1', + uri: 'https://example.com/1.json', + }; + const metadata2: TokenMetadata = { + mint: Keypair.generate().publicKey, + name: 'Token 2', + symbol: 'T2', + uri: 'https://example.com/2.json', + }; + + const mint: CompressedMint = { + base: { + mintAuthority: null, + supply: BigInt(0), + decimals: 9, + isInitialized: true, + freezeAuthority: null, + }, + mintContext: { + version: 1, + splMintInitialized: false, + splMint: PublicKey.default, + }, + extensions: [ + { + extensionType: ExtensionType.TokenMetadata, + data: encodeTokenMetadata(metadata1), + }, + { + extensionType: ExtensionType.TokenMetadata, + data: encodeTokenMetadata(metadata2), + }, + ], + }; + + const serialized = serializeMint(mint); + const deserialized = deserializeMint(serialized); + + expect(deserialized.extensions).not.toBeNull(); + expect(deserialized.extensions!.length).toBe(2); + + const ext1Metadata = decodeTokenMetadata( + deserialized.extensions![0].data, + ); + const ext2Metadata = decodeTokenMetadata( + deserialized.extensions![1].data, + ); + + expect(ext1Metadata!.name).toBe(metadata1.name); + expect(ext2Metadata!.name).toBe(metadata2.name); + }); + }); + + describe('parseTokenMetadata alias', () => { + it('parseTokenMetadata should be alias for decodeTokenMetadata', () => { + // parseTokenMetadata is exported as an alias (deprecated) + expect(parseTokenMetadata).toBe(decodeTokenMetadata); + }); + }); + + describe('TokenMetadata updateAuthority edge cases', () => { + it('should return undefined for zero updateAuthority when decoding', () => { + // Encode with no updateAuthority (uses zero pubkey) + const metadata: TokenMetadata = { + mint: Keypair.generate().publicKey, + name: 'Test', + symbol: 'TST', + uri: 'https://test.com', + }; + + const encoded = encodeTokenMetadata(metadata); + const decoded = decodeTokenMetadata(encoded); + + expect(decoded).not.toBeNull(); + // Zero pubkey should be returned as undefined + expect(decoded!.updateAuthority).toBeUndefined(); + }); + + it('should preserve non-zero updateAuthority', () => { + const authority = Keypair.generate().publicKey; + const metadata: TokenMetadata = { + updateAuthority: authority, + mint: Keypair.generate().publicKey, + name: 'Test', + symbol: 'TST', + uri: 'https://test.com', + }; + + const encoded = encodeTokenMetadata(metadata); + const decoded = decodeTokenMetadata(encoded); + + expect(decoded).not.toBeNull(); + expect(decoded!.updateAuthority).not.toBeUndefined(); + expect(decoded!.updateAuthority!.toBase58()).toBe( + authority.toBase58(), + ); + }); + + it('should handle null updateAuthority same as undefined', () => { + const metadata: TokenMetadata = { + updateAuthority: null, + mint: Keypair.generate().publicKey, + name: 'Test', + symbol: 'TST', + uri: 'https://test.com', + }; + + const encoded = encodeTokenMetadata(metadata); + const decoded = decodeTokenMetadata(encoded); + + expect(decoded).not.toBeNull(); + expect(decoded!.updateAuthority).toBeUndefined(); + }); + }); + + describe('TokenMetadata with mint field (encoding includes mint)', () => { + it('TokenMetadataLayout should include mint field in encoding', () => { + // Verify the layout includes the mint field + const mintPubkey = Keypair.generate().publicKey; + const metadata: TokenMetadata = { + mint: mintPubkey, + name: 'Test', + symbol: 'TST', + uri: 'https://test.com', + }; + + const encoded = encodeTokenMetadata(metadata); + + // Encoded should have: updateAuthority (32) + mint (32) + name vec + symbol vec + uri vec + additional vec + // Minimum: 32 + 32 + 4 + 4 + 4 + 4 = 80 bytes + expect(encoded.length).toBeGreaterThanOrEqual(80); + }); + + it('encodeTokenMetadata should encode mint field correctly', () => { + const mintPubkey = Keypair.generate().publicKey; + const metadata: TokenMetadata = { + mint: mintPubkey, + name: 'Test', + symbol: 'TST', + uri: 'https://test.com', + }; + + const encoded = encodeTokenMetadata(metadata); + + // Bytes 32-63 should be the mint pubkey (after updateAuthority) + const mintBytes = encoded.slice(32, 64); + expect(Buffer.from(mintBytes).equals(mintPubkey.toBuffer())).toBe( + true, + ); + }); + }); + + describe('decodeTokenMetadata malformed data', () => { + it('should return null for data shorter than 80 bytes (minimum Borsh size)', () => { + const shortData = Buffer.alloc(79); + expect(decodeTokenMetadata(shortData)).toBeNull(); + }); + + it('should decode 80 bytes of zeros as empty metadata', () => { + // Minimum size: 32 (updateAuthority) + 32 (mint) + 4*4 (vec lengths) = 80 bytes + // All zeros means: zero pubkeys and empty vecs - this is actually valid + const data = Buffer.alloc(80); + const result = decodeTokenMetadata(data); + expect(result).not.toBeNull(); + expect(result!.name).toBe(''); + expect(result!.symbol).toBe(''); + expect(result!.uri).toBe(''); + expect(result!.updateAuthority).toBeUndefined(); // zero pubkey -> undefined + }); + + it('should handle corrupted vec length gracefully', () => { + // Create valid header but corrupted name length + const data = Buffer.alloc(100); + // Set name length to a huge value at offset 64 (after updateAuthority + mint) + data.writeUInt32LE(0xffffffff, 64); + // Should return null due to try/catch + expect(decodeTokenMetadata(data)).toBeNull(); + }); + }); + + describe('encodeTokenMetadata buffer allocation', () => { + it('should handle metadata that fits within 2000 byte buffer', () => { + const metadata: TokenMetadata = { + mint: Keypair.generate().publicKey, + name: 'A'.repeat(500), + symbol: 'B'.repeat(100), + uri: 'C'.repeat(500), + additionalMetadata: [ + { key: 'k1', value: 'v'.repeat(100) }, + { key: 'k2', value: 'v'.repeat(100) }, + ], + }; + + const encoded = encodeTokenMetadata(metadata); + expect(encoded.length).toBeLessThan(2000); + + // Should roundtrip + const decoded = decodeTokenMetadata(encoded); + expect(decoded!.name).toBe(metadata.name); + expect(decoded!.symbol).toBe(metadata.symbol); + expect(decoded!.uri).toBe(metadata.uri); + }); + }); + + describe('toMintInstructionData conversion', () => { + it('should convert CompressedMint without extensions', () => { + const splMint = Keypair.generate().publicKey; + const mintAuthority = Keypair.generate().publicKey; + + const compressedMint: CompressedMint = { + base: { + mintAuthority, + supply: BigInt(1_000_000), + decimals: 9, + isInitialized: true, + freezeAuthority: null, + }, + mintContext: { + version: 1, + splMintInitialized: true, + splMint, + }, + extensions: null, + }; + + const result = toMintInstructionData(compressedMint); + + expect(result.supply).toBe(BigInt(1_000_000)); + expect(result.decimals).toBe(9); + expect(result.mintAuthority?.toBase58()).toBe( + mintAuthority.toBase58(), + ); + expect(result.freezeAuthority).toBeNull(); + expect(result.splMint.toBase58()).toBe(splMint.toBase58()); + expect(result.splMintInitialized).toBe(true); + expect(result.version).toBe(1); + expect(result.metadata).toBeUndefined(); + }); + + it('should convert CompressedMint with TokenMetadata extension', () => { + const splMint = Keypair.generate().publicKey; + const updateAuthority = Keypair.generate().publicKey; + + const tokenMetadata: TokenMetadata = { + updateAuthority, + mint: Keypair.generate().publicKey, + name: 'Test Token', + symbol: 'TEST', + uri: 'https://example.com/metadata.json', + }; + + const compressedMint: CompressedMint = { + base: { + mintAuthority: null, + supply: BigInt(500_000), + decimals: 6, + isInitialized: true, + freezeAuthority: null, + }, + mintContext: { + version: 2, + splMintInitialized: false, + splMint, + }, + extensions: [ + { + extensionType: ExtensionType.TokenMetadata, + data: encodeTokenMetadata(tokenMetadata), + }, + ], + }; + + const result = toMintInstructionData(compressedMint); + + expect(result.supply).toBe(BigInt(500_000)); + expect(result.decimals).toBe(6); + expect(result.version).toBe(2); + expect(result.metadata).toBeDefined(); + expect(result.metadata!.name).toBe('Test Token'); + expect(result.metadata!.symbol).toBe('TEST'); + expect(result.metadata!.uri).toBe( + 'https://example.com/metadata.json', + ); + expect(result.metadata!.updateAuthority?.toBase58()).toBe( + updateAuthority.toBase58(), + ); + }); + + it('should handle CompressedMint with empty extensions array', () => { + const compressedMint: CompressedMint = { + base: { + mintAuthority: null, + supply: BigInt(0), + decimals: 9, + isInitialized: true, + freezeAuthority: null, + }, + mintContext: { + version: 1, + splMintInitialized: false, + splMint: PublicKey.default, + }, + extensions: [], + }; + + const result = toMintInstructionData(compressedMint); + expect(result.metadata).toBeUndefined(); + }); + + it('should handle metadata with null updateAuthority', () => { + const tokenMetadata: TokenMetadata = { + mint: Keypair.generate().publicKey, + name: 'No Authority', + symbol: 'NA', + uri: 'https://example.com', + }; + + const compressedMint: CompressedMint = { + base: { + mintAuthority: null, + supply: BigInt(100), + decimals: 0, + isInitialized: true, + freezeAuthority: null, + }, + mintContext: { + version: 1, + splMintInitialized: false, + splMint: PublicKey.default, + }, + extensions: [ + { + extensionType: ExtensionType.TokenMetadata, + data: encodeTokenMetadata(tokenMetadata), + }, + ], + }; + + const result = toMintInstructionData(compressedMint); + expect(result.metadata).toBeDefined(); + expect(result.metadata!.updateAuthority).toBeNull(); + }); + }); + + describe('toMintInstructionDataWithMetadata conversion', () => { + it('should convert CompressedMint with metadata extension', () => { + const tokenMetadata: TokenMetadata = { + updateAuthority: Keypair.generate().publicKey, + mint: Keypair.generate().publicKey, + name: 'With Metadata', + symbol: 'WM', + uri: 'https://wm.com', + }; + + const compressedMint: CompressedMint = { + base: { + mintAuthority: Keypair.generate().publicKey, + supply: BigInt(1000), + decimals: 9, + isInitialized: true, + freezeAuthority: null, + }, + mintContext: { + version: 1, + splMintInitialized: true, + splMint: Keypair.generate().publicKey, + }, + extensions: [ + { + extensionType: ExtensionType.TokenMetadata, + data: encodeTokenMetadata(tokenMetadata), + }, + ], + }; + + const result = toMintInstructionDataWithMetadata(compressedMint); + + // Metadata field should be required (not optional) + expect(result.metadata.name).toBe('With Metadata'); + expect(result.metadata.symbol).toBe('WM'); + expect(result.metadata.uri).toBe('https://wm.com'); + }); + + it('should throw if CompressedMint has no metadata extension', () => { + const compressedMint: CompressedMint = { + base: { + mintAuthority: null, + supply: BigInt(0), + decimals: 9, + isInitialized: true, + freezeAuthority: null, + }, + mintContext: { + version: 1, + splMintInitialized: false, + splMint: PublicKey.default, + }, + extensions: null, + }; + + expect(() => + toMintInstructionDataWithMetadata(compressedMint), + ).toThrow('CompressedMint does not have TokenMetadata extension'); + }); + + it('should throw if extensions array is empty', () => { + const compressedMint: CompressedMint = { + base: { + mintAuthority: null, + supply: BigInt(0), + decimals: 9, + isInitialized: true, + freezeAuthority: null, + }, + mintContext: { + version: 1, + splMintInitialized: false, + splMint: PublicKey.default, + }, + extensions: [], + }; + + expect(() => + toMintInstructionDataWithMetadata(compressedMint), + ).toThrow('CompressedMint does not have TokenMetadata extension'); + }); + }); + + describe('MintInstructionData type structure', () => { + it('MintInstructionData should have correct shape', () => { + const data: MintInstructionData = { + supply: BigInt(1000), + decimals: 9, + mintAuthority: null, + freezeAuthority: null, + splMint: PublicKey.default, + splMintInitialized: false, + version: 1, + }; + + expect(data.supply).toBe(BigInt(1000)); + expect(data.decimals).toBe(9); + expect(data.metadata).toBeUndefined(); + }); + + it('MintInstructionDataWithMetadata should require metadata', () => { + const data: MintInstructionDataWithMetadata = { + supply: BigInt(1000), + decimals: 9, + mintAuthority: null, + freezeAuthority: null, + splMint: PublicKey.default, + splMintInitialized: false, + version: 1, + metadata: { + updateAuthority: null, + name: 'Test', + symbol: 'T', + uri: 'https://test.com', + }, + }; + + expect(data.metadata.name).toBe('Test'); + }); + + it('MintMetadataField should have correct shape', () => { + const metadata: MintMetadataField = { + updateAuthority: Keypair.generate().publicKey, + name: 'Token Name', + symbol: 'TN', + uri: 'https://example.com', + }; + + expect(metadata.name).toBe('Token Name'); + expect(metadata.symbol).toBe('TN'); + expect(metadata.uri).toBe('https://example.com'); + }); + }); +}); diff --git a/js/compressed-token/tests/unit/upload.test.ts b/js/compressed-token/tests/unit/upload.test.ts new file mode 100644 index 0000000000..d6b8de98ed --- /dev/null +++ b/js/compressed-token/tests/unit/upload.test.ts @@ -0,0 +1,438 @@ +import { describe, it, expect } from 'vitest'; +import { + toOffChainMetadataJson, + OffChainTokenMetadata, + OffChainTokenMetadataJson, +} from '../../src/mint/upload'; + +describe('upload', () => { + describe('toOffChainMetadataJson', () => { + it('should format basic metadata with only required fields', () => { + const input: OffChainTokenMetadata = { + name: 'Test Token', + symbol: 'TEST', + }; + + const result = toOffChainMetadataJson(input); + + expect(result).toEqual({ + name: 'Test Token', + symbol: 'TEST', + }); + expect(result.description).toBeUndefined(); + expect(result.image).toBeUndefined(); + expect(result.additionalMetadata).toBeUndefined(); + }); + + it('should include description when provided', () => { + const input: OffChainTokenMetadata = { + name: 'My Token', + symbol: 'MTK', + description: 'A test token for unit testing', + }; + + const result = toOffChainMetadataJson(input); + + expect(result.name).toBe('My Token'); + expect(result.symbol).toBe('MTK'); + expect(result.description).toBe('A test token for unit testing'); + expect(result.image).toBeUndefined(); + }); + + it('should include image when provided', () => { + const input: OffChainTokenMetadata = { + name: 'Image Token', + symbol: 'IMG', + image: 'https://example.com/token.png', + }; + + const result = toOffChainMetadataJson(input); + + expect(result.name).toBe('Image Token'); + expect(result.symbol).toBe('IMG'); + expect(result.image).toBe('https://example.com/token.png'); + expect(result.description).toBeUndefined(); + }); + + it('should include additionalMetadata when provided with items', () => { + const input: OffChainTokenMetadata = { + name: 'Rich Token', + symbol: 'RICH', + additionalMetadata: [ + { key: 'creator', value: 'Alice' }, + { key: 'version', value: '1.0.0' }, + ], + }; + + const result = toOffChainMetadataJson(input); + + expect(result.name).toBe('Rich Token'); + expect(result.symbol).toBe('RICH'); + expect(result.additionalMetadata).toEqual([ + { key: 'creator', value: 'Alice' }, + { key: 'version', value: '1.0.0' }, + ]); + }); + + it('should include all fields when all are provided', () => { + const input: OffChainTokenMetadata = { + name: 'Full Token', + symbol: 'FULL', + description: 'A token with all metadata fields', + image: 'https://arweave.net/abc123', + additionalMetadata: [ + { key: 'website', value: 'https://example.com' }, + { key: 'twitter', value: '@fulltoken' }, + { key: 'category', value: 'utility' }, + ], + }; + + const result = toOffChainMetadataJson(input); + + expect(result).toEqual({ + name: 'Full Token', + symbol: 'FULL', + description: 'A token with all metadata fields', + image: 'https://arweave.net/abc123', + additionalMetadata: [ + { key: 'website', value: 'https://example.com' }, + { key: 'twitter', value: '@fulltoken' }, + { key: 'category', value: 'utility' }, + ], + }); + }); + + it('should exclude empty additionalMetadata array', () => { + const input: OffChainTokenMetadata = { + name: 'Empty Additional', + symbol: 'EA', + additionalMetadata: [], + }; + + const result = toOffChainMetadataJson(input); + + expect(result.name).toBe('Empty Additional'); + expect(result.symbol).toBe('EA'); + expect(result.additionalMetadata).toBeUndefined(); + }); + + it('should handle empty string values', () => { + const input: OffChainTokenMetadata = { + name: '', + symbol: '', + description: '', + image: '', + }; + + const result = toOffChainMetadataJson(input); + + expect(result.name).toBe(''); + expect(result.symbol).toBe(''); + expect(result.description).toBe(''); + expect(result.image).toBe(''); + }); + + it('should handle long string values', () => { + const longName = 'A'.repeat(200); + const longSymbol = 'B'.repeat(50); + const longDescription = 'C'.repeat(1000); + const longImageUrl = 'https://example.com/' + 'x'.repeat(500); + + const input: OffChainTokenMetadata = { + name: longName, + symbol: longSymbol, + description: longDescription, + image: longImageUrl, + }; + + const result = toOffChainMetadataJson(input); + + expect(result.name).toBe(longName); + expect(result.symbol).toBe(longSymbol); + expect(result.description).toBe(longDescription); + expect(result.image).toBe(longImageUrl); + }); + + it('should handle unicode characters', () => { + const input: OffChainTokenMetadata = { + name: 'Token Name', + symbol: 'TKN', + description: 'Description with special chars', + }; + + const result = toOffChainMetadataJson(input); + + expect(result.name).toBe('Token Name'); + expect(result.symbol).toBe('TKN'); + expect(result.description).toBe('Description with special chars'); + }); + + it('should handle special characters in URLs', () => { + const input: OffChainTokenMetadata = { + name: 'URL Token', + symbol: 'URL', + image: 'https://example.com/image?param=value&other=123', + }; + + const result = toOffChainMetadataJson(input); + + expect(result.image).toBe( + 'https://example.com/image?param=value&other=123', + ); + }); + + it('should handle IPFS URLs', () => { + const input: OffChainTokenMetadata = { + name: 'IPFS Token', + symbol: 'IPFS', + image: 'ipfs://QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG', + }; + + const result = toOffChainMetadataJson(input); + + expect(result.image).toBe( + 'ipfs://QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG', + ); + }); + + it('should handle Arweave URLs', () => { + const input: OffChainTokenMetadata = { + name: 'Arweave Token', + symbol: 'AR', + image: 'https://arweave.net/abc123xyz', + }; + + const result = toOffChainMetadataJson(input); + + expect(result.image).toBe('https://arweave.net/abc123xyz'); + }); + + it('should preserve additionalMetadata order', () => { + const input: OffChainTokenMetadata = { + name: 'Order Test', + symbol: 'ORD', + additionalMetadata: [ + { key: 'z_last', value: 'should be last' }, + { key: 'a_first', value: 'should be first' }, + { key: 'm_middle', value: 'should be middle' }, + ], + }; + + const result = toOffChainMetadataJson(input); + + expect(result.additionalMetadata).toEqual([ + { key: 'z_last', value: 'should be last' }, + { key: 'a_first', value: 'should be first' }, + { key: 'm_middle', value: 'should be middle' }, + ]); + }); + + it('should handle additionalMetadata with empty key or value', () => { + const input: OffChainTokenMetadata = { + name: 'Empty KV', + symbol: 'EKV', + additionalMetadata: [ + { key: '', value: 'empty key' }, + { key: 'empty value', value: '' }, + { key: '', value: '' }, + ], + }; + + const result = toOffChainMetadataJson(input); + + expect(result.additionalMetadata).toEqual([ + { key: '', value: 'empty key' }, + { key: 'empty value', value: '' }, + { key: '', value: '' }, + ]); + }); + + it('result should be JSON serializable', () => { + const input: OffChainTokenMetadata = { + name: 'JSON Test', + symbol: 'JSON', + description: 'Testing JSON serialization', + image: 'https://example.com/image.png', + additionalMetadata: [{ key: 'test', value: 'value' }], + }; + + const result = toOffChainMetadataJson(input); + const jsonString = JSON.stringify(result); + const parsed = JSON.parse(jsonString); + + expect(parsed).toEqual(result); + }); + + it('should not include undefined optional fields in output', () => { + const input: OffChainTokenMetadata = { + name: 'Minimal', + symbol: 'MIN', + }; + + const result = toOffChainMetadataJson(input); + const keys = Object.keys(result); + + expect(keys).toEqual(['name', 'symbol']); + expect(keys).not.toContain('description'); + expect(keys).not.toContain('image'); + expect(keys).not.toContain('additionalMetadata'); + }); + + it('should return a new object (not mutate input)', () => { + const input: OffChainTokenMetadata = { + name: 'Immutable', + symbol: 'IMM', + description: 'Test immutability', + }; + + const result = toOffChainMetadataJson(input); + + // Modify result + result.name = 'Modified'; + + // Original should be unchanged + expect(input.name).toBe('Immutable'); + }); + + it('should handle explicitly undefined optional fields', () => { + const input: OffChainTokenMetadata = { + name: 'Explicit Undefined', + symbol: 'EU', + description: undefined, + image: undefined, + additionalMetadata: undefined, + }; + + const result = toOffChainMetadataJson(input); + const keys = Object.keys(result); + + expect(keys).toEqual(['name', 'symbol']); + expect(result.description).toBeUndefined(); + expect(result.image).toBeUndefined(); + expect(result.additionalMetadata).toBeUndefined(); + }); + + it('should handle additionalMetadata with single item', () => { + const input: OffChainTokenMetadata = { + name: 'Single Item', + symbol: 'SI', + additionalMetadata: [{ key: 'only', value: 'one' }], + }; + + const result = toOffChainMetadataJson(input); + + expect(result.additionalMetadata).toEqual([ + { key: 'only', value: 'one' }, + ]); + expect(result.additionalMetadata?.length).toBe(1); + }); + + it('should share additionalMetadata array reference (not deep copy)', () => { + const additionalMetadata = [{ key: 'shared', value: 'ref' }]; + const input: OffChainTokenMetadata = { + name: 'Ref Test', + symbol: 'REF', + additionalMetadata, + }; + + const result = toOffChainMetadataJson(input); + + // Same reference (current behavior) + expect(result.additionalMetadata).toBe(additionalMetadata); + }); + + it('should handle mix of provided and omitted optional fields', () => { + const input: OffChainTokenMetadata = { + name: 'Mixed', + symbol: 'MIX', + description: 'Has description', + // image omitted + additionalMetadata: [{ key: 'has', value: 'metadata' }], + }; + + const result = toOffChainMetadataJson(input); + + expect(result.description).toBe('Has description'); + expect(result.image).toBeUndefined(); + expect(result.additionalMetadata).toBeDefined(); + expect('image' in result).toBe(false); + }); + + it('should handle whitespace-only strings', () => { + const input: OffChainTokenMetadata = { + name: ' ', + symbol: '\t\n', + description: ' spaces ', + }; + + const result = toOffChainMetadataJson(input); + + expect(result.name).toBe(' '); + expect(result.symbol).toBe('\t\n'); + expect(result.description).toBe(' spaces '); + }); + + it('should handle additionalMetadata with many items', () => { + const manyItems = Array.from({ length: 100 }, (_, i) => ({ + key: `key${i}`, + value: `value${i}`, + })); + + const input: OffChainTokenMetadata = { + name: 'Many Items', + symbol: 'MANY', + additionalMetadata: manyItems, + }; + + const result = toOffChainMetadataJson(input); + + expect(result.additionalMetadata?.length).toBe(100); + expect(result.additionalMetadata?.[0]).toEqual({ + key: 'key0', + value: 'value0', + }); + expect(result.additionalMetadata?.[99]).toEqual({ + key: 'key99', + value: 'value99', + }); + }); + }); + + describe('OffChainTokenMetadata type', () => { + it('should allow minimal metadata', () => { + const meta: OffChainTokenMetadata = { + name: 'Test', + symbol: 'T', + }; + expect(meta.name).toBe('Test'); + expect(meta.symbol).toBe('T'); + }); + + it('should allow full metadata', () => { + const meta: OffChainTokenMetadata = { + name: 'Full', + symbol: 'F', + description: 'desc', + image: 'img', + additionalMetadata: [{ key: 'k', value: 'v' }], + }; + expect(meta.name).toBe('Full'); + expect(meta.description).toBe('desc'); + }); + }); + + describe('OffChainTokenMetadataJson type', () => { + it('should have correct shape for JSON output', () => { + const json: OffChainTokenMetadataJson = { + name: 'Output', + symbol: 'OUT', + description: 'Optional desc', + image: 'Optional image', + additionalMetadata: [{ key: 'k', value: 'v' }], + }; + + expect(json.name).toBe('Output'); + expect(json.symbol).toBe('OUT'); + }); + }); +}); diff --git a/js/compressed-token/types/buffer-layout/index.d.ts b/js/compressed-token/types/buffer-layout/index.d.ts index f047cbb1fd..8b23f6d700 100644 --- a/js/compressed-token/types/buffer-layout/index.d.ts +++ b/js/compressed-token/types/buffer-layout/index.d.ts @@ -1,5 +1,5 @@ // From https://github.com/coral-xyz/anchor/blob/master/ts/packages/anchor/types/buffer-layout/index.d.ts -declare module 'buffer-layout' { +declare module '@solana/buffer-layout' { // TODO: remove `any`. export class Layout { span: number; @@ -86,3 +86,8 @@ declare module 'buffer-layout' { export function cstr(property?: string): Layout; export function utf8(maxSpan: number, property?: string): Layout; } + +// Also declare the old package name for backward compatibility +declare module 'buffer-layout' { + export * from '@solana/buffer-layout'; +} diff --git a/js/stateless.js/package.json b/js/stateless.js/package.json index 798a2cc7fd..5cfdb89e4f 100644 --- a/js/stateless.js/package.json +++ b/js/stateless.js/package.json @@ -86,9 +86,10 @@ }, "scripts": { "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-all": "vitest run", - "test:v1": "LIGHT_PROTOCOL_VERSION=V1 pnpm test", - "test:v2": "LIGHT_PROTOCOL_VERSION=V2 pnpm test", "test:unit:all": "vitest run tests/unit --reporter=verbose", "test:unit:all:v1": "LIGHT_PROTOCOL_VERSION=V1 vitest run tests/unit --reporter=verbose", "test:unit:all:v2": "LIGHT_PROTOCOL_VERSION=V2 vitest run tests/unit --reporter=verbose", @@ -114,7 +115,6 @@ "build:v1": "LIGHT_PROTOCOL_VERSION=V1 pnpm build:bundle", "build:v2": "LIGHT_PROTOCOL_VERSION=V2 pnpm build:bundle", "build-ci": "if [ \"$LIGHT_PROTOCOL_VERSION\" = \"V2\" ]; then LIGHT_PROTOCOL_VERSION=V2 pnpm build:bundle; else LIGHT_PROTOCOL_VERSION=V1 pnpm build:bundle; fi", - "test-ci": "pnpm test", "format": "prettier --write .", "lint": "eslint ." }, diff --git a/js/stateless.js/src/actions/create-account.ts b/js/stateless.js/src/actions/create-account.ts index 6f69521bfe..7b128a8e48 100644 --- a/js/stateless.js/src/actions/create-account.ts +++ b/js/stateless.js/src/actions/create-account.ts @@ -15,11 +15,13 @@ import { buildAndSignTx, deriveAddress, deriveAddressSeed, + deriveAddressSeedV2, + deriveAddressV2, selectStateTreeInfo, sendAndConfirmTx, } from '../utils'; -import { getDefaultAddressTreeInfo } from '../constants'; -import { AddressTreeInfo, bn, TreeInfo } from '../state'; +import { featureFlags, getDefaultAddressTreeInfo } from '../constants'; +import { AddressTreeInfo, bn, TreeInfo, TreeType } from '../state'; import BN from 'bn.js'; /** @@ -47,7 +49,18 @@ export async function createAccount( confirmOptions?: ConfirmOptions, ): Promise { const { blockhash } = await rpc.getLatestBlockhash(); - const { tree, queue } = addressTreeInfo ?? getDefaultAddressTreeInfo(); + const resolvedAddressTreeInfo = + addressTreeInfo ?? getDefaultAddressTreeInfo(); + const { tree, queue } = resolvedAddressTreeInfo; + + // V1 only. + const isV2Tree = resolvedAddressTreeInfo.treeType === TreeType.AddressV2; + const isV2 = featureFlags.isV2(); + if (isV2 || isV2Tree) { + throw new Error( + 'You are using V2. create-account/create-address is only supported via CPI.', + ); + } const seed = deriveAddressSeed(seeds, programId); const address = deriveAddress(seed, tree); @@ -133,7 +146,18 @@ export async function createAccountWithLamports( const { blockhash } = await rpc.getLatestBlockhash(); - const { tree } = addressTreeInfo ?? getDefaultAddressTreeInfo(); + const resolvedAddressTreeInfo = + addressTreeInfo ?? getDefaultAddressTreeInfo(); + const { tree } = resolvedAddressTreeInfo; + + // V1 only. + const isV2Tree = resolvedAddressTreeInfo.treeType === TreeType.AddressV2; + const isV2 = featureFlags.isV2(); + if (isV2 || isV2Tree) { + throw new Error( + 'You are using V2. create-account/create-address is only supported via CPI.', + ); + } const seed = deriveAddressSeed(seeds, programId); const address = deriveAddress(seed, tree); diff --git a/js/stateless.js/src/actions/transfer.ts b/js/stateless.js/src/actions/transfer.ts index 8871538f4f..db4a3dee26 100644 --- a/js/stateless.js/src/actions/transfer.ts +++ b/js/stateless.js/src/actions/transfer.ts @@ -36,6 +36,7 @@ export async function transfer( confirmOptions?: ConfirmOptions, ): Promise { let accumulatedLamports = bn(0); + const compressedAccounts: CompressedAccountWithMerkleContext[] = []; let cursor: string | undefined; const batchSize = 1000; // Maximum allowed by the API diff --git a/js/stateless.js/src/constants.ts b/js/stateless.js/src/constants.ts index 6f358007d2..1b26e9c243 100644 --- a/js/stateless.js/src/constants.ts +++ b/js/stateless.js/src/constants.ts @@ -218,12 +218,30 @@ export const localTestActiveStateTreeInfos = (): TreeInfo[] => { ); }; +export const getDefaultAddressSpace = () => { + return getBatchAddressTreeInfo(); +}; + export const getDefaultAddressTreeInfo = () => { + if (featureFlags.isV2()) { + return getBatchAddressTreeInfo(); + } else { + return { + tree: new PublicKey(addressTree), + queue: new PublicKey(addressQueue), + cpiContext: undefined, + treeType: TreeType.AddressV1, + nextTreeInfo: null, + }; + } +}; + +export const getBatchAddressTreeInfo = () => { return { - tree: new PublicKey(addressTree), - queue: new PublicKey(addressQueue), - cpiContext: null, - treeType: TreeType.AddressV1, + tree: new PublicKey(batchAddressTree), + queue: new PublicKey(batchAddressTree), + cpiContext: undefined, + treeType: TreeType.AddressV2, nextTreeInfo: null, }; }; @@ -256,6 +274,8 @@ export const defaultTestStateTreeAccounts2 = () => { export const COMPRESSED_TOKEN_PROGRAM_ID = new PublicKey( 'cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m', ); + +export const CTOKEN_PROGRAM_ID = COMPRESSED_TOKEN_PROGRAM_ID; export const stateTreeLookupTableMainnet = '7i86eQs3GSqHjN47WdWLTCGMW6gde1q96G2EVnUyK2st'; export const nullifiedStateTreeLookupTableMainnet = diff --git a/js/stateless.js/src/rpc-interface.ts b/js/stateless.js/src/rpc-interface.ts index 4602137a44..5ecedb788e 100644 --- a/js/stateless.js/src/rpc-interface.ts +++ b/js/stateless.js/src/rpc-interface.ts @@ -1,4 +1,11 @@ -import { PublicKey, MemcmpFilter, DataSlice } from '@solana/web3.js'; +import { + PublicKey, + MemcmpFilter, + DataSlice, + Commitment, + GetAccountInfoConfig, + AccountInfo, +} from '@solana/web3.js'; import { type as pick, number, @@ -27,6 +34,7 @@ import { TreeInfo, AddressTreeInfo, CompressedProof, + MerkleContext, } from './state'; import BN from 'bn.js'; @@ -117,6 +125,16 @@ export interface AddressWithTreeInfo { treeInfo: AddressTreeInfo; } +export interface AddressWithTreeInfoV2 { + address: Uint8Array; + treeInfo: TreeInfo; +} + +export enum DerivationMode { + compressible = 'compressible', + standard = 'standard', +} + export interface CompressedTransaction { compressionInfo: { closedAccounts: { @@ -864,6 +882,17 @@ export interface CompressionApiInterface { getIndexerHealth(): Promise; getIndexerSlot(): Promise; + + getAccountInfoInterface( + address: PublicKey, + programId: PublicKey, + commitmentOrConfig?: Commitment | GetAccountInfoConfig, + addressSpace?: TreeInfo, + ): Promise<{ + accountInfo: AccountInfo; + isCold: boolean; + loadContext?: MerkleContext; + } | null>; } // Public types for consumers diff --git a/js/stateless.js/src/rpc.ts b/js/stateless.js/src/rpc.ts index 66a05256b0..0c556255fa 100644 --- a/js/stateless.js/src/rpc.ts +++ b/js/stateless.js/src/rpc.ts @@ -1,6 +1,9 @@ import { + AccountInfo, + Commitment, Connection, ConnectionConfig, + GetAccountInfoConfig, PublicKey, SolanaJSONRPCError, } from '@solana/web3.js'; @@ -51,6 +54,10 @@ import { PaginatedOptions, CompressedAccountResultV2, CompressedTokenAccountsByOwnerOrDelegateResultV2, + AddressWithTreeInfo, + HashWithTreeInfo, + DerivationMode, + AddressWithTreeInfoV2, } from './rpc-interface'; import { MerkleContextWithMerkleProof, @@ -64,6 +71,9 @@ import { ValidityProof, TreeType, AddressTreeInfo, + CompressedAccount, + MerkleContext, + CompressedAccountData, } from './state'; import { array, create, nullable } from 'superstruct'; import { @@ -74,6 +84,8 @@ import { versionedEndpoint, featureFlags, batchAddressTree, + CTOKEN_PROGRAM_ID, + getDefaultAddressSpace, } from './constants'; import BN from 'bn.js'; import { toCamelCase, toHex } from './utils/conversion'; @@ -89,7 +101,7 @@ import { getTreeInfoByPubkey, } from './utils/get-state-tree-infos'; import { TreeInfo } from './state/types'; -import { validateNumbersForProof } from './utils'; +import { deriveAddressV2, validateNumbersForProof } from './utils'; /** @internal */ export function parseAccountData({ @@ -1830,6 +1842,59 @@ export class Rpc extends Connection implements CompressionApiInterface { return value; } + /** + * Fetch the latest validity proof for (1) compressed accounts specified by + * an array of account Merkle contexts, and (2) new unique addresses specified by + * an array of address objects with tree info. + * + * Validity proofs prove the presence of compressed accounts in state trees + * and the non-existence of addresses in address trees, respectively. They + * enable verification without recomputing the merkle proof path, thus + * lowering verification and data costs. + */ + async getValidityProofV2( + accountMerkleContexts: (MerkleContext | undefined)[] = [], + newAddresses: AddressWithTreeInfoV2[] = [], + derivationMode?: DerivationMode, + ): Promise { + const hashesWithTrees = accountMerkleContexts + .filter(ctx => ctx !== undefined) + .map(ctx => ({ + hash: ctx.hash, + tree: ctx.treeInfo.tree, + queue: ctx.treeInfo.queue, + })); + + const addressesWithTrees = newAddresses.map(address => { + let derivedAddress: BN; + if ( + derivationMode === DerivationMode.compressible || + derivationMode === undefined + ) { + const publicKey = deriveAddressV2( + Uint8Array.from(address.address), + address.treeInfo.tree, + CTOKEN_PROGRAM_ID, + ); + derivedAddress = bn(publicKey.toBytes()); + } else { + derivedAddress = bn(address.address); + } + + return { + address: derivedAddress, + tree: address.treeInfo.tree, + queue: address.treeInfo.queue, + }; + }); + + const { value } = await this.getValidityProofAndRpcContext( + hashesWithTrees, + addressesWithTrees, + ); + return value; + } + /** * Fetch the latest validity proof for (1) compressed accounts specified by * an array of account hashes. (2) new unique addresses specified by an @@ -1951,4 +2016,110 @@ export class Rpc extends Connection implements CompressionApiInterface { }; } } + + /** + * Fetch all the account info for the specified public key. Returns metadata + * to to load in case the account is cold. + * @param address The account address to fetch. + * @param programId The owner program ID. + * @param commitmentOrConfig Optional. The commitment or config to use + * for the onchain account fetch. + * @param addressSpace Optional. The address space info that was + * used at init. + * + * @returns Account info with load info, or null if + * account doesn't exist. LoadContext is always + * some if the account is compressible. isCold + * indicates the current state of the account, + * if true the account must referenced in a + * load instruction before use. + */ + async getAccountInfoInterface( + address: PublicKey, + programId: PublicKey, + commitmentOrConfig?: Commitment | GetAccountInfoConfig, + addressSpace?: TreeInfo, + ): Promise<{ + accountInfo: AccountInfo; + isCold: boolean; + loadContext?: MerkleContext; + } | null> { + if (!featureFlags.isV2()) { + throw new Error( + 'getAccountInfoInterfacea requires feature flag V2', + ); + } + + addressSpace = addressSpace ?? getDefaultAddressSpace(); + + const cAddress = deriveAddressV2( + address.toBytes(), + addressSpace.tree, + programId, + ); + + const [onchainResult, compressedResult] = await Promise.allSettled([ + this.getAccountInfo(address, commitmentOrConfig), + this.getCompressedAccount(bn(cAddress.toBytes())), + ]); + + const onchainAccount = + onchainResult.status === 'fulfilled' ? onchainResult.value : null; + const compressedAccount = + compressedResult.status === 'fulfilled' + ? compressedResult.value + : null; + + if (onchainAccount) { + if (compressedAccount) { + return { + accountInfo: onchainAccount, + // it's compressible and currently hot. + loadContext: { + treeInfo: compressedAccount.treeInfo, + hash: compressedAccount.hash, + leafIndex: compressedAccount.leafIndex, + proveByIndex: compressedAccount.proveByIndex, + }, + isCold: false, + }; + } + // it's not compressible. + return { + accountInfo: onchainAccount, + loadContext: undefined, + isCold: false, + }; + } + + // is cold. + if ( + compressedAccount && + compressedAccount.data && + compressedAccount.data.data.length > 0 + ) { + const accountInfo: AccountInfo = { + executable: false, + owner: compressedAccount.owner, + lamports: compressedAccount.lamports.toNumber(), + data: Buffer.concat([ + Buffer.from(compressedAccount.data!.discriminator), + compressedAccount.data!.data, + ]), + }; + return { + accountInfo, + loadContext: { + treeInfo: compressedAccount.treeInfo, + hash: compressedAccount.hash, + leafIndex: compressedAccount.leafIndex, + proveByIndex: compressedAccount.proveByIndex, + }, + isCold: true, + }; + } + + // account does not exist. + return null; + } } diff --git a/js/stateless.js/src/test-helpers/test-rpc/get-compressed-token-accounts.ts b/js/stateless.js/src/test-helpers/test-rpc/get-compressed-token-accounts.ts index 5f0c9e96a6..5d440b8a06 100644 --- a/js/stateless.js/src/test-helpers/test-rpc/get-compressed-token-accounts.ts +++ b/js/stateless.js/src/test-helpers/test-rpc/get-compressed-token-accounts.ts @@ -13,15 +13,6 @@ import { TreeType, CompressedAccountLegacy, } from '../../state'; -import { - struct, - publicKey, - u64, - option, - vecU8, - u8, - Layout, -} from '@coral-xyz/borsh'; type TokenData = { mint: PublicKey; @@ -32,16 +23,6 @@ type TokenData = { tlv: Buffer | null; }; -// for test-rpc -export const TokenDataLayout: Layout = struct([ - publicKey('mint'), - publicKey('owner'), - u64('amount'), - option(publicKey(), 'delegate'), - u8('state'), - option(vecU8(), 'tlv'), -]); - export type EventWithParsedTokenTlvData = { inputCompressedAccountHashes: number[][]; outputCompressedAccounts: ParsedTokenAccount[]; @@ -67,9 +48,49 @@ export function parseTokenLayoutWithIdl( `Invalid owner ${compressedAccount.owner.toBase58()} for token layout`, ); } + try { - const decoded = TokenDataLayout.decode(Buffer.from(data)); - return decoded; + const buffer = Buffer.from(data); + let offset = 0; + + // mint: + const mint = new PublicKey(buffer.slice(offset, offset + 32)); + offset += 32; + + // owner: + const owner = new PublicKey(buffer.slice(offset, offset + 32)); + offset += 32; + + // amount: + const amount = new BN(buffer.slice(offset, offset + 8), 'le'); + offset += 8; + + // delegate: fixed size: 1 byte discriminator + 32 bytes pubkey + const delegateOption = buffer[offset]; + offset += 1; + const delegate = delegateOption + ? new PublicKey(buffer.slice(offset, offset + 32)) + : null; + offset += 32; + + // state: + const state = buffer[offset]; + offset += 1; + + // TODO: come back with extensions + // tlv: Option> - 1 byte discriminator, then rest is tlv data + const tlvOption = buffer[offset]; + offset += 1; + const tlv = tlvOption ? buffer.slice(offset) : null; + + return { + mint, + owner, + amount, + delegate, + state, + tlv, + }; } catch (error) { console.error('Decoding error:', error); throw error; diff --git a/js/stateless.js/src/test-helpers/test-rpc/test-rpc.ts b/js/stateless.js/src/test-helpers/test-rpc/test-rpc.ts index a915ce0f4a..ccbee7d71d 100644 --- a/js/stateless.js/src/test-helpers/test-rpc/test-rpc.ts +++ b/js/stateless.js/src/test-helpers/test-rpc/test-rpc.ts @@ -966,4 +966,34 @@ export class TestRpc extends Connection implements CompressionApiInterface { newAddresses.map(address => address.address), ); } + + async getValidityProofV2( + accountMerkleContexts: any[] = [], + newAddresses: any[] = [], + derivationMode?: any, + ): Promise { + const hashes = accountMerkleContexts + .filter(ctx => ctx !== undefined) + .map(ctx => ({ + hash: ctx.hash, + tree: ctx.treeInfo.tree, + queue: ctx.treeInfo.queue, + })); + + const addresses = newAddresses.map(addr => ({ + address: addr.address, + tree: addr.treeInfo.tree, + queue: addr.treeInfo.queue, + })); + + return this.getValidityProofV0(hashes, addresses); + } + + async getAccountInfoInterface( + _address: PublicKey, + _programId: PublicKey, + _addressSpaceInfo: any, + ): Promise { + throw new Error('getAccountInfoInterface not implemented in TestRpc'); + } } diff --git a/js/stateless.js/src/utils/index.ts b/js/stateless.js/src/utils/index.ts index 618974d044..68f3af34c9 100644 --- a/js/stateless.js/src/utils/index.ts +++ b/js/stateless.js/src/utils/index.ts @@ -11,3 +11,4 @@ export * from './sleep'; export * from './validation'; export * from './state-tree-lookup-table'; export * from './get-state-tree-infos'; +export * from './pack-decompress'; diff --git a/js/stateless.js/src/utils/pack-decompress.ts b/js/stateless.js/src/utils/pack-decompress.ts new file mode 100644 index 0000000000..13c889d65a --- /dev/null +++ b/js/stateless.js/src/utils/pack-decompress.ts @@ -0,0 +1,82 @@ +import { PublicKey, AccountMeta } from '@solana/web3.js'; +import { ValidityProof } from '../state'; +import { TreeInfo } from '../state/types'; + +export interface AccountDataWithTreeInfo { + key: string; + data: any; + treeInfo: TreeInfo; +} + +export interface PackedDecompressResult { + proofOption: { 0: ValidityProof | null }; + compressedAccounts: any[]; + systemAccountsOffset: number; + remainingAccounts: AccountMeta[]; +} + +/** + * Pack accounts and proof for decompressAccountsIdempotent instruction. + * This function prepares compressed account data, validity proof, and remaining accounts + * for idempotent decompression operations. + * + * @param programId - The program ID + * @param proof - The validity proof with context + * @param accountsData - Array of account data with tree info + * @param addresses - Array of account addresses + * @returns Packed instruction parameters + */ +export async function packDecompressAccountsIdempotent( + programId: PublicKey, + proof: { compressedProof: ValidityProof | null; treeInfos: TreeInfo[] }, + accountsData: AccountDataWithTreeInfo[], + addresses: PublicKey[], +): Promise { + const remainingAccounts: AccountMeta[] = []; + const remainingAccountsMap = new Map(); + + const getOrAddAccount = ( + pubkey: PublicKey, + isWritable: boolean, + ): number => { + const key = pubkey.toBase58(); + if (!remainingAccountsMap.has(key)) { + const index = remainingAccounts.length; + remainingAccounts.push({ + pubkey, + isSigner: false, + isWritable, + }); + remainingAccountsMap.set(key, index); + return index; + } + return remainingAccountsMap.get(key)!; + }; + + // Add tree accounts to remaining accounts + const compressedAccounts = accountsData.map((acc, index) => { + const merkleTreePubkeyIndex = getOrAddAccount(acc.treeInfo.tree, true); + const queuePubkeyIndex = getOrAddAccount(acc.treeInfo.queue, true); + + return { + [acc.key]: acc.data, + merkleContext: { + merkleTreePubkeyIndex, + queuePubkeyIndex, + }, + }; + }); + + // Add addresses as system accounts + const systemAccountsOffset = remainingAccounts.length; + addresses.forEach(addr => { + getOrAddAccount(addr, true); + }); + + return { + proofOption: { 0: proof.compressedProof }, + compressedAccounts, + systemAccountsOffset, + remainingAccounts, + }; +} diff --git a/js/stateless.js/tests/e2e/compress.test.ts b/js/stateless.js/tests/e2e/compress.test.ts index 2180325662..4592aca688 100644 --- a/js/stateless.js/tests/e2e/compress.test.ts +++ b/js/stateless.js/tests/e2e/compress.test.ts @@ -89,25 +89,49 @@ describe('compress', () => { stateTreeInfo = selectStateTreeInfo(await rpc.getStateTreeInfos()); }); - it('should create account with address', async () => { - const preCreateAccountsBalance = await rpc.getBalance(payer.publicKey); + // createAccount is not supported in V2 (requires programId for address derivation via CPI) + it.skipIf(featureFlags.isV2())( + 'should create account with address', + async () => { + const preCreateAccountsBalance = await rpc.getBalance( + payer.publicKey, + ); - await createAccount( - rpc as TestRpc, - payer, - [ - new Uint8Array([ - 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, - 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, - ]), - ], - LightSystemProgram.programId, - undefined, - stateTreeInfo, - ); + await createAccount( + rpc as TestRpc, + payer, + [ + new Uint8Array([ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, + 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, + 31, 32, + ]), + ], + LightSystemProgram.programId, + undefined, + stateTreeInfo, + ); - await expect( - createAccountWithLamports( + await expect( + createAccountWithLamports( + rpc as TestRpc, + payer, + [ + new Uint8Array([ + 1, 2, 255, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, + 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, + 29, 30, 31, 32, + ]), + ], + 0, + LightSystemProgram.programId, + ), + ).rejects.toThrowError( + 'Neither input accounts nor outputStateTreeInfo are available', + ); + + // 0 lamports => 0 input accounts selected, so outputStateTreeInfo is required + await createAccountWithLamports( rpc as TestRpc, payer, [ @@ -119,56 +143,26 @@ describe('compress', () => { ], 0, LightSystemProgram.programId, - ), - ).rejects.toThrowError( - 'Neither input accounts nor outputStateTreeInfo are available', - ); - - // 0 lamports => 0 input accounts selected, so outputStateTreeInfo is required - await createAccountWithLamports( - rpc as TestRpc, - payer, - [ - new Uint8Array([ - 1, 2, 255, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, - 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, - ]), - ], - 0, - LightSystemProgram.programId, - undefined, - stateTreeInfo, - ); + undefined, + stateTreeInfo, + ); - await createAccount( - rpc as TestRpc, - payer, - [ - new Uint8Array([ - 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, - 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 1, - ]), - ], - LightSystemProgram.programId, - undefined, - stateTreeInfo, - ); + await createAccount( + rpc as TestRpc, + payer, + [ + new Uint8Array([ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, + 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, + 31, 1, + ]), + ], + LightSystemProgram.programId, + undefined, + stateTreeInfo, + ); - await createAccount( - rpc as TestRpc, - payer, - [ - new Uint8Array([ - 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, - 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 2, - ]), - ], - LightSystemProgram.programId, - undefined, - stateTreeInfo, - ); - await expect( - createAccount( + await createAccount( rpc as TestRpc, payer, [ @@ -181,77 +175,102 @@ describe('compress', () => { LightSystemProgram.programId, undefined, stateTreeInfo, - ), - ).rejects.toThrow(); - const postCreateAccountsBalance = await rpc.getBalance(payer.publicKey); - assert.equal( - postCreateAccountsBalance, - preCreateAccountsBalance - - txFees([ - { in: 0, out: 1, addr: 1 }, - { in: 0, out: 1, addr: 1 }, - { in: 0, out: 1, addr: 1 }, - { in: 0, out: 1, addr: 1 }, - ]), - ); - }); + ); + await expect( + createAccount( + rpc as TestRpc, + payer, + [ + new Uint8Array([ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, + 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, + 29, 30, 31, 2, + ]), + ], + LightSystemProgram.programId, + undefined, + stateTreeInfo, + ), + ).rejects.toThrow(); + const postCreateAccountsBalance = await rpc.getBalance( + payer.publicKey, + ); + assert.equal( + postCreateAccountsBalance, + preCreateAccountsBalance - + txFees([ + { in: 0, out: 1, addr: 1 }, + { in: 0, out: 1, addr: 1 }, + { in: 0, out: 1, addr: 1 }, + { in: 0, out: 1, addr: 1 }, + ]), + ); + }, + ); - it('should compress lamports and create an account with address and lamports', async () => { - payer = await newAccountWithLamports(rpc, 1e9, 256); + // createAccountWithLamports is not supported in V2 (requires programId for address derivation via CPI) + it.skipIf(featureFlags.isV2())( + 'should compress lamports and create an account with address and lamports', + async () => { + payer = await newAccountWithLamports(rpc, 1e9, 256); - const compressLamportsAmount = 1e7; - const preCompressBalance = await rpc.getBalance(payer.publicKey); - assert.equal(preCompressBalance, 1e9); + const compressLamportsAmount = 1e7; + const preCompressBalance = await rpc.getBalance(payer.publicKey); + assert.equal(preCompressBalance, 1e9); - await compress( - rpc, - payer, - compressLamportsAmount, - payer.publicKey, - stateTreeInfo, - ); + await compress( + rpc, + payer, + compressLamportsAmount, + payer.publicKey, + stateTreeInfo, + ); - const compressedAccounts = await rpc.getCompressedAccountsByOwner( - payer.publicKey, - ); - assert.equal(compressedAccounts.items.length, 1); - assert.equal( - Number(compressedAccounts.items[0].lamports), - compressLamportsAmount, - ); + const compressedAccounts = await rpc.getCompressedAccountsByOwner( + payer.publicKey, + ); + assert.equal(compressedAccounts.items.length, 1); + assert.equal( + Number(compressedAccounts.items[0].lamports), + compressLamportsAmount, + ); - assert.equal(compressedAccounts.items[0].data, null); - const postCompressBalance = await rpc.getBalance(payer.publicKey); - assert.equal( - postCompressBalance, - preCompressBalance - - compressLamportsAmount - - txFees([{ in: 0, out: 1 }]), - ); + assert.equal(compressedAccounts.items[0].data, null); + const postCompressBalance = await rpc.getBalance(payer.publicKey); + assert.equal( + postCompressBalance, + preCompressBalance - + compressLamportsAmount - + txFees([{ in: 0, out: 1 }]), + ); - await createAccountWithLamports( - rpc as TestRpc, - payer, - [ - new Uint8Array([ - 1, 255, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, - 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, - ]), - ], - 100, - LightSystemProgram.programId, - undefined, - ); + await createAccountWithLamports( + rpc as TestRpc, + payer, + [ + new Uint8Array([ + 1, 255, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, + 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, + 31, 32, + ]), + ], + 100, + LightSystemProgram.programId, + undefined, + ); - const postCreateAccountBalance = await rpc.getBalance(payer.publicKey); - let expectedTxFees = txFees([{ in: 1, out: 2, addr: 1 }]); - assert.equal( - postCreateAccountBalance, - postCompressBalance - expectedTxFees, - ); - }); + const postCreateAccountBalance = await rpc.getBalance( + payer.publicKey, + ); + let expectedTxFees = txFees([{ in: 1, out: 2, addr: 1 }]); + assert.equal( + postCreateAccountBalance, + postCompressBalance - expectedTxFees, + ); + }, + ); - it('should compress lamports and create an account with address and lamports', async () => { + it('should compress and decompress lamports', async () => { payer = await newAccountWithLamports(rpc, 1e9, 256); const compressLamportsAmount = 1e7; diff --git a/js/stateless.js/tests/e2e/rpc-interop.test.ts b/js/stateless.js/tests/e2e/rpc-interop.test.ts index 5fac3c83cf..b706b9b527 100644 --- a/js/stateless.js/tests/e2e/rpc-interop.test.ts +++ b/js/stateless.js/tests/e2e/rpc-interop.test.ts @@ -142,194 +142,204 @@ describe('rpc-interop', () => { executedTxs++; }); - it('getValidityProof [noforester] (new-addresses) should match', async () => { - const newAddressSeeds = [new Uint8Array(randomBytes(32))]; - const newAddressSeed = deriveAddressSeed( - newAddressSeeds, - LightSystemProgram.programId, - ); + // Skip in V2: createAccount is only supported via CPI in V2 + it.skipIf(featureFlags.isV2())( + 'getValidityProof [noforester] (new-addresses) should match', + async () => { + const newAddressSeeds = [new Uint8Array(randomBytes(32))]; + const newAddressSeed = deriveAddressSeed( + newAddressSeeds, + LightSystemProgram.programId, + ); - const newAddress = bn(deriveAddress(newAddressSeed).toBuffer()); + const newAddress = bn(deriveAddress(newAddressSeed).toBuffer()); - /// consistent proof metadata for same address - const validityProof = await rpc.getValidityProof([], [newAddress]); - const validityProofTest = await testRpc.getValidityProof( - [], - [newAddress], - ); + /// consistent proof metadata for same address + const validityProof = await rpc.getValidityProof([], [newAddress]); + const validityProofTest = await testRpc.getValidityProof( + [], + [newAddress], + ); - validityProof.leafIndices.forEach((leafIndex, index) => { - assert.equal(leafIndex, validityProofTest.leafIndices[index]); - }); - validityProof.leaves.forEach((leaf, index) => { - assert.isTrue(leaf.eq(validityProofTest.leaves[index])); - }); - validityProof.roots.forEach((elem, index) => { - assert.isTrue(elem.eq(validityProofTest.roots[index])); - }); - validityProof.rootIndices.forEach((elem, index) => { - assert.equal(elem, validityProofTest.rootIndices[index]); - }); - validityProof.treeInfos.forEach((elem, index) => { - assert.isTrue( - elem.tree.equals(validityProofTest.treeInfos[index].tree), + validityProof.leafIndices.forEach((leafIndex, index) => { + assert.equal(leafIndex, validityProofTest.leafIndices[index]); + }); + validityProof.leaves.forEach((leaf, index) => { + assert.isTrue(leaf.eq(validityProofTest.leaves[index])); + }); + validityProof.roots.forEach((elem, index) => { + assert.isTrue(elem.eq(validityProofTest.roots[index])); + }); + validityProof.rootIndices.forEach((elem, index) => { + assert.equal(elem, validityProofTest.rootIndices[index]); + }); + validityProof.treeInfos.forEach((elem, index) => { + assert.isTrue( + elem.tree.equals(validityProofTest.treeInfos[index].tree), + ); + }); + validityProof.treeInfos.forEach((elem, index) => { + assert.isTrue( + elem.queue.equals(validityProofTest.treeInfos[index].queue), + ); + }); + + /// Need a new unique address because the previous one has been created. + const newAddressSeedsTest = [new Uint8Array(randomBytes(32))]; + /// Creates a compressed account with address using a (non-inclusion) + /// 'validityProof' from Photon + await createAccount( + rpc, + payer, + newAddressSeedsTest, + LightSystemProgram.programId, + undefined, + stateTreeInfo, ); - }); - validityProof.treeInfos.forEach((elem, index) => { - assert.isTrue( - elem.queue.equals(validityProofTest.treeInfos[index].queue), + executedTxs++; + + /// Creates a compressed account with address using a (non-inclusion) + /// 'validityProof' directly from a prover. + await createAccount( + testRpc, + payer, + newAddressSeeds, + LightSystemProgram.programId, + undefined, + stateTreeInfo, ); - }); + executedTxs++; + }, + ); - /// Need a new unique address because the previous one has been created. - const newAddressSeedsTest = [new Uint8Array(randomBytes(32))]; - /// Creates a compressed account with address using a (non-inclusion) - /// 'validityProof' from Photon - await createAccount( - rpc, - payer, - newAddressSeedsTest, - LightSystemProgram.programId, - undefined, - stateTreeInfo, - ); - executedTxs++; + // Skip in V2: createAccountWithLamports is only supported via CPI in V2 + it.skipIf(featureFlags.isV2())( + 'getValidityProof [noforester] (combined) should match', + async () => { + const senderAccountsTest = + await testRpc.getCompressedAccountsByOwner(payer.publicKey); + // wait for photon to be in sync + await sleep(3000); + const senderAccounts = await rpc.getCompressedAccountsByOwner( + payer.publicKey, + ); + const hashTest = bn(senderAccountsTest.items[0].hash); + const hash = bn(senderAccounts.items[0].hash); - /// Creates a compressed account with address using a (non-inclusion) - /// 'validityProof' directly from a prover. - await createAccount( - testRpc, - payer, - newAddressSeeds, - LightSystemProgram.programId, - undefined, - stateTreeInfo, - ); - executedTxs++; - }); + // accounts are the same + assert.isTrue(hash.eq(hashTest)); - it('getValidityProof [noforester] (combined) should match', async () => { - const senderAccountsTest = await testRpc.getCompressedAccountsByOwner( - payer.publicKey, - ); - // wait for photon to be in sync - await sleep(3000); - const senderAccounts = await rpc.getCompressedAccountsByOwner( - payer.publicKey, - ); - const hashTest = bn(senderAccountsTest.items[0].hash); - const hash = bn(senderAccounts.items[0].hash); + const newAddressSeeds = [new Uint8Array(randomBytes(32))]; + const newAddressSeed = deriveAddressSeed( + newAddressSeeds, + LightSystemProgram.programId, + ); + const newAddress = bn(deriveAddress(newAddressSeed).toBytes()); - // accounts are the same - assert.isTrue(hash.eq(hashTest)); + const validityProof = await rpc.getValidityProof( + [hash], + [newAddress], + ); + const validityProofTest = await testRpc.getValidityProof( + [hashTest], + [newAddress], + ); - const newAddressSeeds = [new Uint8Array(randomBytes(32))]; - const newAddressSeed = deriveAddressSeed( - newAddressSeeds, - LightSystemProgram.programId, - ); - const newAddress = bn(deriveAddress(newAddressSeed).toBytes()); + // compressedAccountProofs should match + const compressedAccountProof = ( + await rpc.getMultipleCompressedAccountProofs([hash]) + )[0]; + const compressedAccountProofTest = ( + await testRpc.getMultipleCompressedAccountProofs([hashTest]) + )[0]; - const validityProof = await rpc.getValidityProof([hash], [newAddress]); - const validityProofTest = await testRpc.getValidityProof( - [hashTest], - [newAddress], - ); + compressedAccountProof.merkleProof.forEach((proof, index) => { + assert.isTrue( + proof.eq(compressedAccountProofTest.merkleProof[index]), + ); + }); - // compressedAccountProofs should match - const compressedAccountProof = ( - await rpc.getMultipleCompressedAccountProofs([hash]) - )[0]; - const compressedAccountProofTest = ( - await testRpc.getMultipleCompressedAccountProofs([hashTest]) - )[0]; + // newAddressProofs should match + const newAddressProof = ( + await rpc.getMultipleNewAddressProofs([newAddress]) + )[0]; + const newAddressProofTest = ( + await testRpc.getMultipleNewAddressProofs([newAddress]) + )[0]; - compressedAccountProof.merkleProof.forEach((proof, index) => { assert.isTrue( - proof.eq(compressedAccountProofTest.merkleProof[index]), + newAddressProof.indexHashedIndexedElementLeaf.eq( + newAddressProofTest.indexHashedIndexedElementLeaf, + ), ); - }); - - // newAddressProofs should match - const newAddressProof = ( - await rpc.getMultipleNewAddressProofs([newAddress]) - )[0]; - const newAddressProofTest = ( - await testRpc.getMultipleNewAddressProofs([newAddress]) - )[0]; - - assert.isTrue( - newAddressProof.indexHashedIndexedElementLeaf.eq( - newAddressProofTest.indexHashedIndexedElementLeaf, - ), - ); - assert.isTrue( - newAddressProof.leafHigherRangeValue.eq( - newAddressProofTest.leafHigherRangeValue, - ), - ); - assert.isTrue( - newAddressProof.nextIndex.eq(newAddressProofTest.nextIndex), - ); - assert.isTrue( - newAddressProof.leafLowerRangeValue.eq( - newAddressProofTest.leafLowerRangeValue, - ), - ); - assert.isTrue( - newAddressProof.treeInfo.tree.equals( - newAddressProofTest.treeInfo.tree, - ), - ); - assert.isTrue( - newAddressProof.treeInfo.queue.equals( - newAddressProofTest.treeInfo.queue, - ), - ); - assert.isTrue(newAddressProof.root.eq(newAddressProofTest.root)); - assert.isTrue(newAddressProof.value.eq(newAddressProofTest.value)); - - // validity proof metadata should match - validityProof.leafIndices.forEach((leafIndex, index) => { - assert.equal(leafIndex, validityProofTest.leafIndices[index]); - }); - validityProof.leaves.forEach((leaf, index) => { - assert.isTrue(leaf.eq(validityProofTest.leaves[index])); - }); - validityProof.roots.forEach((elem, index) => { - assert.isTrue(elem.eq(validityProofTest.roots[index])); - }); - validityProof.rootIndices.forEach((elem, index) => { - assert.equal(elem, validityProofTest.rootIndices[index]); - }); - validityProof.treeInfos.forEach((elem, index) => { assert.isTrue( - elem.tree.equals(validityProofTest.treeInfos[index].tree), + newAddressProof.leafHigherRangeValue.eq( + newAddressProofTest.leafHigherRangeValue, + ), ); - }); - validityProof.treeInfos.forEach((elem, index) => { assert.isTrue( - elem.queue.equals(validityProofTest.treeInfos[index].queue), - 'Mismatch in nullifierQueues expected: ' + - elem + - ' got: ' + - validityProofTest.treeInfos[index].queue, + newAddressProof.nextIndex.eq(newAddressProofTest.nextIndex), ); - }); - - /// Creates a compressed account with address and lamports using a - /// (combined) 'validityProof' from Photon - await createAccountWithLamports( - rpc, - payer, - [new Uint8Array(randomBytes(32))], - 0, - LightSystemProgram.programId, - undefined, - stateTreeInfo, - ); - executedTxs++; - }); + assert.isTrue( + newAddressProof.leafLowerRangeValue.eq( + newAddressProofTest.leafLowerRangeValue, + ), + ); + assert.isTrue( + newAddressProof.treeInfo.tree.equals( + newAddressProofTest.treeInfo.tree, + ), + ); + assert.isTrue( + newAddressProof.treeInfo.queue.equals( + newAddressProofTest.treeInfo.queue, + ), + ); + assert.isTrue(newAddressProof.root.eq(newAddressProofTest.root)); + assert.isTrue(newAddressProof.value.eq(newAddressProofTest.value)); + + // validity proof metadata should match + validityProof.leafIndices.forEach((leafIndex, index) => { + assert.equal(leafIndex, validityProofTest.leafIndices[index]); + }); + validityProof.leaves.forEach((leaf, index) => { + assert.isTrue(leaf.eq(validityProofTest.leaves[index])); + }); + validityProof.roots.forEach((elem, index) => { + assert.isTrue(elem.eq(validityProofTest.roots[index])); + }); + validityProof.rootIndices.forEach((elem, index) => { + assert.equal(elem, validityProofTest.rootIndices[index]); + }); + validityProof.treeInfos.forEach((elem, index) => { + assert.isTrue( + elem.tree.equals(validityProofTest.treeInfos[index].tree), + ); + }); + validityProof.treeInfos.forEach((elem, index) => { + assert.isTrue( + elem.queue.equals(validityProofTest.treeInfos[index].queue), + 'Mismatch in nullifierQueues expected: ' + + elem + + ' got: ' + + validityProofTest.treeInfos[index].queue, + ); + }); + + /// Creates a compressed account with address and lamports using a + /// (combined) 'validityProof' from Photon + await createAccountWithLamports( + rpc, + payer, + [new Uint8Array(randomBytes(32))], + 0, + LightSystemProgram.programId, + undefined, + stateTreeInfo, + ); + executedTxs++; + }, + ); /// This assumes support for getMultipleNewAddressProofs in Photon. it('getMultipleNewAddressProofs [noforester] should match', async () => { @@ -497,6 +507,13 @@ describe('rpc-interop', () => { senderAccountsTest.items.length, ); + senderAccounts.items.sort((a, b) => + a.lamports.sub(b.lamports).toNumber(), + ); + senderAccountsTest.items.sort((a, b) => + a.lamports.sub(b.lamports).toNumber(), + ); + senderAccounts.items.forEach((account, index) => { assert.equal( account.owner.toBase58(), @@ -592,32 +609,26 @@ describe('rpc-interop', () => { }); }); - it('[test-rpc missing] getCompressionSignaturesForAccount should match', async () => { - const senderAccounts = await rpc.getCompressedAccountsByOwner( - payer.publicKey, - ); - const signaturesUnspent = await rpc.getCompressionSignaturesForAccount( - bn(senderAccounts.items[0].hash), - ); - - /// most recent therefore unspent account - assert.equal(signaturesUnspent.length, 1); - - /// Note: assumes largest-first selection mechanism - const largestAccount = senderAccounts.items.reduce((acc, account) => - account.lamports.gt(acc.lamports) ? account : acc, - ); + // Skip in V2: test depends on createAccount tests running before it (executedTxs count) + it.skipIf(featureFlags.isV2())( + '[test-rpc missing] getCompressionSignaturesForAccount should match', + async () => { + const senderAccounts = await rpc.getCompressedAccountsByOwner( + payer.publicKey, + ); - await transfer(rpc, payer, 1, payer, bob.publicKey); - executedTxs++; + await transfer(rpc, payer, 1, payer, bob.publicKey); - const signaturesSpent = await rpc.getCompressionSignaturesForAccount( - bn(largestAccount.hash), - ); + executedTxs++; + const signaturesSpent = + await rpc.getCompressionSignaturesForAccount( + bn(senderAccounts.items[0].hash), + ); - /// 1 spent account, so always 2 signatures. - assert.equal(signaturesSpent.length, 2); - }); + /// 1 spent account, so always 2 signatures. + assert.equal(signaturesSpent.length, 2); + }, + ); it('[test-rpc missing] getSignaturesForOwner should match', async () => { const signatures = await rpc.getCompressionSignaturesForOwner( @@ -660,101 +671,126 @@ describe('rpc-interop', () => { assert.notEqual(signatures2[0].signature, signatures3[0].signature); }); - it('[test-rpc missing] getCompressedTransaction should match', async () => { - const signatures = await rpc.getCompressionSignaturesForOwner( - payer.publicKey, - ); - - const compressedTx = await rpc.getTransactionWithCompressionInfo( - signatures.items[0].signature, - ); - - /// is transfer - assert.equal(compressedTx?.compressionInfo.closedAccounts.length, 1); - assert.equal(compressedTx?.compressionInfo.openedAccounts.length, 2); - }); + // Skip in V2: depends on getCompressionSignaturesForAccount having run a transfer + it.skipIf(featureFlags.isV2())( + '[test-rpc missing] getCompressedTransaction should match', + async () => { + const signatures = await rpc.getCompressionSignaturesForOwner( + payer.publicKey, + ); - it('[test-rpc missing] getCompressionSignaturesForAddress should work', async () => { - const seeds = [new Uint8Array(randomBytes(32))]; - const seed = deriveAddressSeed(seeds, LightSystemProgram.programId); - const addressTreeInfo = getDefaultAddressTreeInfo(); - const address = deriveAddress(seed, addressTreeInfo.tree); - - await createAccount( - rpc, - payer, - seeds, - LightSystemProgram.programId, - addressTreeInfo, - stateTreeInfo, - ); + const compressedTx = await rpc.getTransactionWithCompressionInfo( + signatures.items[0].signature, + ); - const accounts = await rpc.getCompressedAccountsByOwner( - payer.publicKey, - ); + /// is transfer + assert.equal( + compressedTx?.compressionInfo.closedAccounts.length, + 1, + ); + assert.equal( + compressedTx?.compressionInfo.openedAccounts.length, + 2, + ); + }, + ); - const allAccountsTestRpc = await testRpc.getCompressedAccountsByOwner( - payer.publicKey, - ); - const allAccountsRpc = await rpc.getCompressedAccountsByOwner( - payer.publicKey, - ); + // Skip in V2: createAccount is only supported via CPI in V2 + it.skipIf(featureFlags.isV2())( + '[test-rpc missing] getCompressionSignaturesForAddress should work', + async () => { + const seeds = [new Uint8Array(randomBytes(32))]; + const seed = deriveAddressSeed(seeds, LightSystemProgram.programId); + const addressTreeInfo = getDefaultAddressTreeInfo(); + const address = deriveAddress(seed, addressTreeInfo.tree); + + await createAccount( + rpc, + payer, + seeds, + LightSystemProgram.programId, + addressTreeInfo, + stateTreeInfo, + ); - const latestAccount = accounts.items[0]; + const accounts = await rpc.getCompressedAccountsByOwner( + payer.publicKey, + ); - // assert the address was indexed - assert.isTrue(new PublicKey(latestAccount.address!).equals(address)); + const allAccountsTestRpc = + await testRpc.getCompressedAccountsByOwner(payer.publicKey); + const allAccountsRpc = await rpc.getCompressedAccountsByOwner( + payer.publicKey, + ); - const signaturesUnspent = await rpc.getCompressionSignaturesForAddress( - new PublicKey(latestAccount.address!), - ); + const latestAccount = accounts.items[0]; - /// most recent therefore unspent account - assert.equal(signaturesUnspent.items.length, 1); - }); + // assert the address was indexed + assert.isTrue( + new PublicKey(latestAccount.address!).equals(address), + ); - it('[test-rpc missing] getCompressedAccount with address param should work ', async () => { - const seeds = [new Uint8Array(randomBytes(32))]; - const seed = deriveAddressSeed(seeds, LightSystemProgram.programId); + const signaturesUnspent = + await rpc.getCompressionSignaturesForAddress( + new PublicKey(latestAccount.address!), + ); - const addressTreeInfo = getDefaultAddressTreeInfo(); - const address = deriveAddress(seed, addressTreeInfo.tree); + /// most recent therefore unspent account + assert.equal(signaturesUnspent.items.length, 1); + }, + ); - await createAccount( - rpc, - payer, - seeds, - LightSystemProgram.programId, - addressTreeInfo, - stateTreeInfo, - ); + // Skip in V2: createAccount is only supported via CPI in V2 + it.skipIf(featureFlags.isV2())( + '[test-rpc missing] getCompressedAccount with address param should work ', + async () => { + const seeds = [new Uint8Array(randomBytes(32))]; + const seed = deriveAddressSeed(seeds, LightSystemProgram.programId); + + const addressTreeInfo = getDefaultAddressTreeInfo(); + const address = deriveAddress(seed, addressTreeInfo.tree); + + await createAccount( + rpc, + payer, + seeds, + LightSystemProgram.programId, + addressTreeInfo, + stateTreeInfo, + ); - // fetch the owners latest account - const accounts = await rpc.getCompressedAccountsByOwner( - payer.publicKey, - ); + // fetch the owners latest account + const accounts = await rpc.getCompressedAccountsByOwner( + payer.publicKey, + ); - const latestAccount = accounts.items[0]; + const latestAccount = accounts.items[0]; - assert.isTrue(new PublicKey(latestAccount.address!).equals(address)); + assert.isTrue( + new PublicKey(latestAccount.address!).equals(address), + ); - const compressedAccountByHash = await rpc.getCompressedAccount( - undefined, - bn(latestAccount.hash), - ); - const compressedAccountByAddress = await rpc.getCompressedAccount( - bn(latestAccount.address!), - undefined, - ); + const compressedAccountByHash = await rpc.getCompressedAccount( + undefined, + bn(latestAccount.hash), + ); + const compressedAccountByAddress = await rpc.getCompressedAccount( + bn(latestAccount.address!), + undefined, + ); - await expect( - testRpc.getCompressedAccount(bn(latestAccount.address!), undefined), - ).rejects.toThrow(); + await expect( + testRpc.getCompressedAccount( + bn(latestAccount.address!), + undefined, + ), + ).rejects.toThrow(); - assert.isTrue( - bn(compressedAccountByHash!.address!).eq( - bn(compressedAccountByAddress!.address!), - ), - ); - }); + assert.isTrue( + bn(compressedAccountByHash!.address!).eq( + bn(compressedAccountByAddress!.address!), + ), + ); + }, + ); }); 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 e019983e80..107b15fecb 100644 --- a/js/stateless.js/tests/e2e/rpc-multi-trees.test.ts +++ b/js/stateless.js/tests/e2e/rpc-multi-trees.test.ts @@ -81,127 +81,141 @@ describe('rpc-multi-trees', () => { }); let address: PublicKey; - it('must create account with random output tree (selectStateTreeInfo)', async () => { - const tree = selectStateTreeInfo(await rpc.getStateTreeInfos()); + it.skipIf(featureFlags.isV2())( + 'must create account with random output tree (selectStateTreeInfo)', + async () => { + const tree = selectStateTreeInfo(await rpc.getStateTreeInfos()); - const seed = randomBytes(32); - const addressSeed = deriveAddressSeed( - [seed], - LightSystemProgram.programId, - ); - address = deriveAddress(addressSeed); - - await createAccount( - rpc, - payer, - [seed], - LightSystemProgram.programId, - undefined, - tree, // output state tree - ); + const seed = randomBytes(32); + const addressSeed = deriveAddressSeed( + [seed], + LightSystemProgram.programId, + ); + address = deriveAddress(addressSeed); + + await createAccount( + rpc, + payer, + [seed], + LightSystemProgram.programId, + undefined, + tree, // output state tree + ); - randTrees.push(tree.tree); - randQueues.push(tree.queue); + randTrees.push(tree.tree); + randQueues.push(tree.queue); - const acc = await rpc.getCompressedAccount(bn(address.toBuffer())); - expect(acc!.treeInfo.tree).toEqual(tree.tree); - expect(acc!.treeInfo.queue).toEqual(tree.queue); - }); + const acc = await rpc.getCompressedAccount(bn(address.toBuffer())); + expect(acc!.treeInfo.tree).toEqual(tree.tree); + expect(acc!.treeInfo.queue).toEqual(tree.queue); + }, + ); - it('getValidityProof [noforester] (inclusion) should return correct trees and queues', async () => { - const acc = await rpc.getCompressedAccount(bn(address.toBuffer())); + it.skipIf(featureFlags.isV2())( + 'getValidityProof [noforester] (inclusion) should return correct trees and queues', + async () => { + const acc = await rpc.getCompressedAccount(bn(address.toBuffer())); - const hash = bn(acc!.hash); - const pos = randTrees.length - 1; - expect(acc?.treeInfo.tree).toEqual(randTrees[pos]); - expect(acc?.treeInfo.queue).toEqual(randQueues[pos]); + const hash = bn(acc!.hash); + const pos = randTrees.length - 1; + expect(acc?.treeInfo.tree).toEqual(randTrees[pos]); + expect(acc?.treeInfo.queue).toEqual(randQueues[pos]); - const validityProof = await rpc.getValidityProof([hash]); + const validityProof = await rpc.getValidityProof([hash]); - expect(validityProof.treeInfos[0].tree).toEqual(randTrees[pos]); - expect(validityProof.treeInfos[0].queue).toEqual(randQueues[pos]); + expect(validityProof.treeInfos[0].tree).toEqual(randTrees[pos]); + expect(validityProof.treeInfos[0].queue).toEqual(randQueues[pos]); - /// Executes transfers using random output trees - const tree1 = selectStateTreeInfo(await rpc.getStateTreeInfos()); - await transfer(rpc, payer, 1e5, payer, bob.publicKey); - executedTxs++; - randTrees.push(tree1.tree); - randQueues.push(tree1.queue); + /// Executes transfers using random output trees + const tree1 = selectStateTreeInfo(await rpc.getStateTreeInfos()); + await transfer(rpc, payer, 1e5, payer, bob.publicKey); + executedTxs++; + randTrees.push(tree1.tree); + randQueues.push(tree1.queue); - const tree2 = selectStateTreeInfo(await rpc.getStateTreeInfos()); - await transfer(rpc, payer, 1e5, payer, bob.publicKey); - executedTxs++; - randTrees.push(tree2.tree); - randQueues.push(tree2.queue); + const tree2 = selectStateTreeInfo(await rpc.getStateTreeInfos()); + await transfer(rpc, payer, 1e5, payer, bob.publicKey); + executedTxs++; + randTrees.push(tree2.tree); + randQueues.push(tree2.queue); - const validityProof2 = await rpc.getValidityProof([hash]); + const validityProof2 = await rpc.getValidityProof([hash]); - expect(validityProof2.treeInfos[0].tree).toEqual(randTrees[pos]); - expect(validityProof2.treeInfos[0].queue).toEqual(randQueues[pos]); - }); + expect(validityProof2.treeInfos[0].tree).toEqual(randTrees[pos]); + expect(validityProof2.treeInfos[0].queue).toEqual(randQueues[pos]); + }, + ); - it('getValidityProof [noforester] (combined) should return correct trees and queues', async () => { - const senderAccounts = await rpc.getCompressedAccountsByOwner( - payer.publicKey, - ); - const hash = bn(senderAccounts.items[0].hash); + it.skipIf(featureFlags.isV2())( + 'getValidityProof [noforester] (combined) should return correct trees and queues', + async () => { + const senderAccounts = await rpc.getCompressedAccountsByOwner( + payer.publicKey, + ); + const hash = bn(senderAccounts.items[0].hash); - const newAddressSeeds = [new Uint8Array(randomBytes(32))]; - const newAddressSeed = deriveAddressSeed( - newAddressSeeds, - LightSystemProgram.programId, - ); - const newAddress = bn(deriveAddress(newAddressSeed).toBytes()); + const newAddressSeeds = [new Uint8Array(randomBytes(32))]; + const newAddressSeed = deriveAddressSeed( + newAddressSeeds, + LightSystemProgram.programId, + ); + const newAddress = bn(deriveAddress(newAddressSeed).toBytes()); + + const validityProof = await rpc.getValidityProof( + [hash], + [newAddress], + ); - const validityProof = await rpc.getValidityProof([hash], [newAddress]); + // compressedAccountProofs should be valid + const compressedAccountProof = ( + await rpc.getMultipleCompressedAccountProofs([hash]) + )[0]; - // compressedAccountProofs should be valid - const compressedAccountProof = ( - await rpc.getMultipleCompressedAccountProofs([hash]) - )[0]; + compressedAccountProof.merkleProof.forEach((proof, index) => { + assert.isTrue( + proof.eq(compressedAccountProof.merkleProof[index]), + ); + }); - compressedAccountProof.merkleProof.forEach((proof, index) => { - assert.isTrue(proof.eq(compressedAccountProof.merkleProof[index])); - }); + // newAddressProofs should be valid + const newAddressProof = ( + await rpc.getMultipleNewAddressProofs([newAddress]) + )[0]; - // newAddressProofs should be valid - const newAddressProof = ( - await rpc.getMultipleNewAddressProofs([newAddress]) - )[0]; - - // only compare state tree - assert.isTrue( - validityProof.treeInfos[0].tree.equals( - senderAccounts.items[0].treeInfo.tree, - ), - 'Mismatch in merkleTrees expected: ' + - senderAccounts.items[0].treeInfo.tree + - ' got: ' + - validityProof.treeInfos[0].tree, - ); - assert.isTrue( - validityProof.treeInfos[0].queue.equals( - senderAccounts.items[0].treeInfo.queue, - ), - `Mismatch in nullifierQueues expected: ${senderAccounts.items[0].treeInfo.queue} got: ${validityProof.treeInfos[0].queue}`, - ); + // only compare state tree + assert.isTrue( + validityProof.treeInfos[0].tree.equals( + senderAccounts.items[0].treeInfo.tree, + ), + 'Mismatch in merkleTrees expected: ' + + senderAccounts.items[0].treeInfo.tree + + ' got: ' + + validityProof.treeInfos[0].tree, + ); + assert.isTrue( + validityProof.treeInfos[0].queue.equals( + senderAccounts.items[0].treeInfo.queue, + ), + `Mismatch in nullifierQueues expected: ${senderAccounts.items[0].treeInfo.queue} got: ${validityProof.treeInfos[0].queue}`, + ); - /// Creates a compressed account with address and lamports using a - /// (combined) 'validityProof' from Photon - const tree = selectStateTreeInfo(await rpc.getStateTreeInfos()); - await createAccountWithLamports( - rpc, - payer, - [new Uint8Array(randomBytes(32))], - 0, - LightSystemProgram.programId, - undefined, - tree, - ); - executedTxs++; - randTrees.push(tree.tree); - randQueues.push(tree.queue); - }); + /// Creates a compressed account with address and lamports using a + /// (combined) 'validityProof' from Photon + const tree = selectStateTreeInfo(await rpc.getStateTreeInfos()); + await createAccountWithLamports( + rpc, + payer, + [new Uint8Array(randomBytes(32))], + 0, + LightSystemProgram.programId, + undefined, + tree, + ); + executedTxs++; + randTrees.push(tree.tree); + randQueues.push(tree.queue); + }, + ); it('getMultipleCompressedAccountProofs in transfer loop should match', async () => { for (let round = 0; round < numberOfTransfers; round++) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 78c30c3872..e0bf5a50e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -192,6 +192,12 @@ importers: '@lightprotocol/stateless.js': specifier: workspace:* version: link:../stateless.js + '@solana/buffer-layout': + specifier: ^4.0.1 + version: 4.0.1 + '@solana/buffer-layout-utils': + specifier: ^0.2.0 + version: 0.2.0(bufferutil@4.0.8)(typescript@5.9.2)(utf-8-validate@5.0.10) bn.js: specifier: ^5.2.1 version: 5.2.1 diff --git a/scripts/devenv/install-photon.sh b/scripts/devenv/install-photon.sh index 34227adf1b..61bcfe3dba 100755 --- a/scripts/devenv/install-photon.sh +++ b/scripts/devenv/install-photon.sh @@ -21,10 +21,10 @@ install_photon() { mkdir -p "${PREFIX}/cargo/bin" touch "$INSTALL_LOG" - # Portable sed -i (macOS vs Linux) - sed_inplace() { - if [[ "$OSTYPE" == "darwin"* ]]; then - sed -i '' "$@" + if [ "$photon_installed" = false ] || [ "$photon_correct_version" = false ]; then + echo "Installing Photon indexer (version $expected_version)..." + RUSTFLAGS="-A dead-code" cargo install --git https://github.com/lightprotocol/photon.git --rev ${PHOTON_COMMIT} --locked --force + log "photon" else sed -i "$@" fi diff --git a/scripts/devenv/versions.sh b/scripts/devenv/versions.sh index 14a1c310f2..c714af38b0 100755 --- a/scripts/devenv/versions.sh +++ b/scripts/devenv/versions.sh @@ -14,6 +14,7 @@ export ANCHOR_VERSION="0.31.1" export JQ_VERSION="1.8.0" export PHOTON_VERSION="0.51.2" export PHOTON_COMMIT="711c47b20330c6bb78feb0a2c15e8292fcd0a7b0" +#df1087d55a8ff237ff69495a48542461a972f4fe export REDIS_VERSION="8.0.1" export ANCHOR_TAG="anchor-v${ANCHOR_VERSION}" diff --git a/sdk-libs/macros/src/compressible/GUIDE.md b/sdk-libs/macros/src/compressible/GUIDE.md index d12b356eca..366ff4e788 100644 --- a/sdk-libs/macros/src/compressible/GUIDE.md +++ b/sdk-libs/macros/src/compressible/GUIDE.md @@ -66,7 +66,7 @@ pub mod my_program {} // Program‑owned ctoken PDA (must provide authority seeds) TreasuryCtoken = (is_token, "treasury_ctoken", ctx.fee_payer, authority = (ctx.treasury)), // User ATA variant (no seeds, derived from owner+mint) - UserAta = (is_token, is_ata) + UserATA = (is_token, is_ata) )] #[program] pub mod my_program {} diff --git a/sdk-libs/macros/src/compressible/instructions.rs b/sdk-libs/macros/src/compressible/instructions.rs index 6469d22290..e8fad9d77b 100644 --- a/sdk-libs/macros/src/compressible/instructions.rs +++ b/sdk-libs/macros/src/compressible/instructions.rs @@ -1550,7 +1550,7 @@ fn generate_error_codes(variant: InstructionVariant) -> Result { #[msg("Missing seed account")] MissingSeedAccount, #[msg("ATA uses SPL ATA derivation")] - AtaDoesNotUseSeedDerivation, + ATADoesNotUseSeedDerivation, }; let variant_specific_errors = match variant { diff --git a/sdk-libs/macros/src/compressible/seed_providers.rs b/sdk-libs/macros/src/compressible/seed_providers.rs index 0b55225fed..0848122d47 100644 --- a/sdk-libs/macros/src/compressible/seed_providers.rs +++ b/sdk-libs/macros/src/compressible/seed_providers.rs @@ -37,7 +37,7 @@ pub fn generate_ctoken_seed_provider_implementation( let get_seeds_arm = quote! { CTokenAccountVariant::#variant_name => { Err(anchor_lang::prelude::ProgramError::Custom( - CompressibleInstructionError::AtaDoesNotUseSeedDerivation.into() + CompressibleInstructionError::ATADoesNotUseSeedDerivation.into() ).into()) } }; @@ -46,7 +46,7 @@ pub fn generate_ctoken_seed_provider_implementation( let authority_arm = quote! { CTokenAccountVariant::#variant_name => { Err(anchor_lang::prelude::ProgramError::Custom( - CompressibleInstructionError::AtaDoesNotUseSeedDerivation.into() + CompressibleInstructionError::ATADoesNotUseSeedDerivation.into() ).into()) } }; From 50637f0a6e125e52347da91396bb1dc9149b916e Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Wed, 3 Dec 2025 17:56:07 -0500 Subject: [PATCH 02/13] bump photon add console logs wip enable opt tokenMetadata additionalMetadata instruction data. createMintInterface action matching spl-token call signature refactor metadata methods, add helpers adjust test suite move back to transparent params export unified tests bump photon version fmt cleanup add fail safe to ata derivation in unified path and tests update imports and test wip refactor ctoken dirs update tests imports fix lint set to temp photon commit fix photon version again dont break old createMint bump photon again fmt fix chunking max topup sync to latest program instruction changes update compresstopubkey use photon main bump photon commit ATA -> Ata add test cov atomic tokenpool helper move to SplInterfacePda naming more unit layout and e2e interface tests add test coverage for mint-to-compressed spin out create-ata-interface.ts cleanup docstrings clean test cov: decompress2 in get-or-create-ata-interface fix decompress2 rn to decompressInterface, add test cov clean moving get-account-interface add invariants to load-ata, add test cov tests wip --- cli/src/commands/create-mint/index.ts | 1 - cli/src/utils/constants.ts | 2 +- cli/test/helpers/helpers.ts | 1 - ctoken_for_payments.md | 333 +++++ js/compressed-token/PAYMENT_MIGRATION.md | 241 ---- js/compressed-token/package.json | 23 +- js/compressed-token/rollup.config.js | 24 +- .../src/actions/approve-and-mint-to.ts | 20 +- .../src/actions/compress-spl-token-account.ts | 18 +- js/compressed-token/src/actions/compress.ts | 18 +- .../src/actions/create-mint.ts | 8 +- .../src/actions/create-token-pool.ts | 26 +- .../src/actions/decompress-delegated.ts | 20 +- js/compressed-token/src/actions/decompress.ts | 19 +- js/compressed-token/src/actions/mint-to.ts | 18 +- .../src/compressible/helpers.ts | 204 --- js/compressed-token/src/compressible/index.ts | 4 - .../src/compressible/unified-load.ts | 539 -------- js/compressed-token/src/index.ts | 140 ++- .../src/mint/actions/decompress2.ts | 168 --- .../actions/get-or-create-ata-interface.ts | 120 -- .../src/mint/actions/update-mint.ts | 185 --- .../src/mint/get-account-interface.ts | 697 ----------- js/compressed-token/src/mint/index.ts | 6 - js/compressed-token/src/program.ts | 128 +- js/compressed-token/src/types.ts | 15 +- js/compressed-token/src/utils/ata-utils.ts | 19 - .../src/utils/get-token-pool-infos.ts | 165 ++- js/compressed-token/src/utils/index.ts | 1 - .../actions/create-associated-ctoken.ts | 2 +- .../actions/create-ata-interface.ts | 80 +- .../actions/create-mint-interface.ts | 18 +- .../src/v3/actions/decompress-interface.ts | 203 +++ .../v3/actions/get-or-create-ata-interface.ts | 439 +++++++ .../src/{mint => v3}/actions/index.ts | 4 +- .../src/v3/actions/load-ata.ts | 435 +++++++ .../actions/mint-to-compressed.ts | 31 +- .../{mint => v3}/actions/mint-to-interface.ts | 4 +- .../src/{mint => v3}/actions/mint-to.ts | 2 +- .../actions/transfer-interface.ts | 160 +-- js/compressed-token/src/v3/actions/unwrap.ts | 151 +++ .../{mint => v3}/actions/update-metadata.ts | 153 +-- .../src/v3/actions/update-mint.ts | 154 +++ .../src/{mint => v3}/actions/wrap.ts | 36 +- js/compressed-token/src/v3/ata-utils.ts | 130 ++ .../src/{compressible => v3}/derivation.ts | 8 +- .../src/v3/get-account-interface.ts | 807 ++++++++++++ .../get-associated-token-address-interface.ts | 37 + .../helpers.ts => v3/get-mint-interface.ts} | 51 +- js/compressed-token/src/v3/index.ts | 8 + .../instructions/create-associated-ctoken.ts | 180 +-- .../v3/instructions/create-ata-interface.ts | 133 ++ ...reate-decompress-interface-instruction.ts} | 195 ++- .../create-load-accounts-params.ts | 245 ++++ .../{mint => v3}/instructions/create-mint.ts | 69 +- .../src/{mint => v3}/instructions/index.ts | 5 +- .../instructions/mint-to-compressed.ts | 43 +- .../instructions/mint-to-interface.ts | 24 +- .../src/{mint => v3}/instructions/mint-to.ts | 5 +- .../instructions/transfer-interface.ts | 28 +- .../src/v3/instructions/unwrap.ts | 118 ++ .../instructions/update-metadata.ts | 206 ++- .../{mint => v3}/instructions/update-mint.ts | 133 +- .../src/{mint => v3}/instructions/wrap.ts | 73 +- js/compressed-token/src/v3/layout/index.ts | 5 + .../layout/layout-mint-action.ts} | 8 +- .../serde.ts => v3/layout/layout-mint.ts} | 0 .../layout/layout-token-metadata.ts} | 0 .../src/{ => v3/layout}/layout-transfer2.ts | 44 +- .../src/{compressible => v3/layout}/serde.ts | 2 +- js/compressed-token/src/v3/unified/index.ts | 353 ++++++ .../e2e/compress-spl-token-account.test.ts | 2 - .../tests/e2e/compress.test.ts | 2 - .../tests/e2e/compressible-load.test.ts | 73 +- .../e2e/create-associated-ctoken.test.ts | 44 +- .../tests/e2e/create-ata-interface.test.ts | 686 ++++++++++ .../tests/e2e/create-compressed-mint.test.ts | 19 +- .../tests/e2e/create-mint-interface.test.ts | 399 ++++++ .../tests/e2e/create-mint.test.ts | 10 +- .../tests/e2e/create-token-pool.test.ts | 2 - .../tests/e2e/decompress-delegated.test.ts | 1 - .../tests/e2e/decompress.test.ts | 1 - .../tests/e2e/decompress2.test.ts | 367 +++++- .../tests/e2e/delegate.test.ts | 1 - .../tests/e2e/get-account-interface.test.ts | 1061 ++++++++++++++++ .../tests/e2e/get-mint-interface.test.ts | 988 +++++++++++++++ .../e2e/get-or-create-ata-interface.test.ts | 1110 +++++++++++++++++ .../tests/e2e/load-ata-combined.test.ts | 422 +++++++ .../tests/e2e/load-ata-spl-t22.test.ts | 545 ++++++++ .../tests/e2e/load-ata-standard.test.ts | 418 +++++++ .../tests/e2e/load-ata-unified.test.ts | 569 +++++++++ .../tests/e2e/merge-token-accounts.test.ts | 1 - .../tests/e2e/mint-to-compressed.test.ts | 11 +- .../tests/e2e/mint-to-ctoken.test.ts | 13 +- .../tests/e2e/mint-to-interface.test.ts | 154 ++- js/compressed-token/tests/e2e/mint-to.test.ts | 1 - .../tests/e2e/mint-workflow.test.ts | 105 +- .../tests/e2e/multi-pool.test.ts | 1 - .../tests/e2e/payment-flows.test.ts | 159 ++- .../tests/e2e/rpc-multi-trees.test.ts | 1 - .../tests/e2e/rpc-token-interop.test.ts | 2 - .../tests/e2e/transfer-delegated.test.ts | 3 - .../tests/e2e/transfer-interface.test.ts | 160 +-- .../tests/e2e/transfer.test.ts | 5 - js/compressed-token/tests/e2e/unwrap.test.ts | 539 ++++++++ .../tests/e2e/update-metadata.test.ts | 59 +- .../tests/e2e/update-mint.test.ts | 29 +- js/compressed-token/tests/e2e/wrap.test.ts | 266 +++- .../tests/unit/derive-token-pool-info.test.ts | 164 +++ ...associated-token-address-interface.test.ts | 332 +++++ .../tests/unit/layout-mint-action.test.ts | 397 ++++++ .../tests/unit/layout-mint.test.ts | 488 ++++++++ .../tests/unit/layout-serde.test.ts | 378 ++++++ .../tests/unit/layout-token-metadata.test.ts | 201 +++ .../tests/unit/layout-transfer2.test.ts | 449 +++++++ js/compressed-token/tests/unit/serde.test.ts | 2 +- .../tests/unit/unified-guards.test.ts | 66 + js/compressed-token/tests/unit/upload.test.ts | 2 +- js/stateless.js/rollup.config.js | 1 + js/stateless.js/src/rpc-interface.ts | 128 ++ js/stateless.js/src/rpc.ts | 234 ++++ .../src/test-helpers/test-rpc/test-rpc.ts | 38 + .../src/utils/get-state-tree-infos.ts | 24 + .../tests/e2e/interface-methods.test.ts | 319 +++++ js/stateless.js/tests/e2e/rpc-interop.test.ts | 74 +- scripts/devenv/versions.sh | 2 +- 126 files changed, 15762 insertions(+), 3711 deletions(-) create mode 100644 ctoken_for_payments.md delete mode 100644 js/compressed-token/PAYMENT_MIGRATION.md delete mode 100644 js/compressed-token/src/compressible/helpers.ts delete mode 100644 js/compressed-token/src/compressible/index.ts delete mode 100644 js/compressed-token/src/compressible/unified-load.ts delete mode 100644 js/compressed-token/src/mint/actions/decompress2.ts delete mode 100644 js/compressed-token/src/mint/actions/get-or-create-ata-interface.ts delete mode 100644 js/compressed-token/src/mint/actions/update-mint.ts delete mode 100644 js/compressed-token/src/mint/get-account-interface.ts delete mode 100644 js/compressed-token/src/mint/index.ts delete mode 100644 js/compressed-token/src/utils/ata-utils.ts rename js/compressed-token/src/{mint => v3}/actions/create-associated-ctoken.ts (98%) rename js/compressed-token/src/{mint => v3}/actions/create-ata-interface.ts (73%) rename js/compressed-token/src/{mint => v3}/actions/create-mint-interface.ts (96%) create mode 100644 js/compressed-token/src/v3/actions/decompress-interface.ts create mode 100644 js/compressed-token/src/v3/actions/get-or-create-ata-interface.ts rename js/compressed-token/src/{mint => v3}/actions/index.ts (81%) create mode 100644 js/compressed-token/src/v3/actions/load-ata.ts rename js/compressed-token/src/{mint => v3}/actions/mint-to-compressed.ts (77%) rename js/compressed-token/src/{mint => v3}/actions/mint-to-interface.ts (96%) rename js/compressed-token/src/{mint => v3}/actions/mint-to.ts (98%) rename js/compressed-token/src/{mint => v3}/actions/transfer-interface.ts (67%) create mode 100644 js/compressed-token/src/v3/actions/unwrap.ts rename js/compressed-token/src/{mint => v3}/actions/update-metadata.ts (51%) create mode 100644 js/compressed-token/src/v3/actions/update-mint.ts rename js/compressed-token/src/{mint => v3}/actions/wrap.ts (71%) create mode 100644 js/compressed-token/src/v3/ata-utils.ts rename js/compressed-token/src/{compressible => v3}/derivation.ts (87%) create mode 100644 js/compressed-token/src/v3/get-account-interface.ts create mode 100644 js/compressed-token/src/v3/get-associated-token-address-interface.ts rename js/compressed-token/src/{mint/helpers.ts => v3/get-mint-interface.ts} (80%) create mode 100644 js/compressed-token/src/v3/index.ts rename js/compressed-token/src/{mint => v3}/instructions/create-associated-ctoken.ts (51%) create mode 100644 js/compressed-token/src/v3/instructions/create-ata-interface.ts rename js/compressed-token/src/{mint/instructions/decompress2.ts => v3/instructions/create-decompress-interface-instruction.ts} (53%) create mode 100644 js/compressed-token/src/v3/instructions/create-load-accounts-params.ts rename js/compressed-token/src/{mint => v3}/instructions/create-mint.ts (77%) rename js/compressed-token/src/{mint => v3}/instructions/index.ts (63%) rename js/compressed-token/src/{mint => v3}/instructions/mint-to-compressed.ts (80%) rename js/compressed-token/src/{mint => v3}/instructions/mint-to-interface.ts (81%) rename js/compressed-token/src/{mint => v3}/instructions/mint-to.ts (97%) rename js/compressed-token/src/{mint => v3}/instructions/transfer-interface.ts (83%) create mode 100644 js/compressed-token/src/v3/instructions/unwrap.ts rename js/compressed-token/src/{mint => v3}/instructions/update-metadata.ts (61%) rename js/compressed-token/src/{mint => v3}/instructions/update-mint.ts (66%) rename js/compressed-token/src/{mint => v3}/instructions/wrap.ts (55%) create mode 100644 js/compressed-token/src/v3/layout/index.ts rename js/compressed-token/src/{mint/instructions/mint-action-layout.ts => v3/layout/layout-mint-action.ts} (98%) rename js/compressed-token/src/{mint/serde.ts => v3/layout/layout-mint.ts} (100%) rename js/compressed-token/src/{mint/upload.ts => v3/layout/layout-token-metadata.ts} (100%) rename js/compressed-token/src/{ => v3/layout}/layout-transfer2.ts (82%) rename js/compressed-token/src/{compressible => v3/layout}/serde.ts (99%) create mode 100644 js/compressed-token/src/v3/unified/index.ts create mode 100644 js/compressed-token/tests/e2e/create-ata-interface.test.ts create mode 100644 js/compressed-token/tests/e2e/create-mint-interface.test.ts create mode 100644 js/compressed-token/tests/e2e/get-account-interface.test.ts create mode 100644 js/compressed-token/tests/e2e/get-mint-interface.test.ts create mode 100644 js/compressed-token/tests/e2e/get-or-create-ata-interface.test.ts create mode 100644 js/compressed-token/tests/e2e/load-ata-combined.test.ts create mode 100644 js/compressed-token/tests/e2e/load-ata-spl-t22.test.ts create mode 100644 js/compressed-token/tests/e2e/load-ata-standard.test.ts create mode 100644 js/compressed-token/tests/e2e/load-ata-unified.test.ts create mode 100644 js/compressed-token/tests/e2e/unwrap.test.ts create mode 100644 js/compressed-token/tests/unit/derive-token-pool-info.test.ts create mode 100644 js/compressed-token/tests/unit/get-associated-token-address-interface.test.ts create mode 100644 js/compressed-token/tests/unit/layout-mint-action.test.ts create mode 100644 js/compressed-token/tests/unit/layout-mint.test.ts create mode 100644 js/compressed-token/tests/unit/layout-serde.test.ts create mode 100644 js/compressed-token/tests/unit/layout-token-metadata.test.ts create mode 100644 js/compressed-token/tests/unit/layout-transfer2.test.ts create mode 100644 js/compressed-token/tests/unit/unified-guards.test.ts create mode 100644 js/stateless.js/tests/e2e/interface-methods.test.ts diff --git a/cli/src/commands/create-mint/index.ts b/cli/src/commands/create-mint/index.ts index 70afe53c89..1ed4fcf2b4 100644 --- a/cli/src/commands/create-mint/index.ts +++ b/cli/src/commands/create-mint/index.ts @@ -49,7 +49,6 @@ class CreateMintCommand extends Command { rpc(), payer, mintAuthority, - null, mintDecimals, mintKeypair, ); diff --git a/cli/src/utils/constants.ts b/cli/src/utils/constants.ts index 8253b49de5..b298bc3798 100644 --- a/cli/src/utils/constants.ts +++ b/cli/src/utils/constants.ts @@ -25,7 +25,7 @@ export const PHOTON_VERSION = "0.51.2"; export const USE_PHOTON_FROM_GIT = true; // If true, will show git install command instead of crates.io. export const PHOTON_GIT_REPO = "https://github.com/lightprotocol/photon.git"; export const PHOTON_GIT_COMMIT = "711c47b20330c6bb78feb0a2c15e8292fcd0a7b0"; // If empty, will use main branch. -//df1087d55a8ff237ff69495a48542461a972f4fe +//8bc3a8baeda38c62a7ac71be1d51d5c28e783842 export const LIGHT_PROTOCOL_PROGRAMS_DIR_ENV = "LIGHT_PROTOCOL_PROGRAMS_DIR"; export const BASE_PATH = "../../bin/"; diff --git a/cli/test/helpers/helpers.ts b/cli/test/helpers/helpers.ts index acb0c52109..c2a2873c61 100644 --- a/cli/test/helpers/helpers.ts +++ b/cli/test/helpers/helpers.ts @@ -38,7 +38,6 @@ export async function createTestMint(mintKeypair: Keypair) { rpc, await getPayer(), (await getPayer()).publicKey, - null, 9, mintKeypair, ); diff --git a/ctoken_for_payments.md b/ctoken_for_payments.md new file mode 100644 index 0000000000..696c6e5935 --- /dev/null +++ b/ctoken_for_payments.md @@ -0,0 +1,333 @@ +# Using c-token for Payments + +**TL;DR**: Same API patterns, 1/200th ATA creation cost. Your users get the same USDC, just stored more efficiently. + +--- + +## Setup + +```typescript +import { createRpc } from "@lightprotocol/stateless.js"; + +import { + getOrCreateAtaInterface, + getAtaInterface, + getAssociatedTokenAddressInterface, + transferInterface, + unwrap, +} from "@lightprotocol/compressed-token/unified"; + +const rpc = createRpc(RPC_ENDPOINT); +``` + +--- + +## 1. Receive Payments + +**SPL Token:** + +```typescript +import { getOrCreateAssociatedTokenAccount } from "@solana/spl-token"; + +const ata = await getOrCreateAssociatedTokenAccount( + connection, + payer, + mint, + recipient +); +// Share ata.address with sender + +console.log(ata.amount); +``` + +**SPL Token (instruction-level):** + +```typescript +import { + getAssociatedTokenAddressSync, + createAssociatedTokenAccountIdempotentInstruction, +} from "@solana/spl-token"; + +const ata = getAssociatedTokenAddressSync(mint, recipient); + +const tx = new Transaction().add( + createAssociatedTokenAccountIdempotentInstruction( + payer.publicKey, + ata, + recipient, + mint + ) +); +``` + +**c-token:** + +```typescript +const ata = await getOrCreateAtaInterface(rpc, payer, mint, recipient); +// Share ata.parsed.address with sender + +console.log(ata.parsed.amount); +``` + +**c-token (instruction-level):** + +```typescript +import { + createAssociatedTokenAccountInterfaceIdempotentInstruction, + getAssociatedTokenAddressInterface, +} from "@lightprotocol/compressed-token/unified"; +import { CTOKEN_PROGRAM_ID } from "@lightprotocol/stateless.js"; + +const ata = getAssociatedTokenAddressInterface(mint, recipient); + +const tx = new Transaction().add( + createAssociatedTokenAccountInterfaceIdempotentInstruction( + payer.publicKey, + ata, + recipient, + mint, + CTOKEN_PROGRAM_ID + ) +); +``` + +--- + +## 2. Send Payments + +**SPL Token:** + +```typescript +import { transfer } from "@solana/spl-token"; +const sourceAta = getAssociatedTokenAddressSync(mint, owner.publicKey); +const destinationAta = getAssociatedTokenAddressSync(mint, recipient); + +await transfer( + connection, + payer, + sourceAta, + destinationAta, + owner, + amount, + decimals +); +``` + +**SPL Token (instruction-level):** + +```typescript +import { + getAssociatedTokenAddressSync, + createTransferInstruction, +} from "@solana/spl-token"; + +const sourceAta = getAssociatedTokenAddressSync(mint, owner.publicKey); +const destinationAta = getAssociatedTokenAddressSync(mint, recipient); + +const tx = new Transaction().add( + createTransferInstruction(sourceAta, destinationAta, owner.publicKey, amount) +); +``` + +**c-token:** + +```typescript +const sourceAta = getAssociatedTokenAddressInterface(mint, owner.publicKey); +const destinationAta = getAssociatedTokenAddressInterface(mint, recipient); + +await transferInterface( + rpc, + payer, + sourceAta, + mint, + destinationAta, + owner, + amount +); +``` + +**c-token (instruction-level):** + +```typescript +import { + createLoadAtaInstructions, + createTransferInterfaceInstruction, + getAssociatedTokenAddressInterface, +} from "@lightprotocol/compressed-token/unified"; + +const sourceAta = getAssociatedTokenAddressInterface(mint, owner.publicKey); +const destinationAta = getAssociatedTokenAddressInterface(mint, recipient); + +const tx = new Transaction().add( + ...(await createLoadAtaInstructions( + rpc, + sourceAta, + owner.publicKey, + mint, + payer.publicKey + )), + createTransferInterfaceInstruction( + sourceAta, + destinationAta, + owner.publicKey, + amount + ) +); +``` + +To ensure your recipient's ATA exists you can prepend an idempotent creation instruction in the same atomic transaction: + +**SPL Token:** + +```typescript +import { + getAssociatedTokenAddressSync, + createAssociatedTokenAccountIdempotentInstruction, +} from "@solana/spl-token"; + +const destinationAta = getAssociatedTokenAddressSync(mint, recipient); +const createAtaIx = createAssociatedTokenAccountIdempotentInstruction( + payer.publicKey, + destinationAta, + recipient, + mint +); + +new Transaction().add(createAtaIx, transferIx); +``` + +**c-token:** + +```typescript +import { + getAssociatedTokenAddressInterface, + createAssociatedTokenAccountInterfaceIdempotentInstruction, +} from "@lightprotocol/compressed-token/unified"; +import { CTOKEN_PROGRAM_ID } from "@lightprotocol/stateless.js"; + +const destinationAta = getAssociatedTokenAddressInterface(mint, recipient); +const createAtaIx = createAssociatedTokenAccountInterfaceIdempotentInstruction( + payer.publicKey, + destinationAta, + recipient, + mint, + CTOKEN_PROGRAM_ID +); + +new Transaction().add(createAtaIx, transferIx); +``` + +--- + +## 3. Show Balance + +**SPL Token:** + +```typescript +import { getAccount } from "@solana/spl-token"; + +const account = await getAccount(connection, ata); +console.log(account.amount); +``` + +**c-token:** + +```typescript +const ata = getAssociatedTokenAddressInterface(mint, owner); +const account = await getAtaInterface(rpc, ata, owner, mint); + +console.log(account.parsed.amount); +``` + +--- + +## 4. Transaction History + +**SPL Token:** + +```typescript +const signatures = await connection.getSignaturesForAddress(ata); +``` + +**c-token:** + +```typescript +// Unified: fetches both on-chain and compressed tx signatures +const result = await rpc.getSignaturesForOwnerInterface(owner); + +console.log(result.signatures); // Merged + deduplicated +console.log(result.solana); // On-chain txs only +console.log(result.compressed); // Compressed txs only +``` + +Use `getSignaturesForAddressInterface(address)` if you want address-specific rather than owner-wide history. + +--- + +## 5. Unwrap to SPL + +When users need vanilla SPL tokens (eg., for CEX off-ramp): + +**c-token -> SPL ATA:** + +```typescript +import { getAssociatedTokenAddressSync } from "@solana/spl-token"; + +// SPL ATA must exist +const splAta = getAssociatedTokenAddressSync(mint, owner.publicKey); + +await unwrap(rpc, payer, owner, mint, splAta, amount); +``` + +**c-token (instruction-level):** + +```typescript +import { getAssociatedTokenAddressSync } from "@solana/spl-token"; +import { + createLoadAtaInstructions, + createUnwrapInstruction, + getAssociatedTokenAddressInterface, +} from "@lightprotocol/compressed-token/unified"; +import { getSplInterfaceInfos } from "@lightprotocol/compressed-token"; + +const ctokenAta = getAssociatedTokenAddressInterface(mint, owner.publicKey); +const splAta = getAssociatedTokenAddressSync(mint, owner.publicKey); + +const splInterfaceInfos = await getSplInterfaceInfos(rpc, mint); +const splInterfaceInfo = splInterfaceInfos.find((i) => i.isInitialized); + +const tx = new Transaction().add( + ...(await createLoadAtaInstructions( + rpc, + ctokenAta, + owner.publicKey, + mint, + payer.publicKey + )), + createUnwrapInstruction( + ctokenAta, + splAta, + owner.publicKey, + mint, + amount, + splInterfaceInfo + ) +); +``` + +--- + +## Quick Reference + +| Operation | SPL Token | c-token (unified) | +| -------------- | ------------------------------------- | -------------------------------------- | +| Get/Create ATA | `getOrCreateAssociatedTokenAccount()` | `getOrCreateAtaInterface()` | +| Derive ATA | `getAssociatedTokenAddress()` | `getAssociatedTokenAddressInterface()` | +| Transfer | `transferChecked()` | `transferInterface()` | +| Get Balance | `getAccount()` | `getAtaInterface()` | +| Tx History | `getSignaturesForAddress()` | `rpc.getSignaturesForOwnerInterface()` | +| Exit to SPL | N/A | `unwrap()` | + +--- + +Need help with integration? Reach out: [support@lightprotocol.com](mailto:support@lightprotocol.com) diff --git a/js/compressed-token/PAYMENT_MIGRATION.md b/js/compressed-token/PAYMENT_MIGRATION.md deleted file mode 100644 index 1a67f86679..0000000000 --- a/js/compressed-token/PAYMENT_MIGRATION.md +++ /dev/null @@ -1,241 +0,0 @@ -# SPL Token to CToken Payment Migration - -Mirrors SPL Token's API. Same pattern, same flow. - -## TL;DR - -```typescript -// SPL Token -import { transfer, getOrCreateAssociatedTokenAccount } from '@solana/spl-token'; - -// CToken -import { - transferInterface, - getOrCreateAtaInterface, -} from '@lightprotocol/compressed-token'; -``` - -## Action Level - -### SPL Token - -```typescript -const recipientAta = await getOrCreateAssociatedTokenAccount( - connection, - payer, - mint, - recipient, -); -await transfer( - connection, - payer, - sourceAta, - recipientAta.address, - owner, - amount, -); -``` - -### CToken - -```typescript -const recipientAta = await getOrCreateAtaInterface(rpc, payer, mint, recipient); -await transferInterface( - rpc, - payer, - sourceAta, - recipientAta.address, - owner, - mint, - amount, -); -``` - -Same two-step pattern. `transferInterface` auto-loads sender's unified balance (cold + SPL + T22). - ---- - -## Instruction Level - -### SPL Token - -```typescript -import { - createAssociatedTokenAccountIdempotentInstruction, - createTransferInstruction, - getAssociatedTokenAddressSync, -} from '@solana/spl-token'; - -const sourceAta = getAssociatedTokenAddressSync(mint, sender); -const recipientAta = getAssociatedTokenAddressSync(mint, recipient); - -const tx = new Transaction().add( - createAssociatedTokenAccountIdempotentInstruction( - payer, - recipientAta, - recipient, - mint, - ), - createTransferInstruction(sourceAta, recipientAta, sender, amount), -); -``` - -### CToken (sender already hot) - -```typescript -import { - getAtaAddressInterface, - createAtaInterfaceIdempotentInstruction, - createTransferInterfaceInstruction, - CTOKEN_PROGRAM_ID, -} from '@lightprotocol/compressed-token'; - -const sourceAta = getAtaAddressInterface(mint, sender); -const recipientAta = getAtaAddressInterface(mint, recipient); - -const tx = new Transaction().add( - createAtaInterfaceIdempotentInstruction( - payer, - recipientAta, - recipient, - mint, - CTOKEN_PROGRAM_ID, - ), - createTransferInterfaceInstruction(sourceAta, recipientAta, sender, amount), -); -``` - -### CToken (sender may be cold - needs loading) - -```typescript -import { - createLoadAtaInstructions, - getAtaAddressInterface, - createAtaInterfaceIdempotentInstruction, - createTransferInterfaceInstruction, - CTOKEN_PROGRAM_ID, -} from '@lightprotocol/compressed-token'; - -// 1. Derive addresses -const sourceAta = getAtaAddressInterface(mint, sender); -const recipientAta = getAtaAddressInterface(mint, recipient); - -// 2. Build load instructions (empty if already hot) -const loadIxs = await createLoadAtaInstructions( - rpc, - payer, - sourceAta, - sender, - mint, -); - -// 3. Build transaction -const tx = new Transaction().add( - ...loadIxs, // Load sender if cold (wrap SPL/T22, decompress) - createAtaInterfaceIdempotentInstruction( - payer, - recipientAta, - recipient, - mint, - CTOKEN_PROGRAM_ID, - ), - createTransferInterfaceInstruction(sourceAta, recipientAta, sender, amount), -); -``` - -### CToken (sender pre-fetched) - -```typescript -import { - getAtaInterface, - createLoadAtaInstructionsFromInterface, - getAtaAddressInterface, - createAtaInterfaceIdempotentInstruction, - createTransferInterfaceInstruction, - CTOKEN_PROGRAM_ID, -} from '@lightprotocol/compressed-token'; - -// 1. Pre-fetch sender's unified balance -const senderAtaInfo = await getAtaInterface(rpc, sender, mint); - -// 2. Build load instructions from interface (empty if already hot) -const loadIxs = await createLoadAtaInstructionsFromInterface( - rpc, - payer, - senderAtaInfo, -); - -// 3. Derive addresses -const sourceAta = getAtaAddressInterface(mint, sender); -const recipientAta = getAtaAddressInterface(mint, recipient); - -// 4. Build transaction -const tx = new Transaction().add( - ...loadIxs, - createAtaInterfaceIdempotentInstruction( - payer, - recipientAta, - recipient, - mint, - CTOKEN_PROGRAM_ID, - ), - createTransferInterfaceInstruction(sourceAta, recipientAta, sender, amount), -); -``` - ---- - -## Instruction Mapping - -| SPL Token | CToken | -| --------------------------------------------------- | ----------------------------------------------------------------- | -| `getAssociatedTokenAddressSync` | `getAtaAddressInterface` | -| `createAssociatedTokenAccountIdempotentInstruction` | `createAtaInterfaceIdempotentInstruction` | -| `createTransferInstruction` | `createTransferInterfaceInstruction` | -| N/A | `createLoadAtaInstructions` (fetch + build) | -| N/A | `createLoadAtaInstructionsFromInterface` (build from pre-fetched) | - ---- - -## Key Differences - -| | SPL Token | CToken | -| ------------------- | ---------------------- | --------------------------------------------- | -| Recipient ATA | Create before transfer | Create before transfer | -| Sender balance | Single ATA | Unified (cold + SPL + T22 + hot) | -| Loading | N/A | `createLoadAtaInstructions` or auto in action | -| `destination` param | ATA address | ATA address | - ---- - -## Common Patterns - -### Check if loading needed - -```typescript -const ata = await getAtaInterface(rpc, owner, mint); -if (ata.isCold) { - // Need to include load instructions -} -``` - -### Get unified balance - -```typescript -const ata = await getAtaInterface(rpc, owner, mint); -const totalBalance = ata.parsed.amount; // All sources combined -``` - -### Idempotent recipient ATA - -Always safe to include - no-op if exists: - -```typescript -createAtaInterfaceIdempotentInstruction( - payer, - recipientAta, - recipient, - mint, - CTOKEN_PROGRAM_ID, -); -``` diff --git a/js/compressed-token/package.json b/js/compressed-token/package.json index db9f186cec..aea4e9cafc 100644 --- a/js/compressed-token/package.json +++ b/js/compressed-token/package.json @@ -11,10 +11,20 @@ "types": "./dist/types/index.d.ts", "default": "./dist/cjs/node/index.cjs" }, + "./unified": { + "require": "./dist/cjs/node/unified/index.cjs", + "types": "./dist/types/unified/index.d.ts", + "default": "./dist/cjs/node/unified/index.cjs" + }, "./browser": { "import": "./dist/es/browser/index.js", "require": "./dist/cjs/browser/index.cjs", "types": "./dist/types/index.d.ts" + }, + "./browser/unified": { + "import": "./dist/es/browser/unified/index.js", + "require": "./dist/cjs/browser/unified/index.cjs", + "types": "./dist/types/unified/index.d.ts" } }, "types": "./dist/types/index.d.ts", @@ -92,7 +102,7 @@ "test-validator": "./../../cli/test_bin/run test-validator", "test-validator-skip-prover": "./../../cli/test_bin/run test-validator --skip-prover", "test:e2e:create-mint": "pnpm test-validator && NODE_OPTIONS='--trace-deprecation' vitest run tests/e2e/create-mint.test.ts --reporter=verbose", - "test:e2e:create-compressed-mint": "pnpm test-validator && vitest run tests/e2e/create-compressed-mint.test.ts --reporter=verbose", + "test:e2e:create-compressed-mint": "pnpm test-validator && vitest run tests/e2e/create-compressed-mint.test.ts --reporter=verbose --bail=1", "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", @@ -113,12 +123,21 @@ "test:e2e:compress-spl-token-account": "pnpm test-validator && vitest run tests/e2e/compress-spl-token-account.test.ts --reporter=verbose", "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:rpc-token-interop": "pnpm test-validator && vitest run tests/e2e/rpc-token-interop.test.ts --reporter=verbose", "test:e2e:rpc-multi-trees": "pnpm test-validator && vitest run tests/e2e/rpc-multi-trees.test.ts --reporter=verbose", "test:e2e:multi-pool": "pnpm test-validator && vitest run tests/e2e/multi-pool.test.ts --reporter=verbose", "test:e2e:legacy:all": "pnpm test-validator && vitest run tests/e2e/create-mint.test.ts && vitest run tests/e2e/mint-to.test.ts && vitest run tests/e2e/transfer.test.ts && vitest run tests/e2e/delegate.test.ts && vitest run tests/e2e/transfer-delegated.test.ts && vitest run tests/e2e/multi-pool.test.ts && vitest run tests/e2e/decompress-delegated.test.ts && pnpm test-validator-skip-prover && vitest run tests/e2e/compress.test.ts && vitest run tests/e2e/compress-spl-token-account.test.ts && vitest run tests/e2e/decompress.test.ts && vitest run tests/e2e/create-token-pool.test.ts && vitest run tests/e2e/approve-and-mint-to.test.ts && vitest run tests/e2e/rpc-token-interop.test.ts && vitest run tests/e2e/rpc-multi-trees.test.ts && vitest run tests/e2e/layout.test.ts && vitest run tests/e2e/select-accounts.test.ts", "test:e2e:wrap": "pnpm test-validator && vitest run tests/e2e/wrap.test.ts --reporter=verbose", - "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 && 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", + "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 && 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", "pull-idl": "../../scripts/push-compressed-token-idl.sh", "build": "if [ \"$LIGHT_PROTOCOL_VERSION\" = \"V2\" ]; then LIGHT_PROTOCOL_VERSION=V2 pnpm build:bundle; else LIGHT_PROTOCOL_VERSION=V1 pnpm build:bundle; fi", "build:bundle": "rimraf dist && rollup -c", diff --git a/js/compressed-token/rollup.config.js b/js/compressed-token/rollup.config.js index 5216a7aa99..17e1371544 100644 --- a/js/compressed-token/rollup.config.js +++ b/js/compressed-token/rollup.config.js @@ -10,11 +10,15 @@ import terser from '@rollup/plugin-terser'; import replace from '@rollup/plugin-replace'; const rolls = (fmt, env) => ({ - input: 'src/index.ts', + input: { + index: 'src/index.ts', + 'unified/index': 'src/v3/unified/index.ts', + }, output: { dir: `dist/${fmt}/${env}`, format: fmt, entryFileNames: `[name].${fmt === 'cjs' ? 'cjs' : 'js'}`, + chunkFileNames: `[name]-[hash].${fmt === 'cjs' ? 'cjs' : 'js'}`, sourcemap: true, }, external: [ @@ -99,9 +103,27 @@ const typesConfig = { ], }; +const typesConfigUnified = { + input: 'src/v3/unified/index.ts', + output: [{ file: 'dist/types/unified/index.d.ts', format: 'es' }], + external: [ + '@coral-xyz/borsh', + '@solana/web3.js', + '@solana/spl-token', + '@lightprotocol/stateless.js', + ], + plugins: [ + dts({ + respectExternal: true, + tsconfig: './tsconfig.json', + }), + ], +}; + export default [ rolls('cjs', 'browser'), rolls('cjs', 'node'), rolls('es', 'browser'), typesConfig, + typesConfigUnified, ]; diff --git a/js/compressed-token/src/actions/approve-and-mint-to.ts b/js/compressed-token/src/actions/approve-and-mint-to.ts index e4dc13ccc9..0e31673aa2 100644 --- a/js/compressed-token/src/actions/approve-and-mint-to.ts +++ b/js/compressed-token/src/actions/approve-and-mint-to.ts @@ -19,9 +19,9 @@ import { CompressedTokenProgram } from '../program'; import { getOrCreateAssociatedTokenAccount } from '@solana/spl-token'; import { - getTokenPoolInfos, - selectTokenPoolInfo, - TokenPoolInfo, + getSplInterfaceInfos, + selectSplInterfaceInfo, + SplInterfaceInfo, } from '../utils/get-token-pool-infos'; /** @@ -36,7 +36,7 @@ import { * @param outputStateTreeInfo Optional: State tree account that the compressed * tokens should be inserted into. Defaults to a * shared state tree account. - * @param tokenPoolInfo Optional: Token pool info. + * @param splInterfaceInfo Optional: SPL interface info. * @param confirmOptions Options for confirming the transaction * * @return Signature of the confirmed transaction @@ -49,15 +49,15 @@ export async function approveAndMintTo( authority: Signer, amount: number | BN, outputStateTreeInfo?: TreeInfo, - tokenPoolInfo?: TokenPoolInfo, + splInterfaceInfo?: SplInterfaceInfo, confirmOptions?: ConfirmOptions, ): Promise { outputStateTreeInfo = outputStateTreeInfo ?? selectStateTreeInfo(await rpc.getStateTreeInfos()); - tokenPoolInfo = - tokenPoolInfo ?? - selectTokenPoolInfo(await getTokenPoolInfos(rpc, mint)); + splInterfaceInfo = + splInterfaceInfo ?? + selectSplInterfaceInfo(await getSplInterfaceInfos(rpc, mint)); const authorityTokenAccount = await getOrCreateAssociatedTokenAccount( rpc, @@ -67,7 +67,7 @@ export async function approveAndMintTo( undefined, undefined, confirmOptions, - tokenPoolInfo.tokenProgram, + splInterfaceInfo.tokenProgram, ); const ixs = await CompressedTokenProgram.approveAndMintTo({ @@ -78,7 +78,7 @@ export async function approveAndMintTo( amount, toPubkey, outputStateTreeInfo, - tokenPoolInfo, + tokenPoolInfo: splInterfaceInfo, }); const { blockhash } = await rpc.getLatestBlockhash(); diff --git a/js/compressed-token/src/actions/compress-spl-token-account.ts b/js/compressed-token/src/actions/compress-spl-token-account.ts index 40bdc1e526..6939226c8f 100644 --- a/js/compressed-token/src/actions/compress-spl-token-account.ts +++ b/js/compressed-token/src/actions/compress-spl-token-account.ts @@ -15,9 +15,9 @@ import { } from '@lightprotocol/stateless.js'; import BN from 'bn.js'; import { - getTokenPoolInfos, - selectTokenPoolInfo, - TokenPoolInfo, + getSplInterfaceInfos, + selectSplInterfaceInfo, + SplInterfaceInfo, } from '../utils/get-token-pool-infos'; import { CompressedTokenProgram } from '../program'; @@ -33,7 +33,7 @@ import { CompressedTokenProgram } from '../program'; * Default: 0 * @param outputStateTreeInfo Optional: State tree account that the compressed * account into - * @param tokenPoolInfo Optional: Token pool info. + * @param splInterfaceInfo Optional: SPL interface info. * @param confirmOptions Options for confirming the transaction * @@ -47,15 +47,15 @@ export async function compressSplTokenAccount( tokenAccount: PublicKey, remainingAmount?: BN, outputStateTreeInfo?: TreeInfo, - tokenPoolInfo?: TokenPoolInfo, + splInterfaceInfo?: SplInterfaceInfo, confirmOptions?: ConfirmOptions, ): Promise { outputStateTreeInfo = outputStateTreeInfo ?? selectStateTreeInfo(await rpc.getStateTreeInfos()); - tokenPoolInfo = - tokenPoolInfo ?? - selectTokenPoolInfo(await getTokenPoolInfos(rpc, mint)); + splInterfaceInfo = + splInterfaceInfo ?? + selectSplInterfaceInfo(await getSplInterfaceInfos(rpc, mint)); const compressIx = await CompressedTokenProgram.compressSplTokenAccount({ feePayer: payer.publicKey, @@ -64,7 +64,7 @@ export async function compressSplTokenAccount( mint, remainingAmount, outputStateTreeInfo, - tokenPoolInfo, + tokenPoolInfo: splInterfaceInfo, }); const blockhashCtx = await rpc.getLatestBlockhash(); diff --git a/js/compressed-token/src/actions/compress.ts b/js/compressed-token/src/actions/compress.ts index 464a5a2985..316df78e08 100644 --- a/js/compressed-token/src/actions/compress.ts +++ b/js/compressed-token/src/actions/compress.ts @@ -17,9 +17,9 @@ import { import BN from 'bn.js'; import { CompressedTokenProgram } from '../program'; import { - getTokenPoolInfos, - selectTokenPoolInfo, - TokenPoolInfo, + getSplInterfaceInfos, + selectSplInterfaceInfo, + SplInterfaceInfo, } from '../utils/get-token-pool-infos'; /** @@ -35,7 +35,7 @@ import { * @param outputStateTreeInfo Optional: State tree account that the compressed * tokens should be inserted into. Defaults to a * shared state tree account. - * @param tokenPoolInfo Optional: Token pool info. + * @param splInterfaceInfo Optional: SPL interface info. * @param confirmOptions Options for confirming the transaction * * @return Signature of the confirmed transaction @@ -49,15 +49,15 @@ export async function compress( sourceTokenAccount: PublicKey, toAddress: PublicKey | Array, outputStateTreeInfo?: TreeInfo, - tokenPoolInfo?: TokenPoolInfo, + splInterfaceInfo?: SplInterfaceInfo, confirmOptions?: ConfirmOptions, ): Promise { outputStateTreeInfo = outputStateTreeInfo ?? selectStateTreeInfo(await rpc.getStateTreeInfos()); - tokenPoolInfo = - tokenPoolInfo ?? - selectTokenPoolInfo(await getTokenPoolInfos(rpc, mint)); + splInterfaceInfo = + splInterfaceInfo ?? + selectSplInterfaceInfo(await getSplInterfaceInfos(rpc, mint)); const compressIx = await CompressedTokenProgram.compress({ payer: payer.publicKey, @@ -67,7 +67,7 @@ export async function compress( amount, mint, outputStateTreeInfo, - tokenPoolInfo, + tokenPoolInfo: splInterfaceInfo, }); const blockhashCtx = await rpc.getLatestBlockhash(); diff --git a/js/compressed-token/src/actions/create-mint.ts b/js/compressed-token/src/actions/create-mint.ts index c08815f62c..51115af2b4 100644 --- a/js/compressed-token/src/actions/create-mint.ts +++ b/js/compressed-token/src/actions/create-mint.ts @@ -26,13 +26,13 @@ import { * @param rpc RPC connection to use * @param payer Fee payer * @param mintAuthority Account that will control minting - * @param freezeAuthority Optional: Account that will control freeze and thaw. * @param decimals Location of the decimal place * @param keypair Optional: Mint keypair. Defaults to a random * keypair. * @param confirmOptions Options for confirming the transaction * @param tokenProgramId Optional: Program ID for the token. Defaults to * TOKEN_PROGRAM_ID. + * @param freezeAuthority Optional: Account that will control freeze and thaw. * * @return Object with mint address and transaction signature */ @@ -40,11 +40,11 @@ export async function createMint( rpc: Rpc, payer: Signer, mintAuthority: PublicKey | Signer, - freezeAuthority: PublicKey | Signer | null, decimals: number, - keypair = Keypair.generate(), + keypair: Keypair = Keypair.generate(), confirmOptions?: ConfirmOptions, tokenProgramId?: PublicKey | boolean, + freezeAuthority?: PublicKey | Signer | null, ): Promise<{ mint: PublicKey; transactionSignature: TransactionSignature }> { const rentExemptBalance = await rpc.getMinimumBalanceForRentExemption(MINT_SIZE); @@ -77,7 +77,7 @@ export async function createMint( const additionalSigners = dedupeSigner( payer, - [mintAuthority, freezeAuthority].filter( + [mintAuthority, freezeAuthority ?? null].filter( (signer): signer is Signer => signer != undefined && 'secretKey' in signer, ), diff --git a/js/compressed-token/src/actions/create-token-pool.ts b/js/compressed-token/src/actions/create-token-pool.ts index fd7e074bb8..03fd3cb0d9 100644 --- a/js/compressed-token/src/actions/create-token-pool.ts +++ b/js/compressed-token/src/actions/create-token-pool.ts @@ -11,7 +11,7 @@ import { buildAndSignTx, sendAndConfirmTx, } from '@lightprotocol/stateless.js'; -import { getTokenPoolInfos } from '../utils/get-token-pool-infos'; +import { getSplInterfaceInfos } from '../utils/get-token-pool-infos'; /** * Register an existing mint with the CompressedToken program @@ -25,7 +25,7 @@ import { getTokenPoolInfos } from '../utils/get-token-pool-infos'; * * @return transaction signature */ -export async function createTokenPool( +export async function createSplInterface( rpc: Rpc, payer: Signer, mint: PublicKey, @@ -52,12 +52,17 @@ export async function createTokenPool( } /** - * Create additional token pools for an existing mint + * @deprecated Use {@link createSplInterface} instead. + */ +export const createTokenPool = createSplInterface; + +/** + * Create additional SPL interfaces for an existing mint * * @param rpc RPC connection to use * @param payer Fee payer * @param mint SPL Mint address - * @param numMaxAdditionalPools Number of additional token pools to create. Max + * @param numMaxAdditionalPools Number of additional SPL interfaces to create. Max * 3. * @param confirmOptions Optional: Options for confirming the transaction * @param tokenProgramId Optional: Address of the token program. Default: @@ -65,7 +70,7 @@ export async function createTokenPool( * * @return transaction signature */ -export async function addTokenPools( +export async function addSplInterfaces( rpc: Rpc, payer: Signer, mint: PublicKey, @@ -78,9 +83,9 @@ export async function addTokenPools( : await CompressedTokenProgram.getMintProgramId(mint, rpc); const instructions: TransactionInstruction[] = []; - const infos = (await getTokenPoolInfos(rpc, mint)).slice(0, 4); + const infos = (await getSplInterfaceInfos(rpc, mint)).slice(0, 4); - // Get indices of uninitialized pools + // Get indices of uninitialized interfaces const uninitializedIndices = []; for (let i = 0; i < infos.length; i++) { if (!infos[i].isInitialized) { @@ -88,7 +93,7 @@ export async function addTokenPools( } } - // Create instructions for requested number of pools + // Create instructions for requested number of interfaces for (let i = 0; i < numMaxAdditionalPools; i++) { if (i >= uninitializedIndices.length) { break; @@ -111,3 +116,8 @@ export async function addTokenPools( return txId; } + +/** + * @deprecated Use {@link addSplInterfaces} instead. + */ +export const addTokenPools = addSplInterfaces; diff --git a/js/compressed-token/src/actions/decompress-delegated.ts b/js/compressed-token/src/actions/decompress-delegated.ts index 00dd1b31a6..35ea8ad93d 100644 --- a/js/compressed-token/src/actions/decompress-delegated.ts +++ b/js/compressed-token/src/actions/decompress-delegated.ts @@ -18,10 +18,10 @@ import BN from 'bn.js'; import { CompressedTokenProgram } from '../program'; import { selectMinCompressedTokenAccountsForTransfer } from '../utils'; import { - selectTokenPoolInfosForDecompression, - TokenPoolInfo, + selectSplInterfaceInfosForDecompression, + SplInterfaceInfo, + getSplInterfaceInfos, } from '../utils/get-token-pool-infos'; -import { getTokenPoolInfos } from '../utils/get-token-pool-infos'; /** * Decompress delegated compressed tokens. Remaining compressed tokens are @@ -34,7 +34,7 @@ import { getTokenPoolInfos } from '../utils/get-token-pool-infos'; * @param owner Owner of the compressed tokens * @param toAddress Destination **uncompressed** token account * address. (ATA) - * @param tokenPoolInfos Optional: Token pool infos. + * @param splInterfaceInfos Optional: SPL interface infos. * @param confirmOptions Options for confirming the transaction * * @return Signature of the confirmed transaction @@ -46,7 +46,7 @@ export async function decompressDelegated( amount: number | BN, owner: Signer, toAddress: PublicKey, - tokenPoolInfos?: TokenPoolInfo[], + splInterfaceInfos?: SplInterfaceInfo[], confirmOptions?: ConfirmOptions, ): Promise { amount = bn(amount); @@ -69,10 +69,10 @@ export async function decompressDelegated( })), ); - const tokenPoolInfosToUse = - tokenPoolInfos ?? - selectTokenPoolInfosForDecompression( - await getTokenPoolInfos(rpc, mint), + const splInterfaceInfosToUse = + splInterfaceInfos ?? + selectSplInterfaceInfosForDecompression( + await getSplInterfaceInfos(rpc, mint), amount, ); @@ -83,7 +83,7 @@ export async function decompressDelegated( amount, recentInputStateRootIndices: proof.rootIndices, recentValidityProof: proof.compressedProof, - tokenPoolInfos: tokenPoolInfosToUse, + tokenPoolInfos: splInterfaceInfosToUse, }); const { blockhash } = await rpc.getLatestBlockhash(); diff --git a/js/compressed-token/src/actions/decompress.ts b/js/compressed-token/src/actions/decompress.ts index 2bfbbe509a..ad4a4c4b79 100644 --- a/js/compressed-token/src/actions/decompress.ts +++ b/js/compressed-token/src/actions/decompress.ts @@ -16,10 +16,10 @@ import BN from 'bn.js'; import { CompressedTokenProgram } from '../program'; import { selectMinCompressedTokenAccountsForTransfer } from '../utils'; import { - selectTokenPoolInfosForDecompression, - TokenPoolInfo, + selectSplInterfaceInfosForDecompression, + SplInterfaceInfo, + getSplInterfaceInfos, } from '../utils/get-token-pool-infos'; -import { getTokenPoolInfos } from '../utils/get-token-pool-infos'; /** * Decompress compressed tokens @@ -31,7 +31,7 @@ import { getTokenPoolInfos } from '../utils/get-token-pool-infos'; * @param owner Owner of the compressed tokens * @param toAddress Destination **uncompressed** token account * address. (ATA) - * @param tokenPoolInfos Optional: Token pool infos. + * @param splInterfaceInfos Optional: SPL interface infos. * @param confirmOptions Options for confirming the transaction * * @return confirmed transaction signature @@ -43,7 +43,7 @@ export async function decompress( amount: number | BN, owner: Signer, toAddress: PublicKey, - tokenPoolInfos?: TokenPoolInfo[], + splInterfaceInfos?: SplInterfaceInfo[], confirmOptions?: ConfirmOptions, ): Promise { amount = bn(amount); @@ -68,10 +68,11 @@ export async function decompress( })), ); - tokenPoolInfos = tokenPoolInfos ?? (await getTokenPoolInfos(rpc, mint)); + splInterfaceInfos = + splInterfaceInfos ?? (await getSplInterfaceInfos(rpc, mint)); - const selectedTokenPoolInfos = selectTokenPoolInfosForDecompression( - tokenPoolInfos, + const selectedSplInterfaceInfos = selectSplInterfaceInfosForDecompression( + splInterfaceInfos, amount, ); @@ -80,7 +81,7 @@ export async function decompress( inputCompressedTokenAccounts: inputAccounts, toAddress, amount, - tokenPoolInfos: selectedTokenPoolInfos, + tokenPoolInfos: selectedSplInterfaceInfos, recentInputStateRootIndices: proof.rootIndices, recentValidityProof: proof.compressedProof, }); diff --git a/js/compressed-token/src/actions/mint-to.ts b/js/compressed-token/src/actions/mint-to.ts index e50f20b57c..bf6100950d 100644 --- a/js/compressed-token/src/actions/mint-to.ts +++ b/js/compressed-token/src/actions/mint-to.ts @@ -16,9 +16,9 @@ import { } from '@lightprotocol/stateless.js'; import { CompressedTokenProgram } from '../program'; import { - getTokenPoolInfos, - selectTokenPoolInfo, - TokenPoolInfo, + getSplInterfaceInfos, + selectSplInterfaceInfo, + SplInterfaceInfo, } from '../utils/get-token-pool-infos'; /** @@ -36,7 +36,7 @@ import { * @param outputStateTreeInfo Optional: State tree account that the compressed * tokens should be part of. Defaults to the * default state tree account. - * @param tokenPoolInfo Optional: Token pool information + * @param splInterfaceInfo Optional: SPL interface information * @param confirmOptions Options for confirming the transaction * * @return Signature of the confirmed transaction @@ -49,15 +49,15 @@ export async function mintTo( authority: Signer, amount: number | BN | number[] | BN[], outputStateTreeInfo?: TreeInfo, - tokenPoolInfo?: TokenPoolInfo, + splInterfaceInfo?: SplInterfaceInfo, confirmOptions?: ConfirmOptions, ): Promise { outputStateTreeInfo = outputStateTreeInfo ?? selectStateTreeInfo(await rpc.getStateTreeInfos()); - tokenPoolInfo = - tokenPoolInfo ?? - selectTokenPoolInfo(await getTokenPoolInfos(rpc, mint)); + splInterfaceInfo = + splInterfaceInfo ?? + selectSplInterfaceInfo(await getSplInterfaceInfos(rpc, mint)); const ix = await CompressedTokenProgram.mintTo({ feePayer: payer.publicKey, @@ -66,7 +66,7 @@ export async function mintTo( amount, toPubkey, outputStateTreeInfo, - tokenPoolInfo, + tokenPoolInfo: splInterfaceInfo, }); const { blockhash } = await rpc.getLatestBlockhash(); diff --git a/js/compressed-token/src/compressible/helpers.ts b/js/compressed-token/src/compressible/helpers.ts deleted file mode 100644 index 52232b6581..0000000000 --- a/js/compressed-token/src/compressible/helpers.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { - Rpc, - MerkleContext, - ValidityProof, - packDecompressAccountsIdempotent, - CTOKEN_PROGRAM_ID, -} from '@lightprotocol/stateless.js'; -import BN from 'bn.js'; -import { - PublicKey, - AccountInfo, - AccountMeta, - Commitment, -} from '@solana/web3.js'; -import { - TOKEN_PROGRAM_ID, - TOKEN_2022_PROGRAM_ID, - getAssociatedTokenAddressSync, - TokenAccountNotFoundError, -} from '@solana/spl-token'; -import { getAssociatedCTokenAddressAndBump } from './derivation'; -import { Account, toAccountInfo } from '../mint/get-account-interface'; -import { Buffer } from 'buffer'; -import { getATAProgramId } from '../utils'; - -function parseTokenData(data: Buffer): { - mint: PublicKey; - owner: PublicKey; - amount: BN; - delegate: PublicKey | null; - state: number; - tlv: Buffer | null; -} | null { - if (!data || data.length === 0) return null; - - try { - let offset = 0; - const mint = new PublicKey(data.slice(offset, offset + 32)); - offset += 32; - const owner = new PublicKey(data.slice(offset, offset + 32)); - offset += 32; - const amount = new BN(data.slice(offset, offset + 8), 'le'); - offset += 8; - const delegateOption = data[offset]; - offset += 1; - const delegate = delegateOption - ? new PublicKey(data.slice(offset, offset + 32)) - : null; - offset += 32; - const state = data[offset]; - offset += 1; - const tlvOption = data[offset]; - offset += 1; - const tlv = tlvOption ? data.slice(offset) : null; - - return { - mint, - owner, - amount, - delegate, - state, - tlv, - }; - } catch (error) { - console.error('Token data parsing error:', error); - return null; - } -} - -function convertTokenDataToAccount( - address: PublicKey, - tokenData: { - mint: PublicKey; - owner: PublicKey; - amount: BN; - delegate: PublicKey | null; - state: number; - tlv: Buffer | null; - }, -): Account { - return { - address, - mint: tokenData.mint, - owner: tokenData.owner, - amount: BigInt(tokenData.amount.toString()), - delegate: tokenData.delegate, - delegatedAmount: BigInt(0), - isInitialized: tokenData.state !== 0, - isFrozen: tokenData.state === 2, - isNative: false, - rentExemptReserve: null, - closeAuthority: null, - tlvData: tokenData.tlv ? Buffer.from(tokenData.tlv) : Buffer.alloc(0), - }; -} - -export interface AccountInput { - address: PublicKey; - info: { - accountInfo?: AccountInfo; - parsed: any; - merkleContext?: MerkleContext; - }; - accountType: string; - tokenVariant?: string; -} - -export interface DecompressInstructionParams { - proofOption: { 0: ValidityProof | null }; - compressedAccounts: any[]; - systemAccountsOffset: number; - remainingAccounts: AccountMeta[]; -} - -/** - * Build decompress params for decompressAccountsIdempotent instruction. - * Automatically handles proof generation and account packing for both - * custom PDAs and cToken accounts. - * - * @param programId The program ID - * @param rpc RPC connection - * @param accounts Array of account inputs with address, parsed data, and merkle context - * @returns Packed params ready for instruction, or null if no compressed accounts - * - * @example - * ```typescript - * const params = await buildDecompressParams(programId, rpc, [ - * { address: poolAddress, info: poolInfo, accountType: "poolState" }, - * { address: vault0, info: vault0Info, accountType: "cTokenData", tokenVariant: "token0Vault" }, - * ]); - * - * if (params) { - * const ix = await program.methods - * .decompressAccountsIdempotent( - * params.proofOption, - * params.compressedAccounts, - * params.systemAccountsOffset - * ) - * .remainingAccounts(params.remainingAccounts) - * .instruction(); - * } - * ``` - */ -export async function buildDecompressParams( - programId: PublicKey, - rpc: Rpc, - accounts: AccountInput[], -): Promise { - const compressedAccounts = accounts.filter( - acc => acc.info.merkleContext !== undefined, - ); - - if (compressedAccounts.length === 0) { - return null; - } - - const proofInputs = compressedAccounts.map(acc => ({ - hash: acc.info.merkleContext!.hash, - tree: acc.info.merkleContext!.treeInfo.tree, - queue: acc.info.merkleContext!.treeInfo.queue, - })); - - const proof = await rpc.getValidityProofV0(proofInputs, []); - - const accountsData = compressedAccounts.map(acc => { - if (acc.accountType === 'cTokenData') { - if (!acc.tokenVariant) { - throw new Error( - `tokenVariant is required when accountType is "cTokenData"`, - ); - } - return { - key: 'cTokenData', - data: { - variant: { [acc.tokenVariant]: {} }, - tokenData: acc.info.parsed, - }, - treeInfo: acc.info.merkleContext!.treeInfo, - }; - } else { - return { - key: acc.accountType, - data: acc.info.parsed, - treeInfo: acc.info.merkleContext!.treeInfo, - }; - } - }); - - const addresses = compressedAccounts.map(acc => acc.address); - - const packed = await packDecompressAccountsIdempotent( - programId, - proof, - accountsData, - addresses, - ); - - return { - proofOption: packed.proofOption, - compressedAccounts: packed.compressedAccounts, - systemAccountsOffset: packed.systemAccountsOffset, - remainingAccounts: packed.remainingAccounts, - }; -} diff --git a/js/compressed-token/src/compressible/index.ts b/js/compressed-token/src/compressible/index.ts deleted file mode 100644 index eae8e3ae88..0000000000 --- a/js/compressed-token/src/compressible/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './derivation'; -export * from './serde'; -export * from './helpers'; -export * from './unified-load'; diff --git a/js/compressed-token/src/compressible/unified-load.ts b/js/compressed-token/src/compressible/unified-load.ts deleted file mode 100644 index 62144b4577..0000000000 --- a/js/compressed-token/src/compressible/unified-load.ts +++ /dev/null @@ -1,539 +0,0 @@ -import { - Rpc, - MerkleContext, - ValidityProof, - packDecompressAccountsIdempotent, - CTOKEN_PROGRAM_ID, - buildAndSignTx, - sendAndConfirmTx, - dedupeSigner, -} from '@lightprotocol/stateless.js'; -import { - PublicKey, - AccountMeta, - TransactionInstruction, - Signer, - TransactionSignature, - ConfirmOptions, - ComputeBudgetProgram, -} from '@solana/web3.js'; -import { - TOKEN_PROGRAM_ID, - TOKEN_2022_PROGRAM_ID, - getAssociatedTokenAddressSync, -} from '@solana/spl-token'; -import { - AccountInterface, - getATAInterface, -} from '../mint/get-account-interface'; -import { getATAAddressInterface } from '../mint/actions/create-ata-interface'; -import { createAssociatedTokenAccountInterfaceIdempotentInstruction } from '../mint/instructions/create-associated-ctoken'; -import { createWrapInstruction } from '../mint/instructions/wrap'; -import { createDecompress2Instruction } from '../mint/instructions/decompress2'; -import { - getTokenPoolInfos, - TokenPoolInfo, -} from '../utils/get-token-pool-infos'; -import { getATAProgramId } from '../utils'; -import { InterfaceOptions } from '../mint'; - -/** - * Account info interface for compressible accounts. - * Matches return structure of getAccountInterface/getATAInterface. - * - * Integrating programs provide their own fetch/parse - this is just the data shape. - */ -export interface ParsedAccountInfoInterface { - /** Parsed account data (program-specific) */ - parsed: T; - /** Load context - present if account is compressed (cold), undefined if hot */ - loadContext?: MerkleContext; -} - -/** - * Input for createLoadAccountsParams. - * Supports both program PDAs and CToken vaults. - * - * The integrating program is responsible for fetching and parsing their accounts. - * This helper just packs them for the decompressAccountsIdempotent instruction. - */ -export interface CompressibleAccountInput { - /** Account address */ - address: PublicKey; - /** - * Account type key for packing: - * - For PDAs: program-specific type name (e.g., "poolState", "observationState") - * - For CToken vaults: "cTokenData" - */ - accountType: string; - /** - * Token variant - required when accountType is "cTokenData". - * Examples: "lpVault", "token0Vault", "token1Vault" - */ - tokenVariant?: string; - /** Parsed account info (from program-specific fetch) */ - info: ParsedAccountInfoInterface; -} - -/** - * Packed compressed account for decompressAccountsIdempotent instruction - */ -export interface PackedCompressedAccount { - [key: string]: unknown; - merkleContext: { - merkleTreePubkeyIndex: number; - queuePubkeyIndex: number; - }; -} - -/** - * Result from building load params - */ -export interface CompressibleLoadParams { - /** Validity proof wrapped in option (null if all proveByIndex) */ - proofOption: { 0: ValidityProof | null }; - /** Packed compressed accounts data for instruction */ - compressedAccounts: PackedCompressedAccount[]; - /** Offset to system accounts in remainingAccounts */ - systemAccountsOffset: number; - /** Account metas for remaining accounts */ - remainingAccounts: AccountMeta[]; -} - -/** - * Result from createLoadAccountsParams - */ -export interface LoadResult { - /** Params for decompressAccountsIdempotent (null if no program accounts need decompressing) */ - decompressParams: CompressibleLoadParams | null; - /** Instructions to load ATAs (create ATA, wrap SPL/T22, decompress2) */ - ataInstructions: TransactionInstruction[]; -} - -// ============================================ -// Shared helper: Build load instructions from AccountInterface -// ============================================ - -/** - * Create instructions to load an ATA from its AccountInterface. - * - * This creates instructions to: - * 1. Create CToken ATA if needed (idempotent) - * 2. Wrap SPL tokens to CToken ATA (if SPL balance > 0) - * 3. Wrap T22 tokens to CToken ATA (if T22 balance > 0) - * 4. Decompress2 compressed tokens to CToken ATA (if cold balance > 0) - * - * Use this when you have a pre-fetched AccountInterface to save an RPC call. - * - * @param rpc RPC connection - * @param payer Fee payer - * @param ata AccountInterface from getATAInterface (must have _isAta, _owner, _mint) - * @param options Optional load options - * @returns Array of instructions (empty if nothing to load) - */ -export async function createLoadATAInstructionsFromInterface( - rpc: Rpc, - payer: PublicKey, - ata: AccountInterface, - options?: InterfaceOptions, -): Promise { - if (!ata._isAta || !ata._owner || !ata._mint) { - throw new Error( - 'AccountInterface must be from getATAInterface (requires _isAta, _owner, _mint)', - ); - } - - const instructions: TransactionInstruction[] = []; - const owner = ata._owner; - const mint = ata._mint; - const sources = ata._sources ?? []; - - // Derive addresses - const ctokenAta = getATAAddressInterface(mint, owner); - const splAta = getAssociatedTokenAddressSync( - mint, - owner, - false, - TOKEN_PROGRAM_ID, - getATAProgramId(TOKEN_PROGRAM_ID), - ); - const t22Ata = getAssociatedTokenAddressSync( - mint, - owner, - false, - TOKEN_2022_PROGRAM_ID, - getATAProgramId(TOKEN_2022_PROGRAM_ID), - ); - - // Check sources for balances - const splSource = sources.find(s => s.type === 'spl'); - const t22Source = sources.find(s => s.type === 'token2022'); - const ctokenHotSource = sources.find(s => s.type === 'ctoken-hot'); - const ctokenColdSource = sources.find(s => s.type === 'ctoken-cold'); - - const splBalance = splSource?.amount ?? BigInt(0); - const t22Balance = t22Source?.amount ?? BigInt(0); - const coldBalance = ctokenColdSource?.amount ?? BigInt(0); - - // Nothing to load - if ( - splBalance === BigInt(0) && - t22Balance === BigInt(0) && - coldBalance === BigInt(0) - ) { - return []; - } - - // 1. Create CToken ATA if needed (idempotent) - if (!ctokenHotSource) { - instructions.push( - createAssociatedTokenAccountInterfaceIdempotentInstruction( - payer, - ctokenAta, - owner, - mint, - CTOKEN_PROGRAM_ID, - ), - ); - } - - // Get token pool info for wrap operations - const tokenPoolInfos = - options?.tokenPoolInfos ?? (await getTokenPoolInfos(rpc, mint)); - const tokenPoolInfo = tokenPoolInfos.find( - (info: TokenPoolInfo) => info.isInitialized, - ); - - // 2. Wrap SPL tokens - if (splBalance > BigInt(0) && tokenPoolInfo) { - instructions.push( - createWrapInstruction( - splAta, - ctokenAta, - owner, - mint, - splBalance, - tokenPoolInfo, - payer, - ), - ); - } - - // 3. Wrap T22 tokens - if (t22Balance > BigInt(0) && tokenPoolInfo) { - instructions.push( - createWrapInstruction( - t22Ata, - ctokenAta, - owner, - mint, - t22Balance, - tokenPoolInfo, - payer, - ), - ); - } - - // 4. Decompress2 compressed tokens - if (coldBalance > BigInt(0) && ctokenColdSource) { - // Need to fetch compressed accounts for decompress2 instruction - const compressedResult = await rpc.getCompressedTokenAccountsByOwner( - owner, - { mint }, - ); - const compressedAccounts = compressedResult.items; - - if (compressedAccounts.length > 0) { - const proof = await rpc.getValidityProofV0( - compressedAccounts.map(acc => ({ - hash: acc.compressedAccount.hash, - tree: acc.compressedAccount.treeInfo.tree, - queue: acc.compressedAccount.treeInfo.queue, - })), - ); - - instructions.push( - createDecompress2Instruction( - payer, - compressedAccounts, - ctokenAta, - coldBalance, - proof.compressedProof, - proof.rootIndices, - ), - ); - } - } - - return instructions; -} - -/** - * Create instructions to load an ATA. - * - * Fetches the AccountInterface internally, then builds instructions to: - * 1. Create CToken ATA if needed (idempotent) - * 2. Wrap SPL tokens to CToken ATA (if SPL balance > 0) - * 3. Wrap T22 tokens to CToken ATA (if T22 balance > 0) - * 4. Decompress2 compressed tokens to CToken ATA (if cold balance > 0) - * - * @param rpc RPC connection - * @param payer Fee payer - * @param ata CToken ATA address (from getATAAddressInterface) - * @param owner ATA owner - * @param mint Token mint - * @param options Optional load options - * @returns Array of instructions (empty if nothing to load) - * - * @example - * ```typescript - * const ata = getATAAddressInterface(mint, sender); - * const instructions = await createLoadATAInstructions(rpc, payer, ata, sender, mint); - * ``` - */ -export async function createLoadATAInstructions( - rpc: Rpc, - payer: PublicKey, - ata: PublicKey, - owner: PublicKey, - mint: PublicKey, - options?: InterfaceOptions, -): Promise { - const ataInterface = await getATAInterface(rpc, owner, mint); - return createLoadATAInstructionsFromInterface( - rpc, - payer, - ataInterface, - options, - ); -} - -/** - * Load ALL token balances into a single CToken ATA (ATA-only, full execute). - * - * This loads: - * 1. SPL ATA balance → wrapped to CToken ATA - * 2. Token-2022 ATA balance → wrapped to CToken ATA - * 3. All compressed tokens → decompressed to CToken ATA - * - * Idempotent: returns null if nothing to load. - * - * @param rpc RPC connection - * @param payer Fee payer (signer) - * @param ata CToken ATA address (from getATAAddressInterface) - * @param owner Owner of the tokens (signer) - * @param mint Mint address - * @param confirmOptions Optional confirm options - * @param options Optional interface options - * @returns Transaction signature, or null if nothing to load - * - * @example - * ```typescript - * const ata = getATAAddressInterface(mint, sender); - * const signature = await loadATA(rpc, payer, ata, sender, mint); - * ``` - */ -export async function loadATA( - rpc: Rpc, - payer: Signer, - ata: PublicKey, - owner: Signer, - mint: PublicKey, - confirmOptions?: ConfirmOptions, - options?: InterfaceOptions, -): Promise { - const ixs = await createLoadATAInstructions( - rpc, - payer.publicKey, - ata, - owner.publicKey, - mint, - options, - ); - - if (ixs.length === 0) { - return null; - } - - const { blockhash } = await rpc.getLatestBlockhash(); - const additionalSigners = dedupeSigner(payer, [owner]); - - const tx = buildAndSignTx( - [ComputeBudgetProgram.setComputeUnitLimit({ units: 500_000 }), ...ixs], - payer, - blockhash, - additionalSigners, - ); - - return sendAndConfirmTx(rpc, tx, confirmOptions); -} - -// ============================================ -// Main function: createLoadAccountsParams -// ============================================ - -/** - * Create params for loading program accounts and ATAs. - * - * Returns: - * - decompressParams: for custom program's decompressAccountsIdempotent instruction - * - ataInstructions: for loading user ATAs (create ATA, wrap SPL/T22, decompress2) - * - * @param rpc RPC connection - * @param payer Fee payer (needed for ATA instructions) - * @param programId Program ID for decompressAccountsIdempotent - * @param programAccounts PDAs and vaults (caller pre-fetches) - * @param atas User ATAs (fetched via getATAInterface) - * @param options Optional load options - * @returns LoadResult with decompressParams and ataInstructions - * - * @example - * ```typescript - * const poolInfo = await myProgram.fetchPoolState(rpc, poolAddress); - * const vault0Info = await getATAInterface(rpc, poolAddress, token0Mint, undefined, CTOKEN_PROGRAM_ID); - * const userAta = await getATAInterface(rpc, userWallet, tokenMint); - * - * const result = await createLoadAccountsParams( - * rpc, - * payer.publicKey, - * programId, - * [ - * { address: poolAddress, accountType: 'poolState', info: poolInfo }, - * { address: vault0, accountType: 'cTokenData', tokenVariant: 'token0Vault', info: vault0Info }, - * ], - * [userAta], - * ); - * - * // Build transaction with both program decompress and ATA load - * const instructions = [...result.ataInstructions]; - * if (result.decompressParams) { - * instructions.push(await program.methods - * .decompressAccountsIdempotent( - * result.decompressParams.proofOption, - * result.decompressParams.compressedAccounts, - * result.decompressParams.systemAccountsOffset, - * ) - * .remainingAccounts(result.decompressParams.remainingAccounts) - * .instruction()); - * } - * ``` - */ -export async function createLoadAccountsParams( - rpc: Rpc, - payer: PublicKey, - programId: PublicKey, - programAccounts: CompressibleAccountInput[] = [], - atas: AccountInterface[] = [], - options?: InterfaceOptions, -): Promise { - // ============================================ - // 1. Build decompressParams for program accounts - // ============================================ - let decompressParams: CompressibleLoadParams | null = null; - - const compressedProgramAccounts = programAccounts.filter( - acc => acc.info.loadContext !== undefined, - ); - - if (compressedProgramAccounts.length > 0) { - // Build proof inputs - const proofInputs = compressedProgramAccounts.map(acc => ({ - hash: acc.info.loadContext!.hash, - tree: acc.info.loadContext!.treeInfo.tree, - queue: acc.info.loadContext!.treeInfo.queue, - })); - - // Get validity proof - const proofResult = await rpc.getValidityProofV0(proofInputs, []); - - // Build accounts data for packing - const accountsData = compressedProgramAccounts.map(acc => { - if (acc.accountType === 'cTokenData') { - if (!acc.tokenVariant) { - throw new Error( - 'tokenVariant is required when accountType is "cTokenData"', - ); - } - return { - key: 'cTokenData', - data: { - variant: { [acc.tokenVariant]: {} }, - tokenData: acc.info.parsed, - }, - treeInfo: acc.info.loadContext!.treeInfo, - }; - } - return { - key: acc.accountType, - data: acc.info.parsed, - treeInfo: acc.info.loadContext!.treeInfo, - }; - }); - - const addresses = compressedProgramAccounts.map(acc => acc.address); - const treeInfos = compressedProgramAccounts.map( - acc => acc.info.loadContext!.treeInfo, - ); - - const packed = await packDecompressAccountsIdempotent( - programId, - { - compressedProof: proofResult.compressedProof, - treeInfos, - }, - accountsData, - addresses, - ); - - decompressParams = { - proofOption: packed.proofOption, - compressedAccounts: - packed.compressedAccounts as PackedCompressedAccount[], - systemAccountsOffset: packed.systemAccountsOffset, - remainingAccounts: packed.remainingAccounts, - }; - } - - // ============================================ - // 2. Build ATA load instructions - // ============================================ - const ataInstructions: TransactionInstruction[] = []; - - for (const ata of atas) { - const ixs = await createLoadATAInstructionsFromInterface( - rpc, - payer, - ata, - options, - ); - ataInstructions.push(...ixs); - } - - return { - decompressParams, - ataInstructions, - }; -} - -/** - * Calculate compute units for compressible load operation - */ -export function calculateCompressibleLoadComputeUnits( - compressedAccountCount: number, - hasValidityProof: boolean, -): number { - let cu = 50_000; // Base - - if (hasValidityProof) { - cu += 100_000; // Proof verification - } - - // Per compressed account - cu += compressedAccountCount * 30_000; - - return cu; -} - -// Re-export for backward compatibility -export { buildDecompressParams } from './helpers'; -export type { AccountInput, DecompressInstructionParams } from './helpers'; diff --git a/js/compressed-token/src/index.ts b/js/compressed-token/src/index.ts index 395b74bce2..a973bea181 100644 --- a/js/compressed-token/src/index.ts +++ b/js/compressed-token/src/index.ts @@ -1,3 +1,18 @@ +import type { + Commitment, + PublicKey, + TransactionInstruction, + Signer, + ConfirmOptions, + TransactionSignature, +} from '@solana/web3.js'; +import type { Rpc } from '@lightprotocol/stateless.js'; +import type { + AccountInterface as MintAccountInterface, + InterfaceOptions, +} from './v3'; +import { getAtaInterface as _mintGetAtaInterface } from './v3'; + export * from './actions'; export * from './utils'; export * from './constants'; @@ -5,19 +20,29 @@ export * from './idl'; export * from './layout'; export * from './program'; export * from './types'; -export * from './compressible'; +import { + createLoadAccountsParams, + createLoadAtaInstructionsFromInterface, + createLoadAtaInstructions as _createLoadAtaInstructions, + loadAta as _loadAta, + calculateCompressibleLoadComputeUnits, + CompressibleAccountInput, + ParsedAccountInfoInterface, + CompressibleLoadParams, + PackedCompressedAccount, + LoadResult, +} from './v3/actions/load-ata'; + export { createLoadAccountsParams, - createLoadATAInstructionsFromInterface, - createLoadATAInstructions, - loadATA, + createLoadAtaInstructionsFromInterface, calculateCompressibleLoadComputeUnits, CompressibleAccountInput, ParsedAccountInfoInterface, CompressibleLoadParams, PackedCompressedAccount, LoadResult, -} from './compressible/unified-load'; +}; // Export mint module with explicit naming to avoid conflicts export { @@ -28,7 +53,7 @@ export { createAssociatedCTokenAccountIdempotentInstruction, createAssociatedTokenAccountInterfaceInstruction, createAssociatedTokenAccountInterfaceIdempotentInstruction, - createATAInterfaceIdempotentInstruction, + createAtaInterfaceIdempotentInstruction, createMintToInstruction, createMintToCompressedInstruction, createMintToInterfaceInstruction, @@ -38,6 +63,7 @@ export { createUpdateMetadataAuthorityInstruction, createRemoveMetadataKeyInstruction, createWrapInstruction, + createDecompressInterfaceInstruction, createTransferInterfaceInstruction, createCTokenTransferInstruction, // Types @@ -47,12 +73,12 @@ export { CreateAssociatedCTokenAccountParams, // Actions createMintInterface, - createATAInterface, - createATAInterfaceIdempotent, - getATAAddressInterface, - getOrCreateATAInterface, + createAtaInterface, + createAtaInterfaceIdempotent, + getAssociatedTokenAddressInterface, + getOrCreateAtaInterface, transferInterface, - decompress2, + decompressInterface, wrap, mintTo as mintToCToken, mintToCompressed, @@ -63,8 +89,8 @@ export { updateMetadataAuthority, removeMetadataKey, // Action types - CreateATAInterfaceParams, - CreateATAInterfaceResult, + CreateAtaInterfaceParams, + CreateAtaInterfaceResult, InterfaceOptions, LoadOptions, TransferInterfaceOptions, @@ -76,7 +102,6 @@ export { unpackMintData, MintInterface, getAccountInterface, - getATAInterface, Account, AccountState, ParsedTokenAccount as ParsedTokenAccountInterface, @@ -103,4 +128,89 @@ export { toOffChainMetadataJson, OffChainTokenMetadata, OffChainTokenMetadataJson, -} from './mint'; +} from './v3'; + +/** + * Retrieve associated token account for a given owner and mint. + * + * @param rpc RPC connection + * @param ata Associated token address + * @param owner Owner public key + * @param mint Mint public key + * @param commitment Optional commitment level + * @param programId Optional program ID + * @returns AccountInterface with ATA metadata + */ +export async function getAtaInterface( + rpc: Rpc, + ata: PublicKey, + owner: PublicKey, + mint: PublicKey, + commitment?: Commitment, + programId?: PublicKey, +): Promise { + return _mintGetAtaInterface(rpc, ata, owner, mint, commitment, programId); +} + +/** + * Create instructions to load token balances into a c-token ATA. + * + * @param rpc RPC connection + * @param ata Associated token address + * @param owner Owner public key + * @param mint Mint public key + * @param payer Fee payer (defaults to owner) + * @param options Optional load options + * @returns Array of instructions (empty if nothing to load) + */ +export async function createLoadAtaInstructions( + rpc: Rpc, + ata: PublicKey, + owner: PublicKey, + mint: PublicKey, + payer?: PublicKey, + options?: InterfaceOptions, +): Promise { + return _createLoadAtaInstructions( + rpc, + ata, + owner, + mint, + payer, + options, + false, + ); +} + +/** + * Load token balances into a c-token ATA. + * + * @param rpc RPC connection + * @param ata Associated token address + * @param owner Owner of the tokens (signer) + * @param mint Mint public key + * @param payer Fee payer (signer, defaults to owner) + * @param confirmOptions Optional confirm options + * @param interfaceOptions Optional interface options + * @returns Transaction signature, or null if nothing to load + */ +export async function loadAta( + rpc: Rpc, + ata: PublicKey, + owner: Signer, + mint: PublicKey, + payer?: Signer, + confirmOptions?: ConfirmOptions, + interfaceOptions?: InterfaceOptions, +): Promise { + return _loadAta( + rpc, + ata, + owner, + mint, + payer, + confirmOptions, + interfaceOptions, + false, + ); +} diff --git a/js/compressed-token/src/mint/actions/decompress2.ts b/js/compressed-token/src/mint/actions/decompress2.ts deleted file mode 100644 index 76bd8467e4..0000000000 --- a/js/compressed-token/src/mint/actions/decompress2.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { - ConfirmOptions, - PublicKey, - Signer, - TransactionSignature, - ComputeBudgetProgram, -} from '@solana/web3.js'; -import { - Rpc, - buildAndSignTx, - sendAndConfirmTx, - dedupeSigner, - ParsedTokenAccount, - bn, -} from '@lightprotocol/stateless.js'; -import BN from 'bn.js'; -import { createDecompress2Instruction } from '../instructions/decompress2'; -import { createAssociatedTokenAccountInterfaceIdempotentInstruction } from '../instructions/create-associated-ctoken'; -import { getATAAddressInterface } from './create-ata-interface'; -import { CTOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; - -/** - * Parameters for decompress2 action - */ -export interface Decompress2ActionParams { - /** RPC connection */ - rpc: Rpc; - /** Fee payer (signer) */ - payer: Signer; - /** Owner of the compressed tokens (signer) */ - owner: Signer; - /** Mint address */ - mint: PublicKey; - /** Optional: specific amount to decompress (defaults to all) */ - amount?: number | bigint | BN; - /** Optional: destination CToken ATA (defaults to owner's ATA) */ - destinationAta?: PublicKey; - /** Optional: confirm options */ - confirmOptions?: ConfirmOptions; -} - -/** - * Decompress compressed tokens to a CToken ATA using Transfer2. - * - * This is more efficient than the old decompress for CToken destinations - * as it doesn't require SPL token pool operations. - * - * @param params Decompress2 action parameters - * @returns Transaction signature, or null if no compressed tokens to decompress - */ -export async function decompress2( - params: Decompress2ActionParams, -): Promise { - const { - rpc, - payer, - owner, - mint, - amount: requestedAmount, - destinationAta, - confirmOptions, - } = params; - - // Get compressed token accounts - const compressedResult = await rpc.getCompressedTokenAccountsByOwner( - owner.publicKey, - { mint }, - ); - const compressedAccounts = compressedResult.items; - - if (compressedAccounts.length === 0) { - return null; // Nothing to decompress - } - - // Calculate total and determine amount - const totalBalance = compressedAccounts.reduce( - (sum, acc) => sum + BigInt(acc.parsed.amount.toString()), - BigInt(0), - ); - - const amount = requestedAmount - ? BigInt(requestedAmount.toString()) - : totalBalance; - - if (amount > totalBalance) { - throw new Error( - `Insufficient compressed balance. Requested: ${amount}, Available: ${totalBalance}`, - ); - } - - // Select accounts to use (for now, use all - could optimize later) - const accountsToUse: ParsedTokenAccount[] = []; - let accumulatedAmount = BigInt(0); - for (const acc of compressedAccounts) { - if (accumulatedAmount >= amount) break; - accountsToUse.push(acc); - accumulatedAmount += BigInt(acc.parsed.amount.toString()); - } - - // Get validity proof - const proof = await rpc.getValidityProofV0( - accountsToUse.map(acc => ({ - hash: acc.compressedAccount.hash, - tree: acc.compressedAccount.treeInfo.tree, - queue: acc.compressedAccount.treeInfo.queue, - })), - ); - - // Determine destination ATA - const ctokenAta = - destinationAta ?? getATAAddressInterface(mint, owner.publicKey); - - // Build instructions - const instructions = []; - - // Create CToken ATA if needed (idempotent) - const ctokenAtaInfo = await rpc.getAccountInfo(ctokenAta); - if (!ctokenAtaInfo) { - instructions.push( - createAssociatedTokenAccountInterfaceIdempotentInstruction( - payer.publicKey, - ctokenAta, - owner.publicKey, - mint, - CTOKEN_PROGRAM_ID, - ), - ); - } - - // Calculate compute units - const hasValidityProof = proof.compressedProof !== null; - let computeUnits = 50_000; // Base - if (hasValidityProof) { - computeUnits += 100_000; - } - for (const acc of accountsToUse) { - const proveByIndex = acc.compressedAccount.proveByIndex ?? false; - computeUnits += proveByIndex ? 10_000 : 30_000; - } - - // Add decompress2 instruction - instructions.push( - createDecompress2Instruction( - payer.publicKey, - accountsToUse, - ctokenAta, - amount, - proof.compressedProof, - proof.rootIndices, - ), - ); - - // Build and send - const { blockhash } = await rpc.getLatestBlockhash(); - const additionalSigners = dedupeSigner(payer, [owner]); - - const tx = buildAndSignTx( - [ - ComputeBudgetProgram.setComputeUnitLimit({ units: computeUnits }), - ...instructions, - ], - payer, - blockhash, - additionalSigners, - ); - - return sendAndConfirmTx(rpc, tx, confirmOptions); -} diff --git a/js/compressed-token/src/mint/actions/get-or-create-ata-interface.ts b/js/compressed-token/src/mint/actions/get-or-create-ata-interface.ts deleted file mode 100644 index f9ab911706..0000000000 --- a/js/compressed-token/src/mint/actions/get-or-create-ata-interface.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { CTOKEN_PROGRAM_ID, Rpc } from '@lightprotocol/stateless.js'; -import { - Account, - ASSOCIATED_TOKEN_PROGRAM_ID, - getAccount, - getAssociatedTokenAddressSync, - TOKEN_PROGRAM_ID, - TokenAccountNotFoundError, - TokenInvalidAccountOwnerError, - TokenInvalidMintError, - TokenInvalidOwnerError, -} from '@solana/spl-token'; -import type { - Commitment, - ConfirmOptions, - PublicKey, - Signer, -} from '@solana/web3.js'; -import { sendAndConfirmTransaction, Transaction } from '@solana/web3.js'; -import { createAssociatedTokenAccountInterfaceInstruction } from '../instructions/create-associated-ctoken'; -import { getAccountInterface } from '../get-account-interface'; -import { getATAProgramId } from '../../utils'; - -/** - * Retrieve the associated token account, or create it if it doesn't exist. - * Follows SPL Token getOrCreateAssociatedTokenAccount signature. - * - * @param rpc Connection to use - * @param payer Payer of the transaction and initialization fees - * @param mint Mint associated with the account to set or verify - * @param owner Owner of the account to set or verify - * @param allowOwnerOffCurve Allow the owner account to be a PDA (Program Derived Address) - * @param commitment Desired level of commitment for querying the state - * @param confirmOptions Options for confirming the transaction - * @param programId SPL Token program account or C token program account - * @param associatedTokenProgramId SPL Associated Token program account or C token program account - * - * @return Address of the new associated token account - */ -export async function getOrCreateATAInterface( - rpc: Rpc, - payer: Signer, - mint: PublicKey, - owner: PublicKey, - allowOwnerOffCurve = false, - commitment?: Commitment, - confirmOptions?: ConfirmOptions, - programId = TOKEN_PROGRAM_ID, - associatedTokenProgramId = getATAProgramId(programId), -): Promise { - const associatedToken = getAssociatedTokenAddressSync( - mint, - owner, - allowOwnerOffCurve, - programId, - associatedTokenProgramId, - ); - - // This is the optimal logic, considering TX fee, client-side computation, RPC roundtrips and guaranteed idempotent. - // Sadly we can't do this atomically. - let account: Account; - try { - // TODO: dynamically handle compressed or partially compressed TOKENS for user+mint - const accountInterface = await getAccountInterface( - rpc, - associatedToken, - commitment, - programId, - ); - account = accountInterface.parsed; - } catch (error: unknown) { - // TokenAccountNotFoundError can be possible if the associated address has already received some lamports, - // becoming a system account. Assuming program derived addressing is safe, this is the only case for the - // TokenInvalidAccountOwnerError in this code path. - if ( - error instanceof TokenAccountNotFoundError || - error instanceof TokenInvalidAccountOwnerError - ) { - // As this isn't atomic, it's possible others can create associated accounts meanwhile. - try { - const transaction = new Transaction().add( - createAssociatedTokenAccountInterfaceInstruction( - payer.publicKey, - associatedToken, - owner, - mint, - programId, - associatedTokenProgramId, - ), - ); - - await sendAndConfirmTransaction( - rpc, - transaction, - [payer], - confirmOptions, - ); - } catch (error: unknown) { - // Ignore all errors; for now there is no API-compatible way to selectively ignore the expected - // instruction error if the associated account exists already. - } - - // Now this should always succeed - const accountInterface = await getAccountInterface( - rpc, - associatedToken, - commitment, - programId, - ); - account = accountInterface.parsed; - } else { - throw error; - } - } - - if (!account.mint.equals(mint)) throw new TokenInvalidMintError(); - if (!account.owner.equals(owner)) throw new TokenInvalidOwnerError(); - - return account; -} diff --git a/js/compressed-token/src/mint/actions/update-mint.ts b/js/compressed-token/src/mint/actions/update-mint.ts deleted file mode 100644 index d5ad2e8c13..0000000000 --- a/js/compressed-token/src/mint/actions/update-mint.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { - ComputeBudgetProgram, - ConfirmOptions, - PublicKey, - Signer, - TransactionSignature, -} from '@solana/web3.js'; -import { - Rpc, - buildAndSignTx, - sendAndConfirmTx, - TreeInfo, - selectStateTreeInfo, - DerivationMode, - bn, - CTOKEN_PROGRAM_ID, -} from '@lightprotocol/stateless.js'; -import { - createUpdateMintAuthorityInstruction, - createUpdateFreezeAuthorityInstruction, -} from '../instructions/update-mint'; -import { getMintInterface } from '../helpers'; - -export async function updateMintAuthority( - rpc: Rpc, - payer: Signer, - mint: PublicKey, - mintSigner: Signer, - currentMintAuthority: Signer, - newMintAuthority: PublicKey | null, - outputStateTreeInfo?: TreeInfo, - confirmOptions?: ConfirmOptions, -): Promise { - outputStateTreeInfo = - outputStateTreeInfo ?? - selectStateTreeInfo(await rpc.getStateTreeInfos()); - - const mintInfo = await getMintInterface( - rpc, - mint, - undefined, - CTOKEN_PROGRAM_ID, - ); - - if (!mintInfo.merkleContext) { - throw new Error('Mint does not have MerkleContext'); - } - - const validityProof = await rpc.getValidityProofV2( - [ - { - hash: bn(mintInfo.merkleContext.hash), - leafIndex: mintInfo.merkleContext.leafIndex, - treeInfo: mintInfo.merkleContext.treeInfo, - proveByIndex: mintInfo.merkleContext.proveByIndex, - }, - ], - [], - DerivationMode.compressible, - ); - - const ix = createUpdateMintAuthorityInstruction( - currentMintAuthority.publicKey, - newMintAuthority, - payer.publicKey, - validityProof, - mintInfo.merkleContext, - { - supply: mintInfo.mint.supply, - decimals: mintInfo.mint.decimals, - mintAuthority: mintInfo.mint.mintAuthority, - freezeAuthority: mintInfo.mint.freezeAuthority, - splMint: mintInfo.mintContext!.splMint, - splMintInitialized: mintInfo.mintContext!.splMintInitialized, - version: mintInfo.mintContext!.version, - metadata: mintInfo.tokenMetadata - ? { - updateAuthority: - mintInfo.tokenMetadata.updateAuthority || null, - name: mintInfo.tokenMetadata.name, - symbol: mintInfo.tokenMetadata.symbol, - uri: mintInfo.tokenMetadata.uri, - } - : undefined, - }, - outputStateTreeInfo.queue, - ); - - const additionalSigners = currentMintAuthority.publicKey.equals( - payer.publicKey, - ) - ? [] - : [currentMintAuthority]; - - const { blockhash } = await rpc.getLatestBlockhash(); - const tx = buildAndSignTx( - [ComputeBudgetProgram.setComputeUnitLimit({ units: 500_000 }), ix], - payer, - blockhash, - additionalSigners, - ); - - return await sendAndConfirmTx(rpc, tx, confirmOptions); -} - -export async function updateFreezeAuthority( - rpc: Rpc, - payer: Signer, - mint: PublicKey, - mintSigner: Signer, - currentFreezeAuthority: Signer, - newFreezeAuthority: PublicKey | null, - outputStateTreeInfo?: TreeInfo, - confirmOptions?: ConfirmOptions, -): Promise { - outputStateTreeInfo = - outputStateTreeInfo ?? - selectStateTreeInfo(await rpc.getStateTreeInfos()); - - const mintInfo = await getMintInterface( - rpc, - mint, - undefined, - CTOKEN_PROGRAM_ID, - ); - if (!mintInfo.merkleContext) { - throw new Error('Mint does not have MerkleContext'); - } - - const validityProof = await rpc.getValidityProofV2( - [ - { - hash: bn(mintInfo.merkleContext.hash), - leafIndex: mintInfo.merkleContext.leafIndex, - treeInfo: mintInfo.merkleContext.treeInfo, - proveByIndex: mintInfo.merkleContext.proveByIndex, - }, - ], - [], - DerivationMode.compressible, - ); - - const ix = createUpdateFreezeAuthorityInstruction( - currentFreezeAuthority.publicKey, - newFreezeAuthority, - payer.publicKey, - validityProof, - mintInfo.merkleContext, - { - supply: mintInfo.mint.supply, - decimals: mintInfo.mint.decimals, - mintAuthority: mintInfo.mint.mintAuthority, - freezeAuthority: mintInfo.mint.freezeAuthority, - splMint: mintInfo.mintContext!.splMint, - splMintInitialized: mintInfo.mintContext!.splMintInitialized, - version: mintInfo.mintContext!.version, - metadata: mintInfo.tokenMetadata - ? { - updateAuthority: - mintInfo.tokenMetadata.updateAuthority || null, - name: mintInfo.tokenMetadata.name, - symbol: mintInfo.tokenMetadata.symbol, - uri: mintInfo.tokenMetadata.uri, - } - : undefined, - }, - outputStateTreeInfo.queue, - ); - - const additionalSigners = currentFreezeAuthority.publicKey.equals( - payer.publicKey, - ) - ? [] - : [currentFreezeAuthority]; - - const { blockhash } = await rpc.getLatestBlockhash(); - const tx = buildAndSignTx( - [ComputeBudgetProgram.setComputeUnitLimit({ units: 500_000 }), ix], - payer, - blockhash, - additionalSigners, - ); - - return await sendAndConfirmTx(rpc, tx, confirmOptions); -} diff --git a/js/compressed-token/src/mint/get-account-interface.ts b/js/compressed-token/src/mint/get-account-interface.ts deleted file mode 100644 index 9b90b857f4..0000000000 --- a/js/compressed-token/src/mint/get-account-interface.ts +++ /dev/null @@ -1,697 +0,0 @@ -import { AccountInfo, Commitment, PublicKey } from '@solana/web3.js'; -import { - TOKEN_PROGRAM_ID, - TOKEN_2022_PROGRAM_ID, - unpackAccount as unpackAccountSPL, - TokenAccountNotFoundError, - getAssociatedTokenAddressSync, - AccountState, - AccountLayout, - Account, -} from '@solana/spl-token'; -import { - Rpc, - CTOKEN_PROGRAM_ID, - MerkleContext, - CompressedAccountWithMerkleContext, - ParsedTokenAccount, -} from '@lightprotocol/stateless.js'; -import { Buffer } from 'buffer'; -import BN from 'bn.js'; -import { getATAProgramId } from '../utils'; - -// Re-export types that are used in the interface -export { Account, AccountState } from '@solana/spl-token'; -export { ParsedTokenAccount } from '@lightprotocol/stateless.js'; - -export interface TokenAccountSource { - type: 'spl' | 'token2022' | 'ctoken-hot' | 'ctoken-cold'; - address: PublicKey; - amount: bigint; - accountInfo: AccountInfo; - loadContext?: MerkleContext; - parsed: Account; -} - -export interface AccountInterface { - accountInfo: AccountInfo; - parsed: Account; - isCold: boolean; - loadContext?: MerkleContext; - _sources?: TokenAccountSource[]; - _needsConsolidation?: boolean; - _hasDelegate?: boolean; - _anyFrozen?: boolean; - /** True when fetched via getATAInterface */ - _isAta?: boolean; - /** ATA owner - set by getATAInterface */ - _owner?: PublicKey; - /** ATA mint - set by getATAInterface */ - _mint?: PublicKey; -} - -function parseTokenData(data: Buffer): { - mint: PublicKey; - owner: PublicKey; - amount: BN; - delegate: PublicKey | null; - state: number; - tlv: Buffer | null; -} | null { - if (!data || data.length === 0) return null; - - try { - let offset = 0; - const mint = new PublicKey(data.slice(offset, offset + 32)); - offset += 32; - const owner = new PublicKey(data.slice(offset, offset + 32)); - offset += 32; - const amount = new BN(data.slice(offset, offset + 8), 'le'); - offset += 8; - const delegateOption = data[offset]; - offset += 1; - const delegate = delegateOption - ? new PublicKey(data.slice(offset, offset + 32)) - : null; - offset += 32; - const state = data[offset]; - offset += 1; - const tlvOption = data[offset]; - offset += 1; - const tlv = tlvOption ? data.slice(offset) : null; - - return { - mint, - owner, - amount, - delegate, - state, - tlv, - }; - } catch (error) { - console.error('Token data parsing error:', error); - return null; - } -} - -export function convertTokenDataToAccount( - address: PublicKey, - tokenData: { - mint: PublicKey; - owner: PublicKey; - amount: BN; - delegate: PublicKey | null; - state: number; - tlv: Buffer | null; - }, -): Account { - return { - address, - mint: tokenData.mint, - owner: tokenData.owner, - amount: BigInt(tokenData.amount.toString()), - delegate: tokenData.delegate, - delegatedAmount: BigInt(0), - isInitialized: tokenData.state !== AccountState.Uninitialized, - isFrozen: tokenData.state === AccountState.Frozen, - isNative: false, - rentExemptReserve: null, - closeAuthority: null, - tlvData: tokenData.tlv ? Buffer.from(tokenData.tlv) : Buffer.alloc(0), - }; -} - -/** normalize compressed account to account info */ -export function toAccountInfo( - compressedAccount: CompressedAccountWithMerkleContext, -): AccountInfo { - // we must define Buffer type explicitly. - const dataDiscriminatorBuffer: Buffer = Buffer.from( - compressedAccount.data!.discriminator, - ); - const dataBuffer: Buffer = Buffer.from(compressedAccount.data!.data); - const data: Buffer = Buffer.concat([dataDiscriminatorBuffer, dataBuffer]); - - return { - executable: false, - owner: compressedAccount.owner, - lamports: compressedAccount.lamports.toNumber(), - data, - rentEpoch: undefined, - }; -} - -export function parseCTokenHot( - address: PublicKey, - accountInfo: AccountInfo, -): { - accountInfo: AccountInfo; - loadContext: undefined; - parsed: Account; - isCold: false; -} { - const parsed = parseTokenData(accountInfo.data); - if (!parsed) throw new Error('Invalid token data'); - return { - accountInfo, - loadContext: undefined, - parsed: convertTokenDataToAccount(address, parsed), - isCold: false, - }; -} - -export function parseCTokenCold( - address: PublicKey, - compressedAccount: CompressedAccountWithMerkleContext, -): { - accountInfo: AccountInfo; - loadContext: MerkleContext; - parsed: Account; - isCold: true; -} { - const parsed = parseTokenData(compressedAccount.data!.data); - if (!parsed) throw new Error('Invalid token data'); - return { - accountInfo: toAccountInfo(compressedAccount), - loadContext: { - treeInfo: compressedAccount.treeInfo, - hash: compressedAccount.hash, - leafIndex: compressedAccount.leafIndex, - proveByIndex: compressedAccount.proveByIndex, - }, - parsed: convertTokenDataToAccount(address, parsed), - isCold: true, - }; -} -/** - * Retrieve information about a token account (SPL, T22, C-Token) - * - * @param rpc RPC connection to use - * @param address Token account address - * @param commitment Desired level of commitment for querying the state - * @param programId Token program ID. If not provided, tries all programs concurrently to auto-detect - * - * @return Token account information with compression context if applicable - */ -export async function getAccountInterface( - rpc: Rpc, - address: PublicKey, - commitment?: Commitment, - programId?: PublicKey, -): Promise { - return _getAccountInterface(rpc, address, commitment, programId, undefined); -} - -/** Retrieve associated token account for a given owner and mint. */ -export async function getATAInterface( - rpc: Rpc, - owner: PublicKey, - mint: PublicKey, - commitment?: Commitment, - programId?: PublicKey, -): Promise { - const result = await _getAccountInterface( - rpc, - undefined, - commitment, - programId, - { - owner, - mint, - }, - ); - result._isAta = true; - result._owner = owner; - result._mint = mint; - return result; -} - -/** - * Helper: Try to fetch SPL Token account - */ -async function _tryFetchSpl( - rpc: Rpc, - address: PublicKey, - commitment?: Commitment, -): Promise<{ - accountInfo: AccountInfo; - parsed: Account; - isCold: false; - loadContext: undefined; -}> { - const info = await rpc.getAccountInfo(address, commitment); - if (!info || !info.owner.equals(TOKEN_PROGRAM_ID)) { - throw new Error('Not a TOKEN_PROGRAM_ID account'); - } - const account = unpackAccountSPL(address, info, TOKEN_PROGRAM_ID); - return { - accountInfo: info, - parsed: account, - isCold: false, - loadContext: undefined, - }; -} - -/** - * Helper: Try to fetch Token-2022 account - */ -async function _tryFetchToken2022( - rpc: Rpc, - address: PublicKey, - commitment?: Commitment, -): Promise<{ - accountInfo: AccountInfo; - parsed: Account; - isCold: false; - loadContext: undefined; -}> { - const info = await rpc.getAccountInfo(address, commitment); - if (!info || !info.owner.equals(TOKEN_2022_PROGRAM_ID)) { - throw new Error('Not a TOKEN_2022_PROGRAM_ID account'); - } - const account = unpackAccountSPL(address, info, TOKEN_2022_PROGRAM_ID); - return { - accountInfo: info, - parsed: account, - isCold: false, - loadContext: undefined, - }; -} - -/** - * Helper: Try to fetch CToken hot (decompressed) account - */ -async function _tryFetchCTokenHot( - rpc: Rpc, - address: PublicKey, - commitment?: Commitment, -): Promise<{ - accountInfo: AccountInfo; - loadContext: undefined; - parsed: Account; - isCold: false; -}> { - const info = await rpc.getAccountInfo(address, commitment); - if (!info || !info.owner.equals(CTOKEN_PROGRAM_ID)) { - throw new Error('Not a CTOKEN onchain account'); - } - return parseCTokenHot(address, info); -} - -/** - * Helper: Try to fetch CToken cold (compressed) account by owner+mint - */ -async function _tryFetchCTokenColdByOwner( - rpc: Rpc, - owner: PublicKey, - mint: PublicKey, - ataAddress: PublicKey, -): Promise<{ - accountInfo: AccountInfo; - loadContext: MerkleContext; - parsed: Account; - isCold: true; -}> { - const result = await rpc.getCompressedTokenAccountsByOwner(owner, { - mint, - }); - const compressedAccount = - result.items.length > 0 ? result.items[0].compressedAccount : null; - if (!compressedAccount?.data?.data.length) { - throw new Error('Not a compressed token account'); - } - if (!compressedAccount.owner.equals(CTOKEN_PROGRAM_ID)) { - throw new Error('Invalid owner for compressed token'); - } - return parseCTokenCold(ataAddress, compressedAccount); -} - -/** - * Helper: Try to fetch CToken cold (compressed) account by address (for non-ATA ctokens) - */ -async function _tryFetchCTokenColdByAddress( - rpc: Rpc, - address: PublicKey, -): Promise<{ - accountInfo: AccountInfo; - loadContext: MerkleContext; - parsed: Account; - isCold: true; -}> { - const result = await rpc.getCompressedTokenAccountsByOwner(address); - const compressedAccount = - result.items.length > 0 ? result.items[0].compressedAccount : null; - if (!compressedAccount?.data?.data.length) { - throw new Error('Not a compressed token account'); - } - if (!compressedAccount.owner.equals(CTOKEN_PROGRAM_ID)) { - throw new Error('Invalid owner for compressed token'); - } - return parseCTokenCold(address, compressedAccount); -} - -// TODO: add test -// -// TODO: implement actual solution for compressed token accounts for vaults for -// spl/t22 mints. -/** - * @internal - * Retrieve information about a token account (SPL, T22, C-Token) - * - * @param rpc RPC connection to use - * @param address Token account address - * @param commitment Desired level of commitment for querying the state - * @param programId Token program ID. If not provided, tries all programs concurrently to auto-detect - * @param fetchByOwner ATA options. If provided, tries to fetch the compressible side by owner and mint instead of address - * - * @return Token account information with compression context if applicable - */ -async function _getAccountInterface( - rpc: Rpc, - address?: PublicKey, - commitment?: Commitment, - programId?: PublicKey, - fetchByOwner?: { - owner: PublicKey; - mint: PublicKey; - }, -): Promise { - if (!address && !fetchByOwner) { - throw new Error('One of Address or fetchByOwner is required'); - } - if (address && fetchByOwner) { - throw new Error('Only one of Address or fetchByOwner can be provided'); - } - - // Auto-detect: try all programs in parallel - if (!programId) { - // Derive ATA addresses for each program (or use provided address) - const cTokenAta = address - ? address - : getAssociatedTokenAddressSync( - fetchByOwner!.mint, - fetchByOwner!.owner, - false, - CTOKEN_PROGRAM_ID, - getATAProgramId(CTOKEN_PROGRAM_ID), - ); - const splTokenAta = address - ? address - : getAssociatedTokenAddressSync( - fetchByOwner!.mint, - fetchByOwner!.owner, - false, - TOKEN_PROGRAM_ID, - getATAProgramId(TOKEN_PROGRAM_ID), - ); - const token2022Ata = address - ? address - : getAssociatedTokenAddressSync( - fetchByOwner!.mint, - fetchByOwner!.owner, - false, - TOKEN_2022_PROGRAM_ID, - getATAProgramId(TOKEN_2022_PROGRAM_ID), - ); - - const results = await Promise.allSettled([ - // 1. SPL Token - _tryFetchSpl(rpc, splTokenAta, commitment), - // 2. Token-2022 - _tryFetchToken2022(rpc, token2022Ata, commitment), - // 3. CToken hot (decompressed) - _tryFetchCTokenHot(rpc, cTokenAta, commitment), - // 4. CToken cold (compressed) - fetchByOwner - ? _tryFetchCTokenColdByOwner( - rpc, - fetchByOwner.owner, - fetchByOwner.mint, - cTokenAta, - ) - : _tryFetchCTokenColdByAddress(rpc, address!), - ]); - - // Collect all successful results - const sources: TokenAccountSource[] = []; - const successfulResults: Array<{ - accountInfo: AccountInfo; - parsed: Account; - isCold: boolean; - loadContext?: MerkleContext; - }> = []; - - for (let i = 0; i < results.length; i++) { - const result = results[i]; - if (result.status === 'fulfilled') { - const value = result.value; - successfulResults.push(value); - - let type: TokenAccountSource['type']; - let addr: PublicKey; - - if (i === 0) { - type = 'spl'; - addr = splTokenAta; - } else if (i === 1) { - type = 'token2022'; - addr = token2022Ata; - } else if (i === 2) { - type = 'ctoken-hot'; - addr = cTokenAta; - } else { - type = 'ctoken-cold'; - addr = cTokenAta; - } - - sources.push({ - type, - address: addr, - amount: value.parsed.amount, - accountInfo: value.accountInfo, - loadContext: value.loadContext, - parsed: value.parsed, - }); - } - } - - // None succeeded - account not found - if (sources.length === 0) { - throw new Error( - `Token account not found. ` + - `Tried TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, and CTOKEN_PROGRAM_ID (both onchain and compressed).`, - ); - } - - // Priority order: CToken hot > CToken cold > SPL/T22 - const priority: TokenAccountSource['type'][] = [ - 'ctoken-hot', - 'ctoken-cold', - 'spl', - 'token2022', - ]; - - sources.sort((a, b) => { - const aIdx = priority.indexOf(a.type); - const bIdx = priority.indexOf(b.type); - return aIdx - bIdx; - }); - - // Aggregate balance from all sources - const totalAmount = sources.reduce( - (sum, src) => sum + src.amount, - BigInt(0), - ); - - // Use the highest priority source as base - const primarySource = sources[0]; - - // Check for concerns - const hasDelegate = sources.some(src => src.parsed.delegate !== null); - const anyFrozen = sources.some(src => src.parsed.isFrozen); - const needsConsolidation = sources.length > 1; - - // Create unified account with aggregated balance - const unifiedAccount: Account = { - ...primarySource.parsed, - address: cTokenAta, - amount: totalAmount, - }; - - const isCold = primarySource.type === 'ctoken-cold'; - - return { - accountInfo: primarySource.accountInfo!, - parsed: unifiedAccount, - isCold, - loadContext: primarySource.loadContext, - _sources: sources, - _needsConsolidation: needsConsolidation, - _hasDelegate: hasDelegate, - _anyFrozen: anyFrozen, - }; - } - - // Handle specific programId - CTOKEN - if (programId.equals(CTOKEN_PROGRAM_ID)) { - // Derive address if not provided - if (!address) { - if (!fetchByOwner) { - throw new Error('fetchByOwner is required'); - } - address = getAssociatedTokenAddressSync( - fetchByOwner.mint, - fetchByOwner.owner, - false, - CTOKEN_PROGRAM_ID, - getATAProgramId(CTOKEN_PROGRAM_ID), - ); - } - - const [onchainResult, compressedResult] = await Promise.allSettled([ - rpc.getAccountInfo(address, commitment), - // Fetch compressed: by owner+mint for ATAs, by address for non-ATAs - fetchByOwner - ? rpc.getCompressedTokenAccountsByOwner(fetchByOwner.owner, { - mint: fetchByOwner.mint, - }) - : rpc.getCompressedTokenAccountsByOwner(address), - ]); - - const onchainAccount = - onchainResult.status === 'fulfilled' ? onchainResult.value : null; - const compressedAccounts = - compressedResult.status === 'fulfilled' - ? compressedResult.value.items.map( - item => item.compressedAccount, - ) - : []; - - const sources: TokenAccountSource[] = []; - - // Collect hot (decompressed) CToken account - if (onchainAccount && onchainAccount.owner.equals(programId)) { - const parsed = parseCTokenHot(address, onchainAccount); - sources.push({ - type: 'ctoken-hot', - address, - amount: parsed.parsed.amount, - accountInfo: onchainAccount, - parsed: parsed.parsed, - }); - } - - // Collect cold (compressed) CToken accounts - for (const compressedAccount of compressedAccounts) { - if ( - compressedAccount && - compressedAccount.data && - compressedAccount.data.data.length > 0 && - compressedAccount.owner.equals(programId) - ) { - const parsed = parseCTokenCold(address, compressedAccount); - sources.push({ - type: 'ctoken-cold', - address, - amount: parsed.parsed.amount, - accountInfo: parsed.accountInfo, - loadContext: parsed.loadContext, - parsed: parsed.parsed, - }); - } - } - - if (sources.length === 0) { - throw new TokenAccountNotFoundError(); - } - - // Priority: hot > cold - sources.sort((a, b) => { - if (a.type === 'ctoken-hot' && b.type === 'ctoken-cold') return -1; - if (a.type === 'ctoken-cold' && b.type === 'ctoken-hot') return 1; - return 0; - }); - - // Aggregate balance - const totalAmount = sources.reduce( - (sum, src) => sum + src.amount, - BigInt(0), - ); - - const primarySource = sources[0]; - const hasDelegate = sources.some(src => src.parsed.delegate !== null); - const anyFrozen = sources.some(src => src.parsed.isFrozen); - const needsConsolidation = sources.length > 1; - - const unifiedAccount: Account = { - ...primarySource.parsed, - address, - amount: totalAmount, - }; - - return { - accountInfo: primarySource.accountInfo!, - parsed: unifiedAccount, - isCold: primarySource.type === 'ctoken-cold', - loadContext: primarySource.loadContext, - _sources: sources, - _needsConsolidation: needsConsolidation, - _hasDelegate: hasDelegate, - _anyFrozen: anyFrozen, - }; - } - - // Handle specific programId - SPL Token or Token-2022 - if ( - programId.equals(TOKEN_PROGRAM_ID) || - programId.equals(TOKEN_2022_PROGRAM_ID) - ) { - // Derive address if not provided - if (!address) { - if (!fetchByOwner) { - throw new Error('fetchByOwner is required'); - } - address = getAssociatedTokenAddressSync( - fetchByOwner.mint, - fetchByOwner.owner, - false, - programId, - getATAProgramId(programId), - ); - } - - const info = await rpc.getAccountInfo(address, commitment); - if (!info) { - throw new TokenAccountNotFoundError(); - } - - const account = unpackAccountSPL(address, info, programId); - - const type: TokenAccountSource['type'] = programId.equals( - TOKEN_PROGRAM_ID, - ) - ? 'spl' - : 'token2022'; - - return { - accountInfo: info, - parsed: account, - isCold: false, - loadContext: undefined, - _sources: [ - { - type, - address, - amount: account.amount, - accountInfo: info, - parsed: account, - }, - ], - _needsConsolidation: false, - _hasDelegate: account.delegate !== null, - _anyFrozen: account.isFrozen, - }; - } - - throw new Error(`Unsupported program ID: ${programId.toBase58()}`); -} diff --git a/js/compressed-token/src/mint/index.ts b/js/compressed-token/src/mint/index.ts deleted file mode 100644 index c2862f46c3..0000000000 --- a/js/compressed-token/src/mint/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * from './instructions'; -export * from './actions'; -export * from './helpers'; -export * from './serde'; -export * from './upload'; -export * from './get-account-interface'; diff --git a/js/compressed-token/src/program.ts b/js/compressed-token/src/program.ts index 391bf11014..25798bbc57 100644 --- a/js/compressed-token/src/program.ts +++ b/js/compressed-token/src/program.ts @@ -62,6 +62,9 @@ import { TokenTransferOutputData, } from './types'; import { + checkSplInterfaceInfo, + SplInterfaceInfo, + // Deprecated aliases checkTokenPoolInfo, TokenPoolInfo, } from './utils/get-token-pool-infos'; @@ -339,10 +342,10 @@ export type MintToParams = { }; /** - * Register an existing SPL mint account to the compressed token program - * Creates an omnibus account for the mint + * Register an existing SPL mint account to the compressed token program. + * Creates an omnibus account (SPL interface) for the mint. */ -export type CreateTokenPoolParams = { +export type CreateSplInterfaceParams = { /** * Fee payer */ @@ -357,7 +360,12 @@ export type CreateTokenPoolParams = { tokenProgramId?: PublicKey; }; -export type AddTokenPoolParams = { +/** + * @deprecated Use {@link CreateSplInterfaceParams} instead. + */ +export type CreateTokenPoolParams = CreateSplInterfaceParams; + +export type AddSplInterfaceParams = { /** * Fee payer */ @@ -367,7 +375,7 @@ export type AddTokenPoolParams = { */ mint: PublicKey; /** - * Token pool index + * SPL interface pool index */ poolIndex: number; /** @@ -376,6 +384,11 @@ export type AddTokenPoolParams = { tokenProgramId?: PublicKey; }; +/** + * @deprecated Use {@link AddSplInterfaceParams} instead. + */ +export type AddTokenPoolParams = AddSplInterfaceParams; + /** * Mint from existing SPL mint to compressed token accounts */ @@ -624,14 +637,14 @@ export class CompressedTokenProgram { } /** - * Derive the token pool pda. - * To derive the token pool pda with bump, use {@link deriveTokenPoolPdaWithIndex}. + * Derive the SPL interface PDA. + * To derive the SPL interface PDA with bump, use {@link deriveSplInterfacePdaWithIndex}. * - * @param mint The mint of the token pool + * @param mint The mint of the SPL interface * - * @returns The token pool pda + * @returns The SPL interface PDA */ - static deriveTokenPoolPda(mint: PublicKey): PublicKey { + static deriveSplInterfacePda(mint: PublicKey): PublicKey { const seeds = [POOL_SEED, mint.toBuffer()]; const [address, _] = PublicKey.findProgramAddressSync( seeds, @@ -641,37 +654,57 @@ export class CompressedTokenProgram { } /** - * Find the index and bump for a given token pool pda and mint. + * @deprecated Use {@link deriveSplInterfacePda} instead. + */ + static deriveTokenPoolPda(mint: PublicKey): PublicKey { + return this.deriveSplInterfacePda(mint); + } + + /** + * Find the index and bump for a given SPL interface PDA and mint. * - * @param poolPda The token pool pda to find the index and bump for - * @param mint The mint of the token pool + * @param poolPda The SPL interface PDA to find the index and bump for + * @param mint The mint of the SPL interface * * @returns The index and bump number. */ - static findTokenPoolIndexAndBump( + static findSplInterfaceIndexAndBump( poolPda: PublicKey, mint: PublicKey, ): [number, number] { for (let index = 0; index < 5; index++) { const derivedPda = - CompressedTokenProgram.deriveTokenPoolPdaWithIndex(mint, index); + CompressedTokenProgram.deriveSplInterfacePdaWithIndex( + mint, + index, + ); if (derivedPda[0].equals(poolPda)) { return [index, derivedPda[1]]; } } - throw new Error('Token pool not found'); + throw new Error('SPL interface not found'); + } + + /** + * @deprecated Use {@link findSplInterfaceIndexAndBump} instead. + */ + static findTokenPoolIndexAndBump( + poolPda: PublicKey, + mint: PublicKey, + ): [number, number] { + return this.findSplInterfaceIndexAndBump(poolPda, mint); } /** - * Derive the token pool pda with index. + * Derive the SPL interface PDA with index. * - * @param mint The mint of the token pool - * @param index Index. starts at 0. The Protocol supports 4 indexes aka token pools + * @param mint The mint of the SPL interface + * @param index Index. starts at 0. The Protocol supports 4 indexes aka SPL interfaces * per mint. * - * @returns The token pool pda and bump. + * @returns The SPL interface PDA and bump. */ - static deriveTokenPoolPdaWithIndex( + static deriveSplInterfacePdaWithIndex( mint: PublicKey, index: number, ): [PublicKey, number] { @@ -692,6 +725,16 @@ export class CompressedTokenProgram { return [address, bump]; } + /** + * @deprecated Use {@link deriveSplInterfacePdaWithIndex} instead. + */ + static deriveTokenPoolPdaWithIndex( + mint: PublicKey, + index: number, + ): [PublicKey, number] { + return this.deriveSplInterfacePdaWithIndex(mint, index); + } + /** @internal */ static get deriveCpiAuthorityPda(): PublicKey { const [address, _] = PublicKey.findProgramAddressSync( @@ -776,15 +819,15 @@ export class CompressedTokenProgram { feePayer, mint, tokenProgramId, - }: CreateTokenPoolParams): Promise { + }: CreateSplInterfaceParams): Promise { const tokenProgram = tokenProgramId ?? TOKEN_PROGRAM_ID; - const tokenPoolPda = this.deriveTokenPoolPdaWithIndex(mint, 0); + const splInterfacePda = this.deriveSplInterfacePdaWithIndex(mint, 0); const keys = createTokenPoolAccountsLayout({ mint, feePayer, - tokenPoolPda: tokenPoolPda[0], + tokenPoolPda: splInterfacePda[0], tokenProgram, cpiAuthorityPda: this.deriveCpiAuthorityPda, systemProgram: SystemProgram.programId, @@ -814,7 +857,7 @@ export class CompressedTokenProgram { mint, poolIndex, tokenProgramId, - }: AddTokenPoolParams): Promise { + }: AddSplInterfaceParams): Promise { if (poolIndex <= 0) { throw new Error( 'Pool index must be greater than 0. For 0, use CreateTokenPool instead.', @@ -828,17 +871,20 @@ export class CompressedTokenProgram { const tokenProgram = tokenProgramId ?? TOKEN_PROGRAM_ID; - const existingTokenPoolPda = this.deriveTokenPoolPdaWithIndex( + const existingSplInterfacePda = this.deriveSplInterfacePdaWithIndex( mint, poolIndex - 1, ); - const tokenPoolPda = this.deriveTokenPoolPdaWithIndex(mint, poolIndex); + const splInterfacePda = this.deriveSplInterfacePdaWithIndex( + mint, + poolIndex, + ); const keys = addTokenPoolAccountsLayout({ mint, feePayer, - tokenPoolPda: tokenPoolPda[0], - existingTokenPoolPda: existingTokenPoolPda[0], + tokenPoolPda: splInterfacePda[0], + existingTokenPoolPda: existingSplInterfacePda[0], tokenProgram, cpiAuthorityPda: this.deriveCpiAuthorityPda, systemProgram: SystemProgram.programId, @@ -878,7 +924,7 @@ export class CompressedTokenProgram { }: MintToParams): Promise { const systemKeys = defaultStaticAccountsStruct(); const tokenProgram = tokenPoolInfo.tokenProgram; - checkTokenPoolInfo(tokenPoolInfo, mint); + checkSplInterfaceInfo(tokenPoolInfo, mint); const amounts = toArray(amount).map(amount => bn(amount)); const toPubkeys = toArray(toPubkey); @@ -895,7 +941,7 @@ export class CompressedTokenProgram { authority, cpiAuthorityPda: this.deriveCpiAuthorityPda, tokenProgram, - tokenPoolPda: tokenPoolInfo.tokenPoolPda, + tokenPoolPda: tokenPoolInfo.splInterfacePda, lightSystemProgram: LightSystemProgram.programId, registeredProgramPda: systemKeys.registeredProgramPda, noopProgram: systemKeys.noopProgram, @@ -1093,7 +1139,7 @@ export class CompressedTokenProgram { if (mints) { optionalMintKeys = [ ...mints, - ...mints.map(mint => this.deriveTokenPoolPda(mint)), + ...mints.map(mint => this.deriveSplInterfacePda(mint)), ]; } @@ -1173,7 +1219,7 @@ export class CompressedTokenProgram { const amountArray = toArray(amount); const toAddressArray = toArray(toAddress); - checkTokenPoolInfo(tokenPoolInfo, mint); + checkSplInterfaceInfo(tokenPoolInfo, mint); if (amountArray.length !== toAddressArray.length) { throw new Error( @@ -1181,8 +1227,8 @@ export class CompressedTokenProgram { ); } if (featureFlags.isV2()) { - const [index, bump] = this.findTokenPoolIndexAndBump( - tokenPoolInfo.tokenPoolPda, + const [index, bump] = this.findSplInterfaceIndexAndBump( + tokenPoolInfo.splInterfacePda, mint, ); const rawData: BatchCompressInstructionData = { @@ -1204,7 +1250,7 @@ export class CompressedTokenProgram { authority: owner, cpiAuthorityPda: this.deriveCpiAuthorityPda, tokenProgram: tokenPoolInfo.tokenProgram, - tokenPoolPda: tokenPoolInfo.tokenPoolPda, + tokenPoolPda: tokenPoolInfo.splInterfacePda, lightSystemProgram: LightSystemProgram.programId, ...defaultStaticAccountsStruct(), merkleTree: outputStateTreeInfo.queue, @@ -1269,7 +1315,7 @@ export class CompressedTokenProgram { lightSystemProgram: LightSystemProgram.programId, selfProgram: this.programId, systemProgram: SystemProgram.programId, - tokenPoolPda: tokenPoolInfo.tokenPoolPda, + tokenPoolPda: tokenPoolInfo.splInterfacePda, compressOrDecompressTokenAccount: source, tokenProgram: tokenPoolInfo.tokenProgram, }); @@ -1325,7 +1371,7 @@ export class CompressedTokenProgram { tokenTransferOutputs: tokenTransferOutputs, remainingAccounts: tokenPoolInfosArray .slice(1) - .map(info => info.tokenPoolPda), + .map(info => info.splInterfacePda), }); const { mint } = parseTokenData(inputCompressedTokenAccounts); @@ -1365,7 +1411,7 @@ export class CompressedTokenProgram { accountCompressionAuthority: accountCompressionAuthority, accountCompressionProgram: accountCompressionProgram, selfProgram: this.programId, - tokenPoolPda: tokenPoolInfosArray[0].tokenPoolPda, + tokenPoolPda: tokenPoolInfosArray[0].splInterfacePda, compressOrDecompressTokenAccount: toAddress, tokenProgram, systemProgram: SystemProgram.programId, @@ -1443,7 +1489,7 @@ export class CompressedTokenProgram { outputStateTreeInfo, tokenPoolInfo, }: CompressSplTokenAccountParams): Promise { - checkTokenPoolInfo(tokenPoolInfo, mint); + checkSplInterfaceInfo(tokenPoolInfo, mint); const remainingAccountMetas: AccountMeta[] = [ { pubkey: @@ -1477,7 +1523,7 @@ export class CompressedTokenProgram { accountCompressionAuthority: accountCompressionAuthority, accountCompressionProgram: accountCompressionProgram, selfProgram: this.programId, - tokenPoolPda: tokenPoolInfo.tokenPoolPda, + tokenPoolPda: tokenPoolInfo.splInterfacePda, compressOrDecompressTokenAccount: tokenAccount, tokenProgram: tokenPoolInfo.tokenProgram, systemProgram: SystemProgram.programId, diff --git a/js/compressed-token/src/types.ts b/js/compressed-token/src/types.ts index 09ff12f1cd..381e2ee779 100644 --- a/js/compressed-token/src/types.ts +++ b/js/compressed-token/src/types.ts @@ -6,7 +6,7 @@ import { PackedMerkleContextLegacy, CompressedCpiContext, } from '@lightprotocol/stateless.js'; -import { TokenPoolInfo } from './utils/get-token-pool-infos'; +import { SplInterfaceInfo, TokenPoolInfo } from './utils/get-token-pool-infos'; export type TokenTransferOutputData = { /** @@ -84,12 +84,17 @@ export type CompressSplTokenAccountInstructionData = { cpiContext: CompressedCpiContext | null; }; -export function isSingleTokenPoolInfo( - tokenPoolInfos: TokenPoolInfo | TokenPoolInfo[], -): tokenPoolInfos is TokenPoolInfo { - return !Array.isArray(tokenPoolInfos); +export function isSingleSplInterfaceInfo( + splInterfaceInfos: SplInterfaceInfo | SplInterfaceInfo[], +): splInterfaceInfos is SplInterfaceInfo { + return !Array.isArray(splInterfaceInfos); } +/** + * @deprecated Use {@link isSingleSplInterfaceInfo} instead. + */ +export const isSingleTokenPoolInfo = isSingleSplInterfaceInfo; + export type CompressedTokenInstructionDataTransfer = { /** * Validity proof diff --git a/js/compressed-token/src/utils/ata-utils.ts b/js/compressed-token/src/utils/ata-utils.ts deleted file mode 100644 index ee7e9b4e12..0000000000 --- a/js/compressed-token/src/utils/ata-utils.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { - ASSOCIATED_TOKEN_PROGRAM_ID, - TOKEN_PROGRAM_ID, - TOKEN_2022_PROGRAM_ID, -} from '@solana/spl-token'; -import { CTOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; -import { PublicKey } from '@solana/web3.js'; - -/** - * Get the appropriate ATA program ID for a given token program ID - * @param tokenProgramId - The token program ID - * @returns The associated token program ID - */ -export function getATAProgramId(tokenProgramId: PublicKey): PublicKey { - if (tokenProgramId.equals(CTOKEN_PROGRAM_ID)) { - return CTOKEN_PROGRAM_ID; - } - return ASSOCIATED_TOKEN_PROGRAM_ID; -} diff --git a/js/compressed-token/src/utils/get-token-pool-infos.ts b/js/compressed-token/src/utils/get-token-pool-infos.ts index 678fffc5c2..bcbdb22c14 100644 --- a/js/compressed-token/src/utils/get-token-pool-infos.ts +++ b/js/compressed-token/src/utils/get-token-pool-infos.ts @@ -5,42 +5,73 @@ import { bn, Rpc } from '@lightprotocol/stateless.js'; import BN from 'bn.js'; /** - * Check if the token pool info is initialized and has a balance. - * @param mint The mint of the token pool - * @param tokenPoolInfo The token pool info - * @returns True if the token pool info is initialized and has a balance + * Derive SplInterfaceInfo for an SPL interface that will be initialized in the + * same transaction. Use this when you need to create an SPL interface and + * compress in a single transaction. + * + * @param mint The mint of the SPL interface + * @param tokenProgramId The token program (TOKEN_PROGRAM_ID or TOKEN_2022_PROGRAM_ID) + * @param poolIndex The pool index. Default 0. + * + * @returns SplInterfaceInfo for the to-be-initialized interface + */ +export function deriveSplInterfaceInfo( + mint: PublicKey, + tokenProgramId: PublicKey, + poolIndex = 0, +): SplInterfaceInfo { + const [splInterfacePda, bump] = + CompressedTokenProgram.deriveSplInterfacePdaWithIndex(mint, poolIndex); + + return { + mint, + splInterfacePda, + tokenProgram: tokenProgramId, + activity: undefined, + balance: bn(0), + isInitialized: true, + poolIndex, + bump, + }; +} + +/** + * Check if the SPL interface info is initialized and has a balance. + * @param mint The mint of the SPL interface + * @param splInterfaceInfo The SPL interface info + * @returns True if the SPL interface info is initialized and has a balance */ -export function checkTokenPoolInfo( - tokenPoolInfo: TokenPoolInfo, +export function checkSplInterfaceInfo( + splInterfaceInfo: SplInterfaceInfo, mint: PublicKey, ): boolean { - if (!tokenPoolInfo.mint.equals(mint)) { - throw new Error(`TokenPool mint does not match the provided mint.`); + if (!splInterfaceInfo.mint.equals(mint)) { + throw new Error(`SplInterface mint does not match the provided mint.`); } - if (!tokenPoolInfo.isInitialized) { + if (!splInterfaceInfo.isInitialized) { throw new Error( - `TokenPool is not initialized. Please create a compressed token pool for mint: ${mint.toBase58()} via createTokenPool().`, + `SplInterface is not initialized. Please create an SPL interface for mint: ${mint.toBase58()} via createSplInterface().`, ); } return true; } /** - * Get the token pool infos for a given mint. + * Get the SPL interface infos for a given mint. * @param rpc The RPC client - * @param mint The mint of the token pool + * @param mint The mint of the SPL interface * @param commitment The commitment to use * - * @returns The token pool infos + * @returns The SPL interface infos */ -export async function getTokenPoolInfos( +export async function getSplInterfaceInfos( rpc: Rpc, mint: PublicKey, commitment?: Commitment, -): Promise { +): Promise { const addressesAndBumps = Array.from({ length: 5 }, (_, i) => - CompressedTokenProgram.deriveTokenPoolPdaWithIndex(mint, i), + CompressedTokenProgram.deriveSplInterfacePdaWithIndex(mint, i), ); const accountInfos = await rpc.getMultipleAccountsInfo( @@ -50,7 +81,7 @@ export async function getTokenPoolInfos( if (accountInfos[0] === null) { throw new Error( - `TokenPool not found. Please create a compressed token pool for mint: ${mint.toBase58()} via createTokenPool().`, + `SplInterface not found. Please create an SPL interface for mint: ${mint.toBase58()} via createSplInterface().`, ); } @@ -69,7 +100,7 @@ export async function getTokenPoolInfos( if (!parsedInfo) { return { mint, - tokenPoolPda: addressesAndBumps[i][0], + splInterfacePda: addressesAndBumps[i][0], tokenProgram, activity: undefined, balance: bn(0), @@ -81,7 +112,7 @@ export async function getTokenPoolInfos( return { mint, - tokenPoolPda: parsedInfo.address, + splInterfacePda: parsedInfo.address, tokenProgram, activity: undefined, balance: bn(parsedInfo.amount.toString()), @@ -92,26 +123,26 @@ export async function getTokenPoolInfos( }); } -export type TokenPoolActivity = { +export type SplInterfaceActivity = { signature: string; amount: BN; action: Action; }; /** - * Token pool pda info. + * SPL interface PDA info. */ -export type TokenPoolInfo = { +export type SplInterfaceInfo = { /** - * The mint of the token pool + * The mint of the SPL interface */ mint: PublicKey; /** - * The token pool address + * The SPL interface address */ - tokenPoolPda: PublicKey; + splInterfacePda: PublicKey; /** - * The token program of the token pool + * The token program of the SPL interface */ tokenProgram: PublicKey; /** @@ -123,19 +154,19 @@ export type TokenPoolInfo = { amountRemoved: BN; }; /** - * Whether the token pool is initialized + * Whether the SPL interface is initialized */ isInitialized: boolean; /** - * The balance of the token pool + * The balance of the SPL interface */ balance: BN; /** - * The index of the token pool + * The index of the SPL interface */ poolIndex: number; /** - * The bump used to derive the token pool pda + * The bump used to derive the SPL interface PDA */ bump: number; }; @@ -162,15 +193,17 @@ const shuffleArray = (array: T[]): T[] => { /** * For `compress` and `mintTo` instructions only. - * Select a random token pool info from the token pool infos. + * Select a random SPL interface info from the SPL interface infos. * - * For `decompress`, use {@link selectTokenPoolInfosForDecompression} instead. + * For `decompress`, use {@link selectSplInterfaceInfosForDecompression} instead. * - * @param infos The token pool infos + * @param infos The SPL interface infos * - * @returns A random token pool info + * @returns A random SPL interface info */ -export function selectTokenPoolInfo(infos: TokenPoolInfo[]): TokenPoolInfo { +export function selectSplInterfaceInfo( + infos: SplInterfaceInfo[], +): SplInterfaceInfo { const shuffledInfos = shuffleArray(infos); // filter only infos that are initialized @@ -178,32 +211,32 @@ export function selectTokenPoolInfo(infos: TokenPoolInfo[]): TokenPoolInfo { if (filteredInfos.length === 0) { throw new Error( - 'Please pass at least one initialized token pool info.', + 'Please pass at least one initialized SPL interface info.', ); } - // Return a single random token pool info + // Return a single random SPL interface info return filteredInfos[0]; } /** - * Select one or multiple token pool infos from the token pool infos. + * Select one or multiple SPL interface infos from the SPL interface infos. * * Use this function for `decompress`. * - * For `compress`, `mintTo` use {@link selectTokenPoolInfo} instead. + * For `compress`, `mintTo` use {@link selectSplInterfaceInfo} instead. * - * @param infos The token pool infos + * @param infos The SPL interface infos * @param decompressAmount The amount of tokens to withdraw * - * @returns Array with one or more token pool infos. + * @returns Array with one or more SPL interface infos. */ -export function selectTokenPoolInfosForDecompression( - infos: TokenPoolInfo[], +export function selectSplInterfaceInfosForDecompression( + infos: SplInterfaceInfo[], decompressAmount: number | BN, -): TokenPoolInfo[] { +): SplInterfaceInfo[] { if (infos.length === 0) { - throw new Error('Please pass at least one token pool info.'); + throw new Error('Please pass at least one SPL interface info.'); } infos = shuffleArray(infos); @@ -220,10 +253,50 @@ export function selectTokenPoolInfosForDecompression( const allBalancesZero = infos.every(info => info.balance.isZero()); if (allBalancesZero) { throw new Error( - 'All provided token pool balances are zero. Please pass recent token pool infos.', + 'All provided SPL interface balances are zero. Please pass recent SPL interface infos.', ); } // If none found, return all infos return sufficientBalanceInfo ? [sufficientBalanceInfo] : infos; } + +// ============================================================================= +// DEPRECATED ALIASES - Use the new SplInterface* names instead +// ============================================================================= + +/** + * @deprecated Use {@link SplInterfaceInfo} instead. + */ +export type TokenPoolInfo = SplInterfaceInfo; + +/** + * @deprecated Use {@link SplInterfaceActivity} instead. + */ +export type TokenPoolActivity = SplInterfaceActivity; + +/** + * @deprecated Use {@link deriveSplInterfaceInfo} instead. + */ +export const deriveTokenPoolInfo = deriveSplInterfaceInfo; + +/** + * @deprecated Use {@link checkSplInterfaceInfo} instead. + */ +export const checkTokenPoolInfo = checkSplInterfaceInfo; + +/** + * @deprecated Use {@link getSplInterfaceInfos} instead. + */ +export const getTokenPoolInfos = getSplInterfaceInfos; + +/** + * @deprecated Use {@link selectSplInterfaceInfo} instead. + */ +export const selectTokenPoolInfo = selectSplInterfaceInfo; + +/** + * @deprecated Use {@link selectSplInterfaceInfosForDecompression} instead. + */ +export const selectTokenPoolInfosForDecompression = + selectSplInterfaceInfosForDecompression; diff --git a/js/compressed-token/src/utils/index.ts b/js/compressed-token/src/utils/index.ts index 8b6ed883af..7e280dc27e 100644 --- a/js/compressed-token/src/utils/index.ts +++ b/js/compressed-token/src/utils/index.ts @@ -2,4 +2,3 @@ export * from './get-token-pool-infos'; export * from './select-input-accounts'; export * from './pack-compressed-token-accounts'; export * from './validation'; -export * from './ata-utils'; diff --git a/js/compressed-token/src/mint/actions/create-associated-ctoken.ts b/js/compressed-token/src/v3/actions/create-associated-ctoken.ts similarity index 98% rename from js/compressed-token/src/mint/actions/create-associated-ctoken.ts rename to js/compressed-token/src/v3/actions/create-associated-ctoken.ts index ed64659c1c..2bf709fd82 100644 --- a/js/compressed-token/src/mint/actions/create-associated-ctoken.ts +++ b/js/compressed-token/src/v3/actions/create-associated-ctoken.ts @@ -15,7 +15,7 @@ import { createAssociatedCTokenAccountIdempotentInstruction, CompressibleConfig, } from '../instructions/create-associated-ctoken'; -import { getAssociatedCTokenAddress } from '../../compressible'; +import { getAssociatedCTokenAddress } from '../derivation'; /** * Create an associated compressed token account. diff --git a/js/compressed-token/src/mint/actions/create-ata-interface.ts b/js/compressed-token/src/v3/actions/create-ata-interface.ts similarity index 73% rename from js/compressed-token/src/mint/actions/create-ata-interface.ts rename to js/compressed-token/src/v3/actions/create-ata-interface.ts index 82afdd4937..49ff2d4251 100644 --- a/js/compressed-token/src/mint/actions/create-ata-interface.ts +++ b/js/compressed-token/src/v3/actions/create-ata-interface.ts @@ -22,15 +22,15 @@ import { createAssociatedTokenAccountInterfaceInstruction, createAssociatedTokenAccountInterfaceIdempotentInstruction, CTokenConfig, -} from '../instructions/create-associated-ctoken'; -import { getAssociatedCTokenAddress } from '../../compressible'; -import { getATAProgramId } from '../../utils'; +} from '../instructions/create-ata-interface'; +import { getAtaProgramId } from '../ata-utils'; +import { getAssociatedTokenAddressInterface } from '../get-associated-token-address-interface'; // Re-export types for backwards compatibility export type { CTokenConfig }; // Keep old interface type for backwards compatibility export -export interface CreateATAInterfaceParams { +export interface CreateAtaInterfaceParams { rpc: Rpc; payer: Signer; owner: PublicKey; @@ -42,49 +42,15 @@ export interface CreateATAInterfaceParams { ctokenConfig?: CTokenConfig; } -export interface CreateATAInterfaceResult { +export interface CreateAtaInterfaceResult { address: PublicKey; transactionSignature: TransactionSignature; } -/** - * Derive the associated token address for any token program. - * Follows SPL Token getAssociatedTokenAddressSync signature. - * Defaults to CToken program. - * - * @param mint - Mint public key - * @param owner - Owner public key - * @param allowOwnerOffCurve - Allow owner to be a PDA (default: false) - * @param programId - Token program ID (default: CTOKEN_PROGRAM_ID) - * @param associatedTokenProgramId - Associated token program ID - */ -export function getATAAddressInterface( - mint: PublicKey, - owner: PublicKey, - allowOwnerOffCurve = false, - programId: PublicKey = CTOKEN_PROGRAM_ID, - associatedTokenProgramId?: PublicKey, -): PublicKey { - const effectiveAtaProgramId = - associatedTokenProgramId ?? getATAProgramId(programId); - - if (programId.equals(CTOKEN_PROGRAM_ID)) { - return getAssociatedCTokenAddress(owner, mint); - } - - return getAssociatedTokenAddressSync( - mint, - owner, - allowOwnerOffCurve, - programId, - effectiveAtaProgramId, - ); -} - /** * Create an associated token account for SPL Token, Token-2022, or Compressed Token. * Follows SPL Token createAssociatedTokenAccount signature. - * Defaults to CToken program. + * Defaults to c-token program. * * Dispatches to the appropriate program based on `programId`: * - `CTOKEN_PROGRAM_ID` -> Compressed Token ATA (default) @@ -99,11 +65,11 @@ export function getATAAddressInterface( * @param confirmOptions Options for confirming the transaction * @param programId Token program ID (default: CTOKEN_PROGRAM_ID) * @param associatedTokenProgramId Associated token program ID (auto-derived if not provided) - * @param ctokenConfig Optional CToken-specific configuration + * @param ctokenConfig Optional c-token-specific configuration * * @example * // Create Compressed Token ATA (default) - * const { address } = await createATAInterface( + * const { address } = await createAtaInterface( * rpc, * payer, * mint, @@ -112,7 +78,7 @@ export function getATAAddressInterface( * * @example * // Create SPL Token ATA - * const { address } = await createATAInterface( + * const { address } = await createAtaInterface( * rpc, * payer, * splMint, @@ -122,7 +88,7 @@ export function getATAAddressInterface( * TOKEN_PROGRAM_ID, * ); */ -export async function createATAInterface( +export async function createAtaInterface( rpc: Rpc, payer: Signer, mint: PublicKey, @@ -132,11 +98,11 @@ export async function createATAInterface( programId: PublicKey = CTOKEN_PROGRAM_ID, associatedTokenProgramId?: PublicKey, ctokenConfig?: CTokenConfig, -): Promise { +): Promise { const effectiveAtaProgramId = - associatedTokenProgramId ?? getATAProgramId(programId); + associatedTokenProgramId ?? getAtaProgramId(programId); - const associatedToken = getATAAddressInterface( + const associatedToken = getAssociatedTokenAddressInterface( mint, owner, allowOwnerOffCurve, @@ -157,7 +123,7 @@ export async function createATAInterface( let txId: TransactionSignature; if (programId.equals(CTOKEN_PROGRAM_ID)) { - // CToken uses Light protocol transaction handling + // c-token uses Light protocol transaction handling const { blockhash } = await rpc.getLatestBlockhash(); const tx = buildAndSignTx( [ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }), ix], @@ -183,7 +149,7 @@ export async function createATAInterface( /** * Create an associated token account idempotently for SPL Token, Token-2022, or Compressed Token. * Follows SPL Token createAssociatedTokenAccountIdempotent signature. - * Defaults to CToken program. + * Defaults to c-token program. * * This is idempotent - if the account already exists, the instruction succeeds without error. * @@ -200,18 +166,18 @@ export async function createATAInterface( * @param confirmOptions Options for confirming the transaction * @param programId Token program ID (default: CTOKEN_PROGRAM_ID) * @param associatedTokenProgramId Associated token program ID (auto-derived if not provided) - * @param ctokenConfig Optional CToken-specific configuration + * @param ctokenConfig Optional c-token-specific configuration * * @example - * // Create or get existing CToken ATA (default) - * const { address } = await createATAInterfaceIdempotent( + * // Create or get existing c-token ATA (default) + * const { address } = await createAtaInterfaceIdempotent( * rpc, * payer, * mint, * wallet.publicKey, * ); */ -export async function createATAInterfaceIdempotent( +export async function createAtaInterfaceIdempotent( rpc: Rpc, payer: Signer, mint: PublicKey, @@ -221,11 +187,11 @@ export async function createATAInterfaceIdempotent( programId: PublicKey = CTOKEN_PROGRAM_ID, associatedTokenProgramId?: PublicKey, ctokenConfig?: CTokenConfig, -): Promise { +): Promise { const effectiveAtaProgramId = - associatedTokenProgramId ?? getATAProgramId(programId); + associatedTokenProgramId ?? getAtaProgramId(programId); - const associatedToken = getATAAddressInterface( + const associatedToken = getAssociatedTokenAddressInterface( mint, owner, allowOwnerOffCurve, @@ -246,7 +212,7 @@ export async function createATAInterfaceIdempotent( let txId: TransactionSignature; if (programId.equals(CTOKEN_PROGRAM_ID)) { - // CToken uses Light protocol transaction handling + // c-token uses Light protocol transaction handling const { blockhash } = await rpc.getLatestBlockhash(); const tx = buildAndSignTx( [ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }), ix], diff --git a/js/compressed-token/src/mint/actions/create-mint-interface.ts b/js/compressed-token/src/v3/actions/create-mint-interface.ts similarity index 96% rename from js/compressed-token/src/mint/actions/create-mint-interface.ts rename to js/compressed-token/src/v3/actions/create-mint-interface.ts index e1e76a3c78..aa23c506df 100644 --- a/js/compressed-token/src/mint/actions/create-mint-interface.ts +++ b/js/compressed-token/src/v3/actions/create-mint-interface.ts @@ -23,7 +23,7 @@ import { createMintInstruction, TokenMetadataInstructionData, } from '../instructions/create-mint'; -import { findMintAddress } from '../../compressible'; +import { findMintAddress } from '../derivation'; import { createMint } from '../../actions/create-mint'; export { TokenMetadataInstructionData }; @@ -41,12 +41,12 @@ export { TokenMetadataInstructionData }; * @param freezeAuthority Optional: Account that will control freeze and thaw. * @param decimals Location of the decimal place * @param keypair Optional: Mint keypair. Defaults to a random keypair. - * @param metadata Optional: Token metadata (only used for compressed mints) - * @param addressTreeInfo Optional: Address tree info (only used for compressed mints) - * @param outputStateTreeInfo Optional: Output state tree info (only used for compressed mints) * @param confirmOptions Optional: Options for confirming the transaction * @param programId Optional: Token program ID. Defaults to CTOKEN_PROGRAM_ID (compressed). * Set to TOKEN_PROGRAM_ID or TOKEN_2022_PROGRAM_ID for SPL mints. + * @param tokenMetadata Optional: Token metadata (only used for compressed mints) + * @param outputStateTreeInfo Optional: Output state tree info (only used for compressed mints) + * @param addressTreeInfo Optional: Address tree info (only used for compressed mints) * * @return Object with mint address and transaction signature */ @@ -57,11 +57,11 @@ export async function createMintInterface( freezeAuthority: PublicKey | Signer | null, decimals: number, keypair: Keypair = Keypair.generate(), - metadata?: TokenMetadataInstructionData, - addressTreeInfo?: AddressTreeInfo, - outputStateTreeInfo?: TreeInfo, confirmOptions?: ConfirmOptions, programId: PublicKey = CTOKEN_PROGRAM_ID, + tokenMetadata?: TokenMetadataInstructionData, + outputStateTreeInfo?: TreeInfo, + addressTreeInfo?: AddressTreeInfo, ): Promise<{ mint: PublicKey; transactionSignature: TransactionSignature }> { // Dispatch to SPL/Token-2022 mint creation if ( @@ -72,11 +72,11 @@ export async function createMintInterface( rpc, payer, mintAuthority, - freezeAuthority, decimals, keypair, confirmOptions, programId, + freezeAuthority, ); } @@ -117,7 +117,7 @@ export async function createMintInterface( validityProof, addressTreeInfo, outputStateTreeInfo, - metadata, + tokenMetadata, ); const additionalSigners = dedupeSigner(payer, [keypair, mintAuthority]); diff --git a/js/compressed-token/src/v3/actions/decompress-interface.ts b/js/compressed-token/src/v3/actions/decompress-interface.ts new file mode 100644 index 0000000000..0f3b11b2a5 --- /dev/null +++ b/js/compressed-token/src/v3/actions/decompress-interface.ts @@ -0,0 +1,203 @@ +import { + ConfirmOptions, + PublicKey, + Signer, + TransactionSignature, + ComputeBudgetProgram, +} from '@solana/web3.js'; +import { + Rpc, + buildAndSignTx, + sendAndConfirmTx, + dedupeSigner, + ParsedTokenAccount, +} from '@lightprotocol/stateless.js'; +import { + createAssociatedTokenAccountIdempotentInstruction, + getAssociatedTokenAddress, +} from '@solana/spl-token'; +import BN from 'bn.js'; +import { createDecompressInterfaceInstruction } from '../instructions/create-decompress-interface-instruction'; +import { createAssociatedTokenAccountInterfaceIdempotentInstruction } from '../instructions/create-ata-interface'; +import { getAssociatedTokenAddressInterface } from '../get-associated-token-address-interface'; +import { CTOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; +import { + SplInterfaceInfo, + getSplInterfaceInfos, + selectSplInterfaceInfosForDecompression, +} from '../../utils/get-token-pool-infos'; + +/** + * Decompress compressed (cold) tokens to an on-chain token account. + * + * Low-level primitive for decompressing tokens. Destination type is determined + * by `splInterfaceInfo`: + * - undefined: Decompress to c-token ATA (default) + * - provided: Decompress to SPL/T22 ATA via token pool + * + * For unified loading, use + * {@link loadAta} instead. + * + * @param rpc RPC connection + * @param payer Fee payer (signer) + * @param owner Owner of the compressed tokens (signer) + * @param mint Mint address + * @param amount Optional: specific amount to decompress (defaults to all) + * @param destinationAta Optional: destination token account address + * @param destinationOwner Optional: owner of the destination ATA + * @param splInterfaceInfo Optional: SPL interface info for SPL/T22 destinations + * @param confirmOptions Optional: confirm options + * @returns Transaction signature, or null if no compressed tokens to decompress + */ +export async function decompressInterface( + rpc: Rpc, + payer: Signer, + owner: Signer, + mint: PublicKey, + amount?: number | bigint | BN, + destinationAta?: PublicKey, + destinationOwner?: PublicKey, + splInterfaceInfo?: SplInterfaceInfo, + confirmOptions?: ConfirmOptions, +): Promise { + // Determine if this is SPL or c-token destination + const isSplDestination = splInterfaceInfo !== undefined; + + // Get compressed token accounts + const compressedResult = await rpc.getCompressedTokenAccountsByOwner( + owner.publicKey, + { mint }, + ); + const compressedAccounts = compressedResult.items; + + if (compressedAccounts.length === 0) { + return null; // Nothing to decompress + } + + // Calculate total and determine amount + const totalBalance = compressedAccounts.reduce( + (sum, acc) => sum + BigInt(acc.parsed.amount.toString()), + BigInt(0), + ); + + const decompressAmount = amount ? BigInt(amount.toString()) : totalBalance; + + if (decompressAmount > totalBalance) { + throw new Error( + `Insufficient compressed balance. Requested: ${decompressAmount}, Available: ${totalBalance}`, + ); + } + + // Select accounts to use (for now, use all - could optimize later) + const accountsToUse: ParsedTokenAccount[] = []; + let accumulatedAmount = BigInt(0); + for (const acc of compressedAccounts) { + if (accumulatedAmount >= decompressAmount) break; + accountsToUse.push(acc); + accumulatedAmount += BigInt(acc.parsed.amount.toString()); + } + + // Get validity proof + const validityProof = await rpc.getValidityProofV0( + accountsToUse.map(acc => ({ + hash: acc.compressedAccount.hash, + tree: acc.compressedAccount.treeInfo.tree, + queue: acc.compressedAccount.treeInfo.queue, + })), + ); + + // Determine destination ATA based on token program + const ataOwner = destinationOwner ?? owner.publicKey; + let destinationAtaAddress: PublicKey; + + if (isSplDestination) { + // SPL destination - use SPL ATA + destinationAtaAddress = + destinationAta ?? + (await getAssociatedTokenAddress( + mint, + ataOwner, + false, + splInterfaceInfo.tokenProgram, + )); + } else { + // c-token destination - use c-token ATA + destinationAtaAddress = + destinationAta ?? + getAssociatedTokenAddressInterface(mint, ataOwner); + } + + // Build instructions + const instructions = []; + + // Create ATA if needed (idempotent) + const ataInfo = await rpc.getAccountInfo(destinationAtaAddress); + if (!ataInfo) { + if (isSplDestination) { + // Create SPL ATA + instructions.push( + createAssociatedTokenAccountIdempotentInstruction( + payer.publicKey, + destinationAtaAddress, + ataOwner, + mint, + splInterfaceInfo.tokenProgram, + ), + ); + } else { + // Create c-token ATA + instructions.push( + createAssociatedTokenAccountInterfaceIdempotentInstruction( + payer.publicKey, + destinationAtaAddress, + ataOwner, + mint, + CTOKEN_PROGRAM_ID, + ), + ); + } + } + + // Calculate compute units + const hasValidityProof = validityProof.compressedProof !== null; + let computeUnits = 50_000; // Base + if (hasValidityProof) { + computeUnits += 100_000; + } + for (const acc of accountsToUse) { + const proveByIndex = acc.compressedAccount.proveByIndex ?? false; + computeUnits += proveByIndex ? 10_000 : 30_000; + } + // SPL decompression needs extra compute for pool operations + if (isSplDestination) { + computeUnits += 50_000; + } + + // Add decompressInterface instruction + instructions.push( + createDecompressInterfaceInstruction( + payer.publicKey, + accountsToUse, + destinationAtaAddress, + decompressAmount, + validityProof, + splInterfaceInfo, + ), + ); + + // Build and send + const { blockhash } = await rpc.getLatestBlockhash(); + const additionalSigners = dedupeSigner(payer, [owner]); + + const tx = buildAndSignTx( + [ + ComputeBudgetProgram.setComputeUnitLimit({ units: computeUnits }), + ...instructions, + ], + payer, + blockhash, + additionalSigners, + ); + + return sendAndConfirmTx(rpc, tx, confirmOptions); +} 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 new file mode 100644 index 0000000000..96f21765c7 --- /dev/null +++ b/js/compressed-token/src/v3/actions/get-or-create-ata-interface.ts @@ -0,0 +1,439 @@ +import { + Rpc, + CTOKEN_PROGRAM_ID, + buildAndSignTx, + sendAndConfirmTx, +} from '@lightprotocol/stateless.js'; +import { + getAssociatedTokenAddressSync, + TOKEN_PROGRAM_ID, + TokenAccountNotFoundError, + TokenInvalidAccountOwnerError, + TokenInvalidMintError, + TokenInvalidOwnerError, +} from '@solana/spl-token'; +import type { + Commitment, + ConfirmOptions, + PublicKey, + Signer, +} from '@solana/web3.js'; +import { + sendAndConfirmTransaction, + Transaction, + ComputeBudgetProgram, +} from '@solana/web3.js'; +import { + createAssociatedTokenAccountInterfaceInstruction, + createAssociatedTokenAccountInterfaceIdempotentInstruction, +} from '../instructions/create-ata-interface'; +import { + getAccountInterface, + getAtaInterface, + AccountInterface, + TokenAccountSourceType, +} from '../get-account-interface'; +import { getAtaProgramId } from '../ata-utils'; +import { loadAta } from './load-ata'; + +/** + * Retrieve the associated token account, or create it if it doesn't exist. + * + * For c-token with Signer owner: + * - Creates hot ATA if it doesn't exist + * - Loads cold (compressed) tokens into hot ATA if any exist + * - Returns account with all tokens ready to use + * + * For c-token with PublicKey owner: + * - Creates hot ATA if it doesn't exist + * - Returns aggregated balance but does NOT auto-load (can't sign) + * - Use loadAta() separately to consolidate + * + * For SPL/T22: standard behavior (create ATA if needed). + * + * Returns AccountInterface with: + * - `parsed.amount`: aggregated balance (hot + cold for c-token) + * - `_sources`: breakdown by source type (hot, cold, spl, token2022) + * - `_needsConsolidation`: true if loadAta() should be called before writes + * + * @param rpc Connection to use + * @param payer Payer of the transaction and initialization + * fees. + * @param mint Mint associated with the account to set or + * verify. + * @param owner Owner of the account. Pass Signer to auto-load + * cold tokens, or PublicKey for read-only. + * @param allowOwnerOffCurve Allow the owner account to be a PDA (Program + * Derived Address). + * @param commitment Desired level of commitment for querying the + * state. + * @param confirmOptions Options for confirming the transaction + * @param programId SPL Token program account or c-token program + * account. + * @param associatedTokenProgramId SPL Associated Token program account or c- + * token program account. + * + * @return AccountInterface with aggregated balance and source breakdown + */ +export async function getOrCreateAtaInterface( + rpc: Rpc, + payer: Signer, + mint: PublicKey, + owner: PublicKey | Signer, + allowOwnerOffCurve = false, + commitment?: Commitment, + confirmOptions?: ConfirmOptions, + programId = TOKEN_PROGRAM_ID, + associatedTokenProgramId = getAtaProgramId(programId), +): Promise { + return _getOrCreateAtaInterface( + rpc, + payer, + mint, + owner, + allowOwnerOffCurve, + commitment, + confirmOptions, + programId, + associatedTokenProgramId, + false, // wrap=false for standard path + ); +} + +/** Helper to check if owner is a Signer (has both publicKey and secretKey) */ +function isSigner(owner: PublicKey | Signer): owner is Signer { + // Check for both publicKey and secretKey properties + // A proper Signer (like Keypair) has secretKey as Uint8Array + if (!('publicKey' in owner) || !('secretKey' in owner)) { + return false; + } + // Verify secretKey is actually present and is a Uint8Array + const signer = owner as Signer; + return ( + signer.secretKey instanceof Uint8Array && signer.secretKey.length > 0 + ); +} + +/** Helper to get PublicKey from owner (which may be Signer or PublicKey) */ +function getOwnerPublicKey(owner: PublicKey | Signer): PublicKey { + return isSigner(owner) ? owner.publicKey : owner; +} + +/** + * @internal + * Internal implementation with wrap parameter. + */ +export async function _getOrCreateAtaInterface( + rpc: Rpc, + payer: Signer, + mint: PublicKey, + owner: PublicKey | Signer, + allowOwnerOffCurve: boolean, + commitment: Commitment | undefined, + confirmOptions: ConfirmOptions | undefined, + programId: PublicKey, + associatedTokenProgramId: PublicKey, + wrap: boolean, +): Promise { + const ownerPubkey = getOwnerPublicKey(owner); + const associatedToken = getAssociatedTokenAddressSync( + mint, + ownerPubkey, + allowOwnerOffCurve, + programId, + associatedTokenProgramId, + ); + + // For c-token, use getAtaInterface which properly aggregates hot+cold balances + // When wrap=true (unified path), also includes SPL/T22 balances + if (programId.equals(CTOKEN_PROGRAM_ID)) { + return getOrCreateCTokenAta( + rpc, + payer, + mint, + owner, + associatedToken, + commitment, + confirmOptions, + wrap, + ); + } + + // For SPL/T22, use standard address-based lookup + return getOrCreateSplAta( + rpc, + payer, + mint, + ownerPubkey, + associatedToken, + programId, + associatedTokenProgramId, + commitment, + confirmOptions, + ); +} + +/** + * Get or create c-token ATA with proper cold balance handling. + * + * Like SPL's getOrCreateAssociatedTokenAccount, this is a write operation: + * 1. Creates hot ATA if it doesn't exist + * 2. If owner is Signer: loads cold (compressed) tokens into hot ATA + * 3. When wrap=true and owner is Signer: also wraps SPL/T22 tokens + * + * After this call (with Signer owner), all tokens are in the hot ATA and ready + * to use. + * + * @internal + */ +async function getOrCreateCTokenAta( + rpc: Rpc, + payer: Signer, + mint: PublicKey, + owner: PublicKey | Signer, + associatedToken: PublicKey, + commitment?: Commitment, + confirmOptions?: ConfirmOptions, + wrap = false, +): Promise { + const ownerPubkey = getOwnerPublicKey(owner); + const ownerIsSigner = isSigner(owner); + + let accountInterface: AccountInterface; + let hasHotAccount = false; + + try { + // Use getAtaInterface which properly fetches by owner+mint and aggregates + // hot+cold balances. When wrap=true, also includes SPL/T22 balances. + accountInterface = await getAtaInterface( + rpc, + associatedToken, + ownerPubkey, + mint, + commitment, + CTOKEN_PROGRAM_ID, + wrap, + ); + + // Check if we have a hot account + hasHotAccount = + accountInterface._sources?.some( + s => s.type === TokenAccountSourceType.CTokenHot, + ) ?? false; + } catch (error: unknown) { + if ( + error instanceof TokenAccountNotFoundError || + error instanceof TokenInvalidAccountOwnerError + ) { + // No account found (neither hot nor cold), create hot ATA + await createCTokenAtaIdempotent( + rpc, + payer, + mint, + ownerPubkey, + associatedToken, + confirmOptions, + ); + + // Fetch the newly created account + accountInterface = await getAtaInterface( + rpc, + associatedToken, + ownerPubkey, + mint, + commitment, + CTOKEN_PROGRAM_ID, + wrap, + ); + hasHotAccount = true; + } else { + throw error; + } + } + + // If we only have cold balance (no hot ATA), create the hot ATA first + if (!hasHotAccount) { + await createCTokenAtaIdempotent( + rpc, + payer, + mint, + ownerPubkey, + associatedToken, + confirmOptions, + ); + } + + // Only auto-load if owner is a Signer (we can sign the load transaction) + // Use direct type guard in the if condition for proper type narrowing + if (isSigner(owner)) { + // Check if we need to load tokens into the hot ATA + // Load if: cold balance exists, or (wrap=true and SPL/T22 balance exists) + const sources = accountInterface._sources ?? []; + const hasCold = sources.some( + s => s.type === TokenAccountSourceType.CTokenCold && s.amount > 0n, + ); + const hasSplToWrap = + wrap && + sources.some( + s => + (s.type === TokenAccountSourceType.Spl || + s.type === TokenAccountSourceType.Token2022) && + s.amount > 0n, + ); + + if (hasCold || hasSplToWrap) { + // Verify owner is a valid Signer before loading + if ( + !(owner.secretKey instanceof Uint8Array) || + owner.secretKey.length === 0 + ) { + throw new Error( + 'Owner must be a valid Signer with secretKey to auto-load', + ); + } + + // Load all tokens into hot ATA (decompress cold, wrap SPL/T22 if + // wrap=true) + await loadAta( + rpc, + associatedToken, + owner, // TypeScript now knows owner is Signer + mint, + payer, + confirmOptions, + undefined, + wrap, + ); + + // Re-fetch the updated account state + accountInterface = await getAtaInterface( + rpc, + associatedToken, + ownerPubkey, + mint, + commitment, + CTOKEN_PROGRAM_ID, + wrap, + ); + } + } + + const account = accountInterface.parsed; + + if (!account.mint.equals(mint)) throw new TokenInvalidMintError(); + if (!account.owner.equals(ownerPubkey)) throw new TokenInvalidOwnerError(); + + return accountInterface; +} + +/** + * Create c-token ATA idempotently. + * @internal + */ +async function createCTokenAtaIdempotent( + rpc: Rpc, + payer: Signer, + mint: PublicKey, + owner: PublicKey, + associatedToken: PublicKey, + confirmOptions?: ConfirmOptions, +): Promise { + try { + const ix = createAssociatedTokenAccountInterfaceIdempotentInstruction( + payer.publicKey, + associatedToken, + owner, + mint, + CTOKEN_PROGRAM_ID, + ); + + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx( + [ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }), ix], + payer, + blockhash, + [], + ); + + await sendAndConfirmTx(rpc, tx, confirmOptions); + } catch { + // Ignore errors - ATA may already exist + } +} + +/** + * Get or create SPL/T22 ATA. + * @internal + */ +async function getOrCreateSplAta( + rpc: Rpc, + payer: Signer, + mint: PublicKey, + owner: PublicKey, + associatedToken: PublicKey, + programId: PublicKey, + associatedTokenProgramId: PublicKey, + commitment?: Commitment, + confirmOptions?: ConfirmOptions, +): Promise { + let accountInterface: AccountInterface; + + try { + accountInterface = await getAccountInterface( + rpc, + associatedToken, + commitment, + programId, + ); + } catch (error: unknown) { + // TokenAccountNotFoundError can be possible if the associated address + // has already received some lamports, becoming a system account. + if ( + error instanceof TokenAccountNotFoundError || + error instanceof TokenInvalidAccountOwnerError + ) { + // As this isn't atomic, it's possible others can create associated + // accounts meanwhile. + try { + const transaction = new Transaction().add( + createAssociatedTokenAccountInterfaceInstruction( + payer.publicKey, + associatedToken, + owner, + mint, + programId, + associatedTokenProgramId, + ), + ); + + await sendAndConfirmTransaction( + rpc, + transaction, + [payer], + confirmOptions, + ); + } catch { + // Ignore all errors; for now there is no API-compatible way to + // selectively ignore the expected instruction error if the + // associated account exists already. + } + + // Now this should always succeed + accountInterface = await getAccountInterface( + rpc, + associatedToken, + commitment, + programId, + ); + } else { + throw error; + } + } + + const account = accountInterface.parsed; + + if (!account.mint.equals(mint)) throw new TokenInvalidMintError(); + if (!account.owner.equals(owner)) throw new TokenInvalidOwnerError(); + + return accountInterface; +} diff --git a/js/compressed-token/src/mint/actions/index.ts b/js/compressed-token/src/v3/actions/index.ts similarity index 81% rename from js/compressed-token/src/mint/actions/index.ts rename to js/compressed-token/src/v3/actions/index.ts index 0a24e8c73a..57c4ff557d 100644 --- a/js/compressed-token/src/mint/actions/index.ts +++ b/js/compressed-token/src/v3/actions/index.ts @@ -8,5 +8,7 @@ export * from './mint-to-compressed'; export * from './mint-to-interface'; export * from './get-or-create-ata-interface'; export * from './transfer-interface'; -export * from './decompress2'; +export * from './decompress-interface'; export * from './wrap'; +export * from './unwrap'; +export * from './load-ata'; diff --git a/js/compressed-token/src/v3/actions/load-ata.ts b/js/compressed-token/src/v3/actions/load-ata.ts new file mode 100644 index 0000000000..8109045903 --- /dev/null +++ b/js/compressed-token/src/v3/actions/load-ata.ts @@ -0,0 +1,435 @@ +import { + Rpc, + CTOKEN_PROGRAM_ID, + buildAndSignTx, + sendAndConfirmTx, + dedupeSigner, +} from '@lightprotocol/stateless.js'; +import { + PublicKey, + TransactionInstruction, + Signer, + TransactionSignature, + ConfirmOptions, + ComputeBudgetProgram, +} from '@solana/web3.js'; +import { + TOKEN_PROGRAM_ID, + TOKEN_2022_PROGRAM_ID, + getAssociatedTokenAddressSync, + createAssociatedTokenAccountIdempotentInstruction, +} from '@solana/spl-token'; +import { + AccountInterface, + getAtaInterface as _getAtaInterface, +} from '../get-account-interface'; +import { getAssociatedTokenAddressInterface } from '../get-associated-token-address-interface'; +import { createAssociatedTokenAccountInterfaceIdempotentInstruction } from '../instructions/create-ata-interface'; +import { createWrapInstruction } from '../instructions/wrap'; +import { createDecompressInterfaceInstruction } from '../instructions/create-decompress-interface-instruction'; +import { + getSplInterfaceInfos, + SplInterfaceInfo, +} from '../../utils/get-token-pool-infos'; +import { getAtaProgramId, validateAtaAddress, AtaType } from '../ata-utils'; +import { InterfaceOptions } from './transfer-interface'; + +// Re-export types moved to instructions +export { + ParsedAccountInfoInterface, + CompressibleAccountInput, + PackedCompressedAccount, + CompressibleLoadParams, + LoadResult, + createLoadAccountsParams, + calculateCompressibleLoadComputeUnits, +} from '../instructions/create-load-accounts-params'; + +/** + * Create instructions to load token balances into an ATA. + * + * Behavior depends on `wrap` parameter: + * - wrap=false (standard): Decompress compressed tokens to the target ATA. + * ATA can be SPL (via pool), T22 (via pool), or c-token (direct). + * - wrap=true (unified): Wrap SPL/T22 + decompress all to c-token ATA. + * ATA must be a c-token ATA. + * + * @param rpc RPC connection + * @param ata Associated token address (SPL, T22, or c-token) + * @param owner Owner public key + * @param mint Mint public key + * @param payer Fee payer (defaults to owner) + * @param options Optional load options + * @param wrap Unified mode: wrap SPL/T22 to c-token (default: false) + * @returns Array of instructions (empty if nothing to load) + */ +export async function createLoadAtaInstructions( + rpc: Rpc, + ata: PublicKey, + owner: PublicKey, + mint: PublicKey, + payer?: PublicKey, + options?: InterfaceOptions, + wrap = false, +): Promise { + payer ??= owner; + + // Validation happens inside getAtaInterface via validateAtaAddress helper: + // - Always validates ata matches mint+owner derivation + // - For wrap=true, additionally requires c-token ATA + const ataInterface = await _getAtaInterface( + rpc, + ata, + owner, + mint, + undefined, + undefined, + wrap, + ); + return createLoadAtaInstructionsFromInterface( + rpc, + payer, + ataInterface, + options, + wrap, + ata, + ); +} + +// Re-export AtaType for backwards compatibility +export { AtaType } from '../ata-utils'; + +/** + * Create instructions to load an ATA from its AccountInterface. + * + * Behavior depends on `wrap` parameter: + * - wrap=false (standard): Decompress compressed tokens to the target ATA type + * (SPL ATA via pool, T22 ATA via pool, or c-token ATA direct) + * - wrap=true (unified): Wrap SPL/T22 + decompress all to c-token ATA + * + * @param rpc RPC connection + * @param payer Fee payer + * @param ata AccountInterface from getAtaInterface (must have _isAta, _owner, _mint) + * @param options Optional load options + * @param wrap Unified mode: wrap SPL/T22 to c-token (default: false) + * @param targetAta Target ATA address (used for type detection in standard mode) + * @returns Array of instructions (empty if nothing to load) + */ +export async function createLoadAtaInstructionsFromInterface( + rpc: Rpc, + payer: PublicKey, + ata: AccountInterface, + options?: InterfaceOptions, + wrap = false, + targetAta?: PublicKey, +): Promise { + if (!ata._isAta || !ata._owner || !ata._mint) { + throw new Error( + 'AccountInterface must be from getAtaInterface (requires _isAta, _owner, _mint)', + ); + } + + const instructions: TransactionInstruction[] = []; + const owner = ata._owner; + const mint = ata._mint; + const sources = ata._sources ?? []; + + // Derive addresses + const ctokenAtaAddress = getAssociatedTokenAddressInterface(mint, owner); + const splAta = getAssociatedTokenAddressSync( + mint, + owner, + false, + TOKEN_PROGRAM_ID, + getAtaProgramId(TOKEN_PROGRAM_ID), + ); + const t22Ata = getAssociatedTokenAddressSync( + mint, + owner, + false, + TOKEN_2022_PROGRAM_ID, + getAtaProgramId(TOKEN_2022_PROGRAM_ID), + ); + + // Validate and detect target ATA type + // If called via createLoadAtaInstructions, validation already happened in getAtaInterface. + // If called directly, this validates the targetAta is correct. + let ataType: AtaType = 'ctoken'; + if (targetAta) { + const validation = validateAtaAddress(targetAta, mint, owner); + ataType = validation.type; + + // For wrap=true, must be c-token ATA + if (wrap && ataType !== 'ctoken') { + throw new Error( + `For wrap=true, targetAta must be c-token ATA. Got ${ataType} ATA.`, + ); + } + } + + // Check sources for balances + const splSource = sources.find(s => s.type === 'spl'); + const t22Source = sources.find(s => s.type === 'token2022'); + const ctokenHotSource = sources.find(s => s.type === 'ctoken-hot'); + const ctokenColdSource = sources.find(s => s.type === 'ctoken-cold'); + + const splBalance = splSource?.amount ?? BigInt(0); + const t22Balance = t22Source?.amount ?? BigInt(0); + const coldBalance = ctokenColdSource?.amount ?? BigInt(0); + + // Nothing to load + if ( + splBalance === BigInt(0) && + t22Balance === BigInt(0) && + coldBalance === BigInt(0) + ) { + return []; + } + + // Get SPL interface info (needed for wrapping or SPL/T22 decompression) + let splInterfaceInfo: SplInterfaceInfo | undefined; + const needsSplInfo = + wrap || + ataType === 'spl' || + ataType === 'token2022' || + splBalance > BigInt(0) || + t22Balance > BigInt(0); + + if (needsSplInfo) { + try { + const splInterfaceInfos = + options?.splInterfaceInfos ?? + (await getSplInterfaceInfos(rpc, mint)); + splInterfaceInfo = splInterfaceInfos.find( + (info: SplInterfaceInfo) => info.isInitialized, + ); + } catch { + // No SPL interface exists + } + } + + if (wrap) { + // UNIFIED MODE: Everything goes to c-token ATA + + // 1. Create c-token ATA if needed + if (!ctokenHotSource) { + instructions.push( + createAssociatedTokenAccountInterfaceIdempotentInstruction( + payer, + ctokenAtaAddress, + owner, + mint, + CTOKEN_PROGRAM_ID, + ), + ); + } + + // 2. Wrap SPL tokens to c-token + if (splBalance > BigInt(0) && splInterfaceInfo) { + instructions.push( + createWrapInstruction( + splAta, + ctokenAtaAddress, + owner, + mint, + splBalance, + splInterfaceInfo, + payer, + ), + ); + } + + // 3. Wrap T22 tokens to c-token + if (t22Balance > BigInt(0) && splInterfaceInfo) { + instructions.push( + createWrapInstruction( + t22Ata, + ctokenAtaAddress, + owner, + mint, + t22Balance, + splInterfaceInfo, + payer, + ), + ); + } + + // 4. Decompress compressed tokens to c-token ATA + if (coldBalance > BigInt(0) && ctokenColdSource) { + const compressedResult = + await rpc.getCompressedTokenAccountsByOwner(owner, { mint }); + const compressedAccounts = compressedResult.items; + + if (compressedAccounts.length > 0) { + const proof = await rpc.getValidityProofV0( + compressedAccounts.map(acc => ({ + hash: acc.compressedAccount.hash, + tree: acc.compressedAccount.treeInfo.tree, + queue: acc.compressedAccount.treeInfo.queue, + })), + ); + + instructions.push( + createDecompressInterfaceInstruction( + payer, + compressedAccounts, + ctokenAtaAddress, + coldBalance, + proof, + ), + ); + } + } + } else { + // STANDARD MODE: Decompress to target ATA type + + if (coldBalance > BigInt(0) && ctokenColdSource) { + const compressedResult = + await rpc.getCompressedTokenAccountsByOwner(owner, { mint }); + const compressedAccounts = compressedResult.items; + + if (compressedAccounts.length > 0) { + const proof = await rpc.getValidityProofV0( + compressedAccounts.map(acc => ({ + hash: acc.compressedAccount.hash, + tree: acc.compressedAccount.treeInfo.tree, + queue: acc.compressedAccount.treeInfo.queue, + })), + ); + + if (ataType === 'ctoken') { + // Decompress to c-token ATA (direct) + if (!ctokenHotSource) { + instructions.push( + createAssociatedTokenAccountInterfaceIdempotentInstruction( + payer, + ctokenAtaAddress, + owner, + mint, + CTOKEN_PROGRAM_ID, + ), + ); + } + instructions.push( + createDecompressInterfaceInstruction( + payer, + compressedAccounts, + ctokenAtaAddress, + coldBalance, + proof, + ), + ); + } else if (ataType === 'spl' && splInterfaceInfo) { + // Decompress to SPL ATA via token pool + // Create SPL ATA if needed + if (!splSource) { + instructions.push( + createAssociatedTokenAccountIdempotentInstruction( + payer, + splAta, + owner, + mint, + TOKEN_PROGRAM_ID, + ), + ); + } + instructions.push( + createDecompressInterfaceInstruction( + payer, + compressedAccounts, + splAta, + coldBalance, + proof, + splInterfaceInfo, + ), + ); + } else if (ataType === 'token2022' && splInterfaceInfo) { + // Decompress to T22 ATA via token pool + // Create T22 ATA if needed + if (!t22Source) { + instructions.push( + createAssociatedTokenAccountIdempotentInstruction( + payer, + t22Ata, + owner, + mint, + TOKEN_2022_PROGRAM_ID, + ), + ); + } + instructions.push( + createDecompressInterfaceInstruction( + payer, + compressedAccounts, + t22Ata, + coldBalance, + proof, + splInterfaceInfo, + ), + ); + } + } + } + } + + return instructions; +} + +/** + * Load token balances into an ATA. + * + * Behavior depends on `wrap` parameter: + * - wrap=false (standard): Decompress compressed tokens to the target ATA. + * ATA can be SPL (via pool), T22 (via pool), or c-token (direct). + * - wrap=true (unified): Wrap SPL/T22 + decompress all to c-token ATA. + * + * Idempotent: returns null if nothing to load. + * + * @param rpc RPC connection + * @param ata Associated token address (SPL, T22, or c-token) + * @param owner Owner of the tokens (signer) + * @param mint Mint public key + * @param payer Fee payer (signer, defaults to owner) + * @param confirmOptions Optional confirm options + * @param interfaceOptions Optional interface options + * @param wrap Unified mode: wrap SPL/T22 to c-token (default: false) + * @returns Transaction signature, or null if nothing to load + */ +export async function loadAta( + rpc: Rpc, + ata: PublicKey, + owner: Signer, + mint: PublicKey, + payer?: Signer, + confirmOptions?: ConfirmOptions, + interfaceOptions?: InterfaceOptions, + wrap = false, +): Promise { + payer ??= owner; + + const ixs = await createLoadAtaInstructions( + rpc, + ata, + owner.publicKey, + mint, + payer.publicKey, + interfaceOptions, + wrap, + ); + + if (ixs.length === 0) { + return null; + } + + const { blockhash } = await rpc.getLatestBlockhash(); + const additionalSigners = dedupeSigner(payer, [owner]); + + const tx = buildAndSignTx( + [ComputeBudgetProgram.setComputeUnitLimit({ units: 500_000 }), ...ixs], + payer, + blockhash, + additionalSigners, + ); + + return sendAndConfirmTx(rpc, tx, confirmOptions); +} diff --git a/js/compressed-token/src/mint/actions/mint-to-compressed.ts b/js/compressed-token/src/v3/actions/mint-to-compressed.ts similarity index 77% rename from js/compressed-token/src/mint/actions/mint-to-compressed.ts rename to js/compressed-token/src/v3/actions/mint-to-compressed.ts index b34553199c..2e6b7367fd 100644 --- a/js/compressed-token/src/mint/actions/mint-to-compressed.ts +++ b/js/compressed-token/src/v3/actions/mint-to-compressed.ts @@ -13,18 +13,30 @@ import { bn, CTOKEN_PROGRAM_ID, selectStateTreeInfo, + TreeInfo, } from '@lightprotocol/stateless.js'; import { createMintToCompressedInstruction } from '../instructions/mint-to-compressed'; -import { getMintInterface } from '../helpers'; +import { getMintInterface } from '../get-mint-interface'; +/** + * Mint compressed tokens directly to compressed accounts. + * + * @param rpc RPC connection + * @param payer Fee payer + * @param mint Mint address + * @param authority Mint authority (must sign) + * @param recipients Array of recipients with amounts + * @param outputStateTreeInfo Optional output state tree info (auto-fetched if not provided) + * @param tokenAccountVersion Token account version (default: 3) + * @param confirmOptions Optional confirm options + */ export async function mintToCompressed( rpc: Rpc, payer: Signer, mint: PublicKey, authority: Signer, recipients: Array<{ recipient: PublicKey; amount: number | bigint }>, - outputQueue?: PublicKey, - tokensOutQueue?: PublicKey, + outputStateTreeInfo?: TreeInfo, tokenAccountVersion: number = 3, confirmOptions?: ConfirmOptions, ): Promise { @@ -39,14 +51,10 @@ export async function mintToCompressed( throw new Error('Mint does not have MerkleContext'); } - if (!outputQueue) { + // Auto-fetch output state tree info if not provided + if (!outputStateTreeInfo) { const trees = await rpc.getStateTreeInfos(); - const tree = selectStateTreeInfo(trees); - outputQueue = tree.queue; - } - - if (!tokensOutQueue) { - tokensOutQueue = outputQueue; + outputStateTreeInfo = selectStateTreeInfo(trees); } const validityProof = await rpc.getValidityProofV2( @@ -85,9 +93,8 @@ export async function mintToCompressed( } : undefined, }, - outputQueue, - tokensOutQueue, recipients, + outputStateTreeInfo, tokenAccountVersion, ); diff --git a/js/compressed-token/src/mint/actions/mint-to-interface.ts b/js/compressed-token/src/v3/actions/mint-to-interface.ts similarity index 96% rename from js/compressed-token/src/mint/actions/mint-to-interface.ts rename to js/compressed-token/src/v3/actions/mint-to-interface.ts index b56b048f0d..d9fbbd6962 100644 --- a/js/compressed-token/src/mint/actions/mint-to-interface.ts +++ b/js/compressed-token/src/v3/actions/mint-to-interface.ts @@ -13,11 +13,11 @@ import { bn, } from '@lightprotocol/stateless.js'; import { createMintToInterfaceInstruction } from '../instructions/mint-to-interface'; -import { getMintInterface } from '../helpers'; +import { getMintInterface } from '../get-mint-interface'; /** * Mint tokens to a decompressed/onchain token account. - * Works with SPL, Token-2022, and compressed token (CToken) mints. + * Works with SPL, Token-2022, and compressed token (c-token) mints. * * This function ONLY mints to decompressed onchain token accounts, never to compressed accounts. * The signature matches the standard SPL mintTo for simplicity and consistency. diff --git a/js/compressed-token/src/mint/actions/mint-to.ts b/js/compressed-token/src/v3/actions/mint-to.ts similarity index 98% rename from js/compressed-token/src/mint/actions/mint-to.ts rename to js/compressed-token/src/v3/actions/mint-to.ts index 0e3f78c9ee..480541e0f7 100644 --- a/js/compressed-token/src/mint/actions/mint-to.ts +++ b/js/compressed-token/src/v3/actions/mint-to.ts @@ -16,7 +16,7 @@ import { TreeInfo, } from '@lightprotocol/stateless.js'; import { createMintToInstruction } from '../instructions/mint-to'; -import { getMintInterface } from '../helpers'; +import { getMintInterface } from '../get-mint-interface'; export async function mintTo( rpc: Rpc, diff --git a/js/compressed-token/src/mint/actions/transfer-interface.ts b/js/compressed-token/src/v3/actions/transfer-interface.ts similarity index 67% rename from js/compressed-token/src/mint/actions/transfer-interface.ts rename to js/compressed-token/src/v3/actions/transfer-interface.ts index 6c64f0986d..d780a51604 100644 --- a/js/compressed-token/src/mint/actions/transfer-interface.ts +++ b/js/compressed-token/src/v3/actions/transfer-interface.ts @@ -20,27 +20,27 @@ import { getAssociatedTokenAddressSync, } from '@solana/spl-token'; import BN from 'bn.js'; -import { getATAProgramId } from '../../utils'; +import { getAtaProgramId } from '../ata-utils'; import { createTransferInterfaceInstruction, createCTokenTransferInstruction, } from '../instructions/transfer-interface'; -import { createAssociatedTokenAccountInterfaceIdempotentInstruction } from '../instructions/create-associated-ctoken'; -import { getATAAddressInterface } from './create-ata-interface'; +import { createAssociatedTokenAccountInterfaceIdempotentInstruction } from '../instructions/create-ata-interface'; +import { getAssociatedTokenAddressInterface } from '../get-associated-token-address-interface'; import { - getTokenPoolInfos, - TokenPoolInfo, + getSplInterfaceInfos, + SplInterfaceInfo, } from '../../utils/get-token-pool-infos'; import { createWrapInstruction } from '../instructions/wrap'; -import { createDecompress2Instruction } from '../instructions/decompress2'; -import { getATAInterface } from '../get-account-interface'; +import { createDecompressInterfaceInstruction } from '../instructions/create-decompress-interface-instruction'; +import { getAtaInterface } from '../get-account-interface'; /** * Options for interface operations (load, transfer) */ export interface InterfaceOptions { - /** Token pool infos (fetched if not provided) */ - tokenPoolInfos?: TokenPoolInfo[]; + /** SPL interface infos (fetched if not provided) */ + splInterfaceInfos?: SplInterfaceInfo[]; } /** @@ -51,7 +51,7 @@ function calculateComputeUnits( hasValidityProof: boolean, splWrapCount: number, ): number { - // Base CU for hot CToken transfer + // Base CU for hot c-token transfer let cu = 5_000; // Compressed token decompression @@ -73,54 +73,49 @@ function calculateComputeUnits( } /** - * Transfer tokens using the CToken interface. - * Mirrors SPL Token's transfer() - destination must exist. + * Transfer tokens using the c-token interface. * - * This action: - * 1. Validates source matches derived ATA from owner + mint - * 2. Loads ALL sender balances to CToken ATA (SPL, T22, compressed) - * 3. Executes hot-to-hot transfer - * - * Note: Like SPL Token, this does NOT create the destination ATA. - * Use getOrCreateATAInterface() first if destination may not exist. + * Matches SPL Token's transferChecked signature order. Destination must exist. * * @param rpc RPC connection * @param payer Fee payer (signer) - * @param source Source CToken ATA address - * @param destination Destination CToken ATA address (must exist) - * @param owner Source owner (signer) + * @param source Source c-token ATA address * @param mint Mint address + * @param destination Destination c-token ATA address (must exist) + * @param owner Source owner (signer) * @param amount Amount to transfer * @param programId Token program ID (default: CTOKEN_PROGRAM_ID) * @param confirmOptions Optional confirm options * @param options Optional interface options + * @param wrap Include SPL/T22 wrapping (default: false) * @returns Transaction signature */ export async function transferInterface( rpc: Rpc, payer: Signer, source: PublicKey, + mint: PublicKey, destination: PublicKey, owner: Signer, - mint: PublicKey, amount: number | bigint | BN, programId: PublicKey = CTOKEN_PROGRAM_ID, confirmOptions?: ConfirmOptions, options?: InterfaceOptions, + wrap = false, ): Promise { const amountBigInt = BigInt(amount.toString()); - const { tokenPoolInfos: providedTokenPoolInfos } = options ?? {}; + const { splInterfaceInfos: providedSplInterfaceInfos } = options ?? {}; const instructions: TransactionInstruction[] = []; - // For non-CToken programs, use simple SPL transfer (no load) + // For non-c-token programs, use simple SPL transfer (no load) if (!programId.equals(CTOKEN_PROGRAM_ID)) { const expectedSource = getAssociatedTokenAddressSync( mint, owner.publicKey, false, programId, - getATAProgramId(programId), + getAtaProgramId(programId), ); if (!source.equals(expectedSource)) { throw new Error( @@ -152,40 +147,66 @@ export async function transferInterface( return sendAndConfirmTx(rpc, tx, confirmOptions); } - // CToken transfer - const expectedSource = getATAAddressInterface(mint, owner.publicKey); + // c-token transfer + const expectedSource = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); if (!source.equals(expectedSource)) { throw new Error( `Source mismatch. Expected ${expectedSource.toBase58()}, got ${source.toBase58()}`, ); } - const ctokenAta = getATAAddressInterface(mint, owner.publicKey); - - // Derive ATAs for all token programs (sender only) - const splAta = getAssociatedTokenAddressSync( - mint, - owner.publicKey, - false, - TOKEN_PROGRAM_ID, - getATAProgramId(TOKEN_PROGRAM_ID), - ); - const t22Ata = getAssociatedTokenAddressSync( + const ctokenAtaAddress = getAssociatedTokenAddressInterface( mint, owner.publicKey, - false, - TOKEN_2022_PROGRAM_ID, - getATAProgramId(TOKEN_2022_PROGRAM_ID), ); - // Fetch sender's accounts in parallel - const [ctokenAtaInfo, splAtaInfo, t22AtaInfo, compressedResult] = - await Promise.all([ - rpc.getAccountInfo(ctokenAta), - rpc.getAccountInfo(splAta), - rpc.getAccountInfo(t22Ata), - rpc.getCompressedTokenAccountsByOwner(owner.publicKey, { mint }), - ]); + // Derive SPL/T22 ATAs only if wrap is true + let splAta: PublicKey | undefined; + let t22Ata: PublicKey | undefined; + + if (wrap) { + splAta = getAssociatedTokenAddressSync( + mint, + owner.publicKey, + false, + TOKEN_PROGRAM_ID, + getAtaProgramId(TOKEN_PROGRAM_ID), + ); + t22Ata = getAssociatedTokenAddressSync( + mint, + owner.publicKey, + false, + TOKEN_2022_PROGRAM_ID, + getAtaProgramId(TOKEN_2022_PROGRAM_ID), + ); + } + + // Fetch sender's accounts in parallel (conditionally include SPL/T22) + const fetchPromises: Promise[] = [ + rpc.getAccountInfo(ctokenAtaAddress), + rpc.getCompressedTokenAccountsByOwner(owner.publicKey, { mint }), + ]; + if (wrap && splAta && t22Ata) { + fetchPromises.push(rpc.getAccountInfo(splAta)); + fetchPromises.push(rpc.getAccountInfo(t22Ata)); + } + + const results = await Promise.all(fetchPromises); + const ctokenAtaInfo = results[0] as Awaited< + ReturnType + >; + const compressedResult = results[1] as Awaited< + ReturnType + >; + const splAtaInfo = wrap + ? (results[2] as Awaited>) + : null; + const t22AtaInfo = wrap + ? (results[3] as Awaited>) + : null; const compressedAccounts = compressedResult.items; @@ -195,11 +216,11 @@ export async function transferInterface( ? ctokenAtaInfo.data.readBigUInt64LE(64) : BigInt(0); const splBalance = - splAtaInfo && splAtaInfo.data.length >= 72 + wrap && splAtaInfo && splAtaInfo.data.length >= 72 ? splAtaInfo.data.readBigUInt64LE(64) : BigInt(0); const t22Balance = - t22AtaInfo && t22AtaInfo.data.length >= 72 + wrap && t22AtaInfo && t22AtaInfo.data.length >= 72 ? t22AtaInfo.data.readBigUInt64LE(64) : BigInt(0); const compressedBalance = compressedAccounts.reduce( @@ -221,12 +242,12 @@ export async function transferInterface( let hasValidityProof = false; let compressedToLoad: ParsedTokenAccount[] = []; - // Create sender's CToken ATA if needed (idempotent) + // Create sender's c-token ATA if needed (idempotent) if (!ctokenAtaInfo) { instructions.push( createAssociatedTokenAccountInterfaceIdempotentInstruction( payer.publicKey, - ctokenAta, + ctokenAtaAddress, owner.publicKey, mint, CTOKEN_PROGRAM_ID, @@ -234,42 +255,42 @@ export async function transferInterface( ); } - // Get token pool infos if we need to load + // Get SPL interface infos if we need to load const needsLoad = splBalance > BigInt(0) || t22Balance > BigInt(0) || compressedBalance > BigInt(0); - const tokenPoolInfos = needsLoad - ? (providedTokenPoolInfos ?? (await getTokenPoolInfos(rpc, mint))) + const splInterfaceInfos = needsLoad + ? (providedSplInterfaceInfos ?? (await getSplInterfaceInfos(rpc, mint))) : []; - const tokenPoolInfo = tokenPoolInfos.find(info => info.isInitialized); + const splInterfaceInfo = splInterfaceInfos.find(info => info.isInitialized); - // Wrap SPL tokens if balance exists - if (splBalance > BigInt(0) && tokenPoolInfo) { + // Wrap SPL tokens if balance exists (only when wrap=true) + if (wrap && splAta && splBalance > BigInt(0) && splInterfaceInfo) { instructions.push( createWrapInstruction( splAta, - ctokenAta, + ctokenAtaAddress, owner.publicKey, mint, splBalance, - tokenPoolInfo, + splInterfaceInfo, payer.publicKey, ), ); splWrapCount++; } - // Wrap T22 tokens if balance exists - if (t22Balance > BigInt(0) && tokenPoolInfo) { + // Wrap T22 tokens if balance exists (only when wrap=true) + if (wrap && t22Ata && t22Balance > BigInt(0) && splInterfaceInfo) { instructions.push( createWrapInstruction( t22Ata, - ctokenAta, + ctokenAtaAddress, owner.publicKey, mint, t22Balance, - tokenPoolInfo, + splInterfaceInfo, payer.publicKey, ), ); @@ -290,13 +311,12 @@ export async function transferInterface( compressedToLoad = compressedAccounts; instructions.push( - createDecompress2Instruction( + createDecompressInterfaceInstruction( payer.publicKey, compressedAccounts, - ctokenAta, + ctokenAtaAddress, compressedBalance, - proof.compressedProof, - proof.rootIndices, + proof, ), ); } diff --git a/js/compressed-token/src/v3/actions/unwrap.ts b/js/compressed-token/src/v3/actions/unwrap.ts new file mode 100644 index 0000000000..7ef1faf21d --- /dev/null +++ b/js/compressed-token/src/v3/actions/unwrap.ts @@ -0,0 +1,151 @@ +import { + ComputeBudgetProgram, + ConfirmOptions, + PublicKey, + Signer, + TransactionSignature, +} from '@solana/web3.js'; +import { + Rpc, + buildAndSignTx, + sendAndConfirmTx, + dedupeSigner, +} from '@lightprotocol/stateless.js'; +import BN from 'bn.js'; +import { createUnwrapInstruction } from '../instructions/unwrap'; +import { + getSplInterfaceInfos, + SplInterfaceInfo, +} from '../../utils/get-token-pool-infos'; +import { getAssociatedTokenAddressInterface } from '../get-associated-token-address-interface'; +import { loadAta as _loadAta } from './load-ata'; + +export interface UnwrapParams { + rpc: Rpc; + payer: Signer; + owner: Signer; + mint: PublicKey; + destination: PublicKey; + amount?: number | bigint | BN; + splInterfaceInfo?: SplInterfaceInfo; + confirmOptions?: ConfirmOptions; +} + +export interface UnwrapResult { + transactionSignature: TransactionSignature; +} + +/** + * Unwrap c-tokens to SPL tokens. + * + * This is the reverse of wrap: converts c-token balance to SPL/T22 balance. + * Destination SPL/T22 ATA must already exist (same as SPL token transfer pattern). + * + * Flow: + * 1. Consolidate all c-token balances (cold -> hot) via loadAta + * 2. Transfer from c-token hot ATA to SPL ATA via token pool + * + * @param rpc RPC connection + * @param payer Fee payer + * @param owner Owner of the c-token (signer) + * @param mint Mint address + * @param destination Destination SPL/T22 token account (must exist) + * @param amount Optional: specific amount to unwrap (defaults to all) + * @param splInterfaceInfo Optional: SPL interface info (will be fetched if not provided) + * @param confirmOptions Optional: confirm options + * + * @example + * // Unwrap to existing SPL ATA + * const splAta = getAssociatedTokenAddressSync(mint, owner.publicKey); + * await unwrap(rpc, payer, owner, mint, splAta, 1000n); + * + * @returns Transaction signature + */ +export async function unwrap( + rpc: Rpc, + payer: Signer, + owner: Signer, + mint: PublicKey, + destination: PublicKey, + amount?: number | bigint | BN, + splInterfaceInfo?: SplInterfaceInfo, + confirmOptions?: ConfirmOptions, +): Promise { + // 1. Get SPL interface info if not provided + let resolvedSplInterfaceInfo = splInterfaceInfo; + if (!resolvedSplInterfaceInfo) { + const splInterfaceInfos = await getSplInterfaceInfos(rpc, mint); + resolvedSplInterfaceInfo = splInterfaceInfos.find( + info => info.isInitialized, + ); + + if (!resolvedSplInterfaceInfo) { + throw new Error( + `No initialized SPL interface found for mint: ${mint.toBase58()}. ` + + `Please create an SPL interface via createSplInterface().`, + ); + } + } + + // 2. Verify destination exists (SPL token pattern - destination must exist) + const destAtaInfo = await rpc.getAccountInfo(destination); + if (!destAtaInfo) { + throw new Error( + `Destination account does not exist: ${destination.toBase58()}. ` + + `Create it first using getOrCreateAssociatedTokenAccount or createAssociatedTokenAccountIdempotentInstruction.`, + ); + } + + // 3. Load all tokens to c-token hot ATA first (consolidate cold -> hot) + const ctokenAta = getAssociatedTokenAddressInterface(mint, owner.publicKey); + await _loadAta(rpc, ctokenAta, owner, mint, payer, confirmOptions); + + // 4. Check c-token hot balance + const ctokenAccountInfo = await rpc.getAccountInfo(ctokenAta); + if (!ctokenAccountInfo) { + throw new Error('No c-token ATA found after loading'); + } + + // Parse c-token account balance (offset 64 for amount in token account layout) + const data = ctokenAccountInfo.data; + const ctokenBalance = data.readBigUInt64LE(64); + + if (ctokenBalance === BigInt(0)) { + throw new Error('No c-token balance to unwrap'); + } + + // 5. Determine amount to unwrap + const unwrapAmount = amount ? BigInt(amount.toString()) : ctokenBalance; + + if (unwrapAmount > ctokenBalance) { + throw new Error( + `Insufficient c-token balance. Requested: ${unwrapAmount}, Available: ${ctokenBalance}`, + ); + } + + // 6. Build unwrap instruction + const ix = createUnwrapInstruction( + ctokenAta, + destination, + owner.publicKey, + mint, + unwrapAmount, + resolvedSplInterfaceInfo, + payer.publicKey, + ); + + // 7. Build and send transaction + const { blockhash } = await rpc.getLatestBlockhash(); + const additionalSigners = dedupeSigner(payer, [owner]); + + const tx = buildAndSignTx( + [ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), ix], + payer, + blockhash, + additionalSigners, + ); + + const txId = await sendAndConfirmTx(rpc, tx, confirmOptions); + + return { transactionSignature: txId }; +} diff --git a/js/compressed-token/src/mint/actions/update-metadata.ts b/js/compressed-token/src/v3/actions/update-metadata.ts similarity index 51% rename from js/compressed-token/src/mint/actions/update-metadata.ts rename to js/compressed-token/src/v3/actions/update-metadata.ts index be4c80674d..95aa7fefeb 100644 --- a/js/compressed-token/src/mint/actions/update-metadata.ts +++ b/js/compressed-token/src/v3/actions/update-metadata.ts @@ -9,8 +9,6 @@ import { Rpc, buildAndSignTx, sendAndConfirmTx, - TreeInfo, - selectStateTreeInfo, DerivationMode, bn, CTOKEN_PROGRAM_ID, @@ -20,43 +18,50 @@ import { createUpdateMetadataAuthorityInstruction, createRemoveMetadataKeyInstruction, } from '../instructions/update-metadata'; -import { getMintInterface } from '../helpers'; - +import { getMintInterface } from '../get-mint-interface'; + +/** + * Update a metadata field on a compressed token mint. + * + * @param rpc RPC connection + * @param payer Fee payer (signer) + * @param mint Mint address + * @param authority Metadata update authority (signer) + * @param fieldType Field to update: 'name', 'symbol', 'uri', or 'custom' + * @param value New value for the field + * @param customKey Custom key name (required if fieldType is 'custom') + * @param extensionIndex Extension index (default: 0) + * @param confirmOptions Optional confirm options + */ export async function updateMetadataField( rpc: Rpc, payer: Signer, mint: PublicKey, - mintSigner: Signer, authority: Signer, fieldType: 'name' | 'symbol' | 'uri' | 'custom', value: string, customKey?: string, extensionIndex: number = 0, - outputStateTreeInfo?: TreeInfo, confirmOptions?: ConfirmOptions, ): Promise { - outputStateTreeInfo = - outputStateTreeInfo ?? - selectStateTreeInfo(await rpc.getStateTreeInfos()); - - const mintInfo = await getMintInterface( + const mintInterface = await getMintInterface( rpc, mint, confirmOptions?.commitment, CTOKEN_PROGRAM_ID, ); - if (!mintInfo.tokenMetadata || !mintInfo.merkleContext) { + if (!mintInterface.tokenMetadata || !mintInterface.merkleContext) { throw new Error('Mint does not have TokenMetadata extension'); } const validityProof = await rpc.getValidityProofV2( [ { - hash: bn(mintInfo.merkleContext.hash), - leafIndex: mintInfo.merkleContext.leafIndex, - treeInfo: mintInfo.merkleContext.treeInfo, - proveByIndex: mintInfo.merkleContext.proveByIndex, + hash: bn(mintInterface.merkleContext.hash), + leafIndex: mintInterface.merkleContext.leafIndex, + treeInfo: mintInterface.merkleContext.treeInfo, + proveByIndex: mintInterface.merkleContext.proveByIndex, }, ], [], @@ -64,27 +69,10 @@ export async function updateMetadataField( ); const ix = createUpdateMetadataFieldInstruction( - mintSigner.publicKey, + mintInterface, authority.publicKey, payer.publicKey, validityProof, - mintInfo.merkleContext, - { - supply: mintInfo.mint.supply, - decimals: mintInfo.mint.decimals, - mintAuthority: mintInfo.mint.mintAuthority, - freezeAuthority: mintInfo.mint.freezeAuthority, - splMint: mintInfo.mintContext!.splMint, - splMintInitialized: mintInfo.mintContext!.splMintInitialized, - version: mintInfo.mintContext!.version, - metadata: { - updateAuthority: mintInfo.tokenMetadata.updateAuthority || null, - name: mintInfo.tokenMetadata.name, - symbol: mintInfo.tokenMetadata.symbol, - uri: mintInfo.tokenMetadata.uri, - }, - }, - outputStateTreeInfo.queue, fieldType, value, customKey, @@ -106,39 +94,44 @@ export async function updateMetadataField( return await sendAndConfirmTx(rpc, tx, confirmOptions); } +/** + * Update the metadata authority of a compressed token mint. + * + * @param rpc RPC connection + * @param payer Fee payer (signer) + * @param mint Mint address + * @param currentAuthority Current metadata update authority (signer) + * @param newAuthority New metadata update authority + * @param extensionIndex Extension index (default: 0) + * @param confirmOptions Optional confirm options + */ export async function updateMetadataAuthority( rpc: Rpc, payer: Signer, mint: PublicKey, - mintSigner: Signer, currentAuthority: Signer, newAuthority: PublicKey, extensionIndex: number = 0, - outputStateTreeInfo?: TreeInfo, confirmOptions?: ConfirmOptions, ): Promise { - outputStateTreeInfo = - outputStateTreeInfo ?? - selectStateTreeInfo(await rpc.getStateTreeInfos()); - - const mintInfo = await getMintInterface( + const mintInterface = await getMintInterface( rpc, mint, confirmOptions?.commitment, CTOKEN_PROGRAM_ID, ); - if (!mintInfo.tokenMetadata || !mintInfo.merkleContext) { + if (!mintInterface.tokenMetadata || !mintInterface.merkleContext) { throw new Error('Mint does not have TokenMetadata extension'); } const validityProof = await rpc.getValidityProofV2( [ { - hash: bn(mintInfo.merkleContext.hash), - leafIndex: mintInfo.merkleContext.leafIndex, - treeInfo: mintInfo.merkleContext.treeInfo, - proveByIndex: mintInfo.merkleContext.proveByIndex, + hash: bn(mintInterface.merkleContext.hash), + leafIndex: mintInterface.merkleContext.leafIndex, + treeInfo: mintInterface.merkleContext.treeInfo, + proveByIndex: mintInterface.merkleContext.proveByIndex, }, ], [], @@ -146,28 +139,11 @@ export async function updateMetadataAuthority( ); const ix = createUpdateMetadataAuthorityInstruction( - mintSigner.publicKey, + mintInterface, currentAuthority.publicKey, newAuthority, payer.publicKey, validityProof, - mintInfo.merkleContext, - { - supply: mintInfo.mint.supply, - decimals: mintInfo.mint.decimals, - mintAuthority: mintInfo.mint.mintAuthority, - freezeAuthority: mintInfo.mint.freezeAuthority, - splMint: mintInfo.mintContext!.splMint, - splMintInitialized: mintInfo.mintContext!.splMintInitialized, - version: mintInfo.mintContext!.version, - metadata: { - updateAuthority: mintInfo.tokenMetadata.updateAuthority || null, - name: mintInfo.tokenMetadata.name, - symbol: mintInfo.tokenMetadata.symbol, - uri: mintInfo.tokenMetadata.uri, - }, - }, - outputStateTreeInfo.queue, extensionIndex, ); @@ -186,40 +162,46 @@ export async function updateMetadataAuthority( return await sendAndConfirmTx(rpc, tx, confirmOptions); } +/** + * Remove a metadata key from a compressed token mint. + * + * @param rpc RPC connection + * @param payer Fee payer (signer) + * @param mint Mint address + * @param authority Metadata update authority (signer) + * @param key Metadata key to remove + * @param idempotent If true, don't error if key doesn't exist (default: false) + * @param extensionIndex Extension index (default: 0) + * @param confirmOptions Optional confirm options + */ export async function removeMetadataKey( rpc: Rpc, payer: Signer, mint: PublicKey, - mintSigner: Signer, authority: Signer, key: string, idempotent: boolean = false, extensionIndex: number = 0, - outputStateTreeInfo?: TreeInfo, confirmOptions?: ConfirmOptions, ): Promise { - outputStateTreeInfo = - outputStateTreeInfo ?? - selectStateTreeInfo(await rpc.getStateTreeInfos()); - - const mintInfo = await getMintInterface( + const mintInterface = await getMintInterface( rpc, mint, confirmOptions?.commitment, CTOKEN_PROGRAM_ID, ); - if (!mintInfo.tokenMetadata || !mintInfo.merkleContext) { + if (!mintInterface.tokenMetadata || !mintInterface.merkleContext) { throw new Error('Mint does not have TokenMetadata extension'); } const validityProof = await rpc.getValidityProofV2( [ { - hash: bn(mintInfo.merkleContext.hash), - leafIndex: mintInfo.merkleContext.leafIndex, - treeInfo: mintInfo.merkleContext.treeInfo, - proveByIndex: mintInfo.merkleContext.proveByIndex, + hash: bn(mintInterface.merkleContext.hash), + leafIndex: mintInterface.merkleContext.leafIndex, + treeInfo: mintInterface.merkleContext.treeInfo, + proveByIndex: mintInterface.merkleContext.proveByIndex, }, ], [], @@ -227,27 +209,10 @@ export async function removeMetadataKey( ); const ix = createRemoveMetadataKeyInstruction( - mintSigner.publicKey, + mintInterface, authority.publicKey, payer.publicKey, validityProof, - mintInfo.merkleContext, - { - supply: mintInfo.mint.supply, - decimals: mintInfo.mint.decimals, - mintAuthority: mintInfo.mint.mintAuthority, - freezeAuthority: mintInfo.mint.freezeAuthority, - splMint: mintInfo.mintContext!.splMint, - splMintInitialized: mintInfo.mintContext!.splMintInitialized, - version: mintInfo.mintContext!.version, - metadata: { - updateAuthority: mintInfo.tokenMetadata.updateAuthority || null, - name: mintInfo.tokenMetadata.name, - symbol: mintInfo.tokenMetadata.symbol, - uri: mintInfo.tokenMetadata.uri, - }, - }, - outputStateTreeInfo.queue, key, idempotent, extensionIndex, diff --git a/js/compressed-token/src/v3/actions/update-mint.ts b/js/compressed-token/src/v3/actions/update-mint.ts new file mode 100644 index 0000000000..838328e573 --- /dev/null +++ b/js/compressed-token/src/v3/actions/update-mint.ts @@ -0,0 +1,154 @@ +import { + ComputeBudgetProgram, + ConfirmOptions, + PublicKey, + Signer, + TransactionSignature, +} from '@solana/web3.js'; +import { + Rpc, + buildAndSignTx, + sendAndConfirmTx, + DerivationMode, + bn, + CTOKEN_PROGRAM_ID, +} from '@lightprotocol/stateless.js'; +import { + createUpdateMintAuthorityInstruction, + createUpdateFreezeAuthorityInstruction, +} from '../instructions/update-mint'; +import { getMintInterface } from '../get-mint-interface'; + +/** + * Update the mint authority of a compressed token mint. + * + * @param rpc RPC connection + * @param payer Fee payer (signer) + * @param mint Mint address + * @param currentMintAuthority Current mint authority (signer) + * @param newMintAuthority New mint authority (or null to revoke) + * @param confirmOptions Optional confirm options + */ +export async function updateMintAuthority( + rpc: Rpc, + payer: Signer, + mint: PublicKey, + currentMintAuthority: Signer, + newMintAuthority: PublicKey | null, + confirmOptions?: ConfirmOptions, +): Promise { + const mintInterface = await getMintInterface( + rpc, + mint, + confirmOptions?.commitment, + CTOKEN_PROGRAM_ID, + ); + + if (!mintInterface.merkleContext) { + throw new Error('Mint does not have MerkleContext'); + } + + const validityProof = await rpc.getValidityProofV2( + [ + { + hash: bn(mintInterface.merkleContext.hash), + leafIndex: mintInterface.merkleContext.leafIndex, + treeInfo: mintInterface.merkleContext.treeInfo, + proveByIndex: mintInterface.merkleContext.proveByIndex, + }, + ], + [], + DerivationMode.compressible, + ); + + const ix = createUpdateMintAuthorityInstruction( + mintInterface, + currentMintAuthority.publicKey, + newMintAuthority, + payer.publicKey, + validityProof, + ); + + const additionalSigners = currentMintAuthority.publicKey.equals( + payer.publicKey, + ) + ? [] + : [currentMintAuthority]; + + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx( + [ComputeBudgetProgram.setComputeUnitLimit({ units: 500_000 }), ix], + payer, + blockhash, + additionalSigners, + ); + + return await sendAndConfirmTx(rpc, tx, confirmOptions); +} + +/** + * Update the freeze authority of a compressed token mint. + * + * @param rpc RPC connection + * @param payer Fee payer (signer) + * @param mint Mint address + * @param currentFreezeAuthority Current freeze authority (signer) + * @param newFreezeAuthority New freeze authority (or null to revoke) + * @param confirmOptions Optional confirm options + */ +export async function updateFreezeAuthority( + rpc: Rpc, + payer: Signer, + mint: PublicKey, + currentFreezeAuthority: Signer, + newFreezeAuthority: PublicKey | null, + confirmOptions?: ConfirmOptions, +): Promise { + const mintInterface = await getMintInterface( + rpc, + mint, + confirmOptions?.commitment, + CTOKEN_PROGRAM_ID, + ); + + if (!mintInterface.merkleContext) { + throw new Error('Mint does not have MerkleContext'); + } + + const validityProof = await rpc.getValidityProofV2( + [ + { + hash: bn(mintInterface.merkleContext.hash), + leafIndex: mintInterface.merkleContext.leafIndex, + treeInfo: mintInterface.merkleContext.treeInfo, + proveByIndex: mintInterface.merkleContext.proveByIndex, + }, + ], + [], + DerivationMode.compressible, + ); + + const ix = createUpdateFreezeAuthorityInstruction( + mintInterface, + currentFreezeAuthority.publicKey, + newFreezeAuthority, + payer.publicKey, + validityProof, + ); + + const additionalSigners = currentFreezeAuthority.publicKey.equals( + payer.publicKey, + ) + ? [] + : [currentFreezeAuthority]; + + const { blockhash } = await rpc.getLatestBlockhash(); + const tx = buildAndSignTx( + [ComputeBudgetProgram.setComputeUnitLimit({ units: 500_000 }), ix], + payer, + blockhash, + additionalSigners, + ); + + return await sendAndConfirmTx(rpc, tx, confirmOptions); +} diff --git a/js/compressed-token/src/mint/actions/wrap.ts b/js/compressed-token/src/v3/actions/wrap.ts similarity index 71% rename from js/compressed-token/src/mint/actions/wrap.ts rename to js/compressed-token/src/v3/actions/wrap.ts index 5e7c6927bf..7d0db1d0b8 100644 --- a/js/compressed-token/src/mint/actions/wrap.ts +++ b/js/compressed-token/src/v3/actions/wrap.ts @@ -13,8 +13,8 @@ import { } from '@lightprotocol/stateless.js'; import { createWrapInstruction } from '../instructions/wrap'; import { - getTokenPoolInfos, - TokenPoolInfo, + getSplInterfaceInfos, + SplInterfaceInfo, } from '../../utils/get-token-pool-infos'; // Keep old interface type for backwards compatibility export @@ -26,7 +26,7 @@ export interface WrapParams { owner: Signer; mint: PublicKey; amount: bigint; - tokenPoolInfo?: TokenPoolInfo; + splInterfaceInfo?: SplInterfaceInfo; confirmOptions?: ConfirmOptions; } @@ -35,7 +35,7 @@ export interface WrapResult { } /** - * Wrap tokens from an SPL/T22 account to a CToken account. + * Wrap tokens from an SPL/T22 account to a c-token account. * * This is an agnostic action that takes explicit account addresses (spl-token style). * Use getAssociatedTokenAddressSync() to derive ATA addresses if needed. @@ -43,16 +43,16 @@ export interface WrapResult { * @param rpc RPC connection * @param payer Fee payer * @param source Source SPL/T22 token account (any token account, not just ATA) - * @param destination Destination CToken account (any CToken account, not just ATA) + * @param destination Destination c-token account * @param owner Owner/authority of the source account (must sign) * @param mint Mint address * @param amount Amount to wrap - * @param tokenPoolInfo Optional: Token pool info (will be fetched if not provided) + * @param splInterfaceInfo Optional: SPL interface info (will be fetched if not provided) * @param confirmOptions Optional: Confirm options * * @example * const splAta = getAssociatedTokenAddressSync(mint, owner.publicKey, false, TOKEN_PROGRAM_ID); - * const ctokenAta = getATAAddressInterface(mint, owner.publicKey); // defaults to CToken + * const ctokenAta = getAssociatedTokenAddressInterface(mint, owner.publicKey); // defaults to c-token * * await wrap( * rpc, @@ -74,19 +74,21 @@ export async function wrap( owner: Signer, mint: PublicKey, amount: bigint, - tokenPoolInfo?: TokenPoolInfo, + splInterfaceInfo?: SplInterfaceInfo, confirmOptions?: ConfirmOptions, ): Promise { - // Get token pool info if not provided - let resolvedTokenPoolInfo = tokenPoolInfo; - if (!resolvedTokenPoolInfo) { - const tokenPoolInfos = await getTokenPoolInfos(rpc, mint); - resolvedTokenPoolInfo = tokenPoolInfos.find(info => info.isInitialized); + // Get SPL interface info if not provided + let resolvedSplInterfaceInfo = splInterfaceInfo; + if (!resolvedSplInterfaceInfo) { + const splInterfaceInfos = await getSplInterfaceInfos(rpc, mint); + resolvedSplInterfaceInfo = splInterfaceInfos.find( + info => info.isInitialized, + ); - if (!resolvedTokenPoolInfo) { + if (!resolvedSplInterfaceInfo) { throw new Error( - `No initialized token pool found for mint: ${mint.toBase58()}. ` + - `Please create a token pool via createTokenPool().`, + `No initialized SPL interface found for mint: ${mint.toBase58()}. ` + + `Please create an SPL interface via createSplInterface().`, ); } } @@ -98,7 +100,7 @@ export async function wrap( owner.publicKey, mint, amount, - resolvedTokenPoolInfo, + resolvedSplInterfaceInfo, payer.publicKey, ); diff --git a/js/compressed-token/src/v3/ata-utils.ts b/js/compressed-token/src/v3/ata-utils.ts new file mode 100644 index 0000000000..68518bac93 --- /dev/null +++ b/js/compressed-token/src/v3/ata-utils.ts @@ -0,0 +1,130 @@ +import { + ASSOCIATED_TOKEN_PROGRAM_ID, + TOKEN_PROGRAM_ID, + TOKEN_2022_PROGRAM_ID, + getAssociatedTokenAddressSync, +} from '@solana/spl-token'; +import { CTOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; +import { PublicKey } from '@solana/web3.js'; + +/** + * Get the appropriate ATA program ID for a given token program ID + * @param tokenProgramId - The token program ID + * @returns The associated token program ID + */ +export function getAtaProgramId(tokenProgramId: PublicKey): PublicKey { + if (tokenProgramId.equals(CTOKEN_PROGRAM_ID)) { + return CTOKEN_PROGRAM_ID; + } + return ASSOCIATED_TOKEN_PROGRAM_ID; +} + +/** ATA type for validation result */ +export type AtaType = 'spl' | 'token2022' | 'ctoken'; + +/** Result of ATA validation */ +export interface AtaValidationResult { + valid: true; + type: AtaType; + programId: PublicKey; +} + +/** + * Validate that an ATA address matches the expected derivation from mint+owner. + * + * Performance: If programId is provided, only derives and checks that one ATA. + * Otherwise derives all three (SPL, T22, c-token) until a match is found. + * + * @param ata The ATA address to validate + * @param mint Mint address + * @param owner Owner address + * @param programId Optional: if known, only check this program's ATA + * @returns Validation result with detected type, or throws on mismatch + */ +export function validateAtaAddress( + ata: PublicKey, + mint: PublicKey, + owner: PublicKey, + programId?: PublicKey, +): AtaValidationResult { + // Hot path: programId specified - only check that one + if (programId) { + const expected = getAssociatedTokenAddressSync( + mint, + owner, + false, + programId, + getAtaProgramId(programId), + ); + if (ata.equals(expected)) { + return { + valid: true, + type: programIdToAtaType(programId), + programId, + }; + } + throw new Error( + `ATA address mismatch for ${programId.toBase58()}. ` + + `Expected: ${expected.toBase58()}, got: ${ata.toBase58()}`, + ); + } + + // Check c-token first (most common for this codebase) + const ctokenExpected = getAssociatedTokenAddressSync( + mint, + owner, + false, + CTOKEN_PROGRAM_ID, + getAtaProgramId(CTOKEN_PROGRAM_ID), + ); + if (ata.equals(ctokenExpected)) { + return { valid: true, type: 'ctoken', programId: CTOKEN_PROGRAM_ID }; + } + + // Check SPL + const splExpected = getAssociatedTokenAddressSync( + mint, + owner, + false, + TOKEN_PROGRAM_ID, + getAtaProgramId(TOKEN_PROGRAM_ID), + ); + if (ata.equals(splExpected)) { + return { valid: true, type: 'spl', programId: TOKEN_PROGRAM_ID }; + } + + // Check T22 + const t22Expected = getAssociatedTokenAddressSync( + mint, + owner, + false, + TOKEN_2022_PROGRAM_ID, + getAtaProgramId(TOKEN_2022_PROGRAM_ID), + ); + if (ata.equals(t22Expected)) { + return { + valid: true, + type: 'token2022', + programId: TOKEN_2022_PROGRAM_ID, + }; + } + + // No match - invalid ATA + throw new Error( + `ATA address does not match any valid derivation from mint+owner. ` + + `Got: ${ata.toBase58()}, expected one of: ` + + `c-token=${ctokenExpected.toBase58()}, ` + + `SPL=${splExpected.toBase58()}, ` + + `T22=${t22Expected.toBase58()}`, + ); +} + +/** + * Convert programId to AtaType + */ +function programIdToAtaType(programId: PublicKey): AtaType { + if (programId.equals(CTOKEN_PROGRAM_ID)) return 'ctoken'; + if (programId.equals(TOKEN_PROGRAM_ID)) return 'spl'; + if (programId.equals(TOKEN_2022_PROGRAM_ID)) return 'token2022'; + throw new Error(`Unknown program ID: ${programId.toBase58()}`); +} diff --git a/js/compressed-token/src/compressible/derivation.ts b/js/compressed-token/src/v3/derivation.ts similarity index 87% rename from js/compressed-token/src/compressible/derivation.ts rename to js/compressed-token/src/v3/derivation.ts index 06e9f8a920..13b29f8ecf 100644 --- a/js/compressed-token/src/compressible/derivation.ts +++ b/js/compressed-token/src/v3/derivation.ts @@ -7,7 +7,7 @@ import { PublicKey } from '@solana/web3.js'; import { Buffer } from 'buffer'; /** - * Returns the compressed mint address as a Array (32 bytes). + * Returns the compressed mint address as bytes. */ export function deriveCompressedMintAddress( mintSeed: PublicKey, @@ -29,7 +29,7 @@ export const COMPRESSED_MINT_SEED: Buffer = Buffer.from([ ]); /** - * Finds the SPL mint PDA for a compressed mint. + * Finds the SPL mint PDA for a c-token mint. * @param mintSeed The mint seed public key. * @returns [PDA, bump] */ @@ -42,7 +42,7 @@ export function findMintAddress(mintSigner: PublicKey): [PublicKey, number] { } /// Same as "getAssociatedTokenAddress" but returns the bump as well. -/// Uses compressed token program ID. +/// Uses c-token program ID. export function getAssociatedCTokenAddressAndBump( owner: PublicKey, mint: PublicKey, @@ -53,7 +53,7 @@ export function getAssociatedCTokenAddressAndBump( ); } -/// Same as "getAssociatedTokenAddress" but implicitly uses compressed token program ID. +/// Same as "getAssociatedTokenAddress" but with c-token program ID. export function getAssociatedCTokenAddress(owner: PublicKey, mint: PublicKey) { return PublicKey.findProgramAddressSync( [owner.toBuffer(), CTOKEN_PROGRAM_ID.toBuffer(), mint.toBuffer()], diff --git a/js/compressed-token/src/v3/get-account-interface.ts b/js/compressed-token/src/v3/get-account-interface.ts new file mode 100644 index 0000000000..d160670570 --- /dev/null +++ b/js/compressed-token/src/v3/get-account-interface.ts @@ -0,0 +1,807 @@ +import { AccountInfo, Commitment, PublicKey } from '@solana/web3.js'; +import { + TOKEN_PROGRAM_ID, + TOKEN_2022_PROGRAM_ID, + unpackAccount as unpackAccountSPL, + TokenAccountNotFoundError, + getAssociatedTokenAddressSync, + AccountState, + Account, +} from '@solana/spl-token'; +import { + Rpc, + CTOKEN_PROGRAM_ID, + MerkleContext, + CompressedAccountWithMerkleContext, + deriveAddressV2, + bn, + getDefaultAddressTreeInfo, +} from '@lightprotocol/stateless.js'; +import { Buffer } from 'buffer'; +import BN from 'bn.js'; +import { getAtaProgramId, validateAtaAddress } from './ata-utils'; +export { Account, AccountState } from '@solana/spl-token'; +export { ParsedTokenAccount } from '@lightprotocol/stateless.js'; + +export const TokenAccountSourceType = { + Spl: 'spl', + Token2022: 'token2022', + SplCold: 'spl-cold', + Token2022Cold: 'token2022-cold', + CTokenHot: 'ctoken-hot', + CTokenCold: 'ctoken-cold', +} as const; + +export type TokenAccountSourceTypeValue = + (typeof TokenAccountSourceType)[keyof typeof TokenAccountSourceType]; + +/** @internal */ +export interface TokenAccountSource { + type: TokenAccountSourceTypeValue; + address: PublicKey; + amount: bigint; + accountInfo: AccountInfo; + loadContext?: MerkleContext; + parsed: Account; +} + +export interface AccountInterface { + accountInfo: AccountInfo; + parsed: Account; + isCold: boolean; + loadContext?: MerkleContext; + _sources?: TokenAccountSource[]; + _needsConsolidation?: boolean; + _hasDelegate?: boolean; + _anyFrozen?: boolean; + /** True when fetched via getAtaInterface */ + _isAta?: boolean; + /** ATA owner - set by getAtaInterface */ + _owner?: PublicKey; + /** ATA mint - set by getAtaInterface */ + _mint?: PublicKey; +} + +/** @internal */ +function parseTokenData(data: Buffer): { + mint: PublicKey; + owner: PublicKey; + amount: BN; + delegate: PublicKey | null; + state: number; + tlv: Buffer | null; +} | null { + if (!data || data.length === 0) return null; + + try { + let offset = 0; + const mint = new PublicKey(data.slice(offset, offset + 32)); + offset += 32; + const owner = new PublicKey(data.slice(offset, offset + 32)); + offset += 32; + const amount = new BN(data.slice(offset, offset + 8), 'le'); + offset += 8; + const delegateOption = data[offset]; + offset += 1; + const delegate = delegateOption + ? new PublicKey(data.slice(offset, offset + 32)) + : null; + offset += 32; + const state = data[offset]; + offset += 1; + const tlvOption = data[offset]; + offset += 1; + const tlv = tlvOption ? data.slice(offset) : null; + + return { + mint, + owner, + amount, + delegate, + state, + tlv, + }; + } catch (error) { + console.error('Token data parsing error:', error); + return null; + } +} + +/** @internal */ +export function convertTokenDataToAccount( + address: PublicKey, + tokenData: { + mint: PublicKey; + owner: PublicKey; + amount: BN; + delegate: PublicKey | null; + state: number; + tlv: Buffer | null; + }, +): Account { + return { + address, + mint: tokenData.mint, + owner: tokenData.owner, + amount: BigInt(tokenData.amount.toString()), + delegate: tokenData.delegate, + delegatedAmount: BigInt(0), + isInitialized: tokenData.state !== AccountState.Uninitialized, + isFrozen: tokenData.state === AccountState.Frozen, + isNative: false, + rentExemptReserve: null, + closeAuthority: null, + tlvData: tokenData.tlv ? Buffer.from(tokenData.tlv) : Buffer.alloc(0), + }; +} + +/** Convert compressed account to AccountInfo */ +export function toAccountInfo( + compressedAccount: CompressedAccountWithMerkleContext, +): AccountInfo { + const dataDiscriminatorBuffer: Buffer = Buffer.from( + compressedAccount.data!.discriminator, + ); + const dataBuffer: Buffer = Buffer.from(compressedAccount.data!.data); + const data: Buffer = Buffer.concat([dataDiscriminatorBuffer, dataBuffer]); + + return { + executable: false, + owner: compressedAccount.owner, + lamports: compressedAccount.lamports.toNumber(), + data, + rentEpoch: undefined, + }; +} + +/** @internal */ +export function parseCTokenHot( + address: PublicKey, + accountInfo: AccountInfo, +): { + accountInfo: AccountInfo; + loadContext: undefined; + parsed: Account; + isCold: false; +} { + const parsed = parseTokenData(accountInfo.data); + if (!parsed) throw new Error('Invalid token data'); + return { + accountInfo, + loadContext: undefined, + parsed: convertTokenDataToAccount(address, parsed), + isCold: false, + }; +} + +/** @internal */ +export function parseCTokenCold( + address: PublicKey, + compressedAccount: CompressedAccountWithMerkleContext, +): { + accountInfo: AccountInfo; + loadContext: MerkleContext; + parsed: Account; + isCold: true; +} { + const parsed = parseTokenData(compressedAccount.data!.data); + if (!parsed) throw new Error('Invalid token data'); + return { + accountInfo: toAccountInfo(compressedAccount), + loadContext: { + treeInfo: compressedAccount.treeInfo, + hash: compressedAccount.hash, + leafIndex: compressedAccount.leafIndex, + proveByIndex: compressedAccount.proveByIndex, + }, + parsed: convertTokenDataToAccount(address, parsed), + isCold: true, + }; +} +/** + * Retrieve information about a token account of SPL/T22/c-token. + * + * @param rpc RPC connection to use + * @param address Token account address + * @param commitment Desired level of commitment for querying the state + * @param programId Token program ID. If not provided, tries all programs concurrently. + * + * @return Token account information with compression context if applicable + */ +export async function getAccountInterface( + rpc: Rpc, + address: PublicKey, + commitment?: Commitment, + programId?: PublicKey, +): Promise { + return _getAccountInterface(rpc, address, commitment, programId, undefined); +} + +/** + * Retrieve associated token account for a given owner and mint. + * + * @param rpc RPC connection + * @param ata Associated token address + * @param owner Owner public key + * @param mint Mint public key + * @param commitment Optional commitment level + * @param programId Optional program ID + * @param wrap Include SPL/T22 balances (default: false) + * @returns AccountInterface with ATA metadata + */ +export async function getAtaInterface( + rpc: Rpc, + ata: PublicKey, + owner: PublicKey, + mint: PublicKey, + commitment?: Commitment, + programId?: PublicKey, + wrap = false, +): Promise { + // Invariant: ata MUST match a valid derivation from mint+owner. + // Hot path: if programId provided, only validate against that program. + // For wrap=true, additionally require c-token ATA. + const validation = validateAtaAddress(ata, mint, owner, programId); + + if (wrap && validation.type !== 'ctoken') { + throw new Error( + `For wrap=true, ata must be the c-token ATA. Got ${validation.type} ATA instead.`, + ); + } + + // Pass both ata address AND fetchByOwner for proper lookups: + // - address is used for on-chain account fetching + // - fetchByOwner is used for compressed token lookup by owner+mint + const result = await _getAccountInterface( + rpc, + ata, + commitment, + programId, + { + owner, + mint, + }, + wrap, + ); + result._isAta = true; + result._owner = owner; + result._mint = mint; + return result; +} + +/** + * @internal + */ +async function _tryFetchSpl( + rpc: Rpc, + address: PublicKey, + commitment?: Commitment, +): Promise<{ + accountInfo: AccountInfo; + parsed: Account; + isCold: false; + loadContext: undefined; +}> { + const info = await rpc.getAccountInfo(address, commitment); + if (!info || !info.owner.equals(TOKEN_PROGRAM_ID)) { + throw new Error('Not a TOKEN_PROGRAM_ID account'); + } + const account = unpackAccountSPL(address, info, TOKEN_PROGRAM_ID); + return { + accountInfo: info, + parsed: account, + isCold: false, + loadContext: undefined, + }; +} + +/** + * @internal + */ +async function _tryFetchToken2022( + rpc: Rpc, + address: PublicKey, + commitment?: Commitment, +): Promise<{ + accountInfo: AccountInfo; + parsed: Account; + isCold: false; + loadContext: undefined; +}> { + const info = await rpc.getAccountInfo(address, commitment); + if (!info || !info.owner.equals(TOKEN_2022_PROGRAM_ID)) { + throw new Error('Not a TOKEN_2022_PROGRAM_ID account'); + } + const account = unpackAccountSPL(address, info, TOKEN_2022_PROGRAM_ID); + return { + accountInfo: info, + parsed: account, + isCold: false, + loadContext: undefined, + }; +} + +/** + * @internal + */ +async function _tryFetchCTokenHot( + rpc: Rpc, + address: PublicKey, + commitment?: Commitment, +): Promise<{ + accountInfo: AccountInfo; + loadContext: undefined; + parsed: Account; + isCold: false; +}> { + const info = await rpc.getAccountInfo(address, commitment); + if (!info || !info.owner.equals(CTOKEN_PROGRAM_ID)) { + throw new Error('Not a CTOKEN onchain account'); + } + return parseCTokenHot(address, info); +} + +/** + * @internal + */ +async function _tryFetchCTokenColdByOwner( + rpc: Rpc, + owner: PublicKey, + mint: PublicKey, + ataAddress: PublicKey, +): Promise<{ + accountInfo: AccountInfo; + loadContext: MerkleContext; + parsed: Account; + isCold: true; +}> { + const result = await rpc.getCompressedTokenAccountsByOwner(owner, { + mint, + }); + const compressedAccount = + result.items.length > 0 ? result.items[0].compressedAccount : null; + if (!compressedAccount?.data?.data.length) { + throw new Error('Not a compressed token account'); + } + if (!compressedAccount.owner.equals(CTOKEN_PROGRAM_ID)) { + throw new Error('Invalid owner for compressed token'); + } + return parseCTokenCold(ataAddress, compressedAccount); +} + +/** + * @internal + * Fetch compressed token account by deriving its compressed address from the on-chain address. + * Uses deriveAddressV2(address, addressTree, CTOKEN_PROGRAM_ID) to get the compressed address. + * + * Note: This only works for accounts that were **compressed from on-chain** (via compress_accounts_idempotent). + * For tokens minted compressed (via mintTo), use getAtaInterface with owner+mint instead. + */ +async function _tryFetchCTokenColdByAddress( + rpc: Rpc, + address: PublicKey, +): Promise<{ + accountInfo: AccountInfo; + loadContext: MerkleContext; + parsed: Account; + isCold: true; +}> { + // Derive compressed address from on-chain token account address + const addressTree = getDefaultAddressTreeInfo().tree; + const compressedAddress = deriveAddressV2( + address.toBytes(), + addressTree, + CTOKEN_PROGRAM_ID, + ); + + // Fetch by derived compressed address + const compressedAccount = await rpc.getCompressedAccount( + bn(compressedAddress.toBytes()), + ); + + if (!compressedAccount?.data?.data.length) { + throw new Error( + 'Compressed token account not found at derived address. ' + + 'Note: getAccountInterface only finds compressed accounts that were ' + + 'compressed from on-chain (via compress_accounts_idempotent). ' + + 'For tokens minted compressed (via mintTo), use getAtaInterface with owner+mint.', + ); + } + if (!compressedAccount.owner.equals(CTOKEN_PROGRAM_ID)) { + throw new Error('Invalid owner for compressed token'); + } + return parseCTokenCold(address, compressedAccount); +} + +/** + * @internal + * Retrieve information about a token account SPL/T22/c-token. + */ +async function _getAccountInterface( + rpc: Rpc, + address?: PublicKey, + commitment?: Commitment, + programId?: PublicKey, + fetchByOwner?: { + owner: PublicKey; + mint: PublicKey; + }, + wrap = false, +): Promise { + // At least one of address or fetchByOwner is required. + // Both can be provided: address for on-chain lookup, fetchByOwner for + // compressed token lookup by owner+mint (useful for PDA owners where + // address derivation might not work with standard allowOwnerOffCurve=false). + if (!address && !fetchByOwner) { + throw new Error('One of address or fetchByOwner is required'); + } + + // Unified mode (auto-detect: c-token + optional SPL/T22) + if (!programId) { + return getUnifiedAccountInterface( + rpc, + address, + commitment, + fetchByOwner, + wrap, + ); + } + + // c-token-only mode + if (programId.equals(CTOKEN_PROGRAM_ID)) { + return getCTokenAccountInterface( + rpc, + address, + commitment, + fetchByOwner, + ); + } + + // SPL / Token-2022 only + if ( + programId.equals(TOKEN_PROGRAM_ID) || + programId.equals(TOKEN_2022_PROGRAM_ID) + ) { + return getSplOrToken2022AccountInterface( + rpc, + address, + commitment, + programId, + fetchByOwner, + ); + } + + throw new Error(`Unsupported program ID: ${programId.toBase58()}`); +} + +async function getUnifiedAccountInterface( + rpc: Rpc, + address: PublicKey | undefined, + commitment: Commitment | undefined, + fetchByOwner: { owner: PublicKey; mint: PublicKey } | undefined, + wrap: boolean, +): Promise { + // Canonical address for unified mode is always the c-token ATA + const cTokenAta = + address ?? + getAssociatedTokenAddressSync( + fetchByOwner!.mint, + fetchByOwner!.owner, + false, + CTOKEN_PROGRAM_ID, + getAtaProgramId(CTOKEN_PROGRAM_ID), + ); + + const fetchPromises: Promise<{ + accountInfo: AccountInfo; + parsed: Account; + isCold: boolean; + loadContext?: MerkleContext; + }>[] = []; + const fetchTypes: TokenAccountSource['type'][] = []; + const fetchAddresses: PublicKey[] = []; + + // c-token hot + cold + 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 + // c-token address. SPL and T22 ATAs are different from c-token ATAs. + if (!fetchByOwner) { + throw new Error( + 'fetchByOwner is required for wrap=true to derive SPL/T22 addresses', + ); + } + const splTokenAta = getAssociatedTokenAddressSync( + fetchByOwner.mint, + fetchByOwner.owner, + false, + TOKEN_PROGRAM_ID, + getAtaProgramId(TOKEN_PROGRAM_ID), + ); + const token2022Ata = getAssociatedTokenAddressSync( + fetchByOwner.mint, + fetchByOwner.owner, + false, + TOKEN_2022_PROGRAM_ID, + getAtaProgramId(TOKEN_2022_PROGRAM_ID), + ); + + fetchPromises.push(_tryFetchSpl(rpc, splTokenAta, commitment)); + fetchTypes.push(TokenAccountSourceType.Spl); + fetchAddresses.push(splTokenAta); + + fetchPromises.push(_tryFetchToken2022(rpc, token2022Ata, commitment)); + fetchTypes.push(TokenAccountSourceType.Token2022); + fetchAddresses.push(token2022Ata); + } + + const results = await Promise.allSettled(fetchPromises); + + // collect all successful results + const sources: TokenAccountSource[] = []; + + for (let i = 0; i < results.length; i++) { + const result = results[i]; + if (result.status === 'fulfilled') { + const value = result.value; + sources.push({ + type: fetchTypes[i], + address: fetchAddresses[i], + amount: value.parsed.amount, + accountInfo: value.accountInfo, + loadContext: value.loadContext, + parsed: value.parsed, + }); + } + } + + // account not found + if (sources.length === 0) { + const triedPrograms = wrap + ? 'TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, and CTOKEN_PROGRAM_ID (both onchain and compressed)' + : 'CTOKEN_PROGRAM_ID (both onchain and compressed)'; + throw new Error(`Token account not found. Tried ${triedPrograms}.`); + } + + // priority order: c-token hot > c-token cold > SPL/T22 + const priority: TokenAccountSource['type'][] = [ + TokenAccountSourceType.CTokenHot, + TokenAccountSourceType.CTokenCold, + TokenAccountSourceType.Spl, + TokenAccountSourceType.Token2022, + ]; + + sources.sort((a, b) => { + const aIdx = priority.indexOf(a.type); + const bIdx = priority.indexOf(b.type); + return aIdx - bIdx; + }); + + return buildAccountInterfaceFromSources(sources, cTokenAta); +} + +async function getCTokenAccountInterface( + rpc: Rpc, + address: PublicKey | undefined, + commitment: Commitment | undefined, + fetchByOwner?: { owner: PublicKey; mint: PublicKey }, +): Promise { + // Derive address if not provided + if (!address) { + if (!fetchByOwner) { + throw new Error('fetchByOwner is required'); + } + address = getAssociatedTokenAddressSync( + fetchByOwner.mint, + fetchByOwner.owner, + false, + CTOKEN_PROGRAM_ID, + getAtaProgramId(CTOKEN_PROGRAM_ID), + ); + } + + const [onchainResult, compressedResult] = await Promise.allSettled([ + rpc.getAccountInfo(address, commitment), + // Fetch compressed: by owner+mint for ATAs, by address for non-ATAs + fetchByOwner + ? rpc.getCompressedTokenAccountsByOwner(fetchByOwner.owner, { + mint: fetchByOwner.mint, + }) + : rpc.getCompressedTokenAccountsByOwner(address), + ]); + + const onchainAccount = + onchainResult.status === 'fulfilled' ? onchainResult.value : null; + const compressedAccounts = + compressedResult.status === 'fulfilled' + ? compressedResult.value.items.map(item => item.compressedAccount) + : []; + + const sources: TokenAccountSource[] = []; + + // Collect hot (decompressed) c-token account + if (onchainAccount && onchainAccount.owner.equals(CTOKEN_PROGRAM_ID)) { + const parsed = parseCTokenHot(address, onchainAccount); + sources.push({ + type: TokenAccountSourceType.CTokenHot, + address, + amount: parsed.parsed.amount, + accountInfo: onchainAccount, + parsed: parsed.parsed, + }); + } + + // Collect cold (compressed) c-token accounts + for (const compressedAccount of compressedAccounts) { + if ( + compressedAccount && + compressedAccount.data && + compressedAccount.data.data.length > 0 && + compressedAccount.owner.equals(CTOKEN_PROGRAM_ID) + ) { + const parsed = parseCTokenCold(address, compressedAccount); + sources.push({ + type: TokenAccountSourceType.CTokenCold, + address, + amount: parsed.parsed.amount, + accountInfo: parsed.accountInfo, + loadContext: parsed.loadContext, + parsed: parsed.parsed, + }); + } + } + + if (sources.length === 0) { + throw new TokenAccountNotFoundError(); + } + + // Priority: hot > cold + sources.sort((a, b) => { + if (a.type === 'ctoken-hot' && b.type === 'ctoken-cold') return -1; + if (a.type === 'ctoken-cold' && b.type === 'ctoken-hot') return 1; + return 0; + }); + + return buildAccountInterfaceFromSources(sources, address); +} + +async function getSplOrToken2022AccountInterface( + rpc: Rpc, + address: PublicKey | undefined, + commitment: Commitment | undefined, + programId: PublicKey, + fetchByOwner?: { owner: PublicKey; mint: PublicKey }, +): Promise { + if (!address) { + if (!fetchByOwner) { + throw new Error('fetchByOwner is required'); + } + address = getAssociatedTokenAddressSync( + fetchByOwner.mint, + fetchByOwner.owner, + false, + programId, + getAtaProgramId(programId), + ); + } + + const info = await rpc.getAccountInfo(address, commitment); + if (!info) { + throw new TokenAccountNotFoundError(); + } + + const account = unpackAccountSPL(address, info, programId); + + const hotType: TokenAccountSource['type'] = programId.equals( + TOKEN_PROGRAM_ID, + ) + ? TokenAccountSourceType.Spl + : TokenAccountSourceType.Token2022; + + const sources: TokenAccountSource[] = [ + { + type: hotType, + address, + amount: account.amount, + accountInfo: info, + parsed: account, + }, + ]; + + // For ATA-based calls (fetchByOwner present), also include cold (compressed) balances + if (fetchByOwner) { + const compressedResult = await rpc.getCompressedTokenAccountsByOwner( + fetchByOwner.owner, + { + mint: fetchByOwner.mint, + }, + ); + const compressedAccounts = compressedResult.items.map( + item => item.compressedAccount, + ); + + const coldType: TokenAccountSource['type'] = programId.equals( + TOKEN_PROGRAM_ID, + ) + ? TokenAccountSourceType.SplCold + : TokenAccountSourceType.Token2022Cold; + + for (const compressedAccount of compressedAccounts) { + if ( + compressedAccount && + compressedAccount.data && + compressedAccount.data.data.length > 0 && + compressedAccount.owner.equals(CTOKEN_PROGRAM_ID) + ) { + // Represent cold supply as belonging to this SPL/T22 ATA + const parsedCold = parseCTokenCold(address, compressedAccount); + sources.push({ + type: coldType, + address, + amount: parsedCold.parsed.amount, + accountInfo: parsedCold.accountInfo, + loadContext: parsedCold.loadContext, + parsed: parsedCold.parsed, + }); + } + } + } + + return buildAccountInterfaceFromSources(sources, address); +} + +function buildAccountInterfaceFromSources( + sources: TokenAccountSource[], + canonicalAddress: PublicKey, +): AccountInterface { + const totalAmount = sources.reduce( + (sum, src) => sum + src.amount, + BigInt(0), + ); + + const primarySource = sources[0]; + + const hasDelegate = sources.some(src => src.parsed.delegate !== null); + const anyFrozen = sources.some(src => src.parsed.isFrozen); + const needsConsolidation = sources.length > 1; + + const unifiedAccount: Account = { + ...primarySource.parsed, + address: canonicalAddress, + amount: totalAmount, + }; + + const coldTypes: TokenAccountSource['type'][] = [ + 'ctoken-cold', + 'spl-cold', + 'token2022-cold', + ]; + + return { + accountInfo: primarySource.accountInfo!, + parsed: unifiedAccount, + isCold: coldTypes.includes(primarySource.type), + loadContext: primarySource.loadContext, + _sources: sources, + _needsConsolidation: needsConsolidation, + _hasDelegate: hasDelegate, + _anyFrozen: anyFrozen, + }; +} diff --git a/js/compressed-token/src/v3/get-associated-token-address-interface.ts b/js/compressed-token/src/v3/get-associated-token-address-interface.ts new file mode 100644 index 0000000000..aa1b902f01 --- /dev/null +++ b/js/compressed-token/src/v3/get-associated-token-address-interface.ts @@ -0,0 +1,37 @@ +import { PublicKey } from '@solana/web3.js'; +import { getAssociatedTokenAddressSync } from '@solana/spl-token'; +import { CTOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; +import { getAtaProgramId } from './ata-utils'; + +/** + * Derive the canonical associated token address for any of SPL/T22/c-token. + * Defaults to using c-token as the canonical ATA. + * + * @param mint Mint public key + * @param owner Owner public key + * @param allowOwnerOffCurve Allow owner to be a PDA. Default false. + * @param programId Token program ID. Default c-token. + * + * @param associatedTokenProgramId Associated token program ID. Default + * auto-detected. + * @returns Associated token address. + */ +export function getAssociatedTokenAddressInterface( + mint: PublicKey, + owner: PublicKey, + allowOwnerOffCurve = false, + programId: PublicKey = CTOKEN_PROGRAM_ID, + associatedTokenProgramId?: PublicKey, +): PublicKey { + const effectiveAssociatedProgramId = + associatedTokenProgramId ?? getAtaProgramId(programId); + + // by passing program id, user indicates preference for the canonical ATA. + return getAssociatedTokenAddressSync( + mint, + owner, + allowOwnerOffCurve, + programId, + effectiveAssociatedProgramId, + ); +} diff --git a/js/compressed-token/src/mint/helpers.ts b/js/compressed-token/src/v3/get-mint-interface.ts similarity index 80% rename from js/compressed-token/src/mint/helpers.ts rename to js/compressed-token/src/v3/get-mint-interface.ts index a5eb934358..6d2473ab4d 100644 --- a/js/compressed-token/src/mint/helpers.ts +++ b/js/compressed-token/src/v3/get-mint-interface.ts @@ -17,31 +17,31 @@ import { } from '@solana/spl-token'; import { deserializeMint, - CompressedMint, MintContext, TokenMetadata, MintExtension, extractTokenMetadata, -} from './serde'; +} from './layout/layout-mint'; export interface MintInterface { mint: Mint; - programId: PublicKey; // Token program that owns this mint (TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, or CTOKEN_PROGRAM_ID) + programId: PublicKey; merkleContext?: MerkleContext; mintContext?: MintContext; - tokenMetadata?: TokenMetadata; // Parsed metadata (first-class) - extensions?: MintExtension[]; // Raw extensions array (optional) + tokenMetadata?: TokenMetadata; + extensions?: MintExtension[]; } /** - * Get mint interface - supports both SPL and compressed mints - * Supports TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID (SPL), and CTOKEN_PROGRAM_ID (compressed) + * Get unified mint info for SPL/T22/c-token mints. * - * @param rpc - RPC connection - * @param address - The mint address - * @param commitment - Optional commitment level - * @param programId - Token program ID. If not provided, tries all programs to auto-detect - * @returns Object with mint, optional merkleContext, mintContext, and tokenMetadata for compressed mints + * @param rpc RPC connection + * @param address The mint address + * @param commitment Optional commitment level + * @param programId Token program ID. If not provided, tries all programs to + * auto-detect. + * @returns Object with mint, optional merkleContext, mintContext, and + * tokenMetadata */ export async function getMintInterface( rpc: Rpc, @@ -49,7 +49,7 @@ export async function getMintInterface( commitment?: Commitment, programId?: PublicKey, ): Promise { - // Auto-detect: try all three programs in parallel + // try all three programs in parallel if (!programId) { const [tokenResult, token2022Result, compressedResult] = await Promise.allSettled([ @@ -63,7 +63,6 @@ export async function getMintInterface( getMintInterface(rpc, address, commitment, CTOKEN_PROGRAM_ID), ]); - // Return whichever succeeded if (tokenResult.status === 'fulfilled') { return tokenResult.value; } @@ -74,14 +73,12 @@ export async function getMintInterface( return compressedResult.value; } - // None succeeded - mint not found throw new Error( `Mint not found: ${address.toString()}. ` + `Tried TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, and CTOKEN_PROGRAM_ID.`, ); } - // If programId is compressed token program, fetch compressed mint if (programId.equals(CTOKEN_PROGRAM_ID)) { const addressTree = getDefaultAddressTreeInfo().tree; const compressedAddress = deriveAddressV2( @@ -120,7 +117,6 @@ export async function getMintInterface( proveByIndex: compressedAccount.proveByIndex, }; - // Extract and parse TokenMetadata const tokenMetadata = extractTokenMetadata( compressedMintData.extensions, ); @@ -134,7 +130,6 @@ export async function getMintInterface( extensions: compressedMintData.extensions || undefined, }; - // Validate: CTOKEN_PROGRAM_ID requires merkleContext and mintContext if (programId.equals(CTOKEN_PROGRAM_ID)) { if (!result.merkleContext) { throw new Error( @@ -151,20 +146,18 @@ export async function getMintInterface( return result; } - // Otherwise, fetch SPL mint (TOKEN_PROGRAM_ID or TOKEN_2022_PROGRAM_ID) + // Otherwise, fetch SPL/T22 mint const mint = await getSplMint(rpc, address, commitment, programId); return { mint, programId }; } /** - * Unpack mint interface from raw account data - * Handles both SPL and compressed mint formats - * Note: merkleContext not available from raw data, use getMintInterface for full context + * Unpack mint info from raw account data for SPL/T22/c-token. * - * @param address - The mint pubkey - * @param data - The raw account data or AccountInfo - * @param programId - Token program ID (defaults to TOKEN_PROGRAM_ID) - * @returns Object with mint, optional mintContext and tokenMetadata for compressed mints + * @param address The mint pubkey + * @param data The raw account data or AccountInfo + * @param programId Token program ID. Default c-token. + * @returns Object with mint, optional mintContext and tokenMetadata. */ export function unpackMintInterface( address: PublicKey, @@ -224,10 +217,10 @@ export function unpackMintInterface( } /** - * Unpack compressed mint context and metadata from raw account data + * Unpack c-token mint context and metadata from raw account data * - * @param data - The raw account data - * @returns Object with mintContext, tokenMetadata, and extensions + * @param data The raw account data + * @returns Object with mintContext, tokenMetadata, and extensions */ export function unpackMintData(data: Buffer | Uint8Array): { mintContext: MintContext; diff --git a/js/compressed-token/src/v3/index.ts b/js/compressed-token/src/v3/index.ts new file mode 100644 index 0000000000..0b6628e694 --- /dev/null +++ b/js/compressed-token/src/v3/index.ts @@ -0,0 +1,8 @@ +export * from './instructions'; +export * from './actions'; +export * from './get-mint-interface'; +export * from './get-account-interface'; +export * from './get-associated-token-address-interface'; +export * from './layout'; +export * from './ata-utils'; +export * from './derivation'; diff --git a/js/compressed-token/src/mint/instructions/create-associated-ctoken.ts b/js/compressed-token/src/v3/instructions/create-associated-ctoken.ts similarity index 51% rename from js/compressed-token/src/mint/instructions/create-associated-ctoken.ts rename to js/compressed-token/src/v3/instructions/create-associated-ctoken.ts index 137b83d3cb..f43f0c6574 100644 --- a/js/compressed-token/src/mint/instructions/create-associated-ctoken.ts +++ b/js/compressed-token/src/v3/instructions/create-associated-ctoken.ts @@ -5,60 +5,55 @@ import { } from '@solana/web3.js'; import { Buffer } from 'buffer'; import { CTOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; -import { - ASSOCIATED_TOKEN_PROGRAM_ID, - TOKEN_PROGRAM_ID, - createAssociatedTokenAccountInstruction as createSplAssociatedTokenAccountInstruction, - createAssociatedTokenAccountIdempotentInstruction as createSplAssociatedTokenAccountIdempotentInstruction, -} from '@solana/spl-token'; -import { struct, u8, publicKey, option, vec } from '@coral-xyz/borsh'; -import { getATAProgramId } from '../../utils'; +import { struct, u8, u32, option, vec, array } from '@coral-xyz/borsh'; const CREATE_ASSOCIATED_TOKEN_ACCOUNT_DISCRIMINATOR = Buffer.from([100]); const CREATE_ASSOCIATED_TOKEN_ACCOUNT_IDEMPOTENT_DISCRIMINATOR = Buffer.from([ 102, ]); +// Matches Rust CompressToPubkey struct +const CompressToPubkeyLayout = struct([ + u8('bump'), + array(u8(), 32, 'programId'), + vec(vec(u8()), 'seeds'), +]); + +// Matches Rust CompressibleExtensionInstructionData struct const CompressibleExtensionInstructionDataLayout = struct([ - u8('rentPayment'), - u8('writeTopUp'), - option(struct([vec(u8(), 'seeds'), u8('bump')]), 'compressToAccountPubkey'), u8('tokenAccountVersion'), + u8('rentPayment'), + u8('hasTopUp'), + u8('compressionOnly'), + u32('writeTopUp'), + option(CompressToPubkeyLayout, 'compressToAccountPubkey'), ]); const CreateAssociatedTokenAccountInstructionDataLayout = struct([ - publicKey('owner'), - publicKey('mint'), u8('bump'), option(CompressibleExtensionInstructionDataLayout, 'compressibleConfig'), ]); +export interface CompressToPubkey { + bump: number; + programId: number[]; + seeds: number[][]; +} + export interface CompressibleConfig { + tokenAccountVersion: number; rentPayment: number; + hasTopUp: number; + compressionOnly: number; writeTopUp: number; - compressToAccountPubkey?: { - seeds: number[]; - bump: number; - }; - tokenAccountVersion: number; + compressToAccountPubkey?: CompressToPubkey | null; } export interface CreateAssociatedCTokenAccountParams { - owner: PublicKey; - mint: PublicKey; bump: number; compressibleConfig?: CompressibleConfig; } -/** - * CToken-specific config for createAssociatedTokenAccountInterfaceInstruction - */ -export interface CTokenConfig { - compressibleConfig?: CompressibleConfig; - configAccount?: PublicKey; - rentPayerPda?: PublicKey; -} - function getAssociatedCTokenAddressAndBump( owner: PublicKey, mint: PublicKey, @@ -76,8 +71,6 @@ function encodeCreateAssociatedCTokenAccountData( const buffer = Buffer.alloc(2000); const len = CreateAssociatedTokenAccountInstructionDataLayout.encode( { - owner: params.owner, - mint: params.mint, bump: params.bump, compressibleConfig: params.compressibleConfig || null, }, @@ -110,6 +103,7 @@ export interface CreateAssociatedCTokenAccountInstructionParams { * @param configAccount Optional config account. * @param rentPayerPda Optional rent payer PDA. */ +// TODO: use createAssociatedCTokenAccount2. export function createAssociatedCTokenAccountInstruction( feePayer: PublicKey, owner: PublicKey, @@ -125,15 +119,22 @@ export function createAssociatedCTokenAccountInstruction( const data = encodeCreateAssociatedCTokenAccountData( { - owner, - mint, bump, compressibleConfig, }, false, ); + // Account order per Rust processor: + // 0. owner (non-mut, non-signer) + // 1. mint (non-mut, non-signer) + // 2. fee_payer (signer, mut) + // 3. associated_token_account (mut) + // 4. system_program + // 5. optional accounts (config, rent_payer, etc.) const keys = [ + { pubkey: owner, isSigner: false, isWritable: false }, + { pubkey: mint, isSigner: false, isWritable: false }, { pubkey: feePayer, isSigner: true, isWritable: true }, { pubkey: associatedTokenAccount, @@ -182,8 +183,6 @@ export function createAssociatedCTokenAccountIdempotentInstruction( const data = encodeCreateAssociatedCTokenAccountData( { - owner, - mint, bump, compressibleConfig, }, @@ -191,6 +190,8 @@ export function createAssociatedCTokenAccountIdempotentInstruction( ); const keys = [ + { pubkey: owner, isSigner: false, isWritable: false }, + { pubkey: mint, isSigner: false, isWritable: false }, { pubkey: feePayer, isSigner: true, isWritable: true }, { pubkey: associatedTokenAccount, @@ -213,112 +214,3 @@ export function createAssociatedCTokenAccountIdempotentInstruction( data, }); } - -// Keep old interface type for backwards compatibility export -export interface CreateAssociatedTokenAccountInterfaceInstructionParams { - payer: PublicKey; - associatedToken: PublicKey; - owner: PublicKey; - mint: PublicKey; - programId?: PublicKey; - associatedTokenProgramId?: PublicKey; - compressibleConfig?: CompressibleConfig; - configAccount?: PublicKey; - rentPayerPda?: PublicKey; -} - -/** - * Create instruction for creating an associated token account (SPL, Token-2022, or CToken). - * Follows SPL Token API signature with optional CToken config at the end. - * - * @param payer Fee payer public key. - * @param associatedToken Associated token account address. - * @param owner Owner of the associated token account. - * @param mint Mint address. - * @param programId Token program ID (default: TOKEN_PROGRAM_ID). - * @param associatedTokenProgramId Associated token program ID. - * @param ctokenConfig Optional CToken-specific configuration. - */ -export function createAssociatedTokenAccountInterfaceInstruction( - payer: PublicKey, - associatedToken: PublicKey, - owner: PublicKey, - mint: PublicKey, - programId: PublicKey = TOKEN_PROGRAM_ID, - associatedTokenProgramId?: PublicKey, - ctokenConfig?: CTokenConfig, -): TransactionInstruction { - const effectiveAssociatedTokenProgramId = - associatedTokenProgramId ?? getATAProgramId(programId); - - if (programId.equals(CTOKEN_PROGRAM_ID)) { - return createAssociatedCTokenAccountInstruction( - payer, - owner, - mint, - ctokenConfig?.compressibleConfig, - ctokenConfig?.configAccount, - ctokenConfig?.rentPayerPda, - ); - } else { - return createSplAssociatedTokenAccountInstruction( - payer, - associatedToken, - owner, - mint, - programId, - effectiveAssociatedTokenProgramId, - ); - } -} - -/** - * Create idempotent instruction for creating an associated token account (SPL, Token-2022, or CToken). - * Follows SPL Token API signature with optional CToken config at the end. - * - * @param payer Fee payer public key. - * @param associatedToken Associated token account address. - * @param owner Owner of the associated token account. - * @param mint Mint address. - * @param programId Token program ID (default: TOKEN_PROGRAM_ID). - * @param associatedTokenProgramId Associated token program ID. - * @param ctokenConfig Optional CToken-specific configuration. - */ -export function createAssociatedTokenAccountInterfaceIdempotentInstruction( - payer: PublicKey, - associatedToken: PublicKey, - owner: PublicKey, - mint: PublicKey, - programId: PublicKey = TOKEN_PROGRAM_ID, - associatedTokenProgramId?: PublicKey, - ctokenConfig?: CTokenConfig, -): TransactionInstruction { - const effectiveAssociatedTokenProgramId = - associatedTokenProgramId ?? getATAProgramId(programId); - - if (programId.equals(CTOKEN_PROGRAM_ID)) { - return createAssociatedCTokenAccountIdempotentInstruction( - payer, - owner, - mint, - ctokenConfig?.compressibleConfig, - ctokenConfig?.configAccount, - ctokenConfig?.rentPayerPda, - ); - } else { - return createSplAssociatedTokenAccountIdempotentInstruction( - payer, - associatedToken, - owner, - mint, - programId, - effectiveAssociatedTokenProgramId, - ); - } -} - -/** - * Short alias for createAssociatedTokenAccountInterfaceIdempotentInstruction. - */ -export const createATAInterfaceIdempotentInstruction = - createAssociatedTokenAccountInterfaceIdempotentInstruction; diff --git a/js/compressed-token/src/v3/instructions/create-ata-interface.ts b/js/compressed-token/src/v3/instructions/create-ata-interface.ts new file mode 100644 index 0000000000..7b79870bc0 --- /dev/null +++ b/js/compressed-token/src/v3/instructions/create-ata-interface.ts @@ -0,0 +1,133 @@ +import { PublicKey, TransactionInstruction } from '@solana/web3.js'; +import { + TOKEN_PROGRAM_ID, + createAssociatedTokenAccountInstruction as createSplAssociatedTokenAccountInstruction, + createAssociatedTokenAccountIdempotentInstruction as createSplAssociatedTokenAccountIdempotentInstruction, +} from '@solana/spl-token'; +import { CTOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; +import { getAtaProgramId } from '../ata-utils'; +import { + createAssociatedCTokenAccountInstruction, + createAssociatedCTokenAccountIdempotentInstruction, + CompressibleConfig, +} from './create-associated-ctoken'; + +/** + * c-token-specific config for createAssociatedTokenAccountInterfaceInstruction + */ +export interface CTokenConfig { + compressibleConfig?: CompressibleConfig; + configAccount?: PublicKey; + rentPayerPda?: PublicKey; +} + +// Keep old interface type for backwards compatibility export +export interface CreateAssociatedTokenAccountInterfaceInstructionParams { + payer: PublicKey; + associatedToken: PublicKey; + owner: PublicKey; + mint: PublicKey; + programId?: PublicKey; + associatedTokenProgramId?: PublicKey; + compressibleConfig?: CompressibleConfig; + configAccount?: PublicKey; + rentPayerPda?: PublicKey; +} + +/** + * Create instruction for creating an associated token account (SPL, Token-2022, + * or c-token). Follows SPL Token API signature with optional c-token config at the + * end. + * + * @param payer Fee payer public key. + * @param associatedToken Associated token account address. + * @param owner Owner of the associated token account. + * @param mint Mint address. + * @param programId Token program ID (default: TOKEN_PROGRAM_ID). + * @param associatedTokenProgramId Associated token program ID. + * @param ctokenConfig Optional c-token-specific configuration. + */ +export function createAssociatedTokenAccountInterfaceInstruction( + payer: PublicKey, + associatedToken: PublicKey, + owner: PublicKey, + mint: PublicKey, + programId: PublicKey = TOKEN_PROGRAM_ID, + associatedTokenProgramId?: PublicKey, + ctokenConfig?: CTokenConfig, +): TransactionInstruction { + const effectiveAssociatedTokenProgramId = + associatedTokenProgramId ?? getAtaProgramId(programId); + + if (programId.equals(CTOKEN_PROGRAM_ID)) { + return createAssociatedCTokenAccountInstruction( + payer, + owner, + mint, + ctokenConfig?.compressibleConfig, + ctokenConfig?.configAccount, + ctokenConfig?.rentPayerPda, + ); + } else { + return createSplAssociatedTokenAccountInstruction( + payer, + associatedToken, + owner, + mint, + programId, + effectiveAssociatedTokenProgramId, + ); + } +} + +/** + * Create idempotent instruction for creating an associated token account (SPL, + * Token-2022, or c-token). Follows SPL Token API signature with optional c-token + * config at the end. + * + * @param payer Fee payer public key. + * @param associatedToken Associated token account address. + * @param owner Owner of the associated token account. + * @param mint Mint address. + * @param programId Token program ID (default: TOKEN_PROGRAM_ID). + * @param associatedTokenProgramId Associated token program ID. + * @param ctokenConfig Optional c-token-specific configuration. + */ +export function createAssociatedTokenAccountInterfaceIdempotentInstruction( + payer: PublicKey, + associatedToken: PublicKey, + owner: PublicKey, + mint: PublicKey, + programId: PublicKey = TOKEN_PROGRAM_ID, + associatedTokenProgramId?: PublicKey, + ctokenConfig?: CTokenConfig, +): TransactionInstruction { + const effectiveAssociatedTokenProgramId = + associatedTokenProgramId ?? getAtaProgramId(programId); + + if (programId.equals(CTOKEN_PROGRAM_ID)) { + return createAssociatedCTokenAccountIdempotentInstruction( + payer, + owner, + mint, + ctokenConfig?.compressibleConfig, + ctokenConfig?.configAccount, + ctokenConfig?.rentPayerPda, + ); + } else { + return createSplAssociatedTokenAccountIdempotentInstruction( + payer, + associatedToken, + owner, + mint, + programId, + effectiveAssociatedTokenProgramId, + ); + } +} + +/** + * Short alias for createAssociatedTokenAccountInterfaceIdempotentInstruction. + */ +export const createAtaInterfaceIdempotentInstruction = + createAssociatedTokenAccountInterfaceIdempotentInstruction; diff --git a/js/compressed-token/src/mint/instructions/decompress2.ts b/js/compressed-token/src/v3/instructions/create-decompress-interface-instruction.ts similarity index 53% rename from js/compressed-token/src/mint/instructions/decompress2.ts rename to js/compressed-token/src/v3/instructions/create-decompress-interface-instruction.ts index 7eaf628984..f7dc5a555c 100644 --- a/js/compressed-token/src/mint/instructions/decompress2.ts +++ b/js/compressed-token/src/v3/instructions/create-decompress-interface-instruction.ts @@ -8,8 +8,7 @@ import { LightSystemProgram, defaultStaticAccountsStruct, ParsedTokenAccount, - bn, - CompressedProof, + ValidityProofWithContext, } from '@lightprotocol/stateless.js'; import { CompressedTokenProgram } from '../../program'; import { @@ -18,8 +17,38 @@ import { MultiInputTokenDataWithContext, COMPRESSION_MODE_DECOMPRESS, Compression, -} from '../../layout-transfer2'; +} from '../layout/layout-transfer2'; import { TokenDataVersion } from '../../constants'; +import { SplInterfaceInfo } from '../../utils/get-token-pool-infos'; + +/** + * Get token data version from compressed account discriminator. + */ +function getVersionFromDiscriminator( + discriminator: number[] | undefined, +): number { + if (!discriminator || discriminator.length < 8) { + // Default to ShaFlat for new accounts without discriminator + return TokenDataVersion.ShaFlat; + } + + // V1 has discriminator[0] = 2 + if (discriminator[0] === 2) { + return TokenDataVersion.V1; + } + + // V2 and ShaFlat have version in discriminator[7] + const versionByte = discriminator[7]; + if (versionByte === 3) { + return TokenDataVersion.V2; + } + if (versionByte === 4) { + return TokenDataVersion.ShaFlat; + } + + // Default to ShaFlat + return TokenDataVersion.ShaFlat; +} /** * Build input token data for Transfer2 from parsed token accounts @@ -30,9 +59,13 @@ function buildInputTokenData( packedAccountIndices: Map, ): MultiInputTokenDataWithContext[] { return accounts.map((acc, i) => { - const ownerKey = acc.compressedAccount.owner.toBase58(); + const ownerKey = acc.parsed.owner.toBase58(); const mintKey = acc.parsed.mint.toBase58(); + const version = getVersionFromDiscriminator( + acc.compressedAccount.data?.discriminator, + ); + return { owner: packedAccountIndices.get(ownerKey)!, amount: BigInt(acc.parsed.amount.toString()), @@ -42,7 +75,7 @@ function buildInputTokenData( 0) : 0, mint: packedAccountIndices.get(mintKey)!, - version: TokenDataVersion.ShaFlat, + version, merkleContext: { merkleTreePubkeyIndex: packedAccountIndices.get( acc.compressedAccount.treeInfo.tree.toBase58(), @@ -59,37 +92,37 @@ function buildInputTokenData( } /** - * Create decompress2 instruction using Transfer2. + * Create decompressInterface instruction using Transfer2. * - * This decompresses compressed tokens to a CToken account using the unified - * Transfer2 instruction. It's more efficient than the old decompress as it - * doesn't require SPL token pool operations for CToken destinations. + * Supports decompressing to both c-token accounts and SPL token accounts: + * - For c-token destinations: No splInterfaceInfo needed + * - For SPL destinations: Provide splInterfaceInfo (token pool info) * * @param payer Fee payer public key * @param inputCompressedTokenAccounts Input compressed token accounts - * @param toAddress Destination CToken account address + * @param toAddress Destination token account address (c-token or SPL ATA) * @param amount Amount to decompress - * @param proof Validity proof (null if all accounts are proveByIndex) - * @param rootIndices Root indices for each input account + * @param validityProof Validity proof (contains compressedProof and rootIndices) + * @param splInterfaceInfo Optional: SPL interface info for SPL destinations * @returns TransactionInstruction */ -export function createDecompress2Instruction( +export function createDecompressInterfaceInstruction( payer: PublicKey, inputCompressedTokenAccounts: ParsedTokenAccount[], toAddress: PublicKey, amount: bigint, - proof: CompressedProof | null, - rootIndices: number[], + validityProof: ValidityProofWithContext, + splInterfaceInfo?: SplInterfaceInfo, ): TransactionInstruction { if (inputCompressedTokenAccounts.length === 0) { throw new Error('No input compressed token accounts provided'); } const mint = inputCompressedTokenAccounts[0].parsed.mint; - const owner = inputCompressedTokenAccounts[0].compressedAccount.owner; + const owner = inputCompressedTokenAccounts[0].parsed.owner; // Build packed accounts map - // Order: trees/queues first, then mint, owner, CToken account, CToken program + // Order: trees/queues first, then mint, owner, c-token account, c-token program const packedAccountIndices = new Map(); const packedAccounts: PublicKey[] = []; @@ -107,8 +140,13 @@ export function createDecompress2Instruction( packedAccounts.push(new PublicKey(tree)); } - // Add queues + let firstQueueIndex = 0; + let isFirstQueue = true; for (const queue of queueSet) { + if (isFirstQueue) { + firstQueueIndex = packedAccounts.length; + isFirstQueue = false; + } packedAccountIndices.set(queue, packedAccounts.length); packedAccounts.push(new PublicKey(queue)); } @@ -123,23 +161,80 @@ export function createDecompress2Instruction( packedAccountIndices.set(owner.toBase58(), ownerIndex); packedAccounts.push(owner); - // Add destination CToken account + // Add destination token account (c-token or SPL) const destinationIndex = packedAccounts.length; packedAccountIndices.set(toAddress.toBase58(), destinationIndex); packedAccounts.push(toAddress); - // Add CToken program (for decompress to CToken) - const ctokenProgramIndex = packedAccounts.length; - packedAccounts.push(CTOKEN_PROGRAM_ID); + // For SPL decompression, add pool account and token program + let poolAccountIndex = 0; + let poolIndex = 0; + let poolBump = 0; + let tokenProgramIndex = 0; + + if (splInterfaceInfo) { + // Add SPL interface PDA (token pool) + poolAccountIndex = packedAccounts.length; + packedAccountIndices.set( + splInterfaceInfo.splInterfacePda.toBase58(), + poolAccountIndex, + ); + packedAccounts.push(splInterfaceInfo.splInterfacePda); + + // Add SPL token program + tokenProgramIndex = packedAccounts.length; + packedAccountIndices.set( + splInterfaceInfo.tokenProgram.toBase58(), + tokenProgramIndex, + ); + packedAccounts.push(splInterfaceInfo.tokenProgram); + + poolIndex = splInterfaceInfo.poolIndex; + poolBump = splInterfaceInfo.bump; + } // Build input token data const inTokenData = buildInputTokenData( inputCompressedTokenAccounts, - rootIndices, + validityProof.rootIndices, packedAccountIndices, ); + // Calculate total input amount and change + const totalInputAmount = inputCompressedTokenAccounts.reduce( + (sum, acc) => sum + BigInt(acc.parsed.amount.toString()), + BigInt(0), + ); + const changeAmount = totalInputAmount - amount; + + const outTokenData: { + owner: number; + amount: bigint; + hasDelegate: boolean; + delegate: number; + mint: number; + version: number; + }[] = []; + + if (changeAmount > 0) { + const version = getVersionFromDiscriminator( + inputCompressedTokenAccounts[0].compressedAccount.data + ?.discriminator, + ); + + outTokenData.push({ + owner: ownerIndex, + amount: changeAmount, + hasDelegate: false, + delegate: 0, + mint: mintIndex, + version, + }); + } + // Build decompress compression + // For c-token: pool values are 0 (unused) + // For SPL: pool values point to SPL interface PDA const compressions: Compression[] = [ { mode: COMPRESSION_MODE_DECOMPRESS, @@ -147,9 +242,10 @@ export function createDecompress2Instruction( mint: mintIndex, sourceOrRecipient: destinationIndex, authority: 0, // Not needed for decompress - poolAccountIndex: ctokenProgramIndex, // CToken program - poolIndex: 0, - bump: 0, + poolAccountIndex: splInterfaceInfo ? poolAccountIndex : 0, + poolIndex: splInterfaceInfo ? poolIndex : 0, + bump: splInterfaceInfo ? poolBump : 0, + decimals: 0, }, ]; @@ -159,18 +255,19 @@ export function createDecompress2Instruction( withLamportsChangeAccountMerkleTreeIndex: false, lamportsChangeAccountMerkleTreeIndex: 0, lamportsChangeAccountOwnerIndex: 0, - outputQueue: 0, // First queue in packed accounts + outputQueue: firstQueueIndex, // First queue in packed accounts + maxTopUp: 0, cpiContext: null, compressions, - proof: proof + proof: validityProof.compressedProof ? { - a: Array.from(proof.a), - b: Array.from(proof.b), - c: Array.from(proof.c), + a: Array.from(validityProof.compressedProof.a), + b: Array.from(validityProof.compressedProof.b), + c: Array.from(validityProof.compressedProof.c), } : null, inTokenData, - outTokenData: [], // No compressed outputs + outTokenData, inLamports: null, outLamports: null, inTlv: null, @@ -182,7 +279,6 @@ export function createDecompress2Instruction( // Build accounts for Transfer2 with compressed accounts (full path) const { accountCompressionAuthority, - noopProgram, registeredProgramPda, accountCompressionProgram, } = defaultStaticAccountsStruct(); @@ -196,53 +292,52 @@ export function createDecompress2Instruction( }, // 1: fee_payer (signer, mutable) { pubkey: payer, isSigner: true, isWritable: true }, - // 2: authority (signer) - { pubkey: owner, isSigner: true, isWritable: false }, - // 3: cpi_authority_pda + // 2: cpi_authority_pda { pubkey: CompressedTokenProgram.deriveCpiAuthorityPda, isSigner: false, isWritable: false, }, - // 4: registered_program_pda + // 3: registered_program_pda { pubkey: registeredProgramPda, isSigner: false, isWritable: false, }, - // 5: account_compression_authority + // 4: account_compression_authority { pubkey: accountCompressionAuthority, isSigner: false, isWritable: false, }, - // 6: account_compression_program + // 5: account_compression_program { pubkey: accountCompressionProgram, isSigner: false, isWritable: false, }, - // 7: system_program + // 6: system_program { pubkey: SystemProgram.programId, isSigner: false, isWritable: false, }, - // 8: noop_program (for logging) - { - pubkey: noopProgram, - isSigner: false, - isWritable: false, - }, - // Packed accounts (trees/queues come first, identified by ownership) + // 7+: packed_accounts (trees/queues come first) ...packedAccounts.map((pubkey, i) => { - // Trees and destination CToken account need to be writable + // Trees need to be writable const isTreeOrQueue = i < treeSet.size + queueSet.size; + // Destination account needs to be writable const isDestination = pubkey.equals(toAddress); + // SPL interface PDA (pool) needs to be writable for SPL decompression + const isPool = + splInterfaceInfo !== undefined && + pubkey.equals(splInterfaceInfo.splInterfacePda); + // Owner must be marked as signer in packed accounts + const isOwner = i === ownerIndex; return { pubkey, - isSigner: false, - isWritable: isTreeOrQueue || isDestination, + isSigner: isOwner, + isWritable: isTreeOrQueue || isDestination || isPool, }; }), ]; diff --git a/js/compressed-token/src/v3/instructions/create-load-accounts-params.ts b/js/compressed-token/src/v3/instructions/create-load-accounts-params.ts new file mode 100644 index 0000000000..41e187dd7a --- /dev/null +++ b/js/compressed-token/src/v3/instructions/create-load-accounts-params.ts @@ -0,0 +1,245 @@ +import { + Rpc, + MerkleContext, + ValidityProof, + packDecompressAccountsIdempotent, +} from '@lightprotocol/stateless.js'; +import { + PublicKey, + AccountMeta, + TransactionInstruction, +} from '@solana/web3.js'; +import { AccountInterface } from '../get-account-interface'; +import { createLoadAtaInstructionsFromInterface } from '../actions/load-ata'; +import { InterfaceOptions } from '../actions/transfer-interface'; + +/** + * Account info interface for compressible accounts. + * Matches return structure of getAccountInterface/getAtaInterface. + * + * Integrating programs provide their own fetch/parse - this is just the data shape. + */ +export interface ParsedAccountInfoInterface { + /** Parsed account data (program-specific) */ + parsed: T; + /** Load context - present if account is compressed (cold), undefined if hot */ + loadContext?: MerkleContext; +} + +/** + * Input for createLoadAccountsParams. + * Supports both program PDAs and c-token vaults. + * + * The integrating program is responsible for fetching and parsing their accounts. + * This helper just packs them for the decompressAccountsIdempotent instruction. + */ +export interface CompressibleAccountInput { + /** Account address */ + address: PublicKey; + /** + * Account type key for packing: + * - For PDAs: program-specific type name (e.g., "poolState", "observationState") + * - For c-token vaults: "cTokenData" + */ + accountType: string; + /** + * Token variant - required when accountType is "cTokenData". + * Examples: "lpVault", "token0Vault", "token1Vault" + */ + tokenVariant?: string; + /** Parsed account info (from program-specific fetch) */ + info: ParsedAccountInfoInterface; +} + +/** + * Packed compressed account for decompressAccountsIdempotent instruction + */ +export interface PackedCompressedAccount { + [key: string]: unknown; + merkleContext: { + merkleTreePubkeyIndex: number; + queuePubkeyIndex: number; + }; +} + +/** + * Result from building load params + */ +export interface CompressibleLoadParams { + /** Validity proof wrapped in option (null if all proveByIndex) */ + proofOption: { 0: ValidityProof | null }; + /** Packed compressed accounts data for instruction */ + compressedAccounts: PackedCompressedAccount[]; + /** Offset to system accounts in remainingAccounts */ + systemAccountsOffset: number; + /** Account metas for remaining accounts */ + remainingAccounts: AccountMeta[]; +} + +/** + * Result from createLoadAccountsParams + */ +export interface LoadResult { + /** Params for decompressAccountsIdempotent (null if no program accounts need decompressing) */ + decompressParams: CompressibleLoadParams | null; + /** Instructions to load ATAs (create ATA, wrap SPL/T22, decompressInterface) */ + ataInstructions: TransactionInstruction[]; +} + +/** + * Create params for loading program accounts and ATAs. + * + * Returns: + * - decompressParams: for a caller program's standardized + * decompressAccountsIdempotent instruction + * - ataInstructions: for loading user ATAs + * + * @param rpc RPC connection + * @param payer Fee payer (needed for ATA instructions) + * @param programId Program ID for decompressAccountsIdempotent + * @param programAccounts PDAs and vaults (caller pre-fetches) + * @param atas User ATAs (fetched via getAtaInterface) + * @param options Optional load options + * @returns LoadResult with decompressParams and ataInstructions + * + * @example + * ```typescript + * const poolInfo = await myProgram.fetchPoolState(rpc, poolAddress); + * const vault0Ata = getAssociatedTokenAddressInterface(token0Mint, poolAddress); + * const vault0Info = await getAtaInterface(rpc, vault0Ata, poolAddress, token0Mint, undefined, CTOKEN_PROGRAM_ID); + * const userAta = getAssociatedTokenAddressInterface(tokenMint, userWallet); + * const userAtaInfo = await getAtaInterface(rpc, userAta, userWallet, tokenMint); + * + * const result = await createLoadAccountsParams( + * rpc, + * payer.publicKey, + * programId, + * [ + * { address: poolAddress, accountType: 'poolState', info: poolInfo }, + * { address: vault0, accountType: 'cTokenData', tokenVariant: 'token0Vault', info: vault0Info }, + * ], + * [userAta], + * ); + * + * // Build transaction with both program decompress and ATA load + * const instructions = [...result.ataInstructions]; + * if (result.decompressParams) { + * instructions.push(await program.methods + * .decompressAccountsIdempotent( + * result.decompressParams.proofOption, + * result.decompressParams.compressedAccounts, + * result.decompressParams.systemAccountsOffset, + * ) + * .remainingAccounts(result.decompressParams.remainingAccounts) + * .instruction()); + * } + * ``` + */ +export async function createLoadAccountsParams( + rpc: Rpc, + payer: PublicKey, + programId: PublicKey, + programAccounts: CompressibleAccountInput[] = [], + atas: AccountInterface[] = [], + options?: InterfaceOptions, +): Promise { + let decompressParams: CompressibleLoadParams | null = null; + + const compressedProgramAccounts = programAccounts.filter( + acc => acc.info.loadContext !== undefined, + ); + + if (compressedProgramAccounts.length > 0) { + // Build proof inputs + const proofInputs = compressedProgramAccounts.map(acc => ({ + hash: acc.info.loadContext!.hash, + tree: acc.info.loadContext!.treeInfo.tree, + queue: acc.info.loadContext!.treeInfo.queue, + })); + + const proofResult = await rpc.getValidityProofV0(proofInputs, []); + + // Build accounts data for packing + const accountsData = compressedProgramAccounts.map(acc => { + if (acc.accountType === 'cTokenData') { + if (!acc.tokenVariant) { + throw new Error( + 'tokenVariant is required when accountType is "cTokenData"', + ); + } + return { + key: 'cTokenData', + data: { + variant: { [acc.tokenVariant]: {} }, + tokenData: acc.info.parsed, + }, + treeInfo: acc.info.loadContext!.treeInfo, + }; + } + return { + key: acc.accountType, + data: acc.info.parsed, + treeInfo: acc.info.loadContext!.treeInfo, + }; + }); + + const addresses = compressedProgramAccounts.map(acc => acc.address); + const treeInfos = compressedProgramAccounts.map( + acc => acc.info.loadContext!.treeInfo, + ); + + const packed = await packDecompressAccountsIdempotent( + programId, + { + compressedProof: proofResult.compressedProof, + treeInfos, + }, + accountsData, + addresses, + ); + + decompressParams = { + proofOption: packed.proofOption, + compressedAccounts: + packed.compressedAccounts as PackedCompressedAccount[], + systemAccountsOffset: packed.systemAccountsOffset, + remainingAccounts: packed.remainingAccounts, + }; + } + + const ataInstructions: TransactionInstruction[] = []; + + for (const ata of atas) { + const ixs = await createLoadAtaInstructionsFromInterface( + rpc, + payer, + ata, + options, + ); + ataInstructions.push(...ixs); + } + + return { + decompressParams, + ataInstructions, + }; +} + +/** + * Calculate compute units for compressible load operation + */ +export function calculateCompressibleLoadComputeUnits( + compressedAccountCount: number, + hasValidityProof: boolean, +): number { + let cu = 50_000; // Base + + if (hasValidityProof) { + cu += 100_000; // Proof verification + } + + // Per compressed account + cu += compressedAccountCount * 30_000; + + return cu; +} diff --git a/js/compressed-token/src/mint/instructions/create-mint.ts b/js/compressed-token/src/v3/instructions/create-mint.ts similarity index 77% rename from js/compressed-token/src/mint/instructions/create-mint.ts rename to js/compressed-token/src/v3/instructions/create-mint.ts index caeb1b529b..56e3b32b6b 100644 --- a/js/compressed-token/src/mint/instructions/create-mint.ts +++ b/js/compressed-token/src/v3/instructions/create-mint.ts @@ -12,35 +12,30 @@ import { deriveAddressV2, TreeInfo, AddressTreeInfo, + ValidityProof, } from '@lightprotocol/stateless.js'; import { CompressedTokenProgram } from '../../program'; -import { findMintAddress } from '../../compressible/derivation'; +import { findMintAddress } from '../derivation'; import { + AdditionalMetadata, encodeMintActionInstructionData, MintActionCompressedInstructionData, - TokenMetadataInstructionData as TokenMetadataBorshData, -} from './mint-action-layout'; + TokenMetadataLayoutData as TokenMetadataBorshData, +} from '../layout/layout-mint-action'; import { TokenDataVersion } from '../../constants'; /** - * Token metadata for creating a compressed mint - * Uses strings for user-friendly input + * Token metadata for creating a c-token mint. */ export interface TokenMetadataInstructionData { name: string; symbol: string; uri: string; updateAuthority?: PublicKey | null; - additionalMetadata?: { - key: string; - value: string; - }[]; + additionalMetadata: AdditionalMetadata[] | null; } -/** @deprecated Use TokenMetadataInstructionData instead */ -export type TokenMetadataInstructionDataInput = TokenMetadataInstructionData; - -interface EncodeCreateMintInstructionParams { +export interface EncodeCreateMintInstructionParams { mintSigner: PublicKey; mintAuthority: PublicKey; freezeAuthority: PublicKey | null; @@ -48,7 +43,7 @@ interface EncodeCreateMintInstructionParams { addressTree: PublicKey; outputQueue: PublicKey; rootIndex: number; - proof: { a: number[]; b: number[]; c: number[] } | null; + proof: ValidityProof | null; metadata?: TokenMetadataInstructionData; } @@ -57,16 +52,47 @@ export function createTokenMetadata( symbol: string, uri: string, updateAuthority?: PublicKey | null, + additionalMetadata: AdditionalMetadata[] | null = null, ): TokenMetadataInstructionData { return { name, symbol, uri, updateAuthority: updateAuthority ?? null, + additionalMetadata: additionalMetadata ?? null, }; } -function encodeCreateMintInstructionData( +/** + * Validate and normalize proof arrays to ensure correct sizes for Borsh serialization. + * The compressed proof must have exactly: a[32], b[64], c[32] bytes. + */ +function validateProofArrays( + proof: ValidityProof | null, +): ValidityProof | null { + if (!proof) return null; + + // Validate array sizes + if (proof.a.length !== 32) { + throw new Error( + `Invalid proof.a length: expected 32, got ${proof.a.length}`, + ); + } + if (proof.b.length !== 64) { + throw new Error( + `Invalid proof.b length: expected 64, got ${proof.b.length}`, + ); + } + if (proof.c.length !== 32) { + throw new Error( + `Invalid proof.c length: expected 32, got ${proof.c.length}`, + ); + } + + return proof; +} + +export function encodeCreateMintInstructionData( params: EncodeCreateMintInstructionParams, ): Buffer { const [splMintPda] = findMintAddress(params.mintSigner); @@ -86,12 +112,16 @@ function encodeCreateMintInstructionData( name: Buffer.from(params.metadata.name), symbol: Buffer.from(params.metadata.symbol), uri: Buffer.from(params.metadata.uri), - additionalMetadata: null, + additionalMetadata: params.metadata.additionalMetadata, }, }, ]; } + // Validate proof arrays before encoding + const validatedProof = validateProofArrays(params.proof); + + /** TODO: check leafIndex */ const instructionData: MintActionCompressedInstructionData = { leafIndex: 0, proveByIndex: false, @@ -99,12 +129,13 @@ function encodeCreateMintInstructionData( compressedAddress: Array.from(compressedAddress.toBytes()), tokenPoolBump: 0, tokenPoolIndex: 0, + maxTopUp: 0, createMint: { readOnlyAddressTrees: [0, 0, 0, 0], readOnlyAddressTreeRootIndices: [0, 0, 0, 0], }, actions: [], // No actions for create mint - proof: params.proof, + proof: validatedProof, cpiContext: null, mint: { supply: BigInt(0), @@ -137,14 +168,14 @@ export interface CreateMintInstructionParams { } /** - * Create instruction for initializing a compressed token mint. + * Create instruction for initializing a c-token mint. * * @param mintSigner Mint signer keypair public key. * @param decimals Number of decimals for the mint. * @param mintAuthority Mint authority public key. * @param freezeAuthority Optional freeze authority public key. * @param payer Fee payer public key. - * @param validityProof Validity proof for the compressed account. + * @param validityProof Validity proof for the mint account. * @param addressTreeInfo Address tree info for the mint. * @param outputStateTreeInfo Output state tree info. * @param metadata Optional token metadata. diff --git a/js/compressed-token/src/mint/instructions/index.ts b/js/compressed-token/src/v3/instructions/index.ts similarity index 63% rename from js/compressed-token/src/mint/instructions/index.ts rename to js/compressed-token/src/v3/instructions/index.ts index 6372bf6677..c4042bacea 100644 --- a/js/compressed-token/src/mint/instructions/index.ts +++ b/js/compressed-token/src/v3/instructions/index.ts @@ -2,9 +2,12 @@ export * from './create-mint'; export * from './update-mint'; export * from './update-metadata'; export * from './create-associated-ctoken'; +export * from './create-ata-interface'; export * from './mint-to'; export * from './mint-to-compressed'; export * from './mint-to-interface'; export * from './transfer-interface'; -export * from './decompress2'; +export * from './create-decompress-interface-instruction'; +export * from './create-load-accounts-params'; export * from './wrap'; +export * from './unwrap'; diff --git a/js/compressed-token/src/mint/instructions/mint-to-compressed.ts b/js/compressed-token/src/v3/instructions/mint-to-compressed.ts similarity index 80% rename from js/compressed-token/src/mint/instructions/mint-to-compressed.ts rename to js/compressed-token/src/v3/instructions/mint-to-compressed.ts index 20dcf6353d..c6ab187f77 100644 --- a/js/compressed-token/src/mint/instructions/mint-to-compressed.ts +++ b/js/compressed-token/src/v3/instructions/mint-to-compressed.ts @@ -12,13 +12,15 @@ import { deriveAddressV2, getDefaultAddressTreeInfo, MerkleContext, + TreeInfo, + getOutputQueue, } from '@lightprotocol/stateless.js'; import { CompressedTokenProgram } from '../../program'; -import { MintInstructionData } from '../serde'; +import { MintInstructionData } from '../layout/layout-mint'; import { encodeMintActionInstructionData, MintActionCompressedInstructionData, -} from './mint-action-layout'; +} from '../layout/layout-mint-action'; import { TokenDataVersion } from '../../constants'; interface EncodeCompressedMintToInstructionParams { @@ -54,6 +56,7 @@ function encodeCompressedMintToInstructionData( compressedAddress: Array.from(compressedAddress.toBytes()), tokenPoolBump: 0, tokenPoolIndex: 0, + maxTopUp: 0, createMint: null, actions: [ { @@ -93,24 +96,26 @@ export interface CreateMintToCompressedInstructionParams { validityProof: ValidityProofWithContext; merkleContext: MerkleContext; mintData: MintInstructionData; - outputQueue: PublicKey; - tokensOutQueue: PublicKey; recipients: Array<{ recipient: PublicKey; amount: number | bigint }>; + outputStateTreeInfo?: TreeInfo; tokenAccountVersion?: TokenDataVersion; } /** - * Create instruction for minting compressed tokens to compressed accounts. + * Create instruction for minting tokens from a c-mint to compressed accounts. + * To mint to onchain token accounts across SPL/T22/c-mints, use + * {@link createMintToInterfaceInstruction} instead. * - * @param authority Mint authority public key. - * @param payer Fee payer public key. - * @param validityProof Validity proof for the compressed mint. - * @param merkleContext Merkle context of the compressed mint. - * @param mintData Mint instruction data. - * @param outputQueue Output queue for state changes. - * @param tokensOutQueue Queue for token outputs. - * @param recipients Array of recipients with amounts. - * @param tokenAccountVersion Token account version (default: TokenDataVersion.ShaFlat). + * @param authority Mint authority public key. + * @param payer Fee payer public key. + * @param validityProof Validity proof for the compressed mint. + * @param merkleContext Merkle context of the compressed mint. + * @param mintData Mint instruction data. + * @param recipients Array of recipients with amounts. + * @param outputStateTreeInfo Optional output state tree info. Uses merkle + * context queue if not provided. + * @param tokenAccountVersion Token account version (default: + * TokenDataVersion.ShaFlat). */ export function createMintToCompressedInstruction( authority: PublicKey, @@ -118,9 +123,8 @@ export function createMintToCompressedInstruction( validityProof: ValidityProofWithContext, merkleContext: MerkleContext, mintData: MintInstructionData, - outputQueue: PublicKey, - tokensOutQueue: PublicKey, recipients: Array<{ recipient: PublicKey; amount: number | bigint }>, + outputStateTreeInfo?: TreeInfo, tokenAccountVersion: TokenDataVersion = TokenDataVersion.ShaFlat, ): TransactionInstruction { const addressTreeInfo = getDefaultAddressTreeInfo(); @@ -134,6 +138,10 @@ export function createMintToCompressedInstruction( tokenAccountVersion, }); + // Use outputStateTreeInfo.queue if provided, otherwise derive from merkleContext + const outputQueue = + outputStateTreeInfo?.queue ?? getOutputQueue(merkleContext); + const sys = defaultStaticAccountsStruct(); const keys = [ { @@ -175,7 +183,8 @@ export function createMintToCompressedInstruction( isSigner: false, isWritable: true, }, - { pubkey: tokensOutQueue, isSigner: false, isWritable: true }, + // Use same queue for tokens out + { pubkey: outputQueue, isSigner: false, isWritable: true }, ]; return new TransactionInstruction({ diff --git a/js/compressed-token/src/mint/instructions/mint-to-interface.ts b/js/compressed-token/src/v3/instructions/mint-to-interface.ts similarity index 81% rename from js/compressed-token/src/mint/instructions/mint-to-interface.ts rename to js/compressed-token/src/v3/instructions/mint-to-interface.ts index c155634cb7..01eaa3c6e7 100644 --- a/js/compressed-token/src/mint/instructions/mint-to-interface.ts +++ b/js/compressed-token/src/v3/instructions/mint-to-interface.ts @@ -1,8 +1,11 @@ import { PublicKey, TransactionInstruction } from '@solana/web3.js'; -import { ValidityProofWithContext } from '@lightprotocol/stateless.js'; +import { + getOutputTreeInfo, + ValidityProofWithContext, +} from '@lightprotocol/stateless.js'; import { createMintToInstruction as createSplMintToInstruction } from '@solana/spl-token'; import { createMintToInstruction as createCtokenMintToInstruction } from './mint-to'; -import { MintInterface } from '../helpers'; +import { MintInterface } from '../get-mint-interface'; // Keep old interface type for backwards compatibility export export interface CreateMintToInterfaceInstructionParams { @@ -39,7 +42,7 @@ export function createMintToInterfaceInstruction( const mint = mintInterface.mint.address; const programId = mintInterface.programId; - // For SPL and Token-2022 mints (no merkleContext) + // SPL/T22 if (!mintInterface.merkleContext) { return createSplMintToInstruction( mint, @@ -51,22 +54,13 @@ export function createMintToInterfaceInstruction( ); } - // For compressed mints (has merkleContext) - mint to decompressed CToken account if (!validityProof) { - throw new Error( - 'Validity proof required for compressed mint operations', - ); + throw new Error('Validity proof required for c-token mint-to'); } - if (!mintInterface.mintContext) { - throw new Error('mintContext required for compressed mint operations'); + throw new Error('mintContext required for c-token mint-to'); } - // ensure we rollover if needed. - const outputStateTreeInfo = - mintInterface.merkleContext.treeInfo.nextTreeInfo ?? - mintInterface.merkleContext.treeInfo; - const mintData = { supply: mintInterface.mint.supply, decimals: mintInterface.mint.decimals, @@ -92,7 +86,7 @@ export function createMintToInterfaceInstruction( validityProof, mintInterface.merkleContext, mintData, - outputStateTreeInfo, + getOutputTreeInfo(mintInterface.merkleContext), destination, amount, ); diff --git a/js/compressed-token/src/mint/instructions/mint-to.ts b/js/compressed-token/src/v3/instructions/mint-to.ts similarity index 97% rename from js/compressed-token/src/mint/instructions/mint-to.ts rename to js/compressed-token/src/v3/instructions/mint-to.ts index 3d0f8eff33..c4ca8ca0d4 100644 --- a/js/compressed-token/src/mint/instructions/mint-to.ts +++ b/js/compressed-token/src/v3/instructions/mint-to.ts @@ -15,11 +15,11 @@ import { TreeInfo, } from '@lightprotocol/stateless.js'; import { CompressedTokenProgram } from '../../program'; -import { MintInstructionData } from '../serde'; +import { MintInstructionData } from '../layout/layout-mint'; import { encodeMintActionInstructionData, MintActionCompressedInstructionData, -} from './mint-action-layout'; +} from '../layout/layout-mint-action'; interface EncodeMintToCTokenInstructionParams { addressTree: PublicKey; @@ -54,6 +54,7 @@ function encodeMintToCTokenInstructionData( compressedAddress: Array.from(compressedAddress.toBytes()), tokenPoolBump: 0, tokenPoolIndex: 0, + maxTopUp: 0, createMint: null, actions: [ { diff --git a/js/compressed-token/src/mint/instructions/transfer-interface.ts b/js/compressed-token/src/v3/instructions/transfer-interface.ts similarity index 83% rename from js/compressed-token/src/mint/instructions/transfer-interface.ts rename to js/compressed-token/src/v3/instructions/transfer-interface.ts index ecf89110f7..586ea973b7 100644 --- a/js/compressed-token/src/mint/instructions/transfer-interface.ts +++ b/js/compressed-token/src/v3/instructions/transfer-interface.ts @@ -7,26 +7,26 @@ import { } from '@solana/spl-token'; /** - * CToken Transfer discriminator (matches InstructionType::CTokenTransfer = 3) + * c-token Transfer discriminator (matches InstructionType::CTokenTransfer = 3) */ const CTOKEN_TRANSFER_DISCRIMINATOR = 3; /** - * Create a CToken transfer instruction for hot (on-chain) accounts. + * Create a c-token transfer instruction for hot (on-chain) accounts. * Uses CTokenTransfer instruction (discriminator 3) which wraps SPL Token transfer. * * Accounts: - * 1. source (mutable) - Source CToken account - * 2. destination (mutable) - Destination CToken account + * 1. source (mutable) - Source c-token account + * 2. destination (mutable) - Destination c-token account * 3. authority (signer) - Owner of source account * 4. payer (optional, signer, mutable) - For compressible extension top-up * - * @param source Source CToken account - * @param destination Destination CToken account + * @param source Source c-token account + * @param destination Destination c-token account * @param owner Owner of the source account (signer) * @param amount Amount to transfer * @param payer Optional payer for compressible extension top-up - * @returns TransactionInstruction for CToken transfer + * @returns TransactionInstruction for c-token transfer */ export function createCTokenTransferInstruction( source: PublicKey, @@ -64,18 +64,18 @@ export function createCTokenTransferInstruction( } /** - * Construct a transfer instruction for SPL Token, Token-2022, or CToken (hot accounts). + * Construct a transfer instruction for SPL Token, Token-2022, or c-token (hot accounts). * Matches SPL Token createTransferInstruction signature exactly. - * Defaults to CToken program. + * Defaults to c-token program. * * Dispatches to the appropriate program based on `programId`: - * - `CTOKEN_PROGRAM_ID` -> CToken hot-to-hot transfer (default) + * - `CTOKEN_PROGRAM_ID` -> c-token hot-to-hot transfer (default) * - `TOKEN_PROGRAM_ID` -> SPL Token transfer * - `TOKEN_2022_PROGRAM_ID` -> Token-2022 transfer * * Note: This is for on-chain (hot) token accounts only. * For compressed (cold) token transfers, use the `transfer` action. - * For cross-program transfers (SPL <> CToken), use `wrap`/`unwrap`. + * For cross-program transfers (SPL <> c-token), use `wrap`/`unwrap`. * * @param source Source token account * @param destination Destination token account @@ -83,10 +83,10 @@ export function createCTokenTransferInstruction( * @param amount Amount to transfer * @param multiSigners Signing accounts if `owner` is a multisig (SPL only) * @param programId Token program ID (default: CTOKEN_PROGRAM_ID) - * @param payer Fee payer for compressible top-up (CToken only) + * @param payer Fee payer for compressible top-up (c-token only) * * @example - * // CToken hot transfer (default) - same signature as SPL! + * // c-token hot transfer (default) - same signature as SPL! * const ix = createTransferInterfaceInstruction( * sourceCtokenAccount, * destCtokenAccount, @@ -118,7 +118,7 @@ export function createTransferInterfaceInstruction( if (programId.equals(CTOKEN_PROGRAM_ID)) { if (multiSigners.length > 0) { throw new Error( - 'CToken transfer does not support multi-signers. Use a single owner.', + 'c-token transfer does not support multi-signers. Use a single owner.', ); } return createCTokenTransferInstruction( diff --git a/js/compressed-token/src/v3/instructions/unwrap.ts b/js/compressed-token/src/v3/instructions/unwrap.ts new file mode 100644 index 0000000000..6becaa176c --- /dev/null +++ b/js/compressed-token/src/v3/instructions/unwrap.ts @@ -0,0 +1,118 @@ +import { PublicKey, TransactionInstruction } from '@solana/web3.js'; +import { CTOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; +import { CompressedTokenProgram } from '../../program'; +import { SplInterfaceInfo } from '../../utils/get-token-pool-infos'; +import { + encodeTransfer2InstructionData, + createCompressCtoken, + createDecompressSpl, + Transfer2InstructionData, + Compression, +} from '../layout/layout-transfer2'; + +/** + * Create an unwrap instruction that moves tokens from a c-token account to an + * SPL/T22 account. + * + * This is the reverse of wrap: c-token ATA -> pool -> SPL ATA + * + * @param source Source c-token account + * @param destination Destination SPL/T22 token account + * @param owner Owner/authority of the source account (must sign) + * @param mint Mint address + * @param amount Amount to unwrap + * @param splInterfaceInfo SPL interface info for the decompression + * @param payer Fee payer (defaults to owner if not provided) + * @returns TransactionInstruction to unwrap tokens + */ +export function createUnwrapInstruction( + source: PublicKey, + destination: PublicKey, + owner: PublicKey, + mint: PublicKey, + amount: bigint, + splInterfaceInfo: SplInterfaceInfo, + payer: PublicKey = owner, +): TransactionInstruction { + const MINT_INDEX = 0; + const OWNER_INDEX = 1; + const SOURCE_INDEX = 2; + const DESTINATION_INDEX = 3; + const POOL_INDEX = 4; + const _SPL_TOKEN_PROGRAM_INDEX = 5; + const CTOKEN_PROGRAM_INDEX = 6; + + // Unwrap flow: compress from c-token, decompress to SPL + const compressions: Compression[] = [ + createCompressCtoken( + amount, + MINT_INDEX, + SOURCE_INDEX, + OWNER_INDEX, + CTOKEN_PROGRAM_INDEX, + ), + createDecompressSpl( + amount, + MINT_INDEX, + DESTINATION_INDEX, + POOL_INDEX, + splInterfaceInfo.poolIndex, + splInterfaceInfo.bump, + ), + ]; + + const instructionData: Transfer2InstructionData = { + withTransactionHash: false, + withLamportsChangeAccountMerkleTreeIndex: false, + lamportsChangeAccountMerkleTreeIndex: 0, + lamportsChangeAccountOwnerIndex: 0, + outputQueue: 0, + maxTopUp: 0, + cpiContext: null, + compressions, + proof: null, + inTokenData: [], + outTokenData: [], + inLamports: null, + outLamports: null, + inTlv: null, + outTlv: null, + }; + + const data = encodeTransfer2InstructionData(instructionData); + + // Account order matches wrap instruction for consistency + const keys = [ + { + pubkey: CompressedTokenProgram.deriveCpiAuthorityPda, + isSigner: false, + isWritable: false, + }, + { pubkey: payer, isSigner: true, isWritable: true }, + { pubkey: mint, isSigner: false, isWritable: false }, + { pubkey: owner, isSigner: true, isWritable: false }, + { pubkey: source, isSigner: false, isWritable: true }, + { pubkey: destination, isSigner: false, isWritable: true }, + { + pubkey: splInterfaceInfo.splInterfacePda, + isSigner: false, + isWritable: true, + }, + { + pubkey: splInterfaceInfo.tokenProgram, + isSigner: false, + isWritable: false, + }, + { + pubkey: CTOKEN_PROGRAM_ID, + isSigner: false, + isWritable: false, + }, + ]; + + return new TransactionInstruction({ + programId: CompressedTokenProgram.programId, + keys, + data, + }); +} diff --git a/js/compressed-token/src/mint/instructions/update-metadata.ts b/js/compressed-token/src/v3/instructions/update-metadata.ts similarity index 61% rename from js/compressed-token/src/mint/instructions/update-metadata.ts rename to js/compressed-token/src/v3/instructions/update-metadata.ts index 1bdef7e591..ad41b6295f 100644 --- a/js/compressed-token/src/mint/instructions/update-metadata.ts +++ b/js/compressed-token/src/v3/instructions/update-metadata.ts @@ -11,16 +11,15 @@ import { defaultStaticAccountsStruct, deriveAddressV2, getDefaultAddressTreeInfo, - MerkleContext, + getOutputQueue, } from '@lightprotocol/stateless.js'; import { CompressedTokenProgram } from '../../program'; -import { findMintAddress } from '../../compressible/derivation'; -import { MintInstructionDataWithMetadata } from '../serde'; +import { MintInterface } from '../get-mint-interface'; import { encodeMintActionInstructionData, MintActionCompressedInstructionData, Action, -} from './mint-action-layout'; +} from '../layout/layout-mint-action'; type UpdateMetadataAction = | { @@ -43,12 +42,12 @@ type UpdateMetadataAction = }; interface EncodeUpdateMetadataInstructionParams { - mintSigner: PublicKey; + splMint: PublicKey; addressTree: PublicKey; leafIndex: number; rootIndex: number; proof: { a: number[]; b: number[]; c: number[] } | null; - mintData: MintInstructionDataWithMetadata; + mintInterface: MintInterface; action: UpdateMetadataAction; } @@ -83,13 +82,20 @@ function convertActionToBorsh(action: UpdateMetadataAction): Action { function encodeUpdateMetadataInstructionData( params: EncodeUpdateMetadataInstructionParams, ): Buffer { - const [splMintPda] = findMintAddress(params.mintSigner); const compressedAddress = deriveAddressV2( - splMintPda.toBytes(), + params.splMint.toBytes(), params.addressTree, CTOKEN_PROGRAM_ID, ); + const mintInterface = params.mintInterface; + + if (!mintInterface.tokenMetadata) { + throw new Error( + 'MintInterface must have tokenMetadata for metadata operations', + ); + } + const instructionData: MintActionCompressedInstructionData = { leafIndex: params.leafIndex, proveByIndex: params.proof === null, @@ -97,28 +103,30 @@ function encodeUpdateMetadataInstructionData( compressedAddress: Array.from(compressedAddress.toBytes()), tokenPoolBump: 0, tokenPoolIndex: 0, + maxTopUp: 0, createMint: null, actions: [convertActionToBorsh(params.action)], proof: params.proof, cpiContext: null, mint: { - supply: params.mintData.supply, - decimals: params.mintData.decimals, + supply: mintInterface.mint.supply, + decimals: mintInterface.mint.decimals, metadata: { - version: params.mintData.version, - splMintInitialized: params.mintData.splMintInitialized, - mint: params.mintData.splMint, + version: mintInterface.mintContext!.version, + splMintInitialized: + mintInterface.mintContext!.splMintInitialized, + mint: mintInterface.mintContext!.splMint, }, - mintAuthority: params.mintData.mintAuthority, - freezeAuthority: params.mintData.freezeAuthority, + mintAuthority: mintInterface.mint.mintAuthority, + freezeAuthority: mintInterface.mint.freezeAuthority, extensions: [ { tokenMetadata: { updateAuthority: - params.mintData.metadata.updateAuthority ?? null, - name: Buffer.from(params.mintData.metadata.name), - symbol: Buffer.from(params.mintData.metadata.symbol), - uri: Buffer.from(params.mintData.metadata.uri), + mintInterface.tokenMetadata.updateAuthority ?? null, + name: Buffer.from(mintInterface.tokenMetadata.name), + symbol: Buffer.from(mintInterface.tokenMetadata.symbol), + uri: Buffer.from(mintInterface.tokenMetadata.uri), additionalMetadata: null, }, }, @@ -130,23 +138,39 @@ function encodeUpdateMetadataInstructionData( } function createUpdateMetadataInstruction( - mintSigner: PublicKey, + mintInterface: MintInterface, authority: PublicKey, payer: PublicKey, validityProof: ValidityProofWithContext, - merkleContext: MerkleContext, - mintData: MintInstructionDataWithMetadata, - outputQueue: PublicKey, action: UpdateMetadataAction, ): TransactionInstruction { + if (!mintInterface.merkleContext) { + throw new Error( + 'MintInterface must have merkleContext for compressed mint operations', + ); + } + if (!mintInterface.mintContext) { + throw new Error( + 'MintInterface must have mintContext for compressed mint operations', + ); + } + if (!mintInterface.tokenMetadata) { + throw new Error( + 'MintInterface must have tokenMetadata for metadata operations', + ); + } + + const merkleContext = mintInterface.merkleContext; + const outputQueue = getOutputQueue(merkleContext); + const addressTreeInfo = getDefaultAddressTreeInfo(); const data = encodeUpdateMetadataInstructionData({ - mintSigner, + splMint: mintInterface.mintContext.splMint, addressTree: addressTreeInfo.tree, leafIndex: merkleContext.leafIndex, rootIndex: validityProof.rootIndices[0], proof: validityProof.compressedProof, - mintData, + mintInterface, action, }); @@ -200,44 +224,26 @@ function createUpdateMetadataInstruction( }); } -// Keep old interface type for backwards compatibility export -export interface CreateUpdateMetadataFieldInstructionParams { - mintSigner: PublicKey; - authority: PublicKey; - payer: PublicKey; - validityProof: ValidityProofWithContext; - merkleContext: MerkleContext; - mintData: MintInstructionDataWithMetadata; - outputQueue: PublicKey; - fieldType: 'name' | 'symbol' | 'uri' | 'custom'; - value: string; - customKey?: string; - extensionIndex?: number; -} - /** * Create instruction for updating a compressed mint's metadata field. * - * @param mintSigner Mint signer public key. - * @param authority Metadata update authority public key. - * @param payer Fee payer public key. - * @param validityProof Validity proof for the compressed mint. - * @param merkleContext Merkle context of the compressed mint. - * @param mintData Mint instruction data with metadata. - * @param outputQueue Output queue for state changes. - * @param fieldType Field to update: 'name', 'symbol', 'uri', or 'custom'. - * @param value New value for the field. - * @param customKey Custom key name (required if fieldType is 'custom'). - * @param extensionIndex Extension index (default: 0). + * Output queue is automatically derived from mintInterface.merkleContext.treeInfo + * (preferring nextTreeInfo.queue if available for rollover support). + * + * @param mintInterface MintInterface from getMintInterface() - must have merkleContext and tokenMetadata + * @param authority Metadata update authority public key (must sign) + * @param payer Fee payer public key + * @param validityProof Validity proof for the compressed mint + * @param fieldType Field to update: 'name', 'symbol', 'uri', or 'custom' + * @param value New value for the field + * @param customKey Custom key name (required if fieldType is 'custom') + * @param extensionIndex Extension index (default: 0) */ export function createUpdateMetadataFieldInstruction( - mintSigner: PublicKey, + mintInterface: MintInterface, authority: PublicKey, payer: PublicKey, validityProof: ValidityProofWithContext, - merkleContext: MerkleContext, - mintData: MintInstructionDataWithMetadata, - outputQueue: PublicKey, fieldType: 'name' | 'symbol' | 'uri' | 'custom', value: string, customKey?: string, @@ -259,52 +265,33 @@ export function createUpdateMetadataFieldInstruction( }; return createUpdateMetadataInstruction( - mintSigner, + mintInterface, authority, payer, validityProof, - merkleContext, - mintData, - outputQueue, action, ); } -// Keep old interface type for backwards compatibility export -export interface CreateUpdateMetadataAuthorityInstructionParams { - mintSigner: PublicKey; - currentAuthority: PublicKey; - newAuthority: PublicKey; - payer: PublicKey; - validityProof: ValidityProofWithContext; - merkleContext: MerkleContext; - mintData: MintInstructionDataWithMetadata; - outputQueue: PublicKey; - extensionIndex?: number; -} - /** * Create instruction for updating a compressed mint's metadata authority. * - * @param mintSigner Mint signer public key. - * @param currentAuthority Current metadata update authority public key. - * @param newAuthority New metadata update authority public key. - * @param payer Fee payer public key. - * @param validityProof Validity proof for the compressed mint. - * @param merkleContext Merkle context of the compressed mint. - * @param mintData Mint instruction data with metadata. - * @param outputQueue Output queue for state changes. - * @param extensionIndex Extension index (default: 0). + * Output queue is automatically derived from mintInterface.merkleContext.treeInfo + * (preferring nextTreeInfo.queue if available for rollover support). + * + * @param mintInterface MintInterface from getMintInterface() - must have merkleContext and tokenMetadata + * @param currentAuthority Current metadata update authority public key (must sign) + * @param newAuthority New metadata update authority public key + * @param payer Fee payer public key + * @param validityProof Validity proof for the compressed mint + * @param extensionIndex Extension index (default: 0) */ export function createUpdateMetadataAuthorityInstruction( - mintSigner: PublicKey, + mintInterface: MintInterface, currentAuthority: PublicKey, newAuthority: PublicKey, payer: PublicKey, validityProof: ValidityProofWithContext, - merkleContext: MerkleContext, - mintData: MintInstructionDataWithMetadata, - outputQueue: PublicKey, extensionIndex: number = 0, ): TransactionInstruction { const action: UpdateMetadataAction = { @@ -314,53 +301,33 @@ export function createUpdateMetadataAuthorityInstruction( }; return createUpdateMetadataInstruction( - mintSigner, + mintInterface, currentAuthority, payer, validityProof, - merkleContext, - mintData, - outputQueue, action, ); } -// Keep old interface type for backwards compatibility export -export interface CreateRemoveMetadataKeyInstructionParams { - mintSigner: PublicKey; - authority: PublicKey; - payer: PublicKey; - validityProof: ValidityProofWithContext; - merkleContext: MerkleContext; - mintData: MintInstructionDataWithMetadata; - outputQueue: PublicKey; - key: string; - idempotent?: boolean; - extensionIndex?: number; -} - /** * Create instruction for removing a metadata key from a compressed mint. * - * @param mintSigner Mint signer public key. - * @param authority Metadata update authority public key. - * @param payer Fee payer public key. - * @param validityProof Validity proof for the compressed mint. - * @param merkleContext Merkle context of the compressed mint. - * @param mintData Mint instruction data with metadata. - * @param outputQueue Output queue for state changes. - * @param key Metadata key to remove. - * @param idempotent If true, don't error if key doesn't exist (default: false). - * @param extensionIndex Extension index (default: 0). + * Output queue is automatically derived from mintInterface.merkleContext.treeInfo + * (preferring nextTreeInfo.queue if available for rollover support). + * + * @param mintInterface MintInterface from getMintInterface() - must have merkleContext and tokenMetadata + * @param authority Metadata update authority public key (must sign) + * @param payer Fee payer public key + * @param validityProof Validity proof for the compressed mint + * @param key Metadata key to remove + * @param idempotent If true, don't error if key doesn't exist (default: false) + * @param extensionIndex Extension index (default: 0) */ export function createRemoveMetadataKeyInstruction( - mintSigner: PublicKey, + mintInterface: MintInterface, authority: PublicKey, payer: PublicKey, validityProof: ValidityProofWithContext, - merkleContext: MerkleContext, - mintData: MintInstructionDataWithMetadata, - outputQueue: PublicKey, key: string, idempotent: boolean = false, extensionIndex: number = 0, @@ -373,13 +340,10 @@ export function createRemoveMetadataKeyInstruction( }; return createUpdateMetadataInstruction( - mintSigner, + mintInterface, authority, payer, validityProof, - merkleContext, - mintData, - outputQueue, action, ); } diff --git a/js/compressed-token/src/mint/instructions/update-mint.ts b/js/compressed-token/src/v3/instructions/update-mint.ts similarity index 66% rename from js/compressed-token/src/mint/instructions/update-mint.ts rename to js/compressed-token/src/v3/instructions/update-mint.ts index b46fb85d69..6891bd5c5b 100644 --- a/js/compressed-token/src/mint/instructions/update-mint.ts +++ b/js/compressed-token/src/v3/instructions/update-mint.ts @@ -11,24 +11,25 @@ import { defaultStaticAccountsStruct, deriveAddressV2, getDefaultAddressTreeInfo, - MerkleContext, + getOutputQueue, } from '@lightprotocol/stateless.js'; import { CompressedTokenProgram } from '../../program'; -import { MintInstructionData } from '../serde'; +import { MintInterface } from '../get-mint-interface'; import { encodeMintActionInstructionData, MintActionCompressedInstructionData, Action, ExtensionInstructionData, -} from './mint-action-layout'; +} from '../layout/layout-mint-action'; interface EncodeUpdateMintInstructionParams { + splMint: PublicKey; addressTree: PublicKey; leafIndex: number; proveByIndex: boolean; rootIndex: number; proof: { a: number[]; b: number[]; c: number[] } | null; - mintData: MintInstructionData; + mintInterface: MintInterface; newAuthority: PublicKey | null; actionType: 'mintAuthority' | 'freezeAuthority'; } @@ -37,7 +38,7 @@ function encodeUpdateMintInstructionData( params: EncodeUpdateMintInstructionParams, ): Buffer { const compressedAddress = deriveAddressV2( - params.mintData.splMint.toBytes(), + params.splMint.toBytes(), params.addressTree, CTOKEN_PROGRAM_ID, ); @@ -50,15 +51,18 @@ function encodeUpdateMintInstructionData( // Build extensions if metadata present let extensions: ExtensionInstructionData[] | null = null; - if (params.mintData.metadata) { + if (params.mintInterface.tokenMetadata) { extensions = [ { tokenMetadata: { updateAuthority: - params.mintData.metadata.updateAuthority ?? null, - name: Buffer.from(params.mintData.metadata.name), - symbol: Buffer.from(params.mintData.metadata.symbol), - uri: Buffer.from(params.mintData.metadata.uri), + params.mintInterface.tokenMetadata.updateAuthority ?? + null, + name: Buffer.from(params.mintInterface.tokenMetadata.name), + symbol: Buffer.from( + params.mintInterface.tokenMetadata.symbol, + ), + uri: Buffer.from(params.mintInterface.tokenMetadata.uri), additionalMetadata: null, }, }, @@ -72,20 +76,22 @@ function encodeUpdateMintInstructionData( compressedAddress: Array.from(compressedAddress.toBytes()), tokenPoolBump: 0, tokenPoolIndex: 0, + maxTopUp: 0, createMint: null, actions: [action], proof: params.proof, cpiContext: null, mint: { - supply: params.mintData.supply, - decimals: params.mintData.decimals, + supply: params.mintInterface.mint.supply, + decimals: params.mintInterface.mint.decimals, metadata: { - version: params.mintData.version, - splMintInitialized: params.mintData.splMintInitialized, - mint: params.mintData.splMint, + version: params.mintInterface.mintContext!.version, + splMintInitialized: + params.mintInterface.mintContext!.splMintInitialized, + mint: params.mintInterface.mintContext!.splMint, }, - mintAuthority: params.mintData.mintAuthority, - freezeAuthority: params.mintData.freezeAuthority, + mintAuthority: params.mintInterface.mint.mintAuthority, + freezeAuthority: params.mintInterface.mint.freezeAuthority, extensions, }, }; @@ -93,46 +99,45 @@ function encodeUpdateMintInstructionData( return encodeMintActionInstructionData(instructionData); } -// Keep old interface type for backwards compatibility export -export interface CreateUpdateMintAuthorityInstructionParams { - mintSigner: PublicKey; - currentMintAuthority: PublicKey; - newMintAuthority: PublicKey | null; - payer: PublicKey; - validityProof: ValidityProofWithContext; - merkleContext: MerkleContext; - mintData: MintInstructionData; - outputQueue: PublicKey; -} - /** * Create instruction for updating a compressed mint's mint authority. * - * @param currentMintAuthority Current mint authority public key. - * @param newMintAuthority New mint authority (or null to revoke). - * @param payer Fee payer public key. - * @param validityProof Validity proof for the compressed mint. - * @param merkleContext Merkle context of the compressed mint. - * @param mintData Mint instruction data. - * @param outputQueue Output queue for state changes. + * @param mintInterface MintInterface from getMintInterface() - must have merkleContext + * @param currentMintAuthority Current mint authority public key (must sign) + * @param newMintAuthority New mint authority (or null to revoke) + * @param payer Fee payer public key + * @param validityProof Validity proof for the compressed mint */ export function createUpdateMintAuthorityInstruction( + mintInterface: MintInterface, currentMintAuthority: PublicKey, newMintAuthority: PublicKey | null, payer: PublicKey, validityProof: ValidityProofWithContext, - merkleContext: MerkleContext, - mintData: MintInstructionData, - outputQueue: PublicKey, ): TransactionInstruction { + if (!mintInterface.merkleContext) { + throw new Error( + 'MintInterface must have merkleContext for compressed mint operations', + ); + } + if (!mintInterface.mintContext) { + throw new Error( + 'MintInterface must have mintContext for compressed mint operations', + ); + } + + const merkleContext = mintInterface.merkleContext; + const outputQueue = getOutputQueue(merkleContext); + const addressTreeInfo = getDefaultAddressTreeInfo(); const data = encodeUpdateMintInstructionData({ + splMint: mintInterface.mintContext.splMint, addressTree: addressTreeInfo.tree, leafIndex: merkleContext.leafIndex, proveByIndex: true, rootIndex: validityProof.rootIndices[0], proof: validityProof.compressedProof, - mintData, + mintInterface, newAuthority: newMintAuthority, actionType: 'mintAuthority', }); @@ -187,46 +192,48 @@ export function createUpdateMintAuthorityInstruction( }); } -// Keep old interface type for backwards compatibility export -export interface CreateUpdateFreezeAuthorityInstructionParams { - mintSigner: PublicKey; - currentFreezeAuthority: PublicKey; - newFreezeAuthority: PublicKey | null; - payer: PublicKey; - validityProof: ValidityProofWithContext; - merkleContext: MerkleContext; - mintData: MintInstructionData; - outputQueue: PublicKey; -} - /** * Create instruction for updating a compressed mint's freeze authority. * - * @param currentFreezeAuthority Current freeze authority public key. - * @param newFreezeAuthority New freeze authority (or null to revoke). - * @param payer Fee payer public key. - * @param validityProof Validity proof for the compressed mint. - * @param merkleContext Merkle context of the compressed mint. - * @param mintData Mint instruction data. - * @param outputQueue Output queue for state changes. + * Output queue is automatically derived from mintInterface.merkleContext.treeInfo + * (preferring nextTreeInfo.queue if available for rollover support). + * + * @param mintInterface MintInterface from getMintInterface() - must have merkleContext + * @param currentFreezeAuthority Current freeze authority public key (must sign) + * @param newFreezeAuthority New freeze authority (or null to revoke) + * @param payer Fee payer public key + * @param validityProof Validity proof for the compressed mint */ export function createUpdateFreezeAuthorityInstruction( + mintInterface: MintInterface, currentFreezeAuthority: PublicKey, newFreezeAuthority: PublicKey | null, payer: PublicKey, validityProof: ValidityProofWithContext, - merkleContext: MerkleContext, - mintData: MintInstructionData, - outputQueue: PublicKey, ): TransactionInstruction { + if (!mintInterface.merkleContext) { + throw new Error( + 'MintInterface must have merkleContext for compressed mint operations', + ); + } + if (!mintInterface.mintContext) { + throw new Error( + 'MintInterface must have mintContext for compressed mint operations', + ); + } + + const merkleContext = mintInterface.merkleContext; + const outputQueue = getOutputQueue(merkleContext); + const addressTreeInfo = getDefaultAddressTreeInfo(); const data = encodeUpdateMintInstructionData({ + splMint: mintInterface.mintContext.splMint, addressTree: addressTreeInfo.tree, leafIndex: merkleContext.leafIndex, proveByIndex: true, rootIndex: validityProof.rootIndices[0], proof: validityProof.compressedProof, - mintData, + mintInterface, newAuthority: newFreezeAuthority, actionType: 'freezeAuthority', }); diff --git a/js/compressed-token/src/mint/instructions/wrap.ts b/js/compressed-token/src/v3/instructions/wrap.ts similarity index 55% rename from js/compressed-token/src/mint/instructions/wrap.ts rename to js/compressed-token/src/v3/instructions/wrap.ts index e69f0d999e..19347892eb 100644 --- a/js/compressed-token/src/mint/instructions/wrap.ts +++ b/js/compressed-token/src/v3/instructions/wrap.ts @@ -1,43 +1,28 @@ import { PublicKey, TransactionInstruction } from '@solana/web3.js'; import { CTOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; import { CompressedTokenProgram } from '../../program'; -import { TokenPoolInfo } from '../../utils/get-token-pool-infos'; +import { SplInterfaceInfo } from '../../utils/get-token-pool-infos'; import { encodeTransfer2InstructionData, createCompressSpl, createDecompressCtoken, Transfer2InstructionData, Compression, -} from '../../layout-transfer2'; - -// Keep old interface type for backwards compatibility export -export interface CreateWrapInstructionParams { - source: PublicKey; - destination: PublicKey; - owner: PublicKey; - mint: PublicKey; - amount: bigint; - tokenPoolInfo: TokenPoolInfo; - payer?: PublicKey; -} +} from '../layout/layout-transfer2'; /** - * Create a wrap instruction that moves tokens from an SPL/T22 account to a CToken account. - * - * This is an agnostic, low-level instruction that takes explicit account addresses. - * Use the wrap() action for a higher-level convenience wrapper. - * - * The wrap operation: - * 1. Compresses tokens from the SPL/T22 source account into the token pool - * 2. Decompresses tokens from the pool to the CToken destination account + * Create a wrap instruction that moves tokens from an SPL/T22 account to a + * c-token account. * - * @param source Source SPL/T22 token account (any token account, not just ATA) - * @param destination Destination CToken account (any CToken account, not just ATA) - * @param owner Owner/authority of the source account (must sign) - * @param mint Mint address - * @param amount Amount to wrap - * @param tokenPoolInfo Token pool info for the compression - * @param payer Fee payer (defaults to owner if not provided) + * @param source Source SPL/T22 token account (any token account, not + * just ATA) + * @param destination Destination c-token account (any c-token account, not + * just ATA) + * @param owner Owner/authority of the source account (must sign) + * @param mint Mint address + * @param amount Amount to wrap + * @param splInterfaceInfo SPL interface info for the compression + * @param payer Fee payer (defaults to owner if not provided) * @returns TransactionInstruction to wrap tokens */ export function createWrapInstruction( @@ -46,28 +31,17 @@ export function createWrapInstruction( owner: PublicKey, mint: PublicKey, amount: bigint, - tokenPoolInfo: TokenPoolInfo, + splInterfaceInfo: SplInterfaceInfo, payer: PublicKey = owner, ): TransactionInstruction { - // Account indices in packed accounts (after fixed accounts): - // 0 = mint - // 1 = owner/authority - // 2 = source (SPL/T22 token account) - // 3 = destination (CToken account) - // 4 = token pool PDA - // 5 = SPL token program (for compress) - // 6 = CToken program (for decompress to CToken) const MINT_INDEX = 0; const OWNER_INDEX = 1; const SOURCE_INDEX = 2; const DESTINATION_INDEX = 3; const POOL_INDEX = 4; - const SPL_TOKEN_PROGRAM_INDEX = 5; + const _SPL_TOKEN_PROGRAM_INDEX = 5; const CTOKEN_PROGRAM_INDEX = 6; - // Build compressions: - // 1. Compress from source (tokens go to pool) - // 2. Decompress to destination (CToken balance increases) const compressions: Compression[] = [ createCompressSpl( amount, @@ -75,8 +49,8 @@ export function createWrapInstruction( SOURCE_INDEX, OWNER_INDEX, POOL_INDEX, - tokenPoolInfo.poolIndex, - tokenPoolInfo.bump, + splInterfaceInfo.poolIndex, + splInterfaceInfo.bump, ), createDecompressCtoken( amount, @@ -92,6 +66,7 @@ export function createWrapInstruction( lamportsChangeAccountMerkleTreeIndex: 0, lamportsChangeAccountOwnerIndex: 0, outputQueue: 0, + maxTopUp: 0, cpiContext: null, compressions, proof: null, @@ -105,35 +80,27 @@ export function createWrapInstruction( const data = encodeTransfer2InstructionData(instructionData); - // Accounts for compressions-only path: - // 0: compressions_only_cpi_authority_pda - // 1: compressions_only_fee_payer (signer) - // Then packed accounts: mint, owner, source, destination, pool, spl_program, ctoken_program const keys = [ - // Fixed accounts for compressions-only { pubkey: CompressedTokenProgram.deriveCpiAuthorityPda, isSigner: false, isWritable: false, }, { pubkey: payer, isSigner: true, isWritable: true }, - // Packed accounts { pubkey: mint, isSigner: false, isWritable: false }, { pubkey: owner, isSigner: true, isWritable: false }, { pubkey: source, isSigner: false, isWritable: true }, { pubkey: destination, isSigner: false, isWritable: true }, { - pubkey: tokenPoolInfo.tokenPoolPda, + pubkey: splInterfaceInfo.splInterfacePda, isSigner: false, isWritable: true, }, - // SPL token program for compress { - pubkey: tokenPoolInfo.tokenProgram, + pubkey: splInterfaceInfo.tokenProgram, isSigner: false, isWritable: false, }, - // CToken program for decompress to CToken { pubkey: CTOKEN_PROGRAM_ID, isSigner: false, diff --git a/js/compressed-token/src/v3/layout/index.ts b/js/compressed-token/src/v3/layout/index.ts new file mode 100644 index 0000000000..15d7eeae67 --- /dev/null +++ b/js/compressed-token/src/v3/layout/index.ts @@ -0,0 +1,5 @@ +export * from './layout-mint'; +export * from './layout-transfer2'; +export * from './layout-token-metadata'; +export * from './layout-mint-action'; +export * from './serde'; diff --git a/js/compressed-token/src/mint/instructions/mint-action-layout.ts b/js/compressed-token/src/v3/layout/layout-mint-action.ts similarity index 98% rename from js/compressed-token/src/mint/instructions/mint-action-layout.ts rename to js/compressed-token/src/v3/layout/layout-mint-action.ts index 09ec15f346..c74aaa5f31 100644 --- a/js/compressed-token/src/mint/instructions/mint-action-layout.ts +++ b/js/compressed-token/src/v3/layout/layout-mint-action.ts @@ -153,6 +153,7 @@ export const MintActionCompressedInstructionDataLayout = struct([ array(u8(), 32, 'compressedAddress'), u8('tokenPoolBump'), u8('tokenPoolIndex'), + u16('maxTopUp'), option(CreateMintLayout, 'createMint'), vec(ActionLayout, 'actions'), option(CompressedProofLayout, 'proof'), @@ -239,7 +240,7 @@ export interface AdditionalMetadata { value: Buffer; } -export interface TokenMetadataInstructionData { +export interface TokenMetadataLayoutData { updateAuthority: PublicKey | null; name: Buffer; symbol: Buffer; @@ -248,7 +249,7 @@ export interface TokenMetadataInstructionData { } export type ExtensionInstructionData = { - tokenMetadata: TokenMetadataInstructionData; + tokenMetadata: TokenMetadataLayoutData; }; export interface CompressedMintMetadata { @@ -273,6 +274,7 @@ export interface MintActionCompressedInstructionData { compressedAddress: number[]; tokenPoolBump: number; tokenPoolIndex: number; + maxTopUp: number; createMint: CreateMint | null; actions: Action[]; proof: ValidityProof | null; @@ -311,7 +313,7 @@ export function encodeMintActionInstructionData( }, }; } - // Handle MintToCToken action + // Handle MintToCToken action (c-token mint-to) if ('mintToCToken' in action && action.mintToCToken) { return { mintToCToken: { diff --git a/js/compressed-token/src/mint/serde.ts b/js/compressed-token/src/v3/layout/layout-mint.ts similarity index 100% rename from js/compressed-token/src/mint/serde.ts rename to js/compressed-token/src/v3/layout/layout-mint.ts diff --git a/js/compressed-token/src/mint/upload.ts b/js/compressed-token/src/v3/layout/layout-token-metadata.ts similarity index 100% rename from js/compressed-token/src/mint/upload.ts rename to js/compressed-token/src/v3/layout/layout-token-metadata.ts diff --git a/js/compressed-token/src/layout-transfer2.ts b/js/compressed-token/src/v3/layout/layout-transfer2.ts similarity index 82% rename from js/compressed-token/src/layout-transfer2.ts rename to js/compressed-token/src/v3/layout/layout-transfer2.ts index 35baa4e298..e98bc5ce2f 100644 --- a/js/compressed-token/src/layout-transfer2.ts +++ b/js/compressed-token/src/v3/layout/layout-transfer2.ts @@ -32,6 +32,7 @@ export interface Compression { poolAccountIndex: number; poolIndex: number; bump: number; + decimals: number; } /** @@ -88,6 +89,7 @@ export interface Transfer2InstructionData { lamportsChangeAccountMerkleTreeIndex: number; lamportsChangeAccountOwnerIndex: number; outputQueue: number; + maxTopUp: number; cpiContext: CompressedCpiContext | null; compressions: Compression[] | null; proof: { a: number[]; b: number[]; c: number[] } | null; @@ -109,6 +111,7 @@ const CompressionLayout = struct([ u8('poolAccountIndex'), u8('poolIndex'), u8('bump'), + u8('decimals'), ]); const PackedMerkleContextLayout = struct([ @@ -156,6 +159,7 @@ const Transfer2InstructionDataLayout = struct([ u8('lamportsChangeAccountMerkleTreeIndex'), u8('lamportsChangeAccountOwnerIndex'), u8('outputQueue'), + u16('maxTopUp'), option(CompressedCpiContextLayout, 'cpiContext'), option(vec(CompressionLayout), 'compressions'), option(CompressedProofLayout, 'proof'), @@ -199,7 +203,7 @@ export function encodeTransfer2InstructionData( } /** - * Create a compression struct for wrapping SPL tokens to CToken + * Create a compression struct for wrapping SPL tokens to c-token * (compress from SPL ATA) */ export function createCompressSpl( @@ -220,15 +224,16 @@ export function createCompressSpl( poolAccountIndex, poolIndex, bump, + decimals: 0, }; } /** - * Create a compression struct for decompressing to CToken ATA + * Create a compression struct for decompressing to c-token ATA * @param amount - Amount to decompress * @param mintIndex - Index of mint in packed accounts - * @param recipientIndex - Index of recipient CToken account in packed accounts - * @param tokenProgramIndex - Index of CToken program in packed accounts (for CPI) + * @param recipientIndex - Index of recipient c-token account in packed accounts + * @param tokenProgramIndex - Index of c-token program in packed accounts (for CPI) */ export function createDecompressCtoken( amount: bigint, @@ -245,6 +250,36 @@ export function createDecompressCtoken( poolAccountIndex: tokenProgramIndex ?? 0, poolIndex: 0, bump: 0, + decimals: 0, + }; +} + +/** + * Create a compression struct for compressing c-token (burn from c-token ATA) + * Used in unwrap flow: c-token ATA -> pool -> SPL ATA + * @param amount - Amount to compress (burn from c-token) + * @param mintIndex - Index of mint in packed accounts + * @param sourceIndex - Index of source c-token account in packed accounts + * @param authorityIndex - Index of authority/owner in packed accounts (must sign) + * @param tokenProgramIndex - Index of c-token program in packed accounts (for CPI) + */ +export function createCompressCtoken( + amount: bigint, + mintIndex: number, + sourceIndex: number, + authorityIndex: number, + tokenProgramIndex?: number, +): Compression { + return { + mode: COMPRESSION_MODE_COMPRESS, + amount, + mint: mintIndex, + sourceOrRecipient: sourceIndex, + authority: authorityIndex, + poolAccountIndex: tokenProgramIndex ?? 0, + poolIndex: 0, + bump: 0, + decimals: 0, }; } @@ -268,5 +303,6 @@ export function createDecompressSpl( poolAccountIndex, poolIndex, bump, + decimals: 0, }; } diff --git a/js/compressed-token/src/compressible/serde.ts b/js/compressed-token/src/v3/layout/serde.ts similarity index 99% rename from js/compressed-token/src/compressible/serde.ts rename to js/compressed-token/src/v3/layout/serde.ts index 80f2737b7e..5081345b3b 100644 --- a/js/compressed-token/src/compressible/serde.ts +++ b/js/compressed-token/src/v3/layout/serde.ts @@ -12,7 +12,7 @@ import { } from '@coral-xyz/borsh'; import { Buffer } from 'buffer'; import { ValidityProof } from '@lightprotocol/stateless.js'; -import { DECOMPRESS_ACCOUNTS_IDEMPOTENT_DISCRIMINATOR } from '../constants'; +import { DECOMPRESS_ACCOUNTS_IDEMPOTENT_DISCRIMINATOR } from '../../constants'; const ValidityProofLayout = struct([ array(u8(), 32, 'a'), diff --git a/js/compressed-token/src/v3/unified/index.ts b/js/compressed-token/src/v3/unified/index.ts new file mode 100644 index 0000000000..ffb02ddadf --- /dev/null +++ b/js/compressed-token/src/v3/unified/index.ts @@ -0,0 +1,353 @@ +/** + * Exports for @lightprotocol/compressed-token/unified + * + * Import from `/unified` to get a single unified ATA for SPL/T22 and c-token + * mints. + */ +import { PublicKey, Signer, ConfirmOptions, Commitment } from '@solana/web3.js'; +import { Rpc, CTOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; +import BN from 'bn.js'; + +import { + getAtaInterface as _getAtaInterface, + AccountInterface, +} from '../get-account-interface'; +import { getAssociatedTokenAddressInterface as _getAssociatedTokenAddressInterface } from '../get-associated-token-address-interface'; +import { + createLoadAtaInstructions as _createLoadAtaInstructions, + loadAta as _loadAta, +} from '../actions/load-ata'; +import { transferInterface as _transferInterface } from '../actions/transfer-interface'; +import { _getOrCreateAtaInterface } from '../actions/get-or-create-ata-interface'; +import { getAtaProgramId } from '../ata-utils'; +import { InterfaceOptions } from '..'; + +/** + * Get associated token account with unified balance + * + * @param rpc RPC connection + * @param ata Associated token address + * @param owner Owner public key + * @param mint Mint public key + * @param commitment Optional commitment level + * @param programId Optional program ID (omit for unified behavior) + * @returns AccountInterface with aggregated balance from all sources + */ +export async function getAtaInterface( + rpc: Rpc, + ata: PublicKey, + owner: PublicKey, + mint: PublicKey, + commitment?: Commitment, + programId?: PublicKey, +): Promise { + return _getAtaInterface(rpc, ata, owner, mint, commitment, programId, true); +} + +/** + * Derive the canonical token ATA for SPL/T22/c-token in the unified path. + * + * Enforces CTOKEN_PROGRAM_ID. + * + * @param mint Mint public key + * @param owner Owner public key + * @param allowOwnerOffCurve Allow owner to be a PDA. Default false. + * @param programId Token program ID. Default c-token. + * @param associatedTokenProgramId Associated token program ID. Default + * auto-detected. + * @returns Associated token address. + */ +export function getAssociatedTokenAddressInterface( + mint: PublicKey, + owner: PublicKey, + allowOwnerOffCurve = false, + programId: PublicKey = CTOKEN_PROGRAM_ID, + associatedTokenProgramId?: PublicKey, +): PublicKey { + if (!programId.equals(CTOKEN_PROGRAM_ID)) { + throw new Error( + 'Please derive the unified ATA from the c-token program; balances across SPL, T22, and c-token are unified under the canonical c-token ATA.', + ); + } + + return _getAssociatedTokenAddressInterface( + mint, + owner, + allowOwnerOffCurve, + programId, + associatedTokenProgramId, + ); +} + +/** + * Create instructions to load ALL token balances into a c-token ATA. + * + * @param rpc RPC connection + * @param ata Associated token address + * @param owner Owner public key + * @param mint Mint public key + * @param payer Fee payer (defaults to owner) + * @param options Optional interface options + * @returns Array of instructions (empty if nothing to load) + */ +export async function createLoadAtaInstructions( + rpc: Rpc, + ata: PublicKey, + owner: PublicKey, + mint: PublicKey, + payer?: PublicKey, + options?: InterfaceOptions, +) { + return _createLoadAtaInstructions( + rpc, + ata, + owner, + mint, + payer, + options, + true, + ); +} + +/** + * Load all token balances into the c-token ATA. + * + * Wraps SPL/Token-2022 balances and decompresses compressed c-tokens + * into the on-chain c-token ATA. Idempotent: returns null if nothing to load. + * + * @param rpc RPC connection + * @param ata Associated token address (c-token) + * @param owner Owner of the tokens (signer) + * @param mint Mint public key + * @param payer Fee payer (signer, defaults to owner) + * @param confirmOptions Optional confirm options + * @param interfaceOptions Optional interface options + * @returns Transaction signature, or null if nothing to load + */ +export async function loadAta( + rpc: Rpc, + ata: PublicKey, + owner: Signer, + mint: PublicKey, + payer?: Signer, + confirmOptions?: ConfirmOptions, + interfaceOptions?: InterfaceOptions, +) { + return _loadAta( + rpc, + ata, + owner, + mint, + payer, + confirmOptions, + interfaceOptions, + true, + ); +} + +/** + * Transfer tokens using the unified ata interface. + * + * Matches SPL Token's transferChecked signature order. Destination must exist. + * + * @param rpc RPC connection + * @param payer Fee payer (signer) + * @param source Source c-token ATA address + * @param mint Mint address + * @param destination Destination c-token ATA address (must exist) + * @param owner Source owner (signer) + * @param amount Amount to transfer + * @param programId Token program ID (default: CTOKEN_PROGRAM_ID) + * @param confirmOptions Optional confirm options + * @param options Optional interface options + * @returns Transaction signature + */ +export async function transferInterface( + rpc: Rpc, + payer: Signer, + source: PublicKey, + mint: PublicKey, + destination: PublicKey, + owner: Signer, + amount: number | bigint | BN, + programId: PublicKey = CTOKEN_PROGRAM_ID, + confirmOptions?: ConfirmOptions, + options?: InterfaceOptions, +) { + return _transferInterface( + rpc, + payer, + source, + mint, + destination, + owner, + amount, + programId, + confirmOptions, + options, + true, + ); +} + +/** + * Get or create c-token ATA with unified balance detection and auto-loading. + * + * Enforces CTOKEN_PROGRAM_ID. Aggregates balances from: + * - c-token hot (on-chain) account + * - c-token cold (compressed) accounts + * - SPL token accounts (for unified wrapping) + * - Token-2022 accounts (for unified wrapping) + * + * When owner is a Signer: + * - Creates hot ATA if it doesn't exist + * - Loads cold (compressed) tokens into hot ATA + * - Wraps SPL/T22 tokens into c-token ATA + * - Returns account with all tokens ready to use + * + * When owner is a PublicKey: + * - Creates hot ATA if it doesn't exist + * - Returns aggregated balance but does NOT auto-load (can't sign) + * + * @param rpc RPC connection + * @param payer Fee payer + * @param mint Mint address + * @param owner Owner (Signer for auto-load, PublicKey for read-only) + * @param allowOwnerOffCurve Allow PDA owners (default: false) + * @param commitment Optional commitment level + * @param confirmOptions Optional confirm options + * @returns AccountInterface with unified balance and source breakdown + */ +export async function getOrCreateAtaInterface( + rpc: Rpc, + payer: Signer, + mint: PublicKey, + owner: PublicKey | Signer, + allowOwnerOffCurve = false, + commitment?: Commitment, + confirmOptions?: ConfirmOptions, +): Promise { + return _getOrCreateAtaInterface( + rpc, + payer, + mint, + owner, + allowOwnerOffCurve, + commitment, + confirmOptions, + CTOKEN_PROGRAM_ID, + getAtaProgramId(CTOKEN_PROGRAM_ID), + true, // wrap=true for unified path + ); +} + +export { + getAccountInterface, + AccountInterface, + TokenAccountSource, + // Note: Account is already exported from @solana/spl-token via get-account-interface + AccountState, + ParsedTokenAccount, + parseCTokenHot, + parseCTokenCold, + toAccountInfo, + convertTokenDataToAccount, +} from '../get-account-interface'; + +export { + createLoadAccountsParams, + createLoadAtaInstructionsFromInterface, + calculateCompressibleLoadComputeUnits, + CompressibleAccountInput, + ParsedAccountInfoInterface, + CompressibleLoadParams, + PackedCompressedAccount, + LoadResult, +} from '../actions/load-ata'; + +export { + LoadOptions, + TransferInterfaceOptions, + InterfaceOptions, +} from '../actions/transfer-interface'; + +export * from '../../actions'; +export * from '../../utils'; +export * from '../../constants'; +export * from '../../idl'; +export * from '../../layout'; +export * from '../../program'; +export * from '../../types'; +export * from '../derivation'; + +export { + // Instructions + createMintInstruction, + createTokenMetadata, + createAssociatedCTokenAccountInstruction, + createAssociatedCTokenAccountIdempotentInstruction, + createAssociatedTokenAccountInterfaceInstruction, + createAssociatedTokenAccountInterfaceIdempotentInstruction, + createAtaInterfaceIdempotentInstruction, + createMintToInstruction, + createMintToCompressedInstruction, + createMintToInterfaceInstruction, + createUpdateMintAuthorityInstruction, + createUpdateFreezeAuthorityInstruction, + createUpdateMetadataFieldInstruction, + createUpdateMetadataAuthorityInstruction, + createRemoveMetadataKeyInstruction, + createWrapInstruction, + createUnwrapInstruction, + createDecompressInterfaceInstruction, + createTransferInterfaceInstruction, + createCTokenTransferInstruction, + // Types + TokenMetadataInstructionData, + CompressibleConfig, + CTokenConfig, + CreateAssociatedCTokenAccountParams, + // Actions + createMintInterface, + createAtaInterface, + createAtaInterfaceIdempotent, + // getOrCreateAtaInterface is defined locally with unified behavior + decompressInterface, + wrap, + unwrap, + mintTo as mintToCToken, + mintToCompressed, + mintToInterface, + updateMintAuthority, + updateFreezeAuthority, + updateMetadataField, + updateMetadataAuthority, + removeMetadataKey, + // Action types + CreateAtaInterfaceParams, + CreateAtaInterfaceResult, + WrapParams, + WrapResult, + UnwrapParams, + UnwrapResult, + // Helpers + getMintInterface, + unpackMintInterface, + unpackMintData, + MintInterface, + // Serde + BaseMint, + MintContext, + MintExtension, + TokenMetadata, + CompressedMint, + deserializeMint, + serializeMint, + decodeTokenMetadata, + encodeTokenMetadata, + extractTokenMetadata, + ExtensionType, + // Metadata formatting + toOffChainMetadataJson, + OffChainTokenMetadata, + OffChainTokenMetadataJson, +} from '..'; diff --git a/js/compressed-token/tests/e2e/compress-spl-token-account.test.ts b/js/compressed-token/tests/e2e/compress-spl-token-account.test.ts index 6ac06a9d39..34ac9a55ce 100644 --- a/js/compressed-token/tests/e2e/compress-spl-token-account.test.ts +++ b/js/compressed-token/tests/e2e/compress-spl-token-account.test.ts @@ -52,7 +52,6 @@ describe('compressSplTokenAccount', () => { rpc, payer, mintAuthority.publicKey, - null, TEST_TOKEN_DECIMALS, mintKeypair, ) @@ -330,7 +329,6 @@ describe('compressSplTokenAccount', () => { rpc, payer, mintAuthority.publicKey, - null, TEST_TOKEN_DECIMALS, mintKeypair, undefined, diff --git a/js/compressed-token/tests/e2e/compress.test.ts b/js/compressed-token/tests/e2e/compress.test.ts index e3f3023102..7a4a3ddb9f 100644 --- a/js/compressed-token/tests/e2e/compress.test.ts +++ b/js/compressed-token/tests/e2e/compress.test.ts @@ -120,7 +120,6 @@ describe('compress', () => { rpc, payer, mintAuthority.publicKey, - null, TEST_TOKEN_DECIMALS, mintKeypair, ) @@ -317,7 +316,6 @@ describe('compress', () => { rpc, payer, mintAuthority.publicKey, - null, TEST_TOKEN_DECIMALS, mintKeypair, undefined, diff --git a/js/compressed-token/tests/e2e/compressible-load.test.ts b/js/compressed-token/tests/e2e/compressible-load.test.ts index 7885f6978a..c44e96bfd6 100644 --- a/js/compressed-token/tests/e2e/compressible-load.test.ts +++ b/js/compressed-token/tests/e2e/compressible-load.test.ts @@ -21,14 +21,14 @@ import { } from '../../src/utils/get-token-pool-infos'; import { createLoadAccountsParams, - createLoadATAInstructionsFromInterface, - createLoadATAInstructions, + createLoadAtaInstructionsFromInterface, + createLoadAtaInstructions, CompressibleAccountInput, ParsedAccountInfoInterface, calculateCompressibleLoadComputeUnits, -} from '../../src/compressible/unified-load'; -import { getATAInterface } from '../../src/mint/get-account-interface'; -import { getATAAddressInterface } from '../../src/mint/actions/create-ata-interface'; +} from '../../src/v3/actions/load-ata'; +import { getAtaInterface } from '../../src/v3/get-account-interface'; +import { getAssociatedTokenAddressInterface } from '../../src/v3/get-associated-token-address-interface'; featureFlags.version = VERSION.V2; @@ -54,7 +54,6 @@ describe('compressible-load', () => { rpc, payer, mintAuthority.publicKey, - null, TEST_TOKEN_DECIMALS, mintKeypair, ) @@ -117,8 +116,9 @@ describe('compressible-load', () => { selectTokenPoolInfo(tokenPoolInfos), ); - const coldInfo = await getATAInterface( + const coldInfo = await getAtaInterface( rpc, + getAssociatedTokenAddressInterface(mint, owner.publicKey), owner.publicKey, mint, undefined, @@ -138,7 +138,10 @@ describe('compressible-load', () => { info: hotInfo, }, { - address: getATAAddressInterface(mint, owner.publicKey), + address: getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ), accountType: 'cTokenData', tokenVariant: 'vault2', info: coldInfo, @@ -175,8 +178,9 @@ describe('compressible-load', () => { selectTokenPoolInfo(tokenPoolInfos), ); - const accountInfo = await getATAInterface( + const accountInfo = await getAtaInterface( rpc, + getAssociatedTokenAddressInterface(mint, owner.publicKey), owner.publicKey, mint, undefined, @@ -185,7 +189,10 @@ describe('compressible-load', () => { const accounts: CompressibleAccountInput[] = [ { - address: getATAAddressInterface(mint, owner.publicKey), + address: getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ), accountType: 'cTokenData', info: accountInfo, }, @@ -216,8 +223,9 @@ describe('compressible-load', () => { selectTokenPoolInfo(tokenPoolInfos), ); - const accountInfo = await getATAInterface( + const accountInfo = await getAtaInterface( rpc, + getAssociatedTokenAddressInterface(mint, owner.publicKey), owner.publicKey, mint, undefined, @@ -226,7 +234,10 @@ describe('compressible-load', () => { const accounts: CompressibleAccountInput[] = [ { - address: getATAAddressInterface(mint, owner.publicKey), + address: getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ), accountType: 'cTokenData', tokenVariant: 'token0Vault', info: accountInfo, @@ -267,8 +278,9 @@ describe('compressible-load', () => { selectTokenPoolInfo(tokenPoolInfos), ); - const ata = await getATAInterface( + const ata = await getAtaInterface( rpc, + getAssociatedTokenAddressInterface(mint, owner.publicKey), owner.publicKey, mint, undefined, @@ -302,15 +314,16 @@ describe('compressible-load', () => { ); // Load first to make it hot - const coldAta = await getATAInterface( + const coldAta = await getAtaInterface( rpc, + getAssociatedTokenAddressInterface(mint, owner.publicKey), owner.publicKey, mint, undefined, CTOKEN_PROGRAM_ID, ); - const loadIxs = await createLoadATAInstructionsFromInterface( + const loadIxs = await createLoadAtaInstructionsFromInterface( rpc, payer.publicKey, coldAta, @@ -324,8 +337,8 @@ describe('compressible-load', () => { }); }); - describe('createLoadATAInstructionsFromInterface', () => { - it('should throw if AccountInterface not from getATAInterface', async () => { + describe('createLoadAtaInstructionsFromInterface', () => { + it('should throw if AccountInterface not from getAtaInterface', async () => { const fakeInterface = { accountInfo: { data: Buffer.alloc(0) }, parsed: {}, @@ -334,19 +347,19 @@ describe('compressible-load', () => { } as any; await expect( - createLoadATAInstructionsFromInterface( + createLoadAtaInstructionsFromInterface( rpc, payer.publicKey, fakeInterface, ), - ).rejects.toThrow('must be from getATAInterface'); + ).rejects.toThrow('must be from getAtaInterface'); }); it('should return empty when nothing to load', async () => { const owner = Keypair.generate(); - // No balance - getATAInterface will throw, so we test the empty case differently - // For an owner with no tokens, getATAInterface throws TokenAccountNotFoundError + // No balance - getAtaInterface will throw, so we test the empty case differently + // For an owner with no tokens, getAtaInterface throws TokenAccountNotFoundError // This is expected behavior }); @@ -364,8 +377,9 @@ describe('compressible-load', () => { selectTokenPoolInfo(tokenPoolInfos), ); - const ata = await getATAInterface( + const ata = await getAtaInterface( rpc, + getAssociatedTokenAddressInterface(mint, owner.publicKey), owner.publicKey, mint, undefined, @@ -376,7 +390,7 @@ describe('compressible-load', () => { expect(ata._owner?.equals(owner.publicKey)).toBe(true); expect(ata._mint?.equals(mint)).toBe(true); - const ixs = await createLoadATAInstructionsFromInterface( + const ixs = await createLoadAtaInstructionsFromInterface( rpc, payer.publicKey, ata, @@ -387,7 +401,7 @@ describe('compressible-load', () => { }); }); - describe('createLoadATAInstructions', () => { + describe('createLoadAtaInstructions', () => { it('should build load instructions by owner and mint', async () => { const owner = await newAccountWithLamports(rpc, 1e9); @@ -402,13 +416,16 @@ describe('compressible-load', () => { selectTokenPoolInfo(tokenPoolInfos), ); - const ata = getATAAddressInterface(mint, owner.publicKey); - const ixs = await createLoadATAInstructions( + const ata = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); + const ixs = await createLoadAtaInstructions( rpc, - payer.publicKey, ata, owner.publicKey, mint, + payer.publicKey, { tokenPoolInfos }, ); @@ -417,7 +434,7 @@ describe('compressible-load', () => { it('should return empty when nothing to load (hot ATA)', async () => { // For a hot ATA with no cold/SPL/T22 balance, should return empty - // This is tested via createLoadATAInstructionsFromInterface since createLoadATAInstructions + // This is tested via createLoadAtaInstructionsFromInterface since createLoadAtaInstructions // fetches internally }); }); diff --git a/js/compressed-token/tests/e2e/create-associated-ctoken.test.ts b/js/compressed-token/tests/e2e/create-associated-ctoken.test.ts index b7068b5e83..05ab953eca 100644 --- a/js/compressed-token/tests/e2e/create-associated-ctoken.test.ts +++ b/js/compressed-token/tests/e2e/create-associated-ctoken.test.ts @@ -8,14 +8,14 @@ import { featureFlags, getDefaultAddressTreeInfo, } from '@lightprotocol/stateless.js'; -import { createMintInterface } from '../../src/mint/actions'; +import { createMintInterface } from '../../src/v3/actions'; import { createAssociatedCTokenAccount, createAssociatedCTokenAccountIdempotent, -} from '../../src/mint/actions/create-associated-ctoken'; -import { createTokenMetadata } from '../../src/mint/instructions'; -import { getAssociatedCTokenAddress } from '../../src/compressible'; -import { findMintAddress } from '../../src/compressible/derivation'; +} from '../../src/v3/actions/create-associated-ctoken'; +import { createTokenMetadata } from '../../src/v3/instructions'; +import { getAssociatedCTokenAddress } from '../../src/v3/derivation'; +import { findMintAddress } from '../../src/v3/derivation'; featureFlags.version = VERSION.V2; @@ -44,9 +44,6 @@ describe('createAssociatedCTokenAccount', () => { null, decimals, mintSigner, - undefined, - addressTreeInfo, - undefined, ); await rpc.confirmTransaction(createMintSig, 'confirmed'); @@ -88,9 +85,6 @@ describe('createAssociatedCTokenAccount', () => { null, decimals, mintSigner, - undefined, - addressTreeInfo, - undefined, ); await rpc.confirmTransaction(createMintSig, 'confirmed'); @@ -124,9 +118,6 @@ describe('createAssociatedCTokenAccount', () => { null, decimals, mintSigner, - undefined, - addressTreeInfo, - undefined, ); await rpc.confirmTransaction(createMintSig, 'confirmed'); @@ -178,9 +169,6 @@ describe('createAssociatedCTokenAccount', () => { null, decimals, mintSigner, - undefined, - addressTreeInfo, - undefined, ); await rpc.confirmTransaction(createMintSig, 'confirmed'); @@ -243,9 +231,6 @@ describe('createAssociatedCTokenAccount', () => { null, decimals, mintSigner, - undefined, - addressTreeInfo, - undefined, ); await rpc.confirmTransaction(createMintSig, 'confirmed'); @@ -311,9 +296,9 @@ describe('createMint -> createAssociatedCTokenAccount flow', () => { null, decimals, mintSigner, - metadata, - addressTreeInfo, undefined, + undefined, + metadata, ); await rpc.confirmTransaction(createMintSig, 'confirmed'); @@ -376,9 +361,6 @@ describe('createMint -> createAssociatedCTokenAccount flow', () => { freezeAuthority.publicKey, decimals, mintSigner, - undefined, - addressTreeInfo, - undefined, ); await rpc.confirmTransaction(createMintSig, 'confirmed'); @@ -415,9 +397,6 @@ describe('createMint -> createAssociatedCTokenAccount flow', () => { null, decimals, mintSigner1, - undefined, - addressTreeInfo, - undefined, ); await rpc.confirmTransaction(createMint1Sig, 'confirmed'); @@ -433,9 +412,6 @@ describe('createMint -> createAssociatedCTokenAccount flow', () => { null, decimals, mintSigner2, - undefined, - addressTreeInfo, - undefined, ); await rpc.confirmTransaction(createMint2Sig, 'confirmed'); @@ -483,9 +459,6 @@ describe('createMint -> createAssociatedCTokenAccount flow', () => { null, decimals, mintSigner, - undefined, - addressTreeInfo, - undefined, ); await rpc.confirmTransaction(createMintSig, 'confirmed'); @@ -522,9 +495,6 @@ describe('createMint -> createAssociatedCTokenAccount flow', () => { null, decimals, mintSigner, - undefined, - addressTreeInfo, - undefined, ); await rpc.confirmTransaction(createMintSig, 'confirmed'); diff --git a/js/compressed-token/tests/e2e/create-ata-interface.test.ts b/js/compressed-token/tests/e2e/create-ata-interface.test.ts new file mode 100644 index 0000000000..08dced7edf --- /dev/null +++ b/js/compressed-token/tests/e2e/create-ata-interface.test.ts @@ -0,0 +1,686 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { Keypair, Signer, PublicKey } from '@solana/web3.js'; +import { + Rpc, + newAccountWithLamports, + createRpc, + VERSION, + featureFlags, + CTOKEN_PROGRAM_ID, +} from '@lightprotocol/stateless.js'; +import { + TOKEN_PROGRAM_ID, + TOKEN_2022_PROGRAM_ID, + createMint, + getMint, + getAssociatedTokenAddressSync, + ASSOCIATED_TOKEN_PROGRAM_ID, +} from '@solana/spl-token'; +import { createMintInterface } from '../../src/v3/actions'; +import { + createAtaInterface, + createAtaInterfaceIdempotent, +} from '../../src/v3/actions/create-ata-interface'; +import { getAssociatedTokenAddressInterface } from '../../src/v3/get-associated-token-address-interface'; +import { findMintAddress } from '../../src/v3/derivation'; + +featureFlags.version = VERSION.V2; + +describe('createAtaInterface', () => { + let rpc: Rpc; + let payer: Signer; + + beforeAll(async () => { + rpc = createRpc(); + payer = await newAccountWithLamports(rpc, 10e9); + }); + + describe('CToken (default programId)', () => { + it('should create CToken ATA with default programId', async () => { + const mintSigner = Keypair.generate(); + const mintAuthority = Keypair.generate(); + const owner = Keypair.generate(); + const [mintPda] = findMintAddress(mintSigner.publicKey); + + await createMintInterface( + rpc, + payer, + mintAuthority, + null, + 9, + mintSigner, + ); + + const { address, transactionSignature } = await createAtaInterface( + rpc, + payer, + mintPda, + owner.publicKey, + ); + + await rpc.confirmTransaction(transactionSignature, 'confirmed'); + + const expectedAddress = getAssociatedTokenAddressInterface( + mintPda, + owner.publicKey, + ); + expect(address.toBase58()).toBe(expectedAddress.toBase58()); + + const accountInfo = await rpc.getAccountInfo(address); + expect(accountInfo).not.toBe(null); + expect(accountInfo?.owner.toBase58()).toBe( + CTOKEN_PROGRAM_ID.toBase58(), + ); + }); + + it('should create CToken ATA with explicit CTOKEN_PROGRAM_ID', async () => { + const mintSigner = Keypair.generate(); + const mintAuthority = Keypair.generate(); + const owner = Keypair.generate(); + const [mintPda] = findMintAddress(mintSigner.publicKey); + + await createMintInterface( + rpc, + payer, + mintAuthority, + null, + 6, + mintSigner, + ); + + const { address, transactionSignature } = await createAtaInterface( + rpc, + payer, + mintPda, + owner.publicKey, + false, + undefined, + CTOKEN_PROGRAM_ID, + ); + + await rpc.confirmTransaction(transactionSignature, 'confirmed'); + + const expectedAddress = getAssociatedTokenAddressInterface( + mintPda, + owner.publicKey, + false, + CTOKEN_PROGRAM_ID, + ); + expect(address.toBase58()).toBe(expectedAddress.toBase58()); + }); + + it('should fail creating CToken ATA twice (non-idempotent)', async () => { + const mintSigner = Keypair.generate(); + const mintAuthority = Keypair.generate(); + const owner = Keypair.generate(); + const [mintPda] = findMintAddress(mintSigner.publicKey); + + await createMintInterface( + rpc, + payer, + mintAuthority, + null, + 9, + mintSigner, + ); + + await createAtaInterface(rpc, payer, mintPda, owner.publicKey); + + await expect( + createAtaInterface(rpc, payer, mintPda, owner.publicKey), + ).rejects.toThrow(); + }); + + it('should create CToken ATA idempotently', async () => { + const mintSigner = Keypair.generate(); + const mintAuthority = Keypair.generate(); + const owner = Keypair.generate(); + const [mintPda] = findMintAddress(mintSigner.publicKey); + + await createMintInterface( + rpc, + payer, + mintAuthority, + null, + 9, + mintSigner, + ); + + const { address: addr1 } = await createAtaInterfaceIdempotent( + rpc, + payer, + mintPda, + owner.publicKey, + ); + + const { address: addr2 } = await createAtaInterfaceIdempotent( + rpc, + payer, + mintPda, + owner.publicKey, + ); + + const { address: addr3 } = await createAtaInterfaceIdempotent( + rpc, + payer, + mintPda, + owner.publicKey, + ); + + expect(addr1.toBase58()).toBe(addr2.toBase58()); + expect(addr2.toBase58()).toBe(addr3.toBase58()); + }); + + it('should create CToken ATAs for multiple owners', async () => { + const mintSigner = Keypair.generate(); + const mintAuthority = Keypair.generate(); + const owner1 = Keypair.generate(); + const owner2 = Keypair.generate(); + const [mintPda] = findMintAddress(mintSigner.publicKey); + + await createMintInterface( + rpc, + payer, + mintAuthority, + null, + 9, + mintSigner, + ); + + const { address: addr1 } = await createAtaInterface( + rpc, + payer, + mintPda, + owner1.publicKey, + ); + + const { address: addr2 } = await createAtaInterface( + rpc, + payer, + mintPda, + owner2.publicKey, + ); + + expect(addr1.toBase58()).not.toBe(addr2.toBase58()); + + const expected1 = getAssociatedTokenAddressInterface( + mintPda, + owner1.publicKey, + ); + const expected2 = getAssociatedTokenAddressInterface( + mintPda, + owner2.publicKey, + ); + + expect(addr1.toBase58()).toBe(expected1.toBase58()); + expect(addr2.toBase58()).toBe(expected2.toBase58()); + }); + }); + + describe('SPL Token (TOKEN_PROGRAM_ID)', () => { + it('should create SPL Token ATA', async () => { + const mintAuthority = Keypair.generate(); + const owner = Keypair.generate(); + + const mint = await createMint( + rpc, + payer, + mintAuthority.publicKey, + null, + 9, + undefined, + undefined, + TOKEN_PROGRAM_ID, + ); + + const { address, transactionSignature } = await createAtaInterface( + rpc, + payer, + mint, + owner.publicKey, + false, + undefined, + TOKEN_PROGRAM_ID, + ); + + await rpc.confirmTransaction(transactionSignature, 'confirmed'); + + const expectedAddress = getAssociatedTokenAddressSync( + mint, + owner.publicKey, + false, + TOKEN_PROGRAM_ID, + ASSOCIATED_TOKEN_PROGRAM_ID, + ); + expect(address.toBase58()).toBe(expectedAddress.toBase58()); + + const accountInfo = await rpc.getAccountInfo(address); + expect(accountInfo).not.toBe(null); + expect(accountInfo?.owner.toBase58()).toBe( + TOKEN_PROGRAM_ID.toBase58(), + ); + }); + + it('should create SPL Token ATA idempotently', async () => { + const mintAuthority = Keypair.generate(); + const owner = Keypair.generate(); + + const mint = await createMint( + rpc, + payer, + mintAuthority.publicKey, + null, + 6, + undefined, + undefined, + TOKEN_PROGRAM_ID, + ); + + const { address: addr1 } = await createAtaInterfaceIdempotent( + rpc, + payer, + mint, + owner.publicKey, + false, + undefined, + TOKEN_PROGRAM_ID, + ); + + const { address: addr2 } = await createAtaInterfaceIdempotent( + rpc, + payer, + mint, + owner.publicKey, + false, + undefined, + TOKEN_PROGRAM_ID, + ); + + expect(addr1.toBase58()).toBe(addr2.toBase58()); + }); + + it('should fail creating SPL Token ATA twice (non-idempotent)', async () => { + const mintAuthority = Keypair.generate(); + const owner = Keypair.generate(); + + const mint = await createMint( + rpc, + payer, + mintAuthority.publicKey, + null, + 9, + undefined, + undefined, + TOKEN_PROGRAM_ID, + ); + + await createAtaInterface( + rpc, + payer, + mint, + owner.publicKey, + false, + undefined, + TOKEN_PROGRAM_ID, + ); + + await expect( + createAtaInterface( + rpc, + payer, + mint, + owner.publicKey, + false, + undefined, + TOKEN_PROGRAM_ID, + ), + ).rejects.toThrow(); + }); + }); + + describe('Token-2022 (TOKEN_2022_PROGRAM_ID)', () => { + it('should create Token-2022 ATA', async () => { + const mintAuthority = Keypair.generate(); + const owner = Keypair.generate(); + + const mint = await createMint( + rpc, + payer, + mintAuthority.publicKey, + null, + 9, + undefined, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + + const { address, transactionSignature } = await createAtaInterface( + rpc, + payer, + mint, + owner.publicKey, + false, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + + await rpc.confirmTransaction(transactionSignature, 'confirmed'); + + const expectedAddress = getAssociatedTokenAddressSync( + mint, + owner.publicKey, + false, + TOKEN_2022_PROGRAM_ID, + ASSOCIATED_TOKEN_PROGRAM_ID, + ); + expect(address.toBase58()).toBe(expectedAddress.toBase58()); + + const accountInfo = await rpc.getAccountInfo(address); + expect(accountInfo).not.toBe(null); + expect(accountInfo?.owner.toBase58()).toBe( + TOKEN_2022_PROGRAM_ID.toBase58(), + ); + }); + + it('should create Token-2022 ATA idempotently', async () => { + const mintAuthority = Keypair.generate(); + const owner = Keypair.generate(); + + const mint = await createMint( + rpc, + payer, + mintAuthority.publicKey, + null, + 6, + undefined, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + + const { address: addr1 } = await createAtaInterfaceIdempotent( + rpc, + payer, + mint, + owner.publicKey, + false, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + + const { address: addr2 } = await createAtaInterfaceIdempotent( + rpc, + payer, + mint, + owner.publicKey, + false, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + + expect(addr1.toBase58()).toBe(addr2.toBase58()); + }); + }); + + describe('PDA owner (allowOwnerOffCurve)', () => { + it('should create CToken ATA for PDA owner with allowOwnerOffCurve=true', async () => { + const mintSigner = Keypair.generate(); + const mintAuthority = Keypair.generate(); + const [mintPda] = findMintAddress(mintSigner.publicKey); + + // Create a PDA owner + const [pdaOwner] = PublicKey.findProgramAddressSync( + [Buffer.from('test-pda-owner')], + CTOKEN_PROGRAM_ID, + ); + + await createMintInterface( + rpc, + payer, + mintAuthority, + null, + 9, + mintSigner, + ); + + const { address, transactionSignature } = await createAtaInterface( + rpc, + payer, + mintPda, + pdaOwner, + true, // allowOwnerOffCurve + ); + + await rpc.confirmTransaction(transactionSignature, 'confirmed'); + + const expectedAddress = getAssociatedTokenAddressInterface( + mintPda, + pdaOwner, + true, + ); + expect(address.toBase58()).toBe(expectedAddress.toBase58()); + }); + + it('should create SPL Token ATA for PDA owner with allowOwnerOffCurve=true', async () => { + const mintAuthority = Keypair.generate(); + + // Create a PDA owner + const [pdaOwner] = PublicKey.findProgramAddressSync( + [Buffer.from('test-spl-pda-owner')], + TOKEN_PROGRAM_ID, + ); + + const mint = await createMint( + rpc, + payer, + mintAuthority.publicKey, + null, + 9, + undefined, + undefined, + TOKEN_PROGRAM_ID, + ); + + const { address, transactionSignature } = await createAtaInterface( + rpc, + payer, + mint, + pdaOwner, + true, // allowOwnerOffCurve + undefined, + TOKEN_PROGRAM_ID, + ); + + await rpc.confirmTransaction(transactionSignature, 'confirmed'); + + const expectedAddress = getAssociatedTokenAddressSync( + mint, + pdaOwner, + true, + TOKEN_PROGRAM_ID, + ASSOCIATED_TOKEN_PROGRAM_ID, + ); + expect(address.toBase58()).toBe(expectedAddress.toBase58()); + }); + }); + + describe('cross-program verification', () => { + it('should produce different ATAs for same owner/mint with different programs', async () => { + const mintAuthority = Keypair.generate(); + const owner = Keypair.generate(); + + // Create SPL mint + const splMint = await createMint( + rpc, + payer, + mintAuthority.publicKey, + null, + 9, + undefined, + undefined, + TOKEN_PROGRAM_ID, + ); + + // Create CToken mint + const mintSigner = Keypair.generate(); + const [ctokenMint] = findMintAddress(mintSigner.publicKey); + await createMintInterface( + rpc, + payer, + mintAuthority, + null, + 9, + mintSigner, + ); + + // Create ATAs for both + const { address: splAta } = await createAtaInterfaceIdempotent( + rpc, + payer, + splMint, + owner.publicKey, + false, + undefined, + TOKEN_PROGRAM_ID, + ); + + const { address: ctokenAta } = await createAtaInterfaceIdempotent( + rpc, + payer, + ctokenMint, + owner.publicKey, + ); + + // ATAs should be different (different mints and programs) + expect(splAta.toBase58()).not.toBe(ctokenAta.toBase58()); + }); + + it('should match expected derivation for each program', async () => { + const owner = Keypair.generate(); + + // SPL Token + const splMintAuth = Keypair.generate(); + const splMint = await createMint( + rpc, + payer, + splMintAuth.publicKey, + null, + 9, + undefined, + undefined, + TOKEN_PROGRAM_ID, + ); + const { address: splAta } = await createAtaInterfaceIdempotent( + rpc, + payer, + splMint, + owner.publicKey, + false, + undefined, + TOKEN_PROGRAM_ID, + ); + const expectedSplAta = getAssociatedTokenAddressSync( + splMint, + owner.publicKey, + false, + TOKEN_PROGRAM_ID, + ASSOCIATED_TOKEN_PROGRAM_ID, + ); + expect(splAta.toBase58()).toBe(expectedSplAta.toBase58()); + + // Token-2022 + const t22MintAuth = Keypair.generate(); + const t22Mint = await createMint( + rpc, + payer, + t22MintAuth.publicKey, + null, + 9, + undefined, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + const { address: t22Ata } = await createAtaInterfaceIdempotent( + rpc, + payer, + t22Mint, + owner.publicKey, + false, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + const expectedT22Ata = getAssociatedTokenAddressSync( + t22Mint, + owner.publicKey, + false, + TOKEN_2022_PROGRAM_ID, + ASSOCIATED_TOKEN_PROGRAM_ID, + ); + expect(t22Ata.toBase58()).toBe(expectedT22Ata.toBase58()); + + // CToken + const mintSigner = Keypair.generate(); + const ctokenMintAuth = Keypair.generate(); + const [ctokenMint] = findMintAddress(mintSigner.publicKey); + await createMintInterface( + rpc, + payer, + ctokenMintAuth, + null, + 9, + mintSigner, + ); + const { address: ctokenAta } = await createAtaInterfaceIdempotent( + rpc, + payer, + ctokenMint, + owner.publicKey, + ); + const expectedCtokenAta = getAssociatedTokenAddressInterface( + ctokenMint, + owner.publicKey, + ); + expect(ctokenAta.toBase58()).toBe(expectedCtokenAta.toBase58()); + }); + }); + + describe('concurrent calls', () => { + it('should handle concurrent idempotent calls for CToken', async () => { + const mintSigner = Keypair.generate(); + const mintAuthority = Keypair.generate(); + const owner = Keypair.generate(); + const [mintPda] = findMintAddress(mintSigner.publicKey); + + await createMintInterface( + rpc, + payer, + mintAuthority, + null, + 9, + mintSigner, + ); + + const promises = Array(3) + .fill(null) + .map(() => + createAtaInterfaceIdempotent( + rpc, + payer, + mintPda, + owner.publicKey, + ), + ); + + const results = await Promise.allSettled(promises); + const successful = results.filter(r => r.status === 'fulfilled'); + + expect(successful.length).toBeGreaterThan(0); + + // All successful results should have same address + const addresses = successful.map( + r => (r as PromiseFulfilledResult).value.address.toBase58(), + ); + const uniqueAddresses = [...new Set(addresses)]; + expect(uniqueAddresses.length).toBe(1); + }); + }); +}); diff --git a/js/compressed-token/tests/e2e/create-compressed-mint.test.ts b/js/compressed-token/tests/e2e/create-compressed-mint.test.ts index 07a4468c18..875f837458 100644 --- a/js/compressed-token/tests/e2e/create-compressed-mint.test.ts +++ b/js/compressed-token/tests/e2e/create-compressed-mint.test.ts @@ -11,7 +11,6 @@ import { createRpc, VERSION, featureFlags, - getDefaultAddressTreeInfo, buildAndSignTx, sendAndConfirmTx, CTOKEN_PROGRAM_ID, @@ -21,10 +20,10 @@ import { import { createMintInstruction, createTokenMetadata, -} from '../../src/mint/instructions'; -import { createMintInterface } from '../../src/mint/actions'; -import { getMintInterface } from '../../src/mint/helpers'; -import { findMintAddress } from '../../src/compressible/derivation'; +} from '../../src/v3/instructions'; +import { createMintInterface } from '../../src/v3/actions'; +import { getMintInterface } from '../../src/v3/get-mint-interface'; +import { findMintAddress } from '../../src/v3/derivation'; featureFlags.version = VERSION.V2; @@ -41,9 +40,8 @@ describe('createMintInterface (compressed)', () => { mintAuthority = Keypair.generate(); }); - it('should create a compressed mint with metadata and fetch it', async () => { + it('should create a compressed mint and fetch it', async () => { const decimals = 9; - const addressTreeInfo = getDefaultAddressTreeInfo(); const [mintPda] = findMintAddress(mintSigner.publicKey); const { transactionSignature: signature } = await createMintInterface( @@ -53,9 +51,7 @@ describe('createMintInterface (compressed)', () => { null, decimals, mintSigner, - undefined, - addressTreeInfo, - undefined, + { skipPreflight: true }, ); await rpc.confirmTransaction(signature, 'confirmed'); @@ -110,9 +106,6 @@ describe('createMintInterface (compressed)', () => { freezeAuthority.publicKey, decimals, mintSigner2, - undefined, - addressTreeInfo, - undefined, ); await rpc.confirmTransaction(signature, 'confirmed'); diff --git a/js/compressed-token/tests/e2e/create-mint-interface.test.ts b/js/compressed-token/tests/e2e/create-mint-interface.test.ts new file mode 100644 index 0000000000..e10e0e15a2 --- /dev/null +++ b/js/compressed-token/tests/e2e/create-mint-interface.test.ts @@ -0,0 +1,399 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { PublicKey, Keypair, Signer } from '@solana/web3.js'; +import { + Rpc, + newAccountWithLamports, + createRpc, + VERSION, + featureFlags, + CTOKEN_PROGRAM_ID, +} from '@lightprotocol/stateless.js'; +import { + TOKEN_PROGRAM_ID, + TOKEN_2022_PROGRAM_ID, + getMint, +} from '@solana/spl-token'; +import { createMintInterface } from '../../src/v3/actions/create-mint-interface'; +import { createTokenMetadata } from '../../src/v3/instructions'; +import { getMintInterface } from '../../src/v3/get-mint-interface'; +import { findMintAddress } from '../../src/v3/derivation'; + +featureFlags.version = VERSION.V2; + +describe('createMintInterface', () => { + let rpc: Rpc; + let payer: Signer; + + beforeAll(async () => { + rpc = createRpc(); + payer = await newAccountWithLamports(rpc, 10e9); + }); + + describe('CToken (compressed) - default programId', () => { + it('should create compressed mint with default programId', async () => { + const mintSigner = Keypair.generate(); + const mintAuthority = Keypair.generate(); + const [mintPda] = findMintAddress(mintSigner.publicKey); + + const { mint, transactionSignature } = await createMintInterface( + rpc, + payer, + mintAuthority, + null, + 9, + mintSigner, + ); + + await rpc.confirmTransaction(transactionSignature, 'confirmed'); + + expect(mint.toBase58()).toBe(mintPda.toBase58()); + + const { mint: fetchedMint } = await getMintInterface( + rpc, + mintPda, + undefined, + CTOKEN_PROGRAM_ID, + ); + expect(fetchedMint.mintAuthority?.toBase58()).toBe( + mintAuthority.publicKey.toBase58(), + ); + expect(fetchedMint.isInitialized).toBe(true); + }); + + it('should create compressed mint with explicit CTOKEN_PROGRAM_ID', async () => { + const mintSigner = Keypair.generate(); + const mintAuthority = Keypair.generate(); + const [mintPda] = findMintAddress(mintSigner.publicKey); + + const { mint, transactionSignature } = await createMintInterface( + rpc, + payer, + mintAuthority, + null, + 6, + mintSigner, + undefined, + CTOKEN_PROGRAM_ID, + ); + + await rpc.confirmTransaction(transactionSignature, 'confirmed'); + + expect(mint.toBase58()).toBe(mintPda.toBase58()); + }); + + it('should create compressed mint with freeze authority', async () => { + const mintSigner = Keypair.generate(); + const mintAuthority = Keypair.generate(); + const freezeAuthority = Keypair.generate(); + const [mintPda] = findMintAddress(mintSigner.publicKey); + + const { mint, transactionSignature } = await createMintInterface( + rpc, + payer, + mintAuthority, + freezeAuthority.publicKey, + 9, + mintSigner, + ); + + await rpc.confirmTransaction(transactionSignature, 'confirmed'); + + const { mint: fetchedMint } = await getMintInterface( + rpc, + mintPda, + undefined, + CTOKEN_PROGRAM_ID, + ); + expect(fetchedMint.freezeAuthority?.toBase58()).toBe( + freezeAuthority.publicKey.toBase58(), + ); + }); + + it('should create compressed mint with token metadata', async () => { + const mintSigner = Keypair.generate(); + const mintAuthority = Keypair.generate(); + const [mintPda] = findMintAddress(mintSigner.publicKey); + + const metadata = createTokenMetadata( + 'Test Token', + 'TEST', + 'https://test.com/metadata.json', + mintAuthority.publicKey, + ); + + const { mint, transactionSignature } = await createMintInterface( + rpc, + payer, + mintAuthority, + null, + 9, + mintSigner, + undefined, + CTOKEN_PROGRAM_ID, + metadata, + ); + + await rpc.confirmTransaction(transactionSignature, 'confirmed'); + + expect(mint.toBase58()).toBe(mintPda.toBase58()); + }); + + it('should fail when mintAuthority is not a Signer for compressed mint', async () => { + const mintSigner = Keypair.generate(); + const mintAuthority = Keypair.generate().publicKey; // PublicKey, not Signer + + await expect( + createMintInterface( + rpc, + payer, + mintAuthority, // This should fail + null, + 9, + mintSigner, + ), + ).rejects.toThrow( + 'mintAuthority must be a Signer for compressed token mints', + ); + }); + }); + + describe('SPL Token (TOKEN_PROGRAM_ID)', () => { + it('should create SPL Token mint', async () => { + const mintKeypair = Keypair.generate(); + const mintAuthority = Keypair.generate(); + + const { mint, transactionSignature } = await createMintInterface( + rpc, + payer, + mintAuthority.publicKey, // Can be PublicKey for SPL + null, + 9, + mintKeypair, + undefined, + TOKEN_PROGRAM_ID, + ); + + await rpc.confirmTransaction(transactionSignature, 'confirmed'); + + expect(mint.toBase58()).toBe(mintKeypair.publicKey.toBase58()); + + const fetchedMint = await getMint(rpc, mint, undefined, TOKEN_PROGRAM_ID); + expect(fetchedMint.mintAuthority?.toBase58()).toBe( + mintAuthority.publicKey.toBase58(), + ); + expect(fetchedMint.isInitialized).toBe(true); + expect(fetchedMint.decimals).toBe(9); + }); + + it('should create SPL Token mint with freeze authority', async () => { + const mintKeypair = Keypair.generate(); + const mintAuthority = Keypair.generate(); + const freezeAuthority = Keypair.generate(); + + const { mint, transactionSignature } = await createMintInterface( + rpc, + payer, + mintAuthority.publicKey, + freezeAuthority.publicKey, + 6, + mintKeypair, + undefined, + TOKEN_PROGRAM_ID, + ); + + await rpc.confirmTransaction(transactionSignature, 'confirmed'); + + const fetchedMint = await getMint(rpc, mint, undefined, TOKEN_PROGRAM_ID); + expect(fetchedMint.freezeAuthority?.toBase58()).toBe( + freezeAuthority.publicKey.toBase58(), + ); + }); + + it('should create SPL mint with various decimals', async () => { + const mintKeypair = Keypair.generate(); + const mintAuthority = Keypair.generate(); + + const { mint, transactionSignature } = await createMintInterface( + rpc, + payer, + mintAuthority.publicKey, + null, + 0, // Zero decimals + mintKeypair, + undefined, + TOKEN_PROGRAM_ID, + ); + + await rpc.confirmTransaction(transactionSignature, 'confirmed'); + + const fetchedMint = await getMint(rpc, mint, undefined, TOKEN_PROGRAM_ID); + expect(fetchedMint.decimals).toBe(0); + }); + }); + + describe('Token-2022 (TOKEN_2022_PROGRAM_ID)', () => { + it('should create Token-2022 mint', async () => { + const mintKeypair = Keypair.generate(); + const mintAuthority = Keypair.generate(); + + const { mint, transactionSignature } = await createMintInterface( + rpc, + payer, + mintAuthority.publicKey, + null, + 9, + mintKeypair, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + + await rpc.confirmTransaction(transactionSignature, 'confirmed'); + + expect(mint.toBase58()).toBe(mintKeypair.publicKey.toBase58()); + + const fetchedMint = await getMint( + rpc, + mint, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + expect(fetchedMint.mintAuthority?.toBase58()).toBe( + mintAuthority.publicKey.toBase58(), + ); + expect(fetchedMint.isInitialized).toBe(true); + }); + + it('should create Token-2022 mint with freeze authority', async () => { + const mintKeypair = Keypair.generate(); + const mintAuthority = Keypair.generate(); + const freezeAuthority = Keypair.generate(); + + const { mint, transactionSignature } = await createMintInterface( + rpc, + payer, + mintAuthority.publicKey, + freezeAuthority.publicKey, + 6, + mintKeypair, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + + await rpc.confirmTransaction(transactionSignature, 'confirmed'); + + const fetchedMint = await getMint( + rpc, + mint, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + expect(fetchedMint.freezeAuthority?.toBase58()).toBe( + freezeAuthority.publicKey.toBase58(), + ); + }); + }); + + describe('decimals variations', () => { + it('should create mint with 0 decimals', async () => { + const mintSigner = Keypair.generate(); + const mintAuthority = Keypair.generate(); + const [mintPda] = findMintAddress(mintSigner.publicKey); + + const { transactionSignature } = await createMintInterface( + rpc, + payer, + mintAuthority, + null, + 0, + mintSigner, + ); + + await rpc.confirmTransaction(transactionSignature, 'confirmed'); + + const { mint: fetchedMint } = await getMintInterface( + rpc, + mintPda, + undefined, + CTOKEN_PROGRAM_ID, + ); + expect(fetchedMint.decimals).toBe(0); + }); + + it('should create SPL mint with max decimals (9)', async () => { + const mintKeypair = Keypair.generate(); + const mintAuthority = Keypair.generate(); + + const { mint, transactionSignature } = await createMintInterface( + rpc, + payer, + mintAuthority.publicKey, + null, + 9, + mintKeypair, + undefined, + TOKEN_PROGRAM_ID, + ); + + await rpc.confirmTransaction(transactionSignature, 'confirmed'); + + const fetchedMint = await getMint(rpc, mint, undefined, TOKEN_PROGRAM_ID); + expect(fetchedMint.decimals).toBe(9); + }); + }); + + describe('cross-program verification', () => { + it('should create different mint addresses for different programs', async () => { + const mintAuthority = Keypair.generate(); + + // CToken mint + const ctokenMintSigner = Keypair.generate(); + const [ctokenMintPda] = findMintAddress(ctokenMintSigner.publicKey); + const { mint: ctokenMint } = await createMintInterface( + rpc, + payer, + mintAuthority, + null, + 9, + ctokenMintSigner, + ); + + // SPL mint + const splMintKeypair = Keypair.generate(); + const { mint: splMint } = await createMintInterface( + rpc, + payer, + mintAuthority.publicKey, + null, + 9, + splMintKeypair, + undefined, + TOKEN_PROGRAM_ID, + ); + + // Token-2022 mint + const t22MintKeypair = Keypair.generate(); + const { mint: t22Mint } = await createMintInterface( + rpc, + payer, + mintAuthority.publicKey, + null, + 9, + t22MintKeypair, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + + // All mints should be different + expect(ctokenMint.toBase58()).not.toBe(splMint.toBase58()); + expect(splMint.toBase58()).not.toBe(t22Mint.toBase58()); + expect(ctokenMint.toBase58()).not.toBe(t22Mint.toBase58()); + + // CToken mint should be PDA + expect(ctokenMint.toBase58()).toBe(ctokenMintPda.toBase58()); + + // SPL/T22 mints should be keypair pubkeys + expect(splMint.toBase58()).toBe(splMintKeypair.publicKey.toBase58()); + expect(t22Mint.toBase58()).toBe(t22MintKeypair.publicKey.toBase58()); + }); + }); +}); diff --git a/js/compressed-token/tests/e2e/create-mint.test.ts b/js/compressed-token/tests/e2e/create-mint.test.ts index 732c074b69..c43e46af96 100644 --- a/js/compressed-token/tests/e2e/create-mint.test.ts +++ b/js/compressed-token/tests/e2e/create-mint.test.ts @@ -66,7 +66,6 @@ describe('createMint (SPL)', () => { rpc, payer, mintAuthority.publicKey, - null, TEST_TOKEN_DECIMALS, mintKeypair, ) @@ -89,7 +88,6 @@ describe('createMint (SPL)', () => { rpc, payer, mintAuthority.publicKey, - null, TEST_TOKEN_DECIMALS, mintKeypair, ), @@ -98,13 +96,7 @@ describe('createMint (SPL)', () => { it('should create mint with payer as authority', async () => { mint = ( - await createMint( - rpc, - payer, - payer.publicKey, - null, - TEST_TOKEN_DECIMALS, - ) + await createMint(rpc, payer, payer.publicKey, TEST_TOKEN_DECIMALS) ).mint; const poolAccount = CompressedTokenProgram.deriveTokenPoolPda(mint); diff --git a/js/compressed-token/tests/e2e/create-token-pool.test.ts b/js/compressed-token/tests/e2e/create-token-pool.test.ts index 9b728fc2b2..a0ff075550 100644 --- a/js/compressed-token/tests/e2e/create-token-pool.test.ts +++ b/js/compressed-token/tests/e2e/create-token-pool.test.ts @@ -126,7 +126,6 @@ describe('createTokenPool', () => { rpc, payer, mintAuthority.publicKey, - null, TEST_TOKEN_DECIMALS, mintKeypair, ), @@ -170,7 +169,6 @@ describe('createTokenPool', () => { rpc, payer, token22MintAuthority.publicKey, - null, TEST_TOKEN_DECIMALS, token22MintKeypair, undefined, diff --git a/js/compressed-token/tests/e2e/decompress-delegated.test.ts b/js/compressed-token/tests/e2e/decompress-delegated.test.ts index b9cccf0d2c..f1b62f65e2 100644 --- a/js/compressed-token/tests/e2e/decompress-delegated.test.ts +++ b/js/compressed-token/tests/e2e/decompress-delegated.test.ts @@ -120,7 +120,6 @@ describe('decompressDelegated', () => { rpc, payer, mintAuthority.publicKey, - null, TEST_TOKEN_DECIMALS, mintKeypair, ) diff --git a/js/compressed-token/tests/e2e/decompress.test.ts b/js/compressed-token/tests/e2e/decompress.test.ts index c431322703..b3ec1400ca 100644 --- a/js/compressed-token/tests/e2e/decompress.test.ts +++ b/js/compressed-token/tests/e2e/decompress.test.ts @@ -90,7 +90,6 @@ describe('decompress', () => { rpc, payer, mintAuthority.publicKey, - null, TEST_TOKEN_DECIMALS, mintKeypair, ) diff --git a/js/compressed-token/tests/e2e/decompress2.test.ts b/js/compressed-token/tests/e2e/decompress2.test.ts index a7bfaf5e9a..ee918a83b3 100644 --- a/js/compressed-token/tests/e2e/decompress2.test.ts +++ b/js/compressed-token/tests/e2e/decompress2.test.ts @@ -11,21 +11,23 @@ import { featureFlags, } from '@lightprotocol/stateless.js'; import { WasmFactory } from '@lightprotocol/hasher.rs'; +import { getAssociatedTokenAddress, TOKEN_PROGRAM_ID } from '@solana/spl-token'; import { createMint, mintTo } from '../../src/actions'; import { getTokenPoolInfos, selectTokenPoolInfo, + selectSplInterfaceInfosForDecompression, TokenPoolInfo, } from '../../src/utils/get-token-pool-infos'; -import { getATAAddressInterface } from '../../src/mint/actions/create-ata-interface'; -import { decompress2 } from '../../src/mint/actions/decompress2'; -import { createDecompress2Instruction } from '../../src/mint/instructions/decompress2'; +import { getAssociatedTokenAddressInterface } from '../../src/'; +import { decompressInterface } from '../../src/v3/actions/decompress-interface'; +import { createDecompressInterfaceInstruction } from '../../src/v3/instructions/create-decompress-interface-instruction'; featureFlags.version = VERSION.V2; const TEST_TOKEN_DECIMALS = 9; -describe('decompress2', () => { +describe('decompressInterface', () => { let rpc: Rpc; let payer: Signer; let mint: PublicKey; @@ -45,7 +47,6 @@ describe('decompress2', () => { rpc, payer, mintAuthority.publicKey, - null, TEST_TOKEN_DECIMALS, mintKeypair, ) @@ -55,16 +56,16 @@ describe('decompress2', () => { tokenPoolInfos = await getTokenPoolInfos(rpc, mint); }, 60_000); - describe('decompress2 action', () => { + describe('decompressInterface action', () => { it('should return null when no compressed tokens', async () => { const owner = await newAccountWithLamports(rpc, 1e9); - const signature = await decompress2({ + const signature = await decompressInterface( rpc, payer, owner, mint, - }); + ); expect(signature).toBeNull(); }); @@ -91,18 +92,21 @@ describe('decompress2', () => { }); expect(compressedBefore.items.length).toBeGreaterThan(0); - // Decompress using decompress2 - const signature = await decompress2({ + // Decompress using decompressInterface + const signature = await decompressInterface( rpc, payer, owner, mint, - }); + ); expect(signature).not.toBeNull(); // Verify CToken ATA has balance - const ctokenAta = getATAAddressInterface(mint, owner.publicKey); + const ctokenAta = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); const ataInfo = await rpc.getAccountInfo(ctokenAta); expect(ataInfo).not.toBeNull(); const hotBalance = ataInfo!.data.readBigUInt64LE(64); @@ -132,21 +136,24 @@ describe('decompress2', () => { ); // Decompress only 3000 - const signature = await decompress2({ + const signature = await decompressInterface( rpc, payer, owner, mint, - amount: BigInt(3000), - }); + BigInt(3000), // amount + ); expect(signature).not.toBeNull(); // Verify CToken ATA has balance - const ctokenAta = getATAAddressInterface(mint, owner.publicKey); + const ctokenAta = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); const ataInfo = await rpc.getAccountInfo(ctokenAta); expect(ataInfo).not.toBeNull(); - // Note: decompress2 decompresses all from selected accounts, + // Note: decompressInterface decompresses all from selected accounts, // so the balance will be 10000 (full account) const hotBalance = ataInfo!.data.readBigUInt64LE(64); expect(hotBalance).toBeGreaterThanOrEqual(BigInt(3000)); @@ -195,17 +202,20 @@ describe('decompress2', () => { expect(compressedBefore.items.length).toBe(3); // Decompress all - const signature = await decompress2({ + const signature = await decompressInterface( rpc, payer, owner, mint, - }); + ); expect(signature).not.toBeNull(); // Verify total hot balance = 6000 - const ctokenAta = getATAAddressInterface(mint, owner.publicKey); + const ctokenAta = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); const ataInfo = await rpc.getAccountInfo(ctokenAta); expect(ataInfo).not.toBeNull(); const hotBalance = ataInfo!.data.readBigUInt64LE(64); @@ -235,13 +245,13 @@ describe('decompress2', () => { ); await expect( - decompress2({ + decompressInterface( rpc, payer, owner, mint, - amount: BigInt(99999), - }), + BigInt(99999), // amount + ), ).rejects.toThrow('Insufficient compressed balance'); }); @@ -249,7 +259,10 @@ describe('decompress2', () => { const owner = await newAccountWithLamports(rpc, 1e9); // Verify ATA doesn't exist - const ctokenAta = getATAAddressInterface(mint, owner.publicKey); + const ctokenAta = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); const beforeInfo = await rpc.getAccountInfo(ctokenAta); expect(beforeInfo).toBeNull(); @@ -266,12 +279,12 @@ describe('decompress2', () => { ); // Decompress - const signature = await decompress2({ + const signature = await decompressInterface( rpc, payer, owner, mint, - }); + ); expect(signature).not.toBeNull(); @@ -297,15 +310,13 @@ describe('decompress2', () => { selectTokenPoolInfo(tokenPoolInfos), ); - await decompress2({ - rpc, - payer, - owner, - mint, - }); + await decompressInterface(rpc, payer, owner, mint); // Verify initial balance - const ctokenAta = getATAAddressInterface(mint, owner.publicKey); + const ctokenAta = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); const midInfo = await rpc.getAccountInfo(ctokenAta); expect(midInfo!.data.readBigUInt64LE(64)).toBe(BigInt(2000)); @@ -322,12 +333,7 @@ describe('decompress2', () => { ); // Decompress again - await decompress2({ - rpc, - payer, - owner, - mint, - }); + await decompressInterface(rpc, payer, owner, mint); // Verify total balance = 5000 const afterInfo = await rpc.getAccountInfo(ctokenAta); @@ -351,17 +357,19 @@ describe('decompress2', () => { ); // Decompress to recipient's ATA - const recipientAta = getATAAddressInterface( + const recipientAta = getAssociatedTokenAddressInterface( mint, recipient.publicKey, ); - const signature = await decompress2({ + const signature = await decompressInterface( rpc, payer, owner, mint, - destinationAta: recipientAta, - }); + undefined, // amount (all) + recipientAta, // destinationAta + recipient.publicKey, // destinationOwner + ); expect(signature).not.toBeNull(); @@ -371,7 +379,10 @@ describe('decompress2', () => { expect(recipientInfo!.data.readBigUInt64LE(64)).toBe(BigInt(4000)); // Owner's ATA should not exist or have 0 balance - const ownerAta = getATAAddressInterface(mint, owner.publicKey); + const ownerAta = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); const ownerInfo = await rpc.getAccountInfo(ownerAta); if (ownerInfo) { expect(ownerInfo.data.readBigUInt64LE(64)).toBe(BigInt(0)); @@ -379,7 +390,7 @@ describe('decompress2', () => { }); }); - describe('createDecompress2Instruction', () => { + describe('createDecompressInterfaceInstruction', () => { it('should build instruction with correct accounts', async () => { const owner = await newAccountWithLamports(rpc, 1e9); @@ -409,15 +420,17 @@ describe('decompress2', () => { })), ); - const ctokenAta = getATAAddressInterface(mint, owner.publicKey); + const ctokenAta = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); - const ix = createDecompress2Instruction( + const ix = createDecompressInterfaceInstruction( payer.publicKey, compressedResult.items, ctokenAta, BigInt(1000), - proof.compressedProof, - proof.rootIndices, + proof, ); // Verify instruction structure @@ -436,22 +449,29 @@ describe('decompress2', () => { expect(ix.keys[1].isSigner).toBe(true); expect(ix.keys[1].isWritable).toBe(true); - // Third account should be authority/owner (signer) - expect(ix.keys[2].pubkey.equals(owner.publicKey)).toBe(true); - expect(ix.keys[2].isSigner).toBe(true); + // Third account should be cpi_authority_pda (not signer) + // Owner is in packed accounts, not at index 2 + expect(ix.keys[2].isSigner).toBe(false); + + // Owner should be in packed accounts (index 7+) and marked as signer + // Find owner in keys array (should be in packed accounts section) + const ownerKeyIndex = ix.keys.findIndex( + k => k.pubkey.equals(owner.publicKey) && k.isSigner, + ); + expect(ownerKeyIndex).toBeGreaterThan(6); // After system accounts }); it('should throw when no input accounts provided', () => { const ctokenAta = Keypair.generate().publicKey; expect(() => - createDecompress2Instruction( + createDecompressInterfaceInstruction( payer.publicKey, [], ctokenAta, BigInt(1000), - null, - [], + // Minimal mock - instruction throws before using proof + { compressedProof: null, rootIndices: [] } as any, ), ).toThrow('No input compressed token accounts provided'); }); @@ -496,15 +516,17 @@ describe('decompress2', () => { })), ); - const ctokenAta = getATAAddressInterface(mint, owner.publicKey); + const ctokenAta = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); - const ix = createDecompress2Instruction( + const ix = createDecompressInterfaceInstruction( payer.publicKey, compressedResult.items, ctokenAta, BigInt(1000), - proof.compressedProof, - proof.rootIndices, + proof, ); // Instruction should be valid @@ -543,15 +565,17 @@ describe('decompress2', () => { })), ); - const ctokenAta = getATAAddressInterface(mint, owner.publicKey); + const ctokenAta = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); - const ix = createDecompress2Instruction( + const ix = createDecompressInterfaceInstruction( payer.publicKey, compressedResult.items, ctokenAta, BigInt(1000), - proof.compressedProof, - proof.rootIndices, + proof, ); // Fee payer should be writable @@ -566,4 +590,221 @@ describe('decompress2', () => { expect(destKey!.isWritable).toBe(true); }); }); + + describe('SPL mint scenarios', () => { + it('should decompress compressed SPL tokens to c-token account', async () => { + // This test explicitly uses an SPL mint (created via createMint with token pools) + // to show that compressed SPL tokens can be decompressed to c-token accounts. + const owner = await newAccountWithLamports(rpc, 1e9); + + // Mint compressed SPL tokens (from SPL mint with token pool) + await mintTo( + rpc, + payer, + mint, // SPL mint with token pool + owner.publicKey, + mintAuthority, + bn(5000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + // Get compressed SPL token balance before + const compressedBefore = + await rpc.getCompressedTokenAccountsByOwner(owner.publicKey, { + mint, + }); + const compressedBalanceBefore = compressedBefore.items.reduce( + (sum, acc) => sum + BigInt(acc.parsed.amount.toString()), + BigInt(0), + ); + expect(compressedBalanceBefore).toBe(BigInt(5000)); + + // Decompress to c-token ATA (NOT SPL ATA) + const signature = await decompressInterface( + rpc, + payer, + owner, + mint, + ); + + expect(signature).not.toBeNull(); + + // Verify c-token ATA has balance + const ctokenAta = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); + const ctokenAtaInfo = await rpc.getAccountInfo(ctokenAta); + expect(ctokenAtaInfo).not.toBeNull(); + + // c-token ATA should have the decompressed amount + const ctokenBalance = ctokenAtaInfo!.data.readBigUInt64LE(64); + expect(ctokenBalance).toBe(BigInt(5000)); + + // Compressed balance should be zero + const compressedAfter = await rpc.getCompressedTokenAccountsByOwner( + owner.publicKey, + { mint }, + ); + expect(compressedAfter.items.length).toBe(0); + }); + + it('should decompress partial amount and keep change compressed', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + // Mint compressed SPL tokens + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(8000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + // Decompress only half to c-token ATA + await decompressInterface( + rpc, + payer, + owner, + mint, + BigInt(4000), // amount + ); + + // Verify c-token ATA has partial amount + const ctokenAta = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); + const ctokenAtaInfo = await rpc.getAccountInfo(ctokenAta); + expect(ctokenAtaInfo).not.toBeNull(); + const ctokenBalance = ctokenAtaInfo!.data.readBigUInt64LE(64); + expect(ctokenBalance).toBe(BigInt(4000)); + + // Remaining should still be compressed + const compressedAfter = await rpc.getCompressedTokenAccountsByOwner( + owner.publicKey, + { mint }, + ); + const compressedBalance = compressedAfter.items.reduce( + (sum, acc) => sum + BigInt(acc.parsed.amount.toString()), + BigInt(0), + ); + expect(compressedBalance).toBe(BigInt(4000)); + }); + + it('should decompress compressed tokens to SPL ATA', async () => { + // This test decompresses compressed tokens to an SPL ATA (via token pool) + const owner = await newAccountWithLamports(rpc, 1e9); + + // Mint compressed tokens + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(6000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + // Get fresh SPL interface info for decompression (pool balance may have changed) + const freshPoolInfos = await getTokenPoolInfos(rpc, mint); + const splInterfaceInfo = selectSplInterfaceInfosForDecompression( + freshPoolInfos, + bn(6000), + )[0]; + + // Decompress to SPL ATA (not c-token) + const signature = await decompressInterface( + rpc, + payer, + owner, + mint, + undefined, // amount (all) + undefined, // destinationAta + undefined, // destinationOwner + splInterfaceInfo, // SPL destination + ); + + expect(signature).not.toBeNull(); + + // Verify SPL ATA has balance + const splAta = await getAssociatedTokenAddress( + mint, + owner.publicKey, + false, + TOKEN_PROGRAM_ID, + ); + const splAtaBalance = await rpc.getTokenAccountBalance(splAta); + expect(BigInt(splAtaBalance.value.amount)).toBe(BigInt(6000)); + + // Compressed balance should be zero + const compressedAfter = await rpc.getCompressedTokenAccountsByOwner( + owner.publicKey, + { mint }, + ); + expect(compressedAfter.items.length).toBe(0); + }); + + it('should decompress partial amount to SPL ATA', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + // Mint compressed tokens + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(10000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + // Get fresh SPL interface info for decompression (pool balance may have changed) + const freshPoolInfos = await getTokenPoolInfos(rpc, mint); + const splInterfaceInfo = selectSplInterfaceInfosForDecompression( + freshPoolInfos, + bn(6000), + )[0]; + + // Decompress partial amount to SPL ATA + await decompressInterface( + rpc, + payer, + owner, + mint, + BigInt(6000), // amount + undefined, // destinationAta + undefined, // destinationOwner + splInterfaceInfo, // SPL destination + ); + + // Verify SPL ATA has partial amount + const splAta = await getAssociatedTokenAddress( + mint, + owner.publicKey, + false, + TOKEN_PROGRAM_ID, + ); + const splAtaBalance = await rpc.getTokenAccountBalance(splAta); + expect(BigInt(splAtaBalance.value.amount)).toBe(BigInt(6000)); + + // Remaining should still be compressed + const compressedAfter = await rpc.getCompressedTokenAccountsByOwner( + owner.publicKey, + { mint }, + ); + const compressedBalance = compressedAfter.items.reduce( + (sum, acc) => sum + BigInt(acc.parsed.amount.toString()), + BigInt(0), + ); + expect(compressedBalance).toBe(BigInt(4000)); + }); + }); }); diff --git a/js/compressed-token/tests/e2e/delegate.test.ts b/js/compressed-token/tests/e2e/delegate.test.ts index c7d310ccf3..7505b16bc0 100644 --- a/js/compressed-token/tests/e2e/delegate.test.ts +++ b/js/compressed-token/tests/e2e/delegate.test.ts @@ -126,7 +126,6 @@ describe('delegate', () => { rpc, payer, mintAuthority.publicKey, - null, TEST_TOKEN_DECIMALS, mintKeypair, ) diff --git a/js/compressed-token/tests/e2e/get-account-interface.test.ts b/js/compressed-token/tests/e2e/get-account-interface.test.ts new file mode 100644 index 0000000000..5700919869 --- /dev/null +++ b/js/compressed-token/tests/e2e/get-account-interface.test.ts @@ -0,0 +1,1061 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { Keypair, Signer, PublicKey } from '@solana/web3.js'; +import { + Rpc, + bn, + newAccountWithLamports, + getTestRpc, + selectStateTreeInfo, + TreeInfo, + VERSION, + featureFlags, + CTOKEN_PROGRAM_ID, +} from '@lightprotocol/stateless.js'; +import { WasmFactory } from '@lightprotocol/hasher.rs'; +import { + createMint as createSplMint, + getOrCreateAssociatedTokenAccount, + mintTo as splMintTo, + TOKEN_PROGRAM_ID, + TOKEN_2022_PROGRAM_ID, + getAssociatedTokenAddressSync, + TokenAccountNotFoundError, +} from '@solana/spl-token'; +import { createMint, mintTo } from '../../src/actions'; +import { + getTokenPoolInfos, + selectTokenPoolInfo, + TokenPoolInfo, +} from '../../src/utils/get-token-pool-infos'; +import { + getAccountInterface, + getAtaInterface, + TokenAccountSourceType, + AccountInterface, +} from '../../src/v3/get-account-interface'; +import { getAssociatedTokenAddressInterface } from '../../src/v3/get-associated-token-address-interface'; +import { createAtaInterfaceIdempotent } from '../../src/v3/actions/create-ata-interface'; +import { decompressInterface } from '../../src/v3/actions/decompress-interface'; + +featureFlags.version = VERSION.V2; + +const TEST_TOKEN_DECIMALS = 9; + +describe('get-account-interface', () => { + let rpc: Rpc; + let payer: Signer; + let mintAuthority: Keypair; + let stateTreeInfo: TreeInfo; + + // c-token mint + let ctokenMint: PublicKey; + let ctokenPoolInfos: TokenPoolInfo[]; + + // SPL mint + let splMint: PublicKey; + let splMintAuthority: Keypair; + + // Token-2022 mint + let t22Mint: PublicKey; + let t22MintAuthority: Keypair; + + beforeAll(async () => { + const lightWasm = await WasmFactory.getInstance(); + rpc = await getTestRpc(lightWasm); + payer = await newAccountWithLamports(rpc, 10e9); + mintAuthority = Keypair.generate(); + stateTreeInfo = selectStateTreeInfo(await rpc.getStateTreeInfos()); + + // Create c-token mint + const ctokenMintKeypair = Keypair.generate(); + ctokenMint = ( + await createMint( + rpc, + payer, + mintAuthority.publicKey, + TEST_TOKEN_DECIMALS, + ctokenMintKeypair, + ) + ).mint; + ctokenPoolInfos = await getTokenPoolInfos(rpc, ctokenMint); + + // Create SPL mint + splMintAuthority = Keypair.generate(); + splMint = await createSplMint( + rpc, + payer as Keypair, + splMintAuthority.publicKey, + null, + TEST_TOKEN_DECIMALS, + undefined, + undefined, + TOKEN_PROGRAM_ID, + ); + + // Create Token-2022 mint + t22MintAuthority = Keypair.generate(); + t22Mint = await createSplMint( + rpc, + payer as Keypair, + t22MintAuthority.publicKey, + null, + TEST_TOKEN_DECIMALS, + undefined, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + }, 120_000); + + describe('getAccountInterface', () => { + describe('SPL token (TOKEN_PROGRAM_ID)', () => { + it('should fetch SPL token account with explicit programId', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + const amount = 5000n; + + // Create and fund SPL ATA + const ataAccount = await getOrCreateAssociatedTokenAccount( + rpc, + payer as Keypair, + splMint, + owner.publicKey, + ); + await splMintTo( + rpc, + payer as Keypair, + splMint, + ataAccount.address, + splMintAuthority, + amount, + ); + + const result = await getAccountInterface( + rpc, + ataAccount.address, + 'confirmed', + TOKEN_PROGRAM_ID, + ); + + expect(result.parsed.address.toBase58()).toBe( + ataAccount.address.toBase58(), + ); + expect(result.parsed.mint.toBase58()).toBe(splMint.toBase58()); + expect(result.parsed.owner.toBase58()).toBe( + owner.publicKey.toBase58(), + ); + expect(result.parsed.amount).toBe(amount); + expect(result.isCold).toBe(false); + expect(result.loadContext).toBeUndefined(); + expect(result._sources).toBeDefined(); + expect(result._sources?.length).toBe(1); + expect(result._sources?.[0].type).toBe( + TokenAccountSourceType.Spl, + ); + }); + + it('should throw when SPL account does not exist', async () => { + const nonExistentAddress = Keypair.generate().publicKey; + + await expect( + getAccountInterface( + rpc, + nonExistentAddress, + 'confirmed', + TOKEN_PROGRAM_ID, + ), + ).rejects.toThrow(TokenAccountNotFoundError); + }); + }); + + describe('Token-2022 (TOKEN_2022_PROGRAM_ID)', () => { + it('should fetch Token-2022 account with explicit programId', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + const amount = 7500n; + + // Create and fund T22 ATA + const ataAccount = await getOrCreateAssociatedTokenAccount( + rpc, + payer as Keypair, + t22Mint, + owner.publicKey, + false, + 'confirmed', + undefined, + TOKEN_2022_PROGRAM_ID, + ); + await splMintTo( + rpc, + payer as Keypair, + t22Mint, + ataAccount.address, + t22MintAuthority, + amount, + [], + undefined, + TOKEN_2022_PROGRAM_ID, + ); + + const result = await getAccountInterface( + rpc, + ataAccount.address, + 'confirmed', + TOKEN_2022_PROGRAM_ID, + ); + + expect(result.parsed.address.toBase58()).toBe( + ataAccount.address.toBase58(), + ); + expect(result.parsed.mint.toBase58()).toBe(t22Mint.toBase58()); + expect(result.parsed.owner.toBase58()).toBe( + owner.publicKey.toBase58(), + ); + expect(result.parsed.amount).toBe(amount); + expect(result.isCold).toBe(false); + expect(result._sources).toBeDefined(); + expect(result._sources?.length).toBe(1); + expect(result._sources?.[0].type).toBe( + TokenAccountSourceType.Token2022, + ); + }); + + it('should throw when Token-2022 account does not exist', async () => { + const nonExistentAddress = Keypair.generate().publicKey; + + await expect( + getAccountInterface( + rpc, + nonExistentAddress, + 'confirmed', + TOKEN_2022_PROGRAM_ID, + ), + ).rejects.toThrow(TokenAccountNotFoundError); + }); + }); + + describe('c-token hot (CTOKEN_PROGRAM_ID)', () => { + it('should fetch c-token hot account with explicit programId', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + const amount = bn(10000); + + // Create c-token ATA and mint compressed, then decompress to make it hot + await createAtaInterfaceIdempotent( + rpc, + payer, + ctokenMint, + owner.publicKey, + ); + + await mintTo( + rpc, + payer, + ctokenMint, + owner.publicKey, + mintAuthority, + amount, + stateTreeInfo, + selectTokenPoolInfo(ctokenPoolInfos), + ); + + // Decompress to make it hot + await decompressInterface(rpc, payer, owner, ctokenMint); + + const ctokenAta = getAssociatedTokenAddressInterface( + ctokenMint, + owner.publicKey, + ); + + const result = await getAccountInterface( + rpc, + ctokenAta, + 'confirmed', + CTOKEN_PROGRAM_ID, + ); + + expect(result.parsed.address.toBase58()).toBe( + ctokenAta.toBase58(), + ); + expect(result.parsed.mint.toBase58()).toBe( + ctokenMint.toBase58(), + ); + expect(result.parsed.owner.toBase58()).toBe( + owner.publicKey.toBase58(), + ); + expect(result.parsed.amount).toBe(BigInt(amount.toString())); + expect(result.isCold).toBe(false); + expect(result.loadContext).toBeUndefined(); + expect(result._sources).toBeDefined(); + expect(result._sources?.length).toBe(1); + expect(result._sources?.[0].type).toBe( + TokenAccountSourceType.CTokenHot, + ); + }); + }); + + describe('c-token cold (compressed)', () => { + it('minted compressed tokens require getAtaInterface (indexed by owner+mint)', async () => { + // Note: Tokens minted compressed via mintTo are indexed by owner+mint, + // NOT by a derived address from the ATA. For these, use getAtaInterface. + // getAccountInterface only finds accounts compressed from on-chain + // (via compress_accounts_idempotent hook). + const owner = await newAccountWithLamports(rpc, 1e9); + const amount = bn(8000); + + // Mint compressed tokens (stays cold - no decompress) + await mintTo( + rpc, + payer, + ctokenMint, + owner.publicKey, + mintAuthority, + amount, + stateTreeInfo, + selectTokenPoolInfo(ctokenPoolInfos), + ); + + const ctokenAta = getAssociatedTokenAddressInterface( + ctokenMint, + owner.publicKey, + ); + + // getAccountInterface cannot find minted-compressed tokens by address + await expect( + getAccountInterface( + rpc, + ctokenAta, + 'confirmed', + CTOKEN_PROGRAM_ID, + ), + ).rejects.toThrow(); + + // Use getAtaInterface with owner+mint for minted-compressed tokens + const result = await getAtaInterface( + rpc, + ctokenAta, + owner.publicKey, + ctokenMint, + 'confirmed', + CTOKEN_PROGRAM_ID, + ); + + expect(result.parsed.mint.toBase58()).toBe( + ctokenMint.toBase58(), + ); + expect(result.parsed.owner.toBase58()).toBe( + owner.publicKey.toBase58(), + ); + expect(result.parsed.amount).toBe(BigInt(amount.toString())); + expect(result.isCold).toBe(true); + expect(result.loadContext).toBeDefined(); + expect(result._sources?.[0].type).toBe( + TokenAccountSourceType.CTokenCold, + ); + }); + }); + + describe('auto-detect mode (no programId)', () => { + it('should auto-detect c-token hot account', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + const amount = bn(3000); + + await createAtaInterfaceIdempotent( + rpc, + payer, + ctokenMint, + owner.publicKey, + ); + + await mintTo( + rpc, + payer, + ctokenMint, + owner.publicKey, + mintAuthority, + amount, + stateTreeInfo, + selectTokenPoolInfo(ctokenPoolInfos), + ); + + await decompressInterface(rpc, payer, owner, ctokenMint); + + const ctokenAta = getAssociatedTokenAddressInterface( + ctokenMint, + owner.publicKey, + ); + + // No programId - should auto-detect + const result = await getAccountInterface( + rpc, + ctokenAta, + 'confirmed', + ); + + expect(result.parsed.amount).toBe(BigInt(amount.toString())); + expect(result.isCold).toBe(false); + expect(result._sources?.[0].type).toBe( + TokenAccountSourceType.CTokenHot, + ); + }); + + it('minted compressed tokens: auto-detect requires getAtaInterface', async () => { + // Minted compressed tokens are indexed by owner+mint, not by derived address. + // getAccountInterface auto-detect mode cannot find them. + const owner = await newAccountWithLamports(rpc, 1e9); + const amount = bn(4000); + + // Mint compressed - stays cold + await mintTo( + rpc, + payer, + ctokenMint, + owner.publicKey, + mintAuthority, + amount, + stateTreeInfo, + selectTokenPoolInfo(ctokenPoolInfos), + ); + + const ctokenAta = getAssociatedTokenAddressInterface( + ctokenMint, + owner.publicKey, + ); + + // getAccountInterface auto-detect cannot find minted-compressed tokens + await expect( + getAccountInterface(rpc, ctokenAta, 'confirmed'), + ).rejects.toThrow(/Token account not found/); + + // Use getAtaInterface for minted-compressed tokens + const result = await getAtaInterface( + rpc, + ctokenAta, + owner.publicKey, + ctokenMint, + 'confirmed', + ); + + expect(result.parsed.amount).toBe(BigInt(amount.toString())); + expect(result.isCold).toBe(true); + expect(result._sources?.[0].type).toBe( + TokenAccountSourceType.CTokenCold, + ); + }); + }); + + describe('error cases', () => { + it('should throw for unsupported program ID', async () => { + const fakeAddress = Keypair.generate().publicKey; + const fakeProgramId = Keypair.generate().publicKey; + + await expect( + getAccountInterface( + rpc, + fakeAddress, + 'confirmed', + fakeProgramId, + ), + ).rejects.toThrow(/Unsupported program ID/); + }); + + it('should throw when account not found in auto-detect mode', async () => { + const owner = Keypair.generate(); + const nonExistentAta = getAssociatedTokenAddressInterface( + ctokenMint, + owner.publicKey, + ); + + await expect( + getAccountInterface(rpc, nonExistentAta, 'confirmed'), + ).rejects.toThrow(/Token account not found/); + }); + }); + }); + + describe('getAtaInterface', () => { + describe('c-token hot only', () => { + it('should return hot ATA with correct metadata', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + const amount = bn(6000); + + await createAtaInterfaceIdempotent( + rpc, + payer, + ctokenMint, + owner.publicKey, + ); + + await mintTo( + rpc, + payer, + ctokenMint, + owner.publicKey, + mintAuthority, + amount, + stateTreeInfo, + selectTokenPoolInfo(ctokenPoolInfos), + ); + + await decompressInterface(rpc, payer, owner, ctokenMint); + + const ctokenAta = getAssociatedTokenAddressInterface( + ctokenMint, + owner.publicKey, + ); + + const result = await getAtaInterface( + rpc, + ctokenAta, + owner.publicKey, + ctokenMint, + ); + + expect(result._isAta).toBe(true); + expect(result._owner?.toBase58()).toBe( + owner.publicKey.toBase58(), + ); + expect(result._mint?.toBase58()).toBe(ctokenMint.toBase58()); + expect(result.parsed.amount).toBe(BigInt(amount.toString())); + expect(result.isCold).toBe(false); + expect(result._needsConsolidation).toBe(false); + expect(result._sources?.length).toBe(1); + expect(result._sources?.[0].type).toBe( + TokenAccountSourceType.CTokenHot, + ); + }); + }); + + describe('c-token cold only', () => { + it('should return cold ATA with loadContext', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + const amount = bn(5500); + + // Mint compressed - stays cold + await mintTo( + rpc, + payer, + ctokenMint, + owner.publicKey, + mintAuthority, + amount, + stateTreeInfo, + selectTokenPoolInfo(ctokenPoolInfos), + ); + + const ctokenAta = getAssociatedTokenAddressInterface( + ctokenMint, + owner.publicKey, + ); + + const result = await getAtaInterface( + rpc, + ctokenAta, + owner.publicKey, + ctokenMint, + ); + + expect(result._isAta).toBe(true); + expect(result._owner?.toBase58()).toBe( + owner.publicKey.toBase58(), + ); + expect(result._mint?.toBase58()).toBe(ctokenMint.toBase58()); + expect(result.parsed.amount).toBe(BigInt(amount.toString())); + expect(result.isCold).toBe(true); + expect(result.loadContext).toBeDefined(); + expect(result.loadContext?.hash).toBeDefined(); + expect(result._needsConsolidation).toBe(false); + expect(result._sources?.length).toBe(1); + expect(result._sources?.[0].type).toBe( + TokenAccountSourceType.CTokenCold, + ); + }); + }); + + describe('c-token hot + cold combined', () => { + it('should aggregate hot and cold balances', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + const hotAmount = bn(2000); + const coldAmount = bn(3000); + + await createAtaInterfaceIdempotent( + rpc, + payer, + ctokenMint, + owner.publicKey, + ); + + // Mint and decompress first batch (hot) + await mintTo( + rpc, + payer, + ctokenMint, + owner.publicKey, + mintAuthority, + hotAmount, + stateTreeInfo, + selectTokenPoolInfo(ctokenPoolInfos), + ); + await decompressInterface(rpc, payer, owner, ctokenMint); + + // Mint second batch (cold) + await mintTo( + rpc, + payer, + ctokenMint, + owner.publicKey, + mintAuthority, + coldAmount, + stateTreeInfo, + selectTokenPoolInfo(ctokenPoolInfos), + ); + + const ctokenAta = getAssociatedTokenAddressInterface( + ctokenMint, + owner.publicKey, + ); + + const result = await getAtaInterface( + rpc, + ctokenAta, + owner.publicKey, + ctokenMint, + ); + + // Total should be hot + cold + const expectedTotal = + BigInt(hotAmount.toString()) + + BigInt(coldAmount.toString()); + expect(result.parsed.amount).toBe(expectedTotal); + + // Should have both sources + expect(result._sources?.length).toBe(2); + expect(result._needsConsolidation).toBe(true); + + // Primary source should be hot (higher priority) + expect(result.isCold).toBe(false); + expect(result._sources?.[0].type).toBe( + TokenAccountSourceType.CTokenHot, + ); + expect(result._sources?.[1].type).toBe( + TokenAccountSourceType.CTokenCold, + ); + + // Verify individual source amounts + expect(result._sources?.[0].amount).toBe( + BigInt(hotAmount.toString()), + ); + expect(result._sources?.[1].amount).toBe( + BigInt(coldAmount.toString()), + ); + }); + }); + + describe('wrap=true (include SPL/T22 balances)', () => { + it('should include SPL balance when wrap=true', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + const ctokenAmount = bn(1500); + const splAmount = 2500n; + + // Setup c-token cold + await mintTo( + rpc, + payer, + ctokenMint, + owner.publicKey, + mintAuthority, + ctokenAmount, + stateTreeInfo, + selectTokenPoolInfo(ctokenPoolInfos), + ); + + // Create SPL ATA with same mint (won't work with c-token mint) + // For this test we need an SPL mint that has a token pool + // Actually, wrap=true requires mint parity - use the c-token mint + // But SPL ATAs need SPL mints. This scenario is for unified view + // where user has tokens across multiple account types. + + const ctokenAta = getAssociatedTokenAddressInterface( + ctokenMint, + owner.publicKey, + ); + + const result = await getAtaInterface( + rpc, + ctokenAta, + owner.publicKey, + ctokenMint, + undefined, + undefined, + true, // wrap=true + ); + + // Should have c-token cold (SPL ATA for c-token mint doesn't exist) + expect(result._isAta).toBe(true); + expect(result.parsed.amount).toBe( + BigInt(ctokenAmount.toString()), + ); + expect(result._sources?.length).toBeGreaterThanOrEqual(1); + }); + }); + + describe('metadata fields', () => { + it('should set _hasDelegate when delegate is present', async () => { + // Note: This would require setting up a delegate, which is complex + // For now, verify the field exists when no delegate + const owner = await newAccountWithLamports(rpc, 1e9); + const amount = bn(1000); + + await mintTo( + rpc, + payer, + ctokenMint, + owner.publicKey, + mintAuthority, + amount, + stateTreeInfo, + selectTokenPoolInfo(ctokenPoolInfos), + ); + + const ctokenAta = getAssociatedTokenAddressInterface( + ctokenMint, + owner.publicKey, + ); + + const result = await getAtaInterface( + rpc, + ctokenAta, + owner.publicKey, + ctokenMint, + ); + + expect(result._hasDelegate).toBe(false); + }); + + it('should set _anyFrozen correctly', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + const amount = bn(1000); + + await mintTo( + rpc, + payer, + ctokenMint, + owner.publicKey, + mintAuthority, + amount, + stateTreeInfo, + selectTokenPoolInfo(ctokenPoolInfos), + ); + + const ctokenAta = getAssociatedTokenAddressInterface( + ctokenMint, + owner.publicKey, + ); + + const result = await getAtaInterface( + rpc, + ctokenAta, + owner.publicKey, + ctokenMint, + ); + + expect(result._anyFrozen).toBe(false); + }); + }); + + describe('error cases', () => { + it('should throw TokenAccountNotFoundError when no account exists', async () => { + const owner = Keypair.generate(); + const nonExistentAta = getAssociatedTokenAddressInterface( + ctokenMint, + owner.publicKey, + ); + + await expect( + getAtaInterface( + rpc, + nonExistentAta, + owner.publicKey, + ctokenMint, + ), + ).rejects.toThrow(/Token account not found/); + }); + }); + + describe('SPL programId scenarios', () => { + it('should fetch SPL ATA with explicit TOKEN_PROGRAM_ID', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + const amount = 4000n; + + const ataAccount = await getOrCreateAssociatedTokenAccount( + rpc, + payer as Keypair, + splMint, + owner.publicKey, + ); + await splMintTo( + rpc, + payer as Keypair, + splMint, + ataAccount.address, + splMintAuthority, + amount, + ); + + const result = await getAtaInterface( + rpc, + ataAccount.address, + owner.publicKey, + splMint, + undefined, + TOKEN_PROGRAM_ID, + ); + + expect(result._isAta).toBe(true); + expect(result.parsed.amount).toBe(amount); + expect(result._sources?.[0].type).toBe( + TokenAccountSourceType.Spl, + ); + }); + + it('should fetch T22 ATA with explicit TOKEN_2022_PROGRAM_ID', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + const amount = 6000n; + + const ataAccount = await getOrCreateAssociatedTokenAccount( + rpc, + payer as Keypair, + t22Mint, + owner.publicKey, + false, + 'confirmed', + undefined, + TOKEN_2022_PROGRAM_ID, + ); + await splMintTo( + rpc, + payer as Keypair, + t22Mint, + ataAccount.address, + t22MintAuthority, + amount, + [], + undefined, + TOKEN_2022_PROGRAM_ID, + ); + + const result = await getAtaInterface( + rpc, + ataAccount.address, + owner.publicKey, + t22Mint, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + + expect(result._isAta).toBe(true); + expect(result.parsed.amount).toBe(amount); + expect(result._sources?.[0].type).toBe( + TokenAccountSourceType.Token2022, + ); + }); + }); + + describe('SPL with cold balance (fetchByOwner)', () => { + it('should include SPL cold balance when SPL ATA has compressed tokens', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + const splHotAmount = 3000n; + const compressedAmount = bn(2000); + + // Create SPL ATA and fund it + const ataAccount = await getOrCreateAssociatedTokenAccount( + rpc, + payer as Keypair, + splMint, + owner.publicKey, + ); + await splMintTo( + rpc, + payer as Keypair, + splMint, + ataAccount.address, + splMintAuthority, + splHotAmount, + ); + + // Note: To have compressed tokens for an SPL mint, we'd need + // to register that mint as a token pool and mint compressed. + // For this test, we verify the basic SPL fetch works. + + const result = await getAtaInterface( + rpc, + ataAccount.address, + owner.publicKey, + splMint, + undefined, + TOKEN_PROGRAM_ID, + ); + + expect(result.parsed.amount).toBe(splHotAmount); + expect(result._sources?.[0].type).toBe( + TokenAccountSourceType.Spl, + ); + }); + }); + }); + + describe('balance aggregation', () => { + it('should correctly aggregate balance from single mint', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + const totalAmount = bn(6000); + + // Single mint with total amount + const sig = await mintTo( + rpc, + payer, + ctokenMint, + owner.publicKey, + mintAuthority, + totalAmount, + stateTreeInfo, + selectTokenPoolInfo(ctokenPoolInfos), + ); + await rpc.confirmTransaction(sig, 'confirmed'); + + const ctokenAta = getAssociatedTokenAddressInterface( + ctokenMint, + owner.publicKey, + ); + + const result = await getAtaInterface( + rpc, + ctokenAta, + owner.publicKey, + ctokenMint, + ); + + expect(result.parsed.amount).toBe(BigInt(totalAmount.toString())); + expect(result._sources?.length).toBeGreaterThanOrEqual(1); + expect(result.isCold).toBe(true); + }); + + it('should aggregate hot and cold balances correctly', async () => { + // This is already tested in hot+cold combined test but verify the math + const owner = await newAccountWithLamports(rpc, 1e9); + const hotAmount = bn(1500); + const coldAmount = bn(2500); + + await createAtaInterfaceIdempotent( + rpc, + payer, + ctokenMint, + owner.publicKey, + ); + + // Mint and decompress to create hot balance + const sig1 = await mintTo( + rpc, + payer, + ctokenMint, + owner.publicKey, + mintAuthority, + hotAmount, + stateTreeInfo, + selectTokenPoolInfo(ctokenPoolInfos), + ); + await rpc.confirmTransaction(sig1, 'confirmed'); + await decompressInterface(rpc, payer, owner, ctokenMint); + + // Mint more to create cold balance + const sig2 = await mintTo( + rpc, + payer, + ctokenMint, + owner.publicKey, + mintAuthority, + coldAmount, + stateTreeInfo, + selectTokenPoolInfo(ctokenPoolInfos), + ); + await rpc.confirmTransaction(sig2, 'confirmed'); + + const ctokenAta = getAssociatedTokenAddressInterface( + ctokenMint, + owner.publicKey, + ); + + const result = await getAtaInterface( + rpc, + ctokenAta, + owner.publicKey, + ctokenMint, + ); + + const expectedTotal = + BigInt(hotAmount.toString()) + BigInt(coldAmount.toString()); + + expect(result.parsed.amount).toBe(expectedTotal); + expect(result._sources?.length).toBe(2); + expect(result._needsConsolidation).toBe(true); + }); + }); + + describe('source priority ordering', () => { + it('should prioritize hot over cold', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + const hotAmount = bn(500); + const coldAmount = bn(1500); + + await createAtaInterfaceIdempotent( + rpc, + payer, + ctokenMint, + owner.publicKey, + ); + + // Create hot first + await mintTo( + rpc, + payer, + ctokenMint, + owner.publicKey, + mintAuthority, + hotAmount, + stateTreeInfo, + selectTokenPoolInfo(ctokenPoolInfos), + ); + await decompressInterface(rpc, payer, owner, ctokenMint); + + // Then add cold + await mintTo( + rpc, + payer, + ctokenMint, + owner.publicKey, + mintAuthority, + coldAmount, + stateTreeInfo, + selectTokenPoolInfo(ctokenPoolInfos), + ); + + const ctokenAta = getAssociatedTokenAddressInterface( + ctokenMint, + owner.publicKey, + ); + + const result = await getAtaInterface( + rpc, + ctokenAta, + owner.publicKey, + ctokenMint, + ); + + // Primary (first) source should be hot + expect(result.isCold).toBe(false); + expect(result._sources?.[0].type).toBe( + TokenAccountSourceType.CTokenHot, + ); + + // Total balance is correct + const expectedTotal = + BigInt(hotAmount.toString()) + BigInt(coldAmount.toString()); + expect(result.parsed.amount).toBe(expectedTotal); + }); + }); +}); diff --git a/js/compressed-token/tests/e2e/get-mint-interface.test.ts b/js/compressed-token/tests/e2e/get-mint-interface.test.ts new file mode 100644 index 0000000000..6571022e92 --- /dev/null +++ b/js/compressed-token/tests/e2e/get-mint-interface.test.ts @@ -0,0 +1,988 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { Keypair, Signer, PublicKey, AccountInfo } from '@solana/web3.js'; +import { + Rpc, + newAccountWithLamports, + createRpc, + VERSION, + featureFlags, + CTOKEN_PROGRAM_ID, +} from '@lightprotocol/stateless.js'; +import { + TOKEN_PROGRAM_ID, + TOKEN_2022_PROGRAM_ID, + MintLayout, + MINT_SIZE, + createInitializeMintInstruction, + createMint as createSplMint, +} from '@solana/spl-token'; +import { Buffer } from 'buffer'; +import { + getMintInterface, + unpackMintInterface, + unpackMintData, + MintInterface, +} from '../../src/v3/get-mint-interface'; +import { createMintInterface } from '../../src/v3/actions'; +import { createTokenMetadata } from '../../src/v3/instructions'; +import { findMintAddress } from '../../src/v3/derivation'; +import { + serializeMint, + encodeTokenMetadata, + CompressedMint, + MintContext, + TokenMetadata, + ExtensionType, + MINT_CONTEXT_SIZE, +} from '../../src/v3/layout/layout-mint'; + +featureFlags.version = VERSION.V2; + +describe('getMintInterface', () => { + let rpc: Rpc; + let payer: Signer; + + beforeAll(async () => { + rpc = createRpc(); + payer = await newAccountWithLamports(rpc, 10e9); + }); + + describe('CToken mint (CTOKEN_PROGRAM_ID)', () => { + it('should fetch compressed mint with explicit programId', async () => { + const mintSigner = Keypair.generate(); + const mintAuthority = Keypair.generate(); + const decimals = 9; + const [mintPda] = findMintAddress(mintSigner.publicKey); + + await createMintInterface( + rpc, + payer, + mintAuthority, + null, + decimals, + mintSigner, + { skipPreflight: true }, + ); + + const result = await getMintInterface( + rpc, + mintPda, + undefined, + CTOKEN_PROGRAM_ID, + ); + + expect(result.mint.address.toBase58()).toBe(mintPda.toBase58()); + expect(result.mint.mintAuthority?.toBase58()).toBe( + mintAuthority.publicKey.toBase58(), + ); + expect(result.mint.decimals).toBe(decimals); + expect(result.mint.supply).toBe(0n); + expect(result.mint.isInitialized).toBe(true); + expect(result.mint.freezeAuthority).toBeNull(); + expect(result.programId.toBase58()).toBe( + CTOKEN_PROGRAM_ID.toBase58(), + ); + expect(result.merkleContext).toBeDefined(); + expect(result.mintContext).toBeDefined(); + }); + + it('should fetch compressed mint with freeze authority', async () => { + const mintSigner = Keypair.generate(); + const mintAuthority = Keypair.generate(); + const freezeAuthority = Keypair.generate(); + const decimals = 6; + const [mintPda] = findMintAddress(mintSigner.publicKey); + + await createMintInterface( + rpc, + payer, + mintAuthority, + freezeAuthority.publicKey, + decimals, + mintSigner, + { skipPreflight: true }, + ); + + const result = await getMintInterface( + rpc, + mintPda, + undefined, + CTOKEN_PROGRAM_ID, + ); + + expect(result.mint.freezeAuthority?.toBase58()).toBe( + freezeAuthority.publicKey.toBase58(), + ); + }); + + it('should fetch compressed mint with metadata', async () => { + const mintSigner = Keypair.generate(); + const mintAuthority = Keypair.generate(); + const decimals = 9; + const [mintPda] = findMintAddress(mintSigner.publicKey); + + const metadata = createTokenMetadata( + 'Test Token', + 'TEST', + 'https://example.com/metadata.json', + mintAuthority.publicKey, + ); + + await createMintInterface( + rpc, + payer, + mintAuthority, + null, + decimals, + mintSigner, + { skipPreflight: true }, + CTOKEN_PROGRAM_ID, + metadata, + ); + + const result = await getMintInterface( + rpc, + mintPda, + undefined, + CTOKEN_PROGRAM_ID, + ); + + expect(result.tokenMetadata).toBeDefined(); + expect(result.tokenMetadata!.name).toBe('Test Token'); + expect(result.tokenMetadata!.symbol).toBe('TEST'); + expect(result.tokenMetadata!.uri).toBe( + 'https://example.com/metadata.json', + ); + expect(result.extensions).toBeDefined(); + expect(result.extensions!.length).toBeGreaterThan(0); + }); + + it('should throw for non-existent compressed mint', async () => { + const fakeMint = Keypair.generate().publicKey; + + await expect( + getMintInterface(rpc, fakeMint, undefined, CTOKEN_PROGRAM_ID), + ).rejects.toThrow('Compressed mint not found'); + }); + }); + + describe('SPL Token mint (TOKEN_PROGRAM_ID)', () => { + it('should fetch SPL mint with explicit programId', async () => { + const mintAuthority = Keypair.generate(); + const decimals = 9; + + const mint = await createSplMint( + rpc, + payer, + mintAuthority.publicKey, + null, + decimals, + undefined, + undefined, + TOKEN_PROGRAM_ID, + ); + + const result = await getMintInterface( + rpc, + mint, + undefined, + TOKEN_PROGRAM_ID, + ); + + expect(result.mint.address.toBase58()).toBe(mint.toBase58()); + expect(result.mint.mintAuthority?.toBase58()).toBe( + mintAuthority.publicKey.toBase58(), + ); + expect(result.mint.decimals).toBe(decimals); + expect(result.mint.supply).toBe(0n); + expect(result.mint.isInitialized).toBe(true); + expect(result.programId.toBase58()).toBe( + TOKEN_PROGRAM_ID.toBase58(), + ); + expect(result.merkleContext).toBeUndefined(); + expect(result.mintContext).toBeUndefined(); + expect(result.tokenMetadata).toBeUndefined(); + }); + + it('should fetch SPL mint with freeze authority', async () => { + const mintAuthority = Keypair.generate(); + const freezeAuthority = Keypair.generate(); + const decimals = 6; + + const mint = await createSplMint( + rpc, + payer, + mintAuthority.publicKey, + freezeAuthority.publicKey, + decimals, + undefined, + undefined, + TOKEN_PROGRAM_ID, + ); + + const result = await getMintInterface( + rpc, + mint, + undefined, + TOKEN_PROGRAM_ID, + ); + + expect(result.mint.freezeAuthority?.toBase58()).toBe( + freezeAuthority.publicKey.toBase58(), + ); + }); + }); + + describe('Token-2022 mint (TOKEN_2022_PROGRAM_ID)', () => { + it('should fetch Token-2022 mint with explicit programId', async () => { + const mintAuthority = Keypair.generate(); + const decimals = 9; + + const mint = await createSplMint( + rpc, + payer, + mintAuthority.publicKey, + null, + decimals, + undefined, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + + const result = await getMintInterface( + rpc, + mint, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + + expect(result.mint.address.toBase58()).toBe(mint.toBase58()); + expect(result.mint.mintAuthority?.toBase58()).toBe( + mintAuthority.publicKey.toBase58(), + ); + expect(result.mint.decimals).toBe(decimals); + expect(result.programId.toBase58()).toBe( + TOKEN_2022_PROGRAM_ID.toBase58(), + ); + expect(result.merkleContext).toBeUndefined(); + expect(result.mintContext).toBeUndefined(); + }); + }); + + describe('Auto-detect mode (no programId)', () => { + it('should auto-detect SPL mint', async () => { + const mintAuthority = Keypair.generate(); + const decimals = 9; + + const mint = await createSplMint( + rpc, + payer, + mintAuthority.publicKey, + null, + decimals, + undefined, + undefined, + TOKEN_PROGRAM_ID, + ); + + const result = await getMintInterface(rpc, mint); + + expect(result.mint.address.toBase58()).toBe(mint.toBase58()); + expect(result.programId.toBase58()).toBe( + TOKEN_PROGRAM_ID.toBase58(), + ); + }); + + it('should auto-detect Token-2022 mint', async () => { + const mintAuthority = Keypair.generate(); + const decimals = 6; + + const mint = await createSplMint( + rpc, + payer, + mintAuthority.publicKey, + null, + decimals, + undefined, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + + const result = await getMintInterface(rpc, mint); + + expect(result.mint.address.toBase58()).toBe(mint.toBase58()); + // Could be detected as either T22 or SPL depending on priority + expect([ + TOKEN_PROGRAM_ID.toBase58(), + TOKEN_2022_PROGRAM_ID.toBase58(), + ]).toContain(result.programId.toBase58()); + }); + + it('should auto-detect compressed mint', async () => { + const mintSigner = Keypair.generate(); + const mintAuthority = Keypair.generate(); + const decimals = 9; + const [mintPda] = findMintAddress(mintSigner.publicKey); + + await createMintInterface( + rpc, + payer, + mintAuthority, + null, + decimals, + mintSigner, + { skipPreflight: true }, + ); + + const result = await getMintInterface(rpc, mintPda); + + expect(result.mint.address.toBase58()).toBe(mintPda.toBase58()); + expect(result.programId.toBase58()).toBe( + CTOKEN_PROGRAM_ID.toBase58(), + ); + expect(result.merkleContext).toBeDefined(); + expect(result.mintContext).toBeDefined(); + }); + + it('should throw for non-existent mint in auto-detect mode', async () => { + const fakeMint = Keypair.generate().publicKey; + + await expect(getMintInterface(rpc, fakeMint)).rejects.toThrow( + 'Mint not found', + ); + }); + }); + + describe('mintContext validation', () => { + it('should have valid mintContext for compressed mint', async () => { + const mintSigner = Keypair.generate(); + const mintAuthority = Keypair.generate(); + const [mintPda] = findMintAddress(mintSigner.publicKey); + + await createMintInterface( + rpc, + payer, + mintAuthority, + null, + 9, + mintSigner, + { skipPreflight: true }, + ); + + const result = await getMintInterface( + rpc, + mintPda, + undefined, + CTOKEN_PROGRAM_ID, + ); + + expect(result.mintContext).toBeDefined(); + expect(result.mintContext!.version).toBeDefined(); + expect(typeof result.mintContext!.splMintInitialized).toBe( + 'boolean', + ); + expect(result.mintContext!.splMint).toBeInstanceOf(PublicKey); + }); + }); + + describe('merkleContext validation', () => { + it('should have valid merkleContext for compressed mint', async () => { + const mintSigner = Keypair.generate(); + const mintAuthority = Keypair.generate(); + const [mintPda] = findMintAddress(mintSigner.publicKey); + + await createMintInterface( + rpc, + payer, + mintAuthority, + null, + 9, + mintSigner, + { skipPreflight: true }, + ); + + const result = await getMintInterface( + rpc, + mintPda, + undefined, + CTOKEN_PROGRAM_ID, + ); + + expect(result.merkleContext).toBeDefined(); + expect(result.merkleContext!.treeInfo).toBeDefined(); + expect(result.merkleContext!.hash).toBeDefined(); + expect(result.merkleContext!.leafIndex).toBeDefined(); + }); + }); +}); + +describe('unpackMintInterface', () => { + describe('SPL Token mint', () => { + it('should unpack SPL mint data', () => { + const mintAddress = Keypair.generate().publicKey; + const mintAuthority = Keypair.generate().publicKey; + const freezeAuthority = Keypair.generate().publicKey; + + const buffer = Buffer.alloc(MINT_SIZE); + MintLayout.encode( + { + mintAuthorityOption: 1, + mintAuthority, + supply: BigInt(1_000_000), + decimals: 9, + isInitialized: true, + freezeAuthorityOption: 1, + freezeAuthority, + }, + buffer, + ); + + const accountInfo: AccountInfo = { + data: buffer, + executable: false, + lamports: 1_000_000, + owner: TOKEN_PROGRAM_ID, + rentEpoch: 0, + }; + + const result = unpackMintInterface( + mintAddress, + accountInfo, + TOKEN_PROGRAM_ID, + ); + + expect(result.mint.address.toBase58()).toBe(mintAddress.toBase58()); + expect(result.mint.mintAuthority?.toBase58()).toBe( + mintAuthority.toBase58(), + ); + expect(result.mint.freezeAuthority?.toBase58()).toBe( + freezeAuthority.toBase58(), + ); + expect(result.mint.supply).toBe(1_000_000n); + expect(result.mint.decimals).toBe(9); + expect(result.mint.isInitialized).toBe(true); + expect(result.programId.toBase58()).toBe( + TOKEN_PROGRAM_ID.toBase58(), + ); + expect(result.mintContext).toBeUndefined(); + expect(result.tokenMetadata).toBeUndefined(); + }); + + it('should unpack SPL mint with null authorities', () => { + const mintAddress = Keypair.generate().publicKey; + + const buffer = Buffer.alloc(MINT_SIZE); + MintLayout.encode( + { + mintAuthorityOption: 0, + mintAuthority: PublicKey.default, + supply: BigInt(0), + decimals: 6, + isInitialized: true, + freezeAuthorityOption: 0, + freezeAuthority: PublicKey.default, + }, + buffer, + ); + + const accountInfo: AccountInfo = { + data: buffer, + executable: false, + lamports: 1_000_000, + owner: TOKEN_PROGRAM_ID, + rentEpoch: 0, + }; + + const result = unpackMintInterface( + mintAddress, + accountInfo, + TOKEN_PROGRAM_ID, + ); + + expect(result.mint.mintAuthority).toBeNull(); + expect(result.mint.freezeAuthority).toBeNull(); + }); + }); + + describe('Token-2022 mint', () => { + it('should unpack Token-2022 mint data', () => { + const mintAddress = Keypair.generate().publicKey; + const mintAuthority = Keypair.generate().publicKey; + + const buffer = Buffer.alloc(MINT_SIZE); + MintLayout.encode( + { + mintAuthorityOption: 1, + mintAuthority, + supply: BigInt(500_000), + decimals: 6, + isInitialized: true, + freezeAuthorityOption: 0, + freezeAuthority: PublicKey.default, + }, + buffer, + ); + + const accountInfo: AccountInfo = { + data: buffer, + executable: false, + lamports: 1_000_000, + owner: TOKEN_2022_PROGRAM_ID, + rentEpoch: 0, + }; + + const result = unpackMintInterface( + mintAddress, + accountInfo, + TOKEN_2022_PROGRAM_ID, + ); + + expect(result.mint.supply).toBe(500_000n); + expect(result.mint.decimals).toBe(6); + expect(result.programId.toBase58()).toBe( + TOKEN_2022_PROGRAM_ID.toBase58(), + ); + }); + }); + + describe('CToken mint', () => { + it('should unpack compressed mint data without extensions', () => { + const mintAddress = Keypair.generate().publicKey; + const mintAuthority = Keypair.generate().publicKey; + const splMint = Keypair.generate().publicKey; + + const compressedMint: CompressedMint = { + base: { + mintAuthority, + supply: BigInt(2_000_000), + decimals: 9, + isInitialized: true, + freezeAuthority: null, + }, + mintContext: { + version: 1, + splMintInitialized: true, + splMint, + }, + extensions: null, + }; + + const buffer = serializeMint(compressedMint); + + const result = unpackMintInterface( + mintAddress, + buffer, + CTOKEN_PROGRAM_ID, + ); + + expect(result.mint.address.toBase58()).toBe(mintAddress.toBase58()); + expect(result.mint.mintAuthority?.toBase58()).toBe( + mintAuthority.toBase58(), + ); + expect(result.mint.supply).toBe(2_000_000n); + expect(result.mint.decimals).toBe(9); + expect(result.mint.isInitialized).toBe(true); + expect(result.programId.toBase58()).toBe( + CTOKEN_PROGRAM_ID.toBase58(), + ); + expect(result.mintContext).toBeDefined(); + expect(result.mintContext!.version).toBe(1); + expect(result.mintContext!.splMintInitialized).toBe(true); + expect(result.mintContext!.splMint.toBase58()).toBe( + splMint.toBase58(), + ); + expect(result.tokenMetadata).toBeUndefined(); + }); + + it('should unpack compressed mint data with TokenMetadata extension', () => { + const mintAddress = Keypair.generate().publicKey; + const mintAuthority = Keypair.generate().publicKey; + const updateAuthority = Keypair.generate().publicKey; + const splMint = Keypair.generate().publicKey; + + const metadata: TokenMetadata = { + updateAuthority, + mint: mintAddress, + name: 'Test Token', + symbol: 'TEST', + uri: 'https://example.com/metadata.json', + }; + + const compressedMint: CompressedMint = { + base: { + mintAuthority, + supply: BigInt(1_000_000), + decimals: 9, + isInitialized: true, + freezeAuthority: null, + }, + mintContext: { + version: 1, + splMintInitialized: false, + splMint, + }, + extensions: [ + { + extensionType: ExtensionType.TokenMetadata, + data: encodeTokenMetadata(metadata), + }, + ], + }; + + const buffer = serializeMint(compressedMint); + + const result = unpackMintInterface( + mintAddress, + buffer, + CTOKEN_PROGRAM_ID, + ); + + expect(result.tokenMetadata).toBeDefined(); + expect(result.tokenMetadata!.name).toBe('Test Token'); + expect(result.tokenMetadata!.symbol).toBe('TEST'); + expect(result.tokenMetadata!.uri).toBe( + 'https://example.com/metadata.json', + ); + expect(result.tokenMetadata!.updateAuthority?.toBase58()).toBe( + updateAuthority.toBase58(), + ); + expect(result.extensions).toBeDefined(); + expect(result.extensions!.length).toBe(1); + }); + + it('should handle Buffer input', () => { + const mintAddress = Keypair.generate().publicKey; + + const compressedMint: CompressedMint = { + base: { + mintAuthority: null, + supply: BigInt(100), + decimals: 6, + isInitialized: true, + freezeAuthority: null, + }, + mintContext: { + version: 1, + splMintInitialized: false, + splMint: PublicKey.default, + }, + extensions: null, + }; + + const buffer = serializeMint(compressedMint); + + const result = unpackMintInterface( + mintAddress, + buffer, + CTOKEN_PROGRAM_ID, + ); + + expect(result.mint.supply).toBe(100n); + }); + + it('should handle Uint8Array input', () => { + const mintAddress = Keypair.generate().publicKey; + + const compressedMint: CompressedMint = { + base: { + mintAuthority: null, + supply: BigInt(200), + decimals: 9, + isInitialized: true, + freezeAuthority: null, + }, + mintContext: { + version: 1, + splMintInitialized: false, + splMint: PublicKey.default, + }, + extensions: null, + }; + + const buffer = serializeMint(compressedMint); + const uint8Array = new Uint8Array(buffer); + + const result = unpackMintInterface( + mintAddress, + uint8Array, + CTOKEN_PROGRAM_ID, + ); + + expect(result.mint.supply).toBe(200n); + }); + + it('should default to TOKEN_PROGRAM_ID when no programId specified', () => { + const mintAddress = Keypair.generate().publicKey; + const mintAuthority = Keypair.generate().publicKey; + + const buffer = Buffer.alloc(MINT_SIZE); + MintLayout.encode( + { + mintAuthorityOption: 1, + mintAuthority, + supply: BigInt(1000), + decimals: 9, + isInitialized: true, + freezeAuthorityOption: 0, + freezeAuthority: PublicKey.default, + }, + buffer, + ); + + const accountInfo: AccountInfo = { + data: buffer, + executable: false, + lamports: 1_000_000, + owner: TOKEN_PROGRAM_ID, + rentEpoch: 0, + }; + + const result = unpackMintInterface(mintAddress, accountInfo); + + expect(result.programId.toBase58()).toBe( + TOKEN_PROGRAM_ID.toBase58(), + ); + }); + }); +}); + +describe('unpackMintData', () => { + it('should unpack compressed mint data and return mintContext', () => { + const splMint = Keypair.generate().publicKey; + + const compressedMint: CompressedMint = { + base: { + mintAuthority: null, + supply: BigInt(0), + decimals: 9, + isInitialized: true, + freezeAuthority: null, + }, + mintContext: { + version: 1, + splMintInitialized: true, + splMint, + }, + extensions: null, + }; + + const buffer = serializeMint(compressedMint); + const result = unpackMintData(buffer); + + expect(result.mintContext).toBeDefined(); + expect(result.mintContext.version).toBe(1); + expect(result.mintContext.splMintInitialized).toBe(true); + expect(result.mintContext.splMint.toBase58()).toBe(splMint.toBase58()); + expect(result.tokenMetadata).toBeUndefined(); + expect(result.extensions).toBeUndefined(); + }); + + it('should unpack compressed mint data with tokenMetadata', () => { + const mintAddress = Keypair.generate().publicKey; + const updateAuthority = Keypair.generate().publicKey; + + const metadata: TokenMetadata = { + updateAuthority, + mint: mintAddress, + name: 'Unpack Test', + symbol: 'UPK', + uri: 'https://unpack.test/metadata.json', + additionalMetadata: [{ key: 'version', value: '1.0' }], + }; + + const compressedMint: CompressedMint = { + base: { + mintAuthority: null, + supply: BigInt(0), + decimals: 9, + isInitialized: true, + freezeAuthority: null, + }, + mintContext: { + version: 1, + splMintInitialized: false, + splMint: PublicKey.default, + }, + extensions: [ + { + extensionType: ExtensionType.TokenMetadata, + data: encodeTokenMetadata(metadata), + }, + ], + }; + + const buffer = serializeMint(compressedMint); + const result = unpackMintData(buffer); + + expect(result.tokenMetadata).toBeDefined(); + expect(result.tokenMetadata!.name).toBe('Unpack Test'); + expect(result.tokenMetadata!.symbol).toBe('UPK'); + expect(result.tokenMetadata!.uri).toBe( + 'https://unpack.test/metadata.json', + ); + expect(result.tokenMetadata!.updateAuthority?.toBase58()).toBe( + updateAuthority.toBase58(), + ); + expect(result.tokenMetadata!.additionalMetadata).toBeDefined(); + expect(result.tokenMetadata!.additionalMetadata!.length).toBe(1); + expect(result.tokenMetadata!.additionalMetadata![0].key).toBe( + 'version', + ); + expect(result.tokenMetadata!.additionalMetadata![0].value).toBe('1.0'); + }); + + it('should return extensions array when present', () => { + const mintAddress = Keypair.generate().publicKey; + + const metadata: TokenMetadata = { + mint: mintAddress, + name: 'Extensions Test', + symbol: 'EXT', + uri: 'https://ext.test', + }; + + const compressedMint: CompressedMint = { + base: { + mintAuthority: null, + supply: BigInt(0), + decimals: 9, + isInitialized: true, + freezeAuthority: null, + }, + mintContext: { + version: 1, + splMintInitialized: false, + splMint: PublicKey.default, + }, + extensions: [ + { + extensionType: ExtensionType.TokenMetadata, + data: encodeTokenMetadata(metadata), + }, + ], + }; + + const buffer = serializeMint(compressedMint); + const result = unpackMintData(buffer); + + expect(result.extensions).toBeDefined(); + expect(result.extensions!.length).toBe(1); + expect(result.extensions![0].extensionType).toBe( + ExtensionType.TokenMetadata, + ); + }); + + it('should handle Uint8Array input', () => { + const compressedMint: CompressedMint = { + base: { + mintAuthority: null, + supply: BigInt(0), + decimals: 9, + isInitialized: true, + freezeAuthority: null, + }, + mintContext: { + version: 2, + splMintInitialized: false, + splMint: PublicKey.default, + }, + extensions: null, + }; + + const buffer = serializeMint(compressedMint); + const uint8Array = new Uint8Array(buffer); + const result = unpackMintData(uint8Array); + + expect(result.mintContext.version).toBe(2); + }); + + it('should handle different version values', () => { + const versions = [0, 1, 127, 255]; + + versions.forEach(version => { + const compressedMint: CompressedMint = { + base: { + mintAuthority: null, + supply: BigInt(0), + decimals: 9, + isInitialized: true, + freezeAuthority: null, + }, + mintContext: { + version, + splMintInitialized: false, + splMint: PublicKey.default, + }, + extensions: null, + }; + + const buffer = serializeMint(compressedMint); + const result = unpackMintData(buffer); + + expect(result.mintContext.version).toBe(version); + }); + }); + + it('should handle splMintInitialized boolean correctly', () => { + [true, false].forEach(initialized => { + const compressedMint: CompressedMint = { + base: { + mintAuthority: null, + supply: BigInt(0), + decimals: 9, + isInitialized: true, + freezeAuthority: null, + }, + mintContext: { + version: 1, + splMintInitialized: initialized, + splMint: PublicKey.default, + }, + extensions: null, + }; + + const buffer = serializeMint(compressedMint); + const result = unpackMintData(buffer); + + expect(result.mintContext.splMintInitialized).toBe(initialized); + }); + }); + + it('should handle metadata without updateAuthority', () => { + const mintAddress = Keypair.generate().publicKey; + + const metadata: TokenMetadata = { + mint: mintAddress, + name: 'No Authority', + symbol: 'NA', + uri: 'https://na.test', + }; + + const compressedMint: CompressedMint = { + base: { + mintAuthority: null, + supply: BigInt(0), + decimals: 9, + isInitialized: true, + freezeAuthority: null, + }, + mintContext: { + version: 1, + splMintInitialized: false, + splMint: PublicKey.default, + }, + extensions: [ + { + extensionType: ExtensionType.TokenMetadata, + data: encodeTokenMetadata(metadata), + }, + ], + }; + + const buffer = serializeMint(compressedMint); + const result = unpackMintData(buffer); + + expect(result.tokenMetadata).toBeDefined(); + expect(result.tokenMetadata!.updateAuthority).toBeUndefined(); + }); +}); diff --git a/js/compressed-token/tests/e2e/get-or-create-ata-interface.test.ts b/js/compressed-token/tests/e2e/get-or-create-ata-interface.test.ts new file mode 100644 index 0000000000..93c7c218f4 --- /dev/null +++ b/js/compressed-token/tests/e2e/get-or-create-ata-interface.test.ts @@ -0,0 +1,1110 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { Keypair, Signer, PublicKey, SystemProgram } from '@solana/web3.js'; +import { + Rpc, + newAccountWithLamports, + createRpc, + VERSION, + featureFlags, + CTOKEN_PROGRAM_ID, +} from '@lightprotocol/stateless.js'; +import { + TOKEN_PROGRAM_ID, + TOKEN_2022_PROGRAM_ID, + createMint, + mintTo, + getAssociatedTokenAddressSync, + ASSOCIATED_TOKEN_PROGRAM_ID, + createAssociatedTokenAccountIdempotent, + TokenInvalidMintError, + TokenInvalidOwnerError, +} from '@solana/spl-token'; +import { createMintInterface } from '../../src/v3/actions/create-mint-interface'; +import { createAtaInterfaceIdempotent } from '../../src/v3/actions/create-ata-interface'; +import { getOrCreateAtaInterface } from '../../src/v3/actions/get-or-create-ata-interface'; +import { getAssociatedTokenAddressInterface } from '../../src/v3/get-associated-token-address-interface'; +import { findMintAddress } from '../../src/v3/derivation'; +import { getAtaProgramId } from '../../src/v3/ata-utils'; +import { mintToCompressed } from '../../src/v3/actions/mint-to-compressed'; + +featureFlags.version = VERSION.V2; + +describe('getOrCreateAtaInterface', () => { + let rpc: Rpc; + let payer: Signer; + + beforeAll(async () => { + rpc = createRpc(); + payer = await newAccountWithLamports(rpc, 10e9); + }); + + describe('SPL Token (TOKEN_PROGRAM_ID)', () => { + let splMint: PublicKey; + + beforeAll(async () => { + const mintAuthority = Keypair.generate(); + splMint = await createMint( + rpc, + payer, + mintAuthority.publicKey, + null, + 9, + undefined, + undefined, + TOKEN_PROGRAM_ID, + ); + }); + + it('should create SPL ATA when it does not exist', async () => { + const owner = Keypair.generate(); + + const expectedAddress = getAssociatedTokenAddressSync( + splMint, + owner.publicKey, + false, + TOKEN_PROGRAM_ID, + ASSOCIATED_TOKEN_PROGRAM_ID, + ); + + // Verify ATA does not exist + const beforeInfo = await rpc.getAccountInfo(expectedAddress); + expect(beforeInfo).toBe(null); + + // Call getOrCreateAtaInterface + const account = await getOrCreateAtaInterface( + rpc, + payer, + splMint, + owner.publicKey, + false, + undefined, + undefined, + TOKEN_PROGRAM_ID, + ); + + // Verify returned account + expect(account.parsed.address.toBase58()).toBe( + expectedAddress.toBase58(), + ); + expect(account.parsed.mint.toBase58()).toBe(splMint.toBase58()); + expect(account.parsed.owner.toBase58()).toBe( + owner.publicKey.toBase58(), + ); + expect(account.parsed.amount).toBe(BigInt(0)); + + // Verify ATA now exists + const afterInfo = await rpc.getAccountInfo(expectedAddress); + expect(afterInfo).not.toBe(null); + expect(afterInfo?.owner.toBase58()).toBe( + TOKEN_PROGRAM_ID.toBase58(), + ); + }); + + it('should return existing SPL ATA without creating new one', async () => { + const owner = Keypair.generate(); + + // Pre-create the ATA + await createAssociatedTokenAccountIdempotent( + rpc, + payer, + splMint, + owner.publicKey, + undefined, + TOKEN_PROGRAM_ID, + ); + + const expectedAddress = getAssociatedTokenAddressSync( + splMint, + owner.publicKey, + false, + TOKEN_PROGRAM_ID, + ASSOCIATED_TOKEN_PROGRAM_ID, + ); + + // Call getOrCreateAtaInterface on existing ATA + const account = await getOrCreateAtaInterface( + rpc, + payer, + splMint, + owner.publicKey, + false, + undefined, + undefined, + TOKEN_PROGRAM_ID, + ); + + expect(account.parsed.address.toBase58()).toBe( + expectedAddress.toBase58(), + ); + expect(account.parsed.mint.toBase58()).toBe(splMint.toBase58()); + expect(account.parsed.owner.toBase58()).toBe( + owner.publicKey.toBase58(), + ); + }); + + it('should work with SPL ATA that has balance', async () => { + const mintAuthority = Keypair.generate(); + const owner = Keypair.generate(); + + // Create mint with mintAuthority we control + const mint = await createMint( + rpc, + payer, + mintAuthority.publicKey, + null, + 9, + undefined, + undefined, + TOKEN_PROGRAM_ID, + ); + + // Create ATA and mint tokens + const ata = await createAssociatedTokenAccountIdempotent( + rpc, + payer, + mint, + owner.publicKey, + undefined, + TOKEN_PROGRAM_ID, + ); + + await mintTo( + rpc, + payer, + mint, + ata, + mintAuthority, + 1000000n, + [], + undefined, + TOKEN_PROGRAM_ID, + ); + + // Call getOrCreateAtaInterface + const account = await getOrCreateAtaInterface( + rpc, + payer, + mint, + owner.publicKey, + false, + undefined, + undefined, + TOKEN_PROGRAM_ID, + ); + + expect(account.parsed.address.toBase58()).toBe(ata.toBase58()); + expect(account.parsed.amount).toBe(BigInt(1000000)); + }); + + it('should create SPL ATA for PDA owner with allowOwnerOffCurve=true', async () => { + // Derive a PDA + const [pdaOwner] = PublicKey.findProgramAddressSync( + [Buffer.from('test-pda-spl')], + SystemProgram.programId, + ); + + const expectedAddress = getAssociatedTokenAddressSync( + splMint, + pdaOwner, + true, + TOKEN_PROGRAM_ID, + ASSOCIATED_TOKEN_PROGRAM_ID, + ); + + const account = await getOrCreateAtaInterface( + rpc, + payer, + splMint, + pdaOwner, + true, + undefined, + undefined, + TOKEN_PROGRAM_ID, + ); + + expect(account.parsed.address.toBase58()).toBe( + expectedAddress.toBase58(), + ); + expect(account.parsed.owner.toBase58()).toBe(pdaOwner.toBase58()); + }); + }); + + describe('Token-2022 (TOKEN_2022_PROGRAM_ID)', () => { + let t22Mint: PublicKey; + + beforeAll(async () => { + const mintAuthority = Keypair.generate(); + t22Mint = await createMint( + rpc, + payer, + mintAuthority.publicKey, + null, + 6, + undefined, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + }); + + it('should create Token-2022 ATA when it does not exist', async () => { + const owner = Keypair.generate(); + + const expectedAddress = getAssociatedTokenAddressSync( + t22Mint, + owner.publicKey, + false, + TOKEN_2022_PROGRAM_ID, + ASSOCIATED_TOKEN_PROGRAM_ID, + ); + + // Verify ATA does not exist + const beforeInfo = await rpc.getAccountInfo(expectedAddress); + expect(beforeInfo).toBe(null); + + // Call getOrCreateAtaInterface + const account = await getOrCreateAtaInterface( + rpc, + payer, + t22Mint, + owner.publicKey, + false, + undefined, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + + expect(account.parsed.address.toBase58()).toBe( + expectedAddress.toBase58(), + ); + expect(account.parsed.mint.toBase58()).toBe(t22Mint.toBase58()); + expect(account.parsed.owner.toBase58()).toBe( + owner.publicKey.toBase58(), + ); + expect(account.parsed.amount).toBe(BigInt(0)); + + // Verify ATA now exists + const afterInfo = await rpc.getAccountInfo(expectedAddress); + expect(afterInfo).not.toBe(null); + expect(afterInfo?.owner.toBase58()).toBe( + TOKEN_2022_PROGRAM_ID.toBase58(), + ); + }); + + it('should return existing Token-2022 ATA without creating new one', async () => { + const owner = Keypair.generate(); + + // Pre-create the ATA + await createAssociatedTokenAccountIdempotent( + rpc, + payer, + t22Mint, + owner.publicKey, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + + const expectedAddress = getAssociatedTokenAddressSync( + t22Mint, + owner.publicKey, + false, + TOKEN_2022_PROGRAM_ID, + ASSOCIATED_TOKEN_PROGRAM_ID, + ); + + const account = await getOrCreateAtaInterface( + rpc, + payer, + t22Mint, + owner.publicKey, + false, + undefined, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + + expect(account.parsed.address.toBase58()).toBe( + expectedAddress.toBase58(), + ); + expect(account.parsed.mint.toBase58()).toBe(t22Mint.toBase58()); + expect(account.parsed.owner.toBase58()).toBe( + owner.publicKey.toBase58(), + ); + }); + + it('should work with Token-2022 ATA that has balance', async () => { + const mintAuthority = Keypair.generate(); + const owner = Keypair.generate(); + + const mint = await createMint( + rpc, + payer, + mintAuthority.publicKey, + null, + 6, + undefined, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + + const ata = await createAssociatedTokenAccountIdempotent( + rpc, + payer, + mint, + owner.publicKey, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + + await mintTo( + rpc, + payer, + mint, + ata, + mintAuthority, + 500000n, + [], + undefined, + TOKEN_2022_PROGRAM_ID, + ); + + const account = await getOrCreateAtaInterface( + rpc, + payer, + mint, + owner.publicKey, + false, + undefined, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + + expect(account.parsed.address.toBase58()).toBe(ata.toBase58()); + expect(account.parsed.amount).toBe(BigInt(500000)); + }); + + it('should create Token-2022 ATA for PDA owner with allowOwnerOffCurve=true', async () => { + const [pdaOwner] = PublicKey.findProgramAddressSync( + [Buffer.from('test-pda-t22')], + SystemProgram.programId, + ); + + const expectedAddress = getAssociatedTokenAddressSync( + t22Mint, + pdaOwner, + true, + TOKEN_2022_PROGRAM_ID, + ASSOCIATED_TOKEN_PROGRAM_ID, + ); + + const account = await getOrCreateAtaInterface( + rpc, + payer, + t22Mint, + pdaOwner, + true, + undefined, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + + expect(account.parsed.address.toBase58()).toBe( + expectedAddress.toBase58(), + ); + expect(account.parsed.owner.toBase58()).toBe(pdaOwner.toBase58()); + }); + }); + + describe('c-token (CTOKEN_PROGRAM_ID)', () => { + let ctokenMint: PublicKey; + let mintAuthority: Keypair; + + beforeAll(async () => { + const mintSigner = Keypair.generate(); + mintAuthority = Keypair.generate(); + const [mintPda] = findMintAddress(mintSigner.publicKey); + + await createMintInterface( + rpc, + payer, + mintAuthority, + null, + 9, + mintSigner, + ); + ctokenMint = mintPda; + }); + + it('should create c-token ATA when it does not exist (uninited)', async () => { + const owner = Keypair.generate(); + + const expectedAddress = getAssociatedTokenAddressInterface( + ctokenMint, + owner.publicKey, + false, + CTOKEN_PROGRAM_ID, + ); + + // Verify ATA does not exist + const beforeInfo = await rpc.getAccountInfo(expectedAddress); + expect(beforeInfo).toBe(null); + + // Call getOrCreateAtaInterface + const account = await getOrCreateAtaInterface( + rpc, + payer, + ctokenMint, + owner.publicKey, + false, + undefined, + undefined, + CTOKEN_PROGRAM_ID, + ); + + expect(account.parsed.address.toBase58()).toBe( + expectedAddress.toBase58(), + ); + expect(account.parsed.mint.toBase58()).toBe(ctokenMint.toBase58()); + expect(account.parsed.owner.toBase58()).toBe( + owner.publicKey.toBase58(), + ); + expect(account.parsed.amount).toBe(BigInt(0)); + + // Verify ATA now exists on-chain (hot) + const afterInfo = await rpc.getAccountInfo(expectedAddress); + expect(afterInfo).not.toBe(null); + expect(afterInfo?.owner.toBase58()).toBe( + CTOKEN_PROGRAM_ID.toBase58(), + ); + }); + + it('should return existing c-token hot ATA without creating new one', async () => { + const owner = Keypair.generate(); + + // Pre-create the ATA using createAtaInterfaceIdempotent + await createAtaInterfaceIdempotent( + rpc, + payer, + ctokenMint, + owner.publicKey, + false, + undefined, + CTOKEN_PROGRAM_ID, + ); + + const expectedAddress = getAssociatedTokenAddressInterface( + ctokenMint, + owner.publicKey, + false, + CTOKEN_PROGRAM_ID, + ); + + // Call getOrCreateAtaInterface on existing hot ATA + const account = await getOrCreateAtaInterface( + rpc, + payer, + ctokenMint, + owner.publicKey, + false, + undefined, + undefined, + CTOKEN_PROGRAM_ID, + ); + + expect(account.parsed.address.toBase58()).toBe( + expectedAddress.toBase58(), + ); + expect(account.parsed.mint.toBase58()).toBe(ctokenMint.toBase58()); + expect(account.parsed.owner.toBase58()).toBe( + owner.publicKey.toBase58(), + ); + }); + + it('should create c-token ATA for PDA owner with allowOwnerOffCurve=true', async () => { + const [pdaOwner] = PublicKey.findProgramAddressSync( + [Buffer.from('test-pda-ctoken')], + SystemProgram.programId, + ); + + const expectedAddress = getAssociatedTokenAddressInterface( + ctokenMint, + pdaOwner, + true, + CTOKEN_PROGRAM_ID, + ); + + const account = await getOrCreateAtaInterface( + rpc, + payer, + ctokenMint, + pdaOwner, + true, + undefined, + undefined, + CTOKEN_PROGRAM_ID, + ); + + expect(account.parsed.address.toBase58()).toBe( + expectedAddress.toBase58(), + ); + expect(account.parsed.owner.toBase58()).toBe(pdaOwner.toBase58()); + }); + + it('should handle c-token hot ATA with balance', async () => { + // Create a fresh mint and owner for this test + const mintSigner = Keypair.generate(); + const testMintAuth = Keypair.generate(); + const [testMint] = findMintAddress(mintSigner.publicKey); + const owner = Keypair.generate(); + + await createMintInterface( + rpc, + payer, + testMintAuth, + null, + 9, + mintSigner, + ); + + // Create ATA + await createAtaInterfaceIdempotent( + rpc, + payer, + testMint, + owner.publicKey, + false, + undefined, + CTOKEN_PROGRAM_ID, + ); + + const expectedAddress = getAssociatedTokenAddressInterface( + testMint, + owner.publicKey, + false, + CTOKEN_PROGRAM_ID, + ); + + // Note: Minting to c-token hot accounts uses mintToInterface which + // requires the mint to be registered. We just verify the account exists. + const account = await getOrCreateAtaInterface( + rpc, + payer, + testMint, + owner.publicKey, + false, + undefined, + undefined, + CTOKEN_PROGRAM_ID, + ); + + expect(account.parsed.address.toBase58()).toBe( + expectedAddress.toBase58(), + ); + expect(account.parsed.mint.toBase58()).toBe(testMint.toBase58()); + }); + + it('should detect cold balance with PublicKey owner (no auto-load)', async () => { + // Create a fresh mint and owner + const mintSigner = Keypair.generate(); + const testMintAuth = Keypair.generate(); + const [testMint] = findMintAddress(mintSigner.publicKey); + const owner = Keypair.generate(); + + await createMintInterface( + rpc, + payer, + testMintAuth, + null, + 9, + mintSigner, + ); + + // Mint compressed tokens directly (creates cold balance, no hot ATA) + const mintAmount = 1000000n; + await mintToCompressed(rpc, payer, testMint, testMintAuth, [ + { recipient: owner.publicKey, amount: mintAmount }, + ]); + + const expectedAddress = getAssociatedTokenAddressInterface( + testMint, + owner.publicKey, + false, + CTOKEN_PROGRAM_ID, + ); + + // Verify NO hot ATA exists before call + const beforeInfo = await rpc.getAccountInfo(expectedAddress); + expect(beforeInfo).toBe(null); + + // Verify compressed balance exists + const compressedBefore = + await rpc.getCompressedTokenAccountsByOwner(owner.publicKey, { + mint: testMint, + }); + expect(compressedBefore.items.length).toBeGreaterThan(0); + + // Call with owner.publicKey (PublicKey) - should NOT auto-load + const account = await getOrCreateAtaInterface( + rpc, + payer, + testMint, + owner.publicKey, // PublicKey, not Signer + false, + undefined, + undefined, + CTOKEN_PROGRAM_ID, + ); + + // Verify account has aggregated balance (from cold) + expect(account.parsed.amount).toBe(mintAmount); + + // Verify hot ATA was created + const afterInfo = await rpc.getAccountInfo(expectedAddress); + expect(afterInfo).not.toBe(null); + + // Verify cold balance still exists (NOT loaded because owner is PublicKey) + const compressedAfter = await rpc.getCompressedTokenAccountsByOwner( + owner.publicKey, + { + mint: testMint, + }, + ); + expect(compressedAfter.items.length).toBeGreaterThan(0); + }); + + it('should auto-load cold balance with Signer owner', async () => { + // Create a fresh mint and owner + const mintSigner = Keypair.generate(); + const testMintAuth = Keypair.generate(); + const [testMint] = findMintAddress(mintSigner.publicKey); + const owner = Keypair.generate(); + + await createMintInterface( + rpc, + payer, + testMintAuth, + null, + 9, + mintSigner, + ); + + // Mint compressed tokens directly (creates cold balance, no hot ATA) + const mintAmount = 1000000n; + await mintToCompressed(rpc, payer, testMint, testMintAuth, [ + { recipient: owner.publicKey, amount: mintAmount }, + ]); + + const expectedAddress = getAssociatedTokenAddressInterface( + testMint, + owner.publicKey, + false, + CTOKEN_PROGRAM_ID, + ); + + // Verify NO hot ATA exists before call + const beforeInfo = await rpc.getAccountInfo(expectedAddress); + expect(beforeInfo).toBe(null); + + // Verify compressed balance exists before + const compressedBefore = + await rpc.getCompressedTokenAccountsByOwner(owner.publicKey, { + mint: testMint, + }); + expect(compressedBefore.items.length).toBeGreaterThan(0); + + // Call with owner (Signer) - should auto-load cold into hot + const account = await getOrCreateAtaInterface( + rpc, + payer, + testMint, + owner, // Signer, triggers auto-load + false, + undefined, + undefined, + CTOKEN_PROGRAM_ID, + ); + + // Verify correct address + expect(account.parsed.address.toBase58()).toBe( + expectedAddress.toBase58(), + ); + + // Verify account has full balance in hot ATA + expect(account.parsed.amount).toBe(mintAmount); + + // Verify hot ATA was created and has balance + const afterInfo = await rpc.getAccountInfo(expectedAddress); + expect(afterInfo).not.toBe(null); + expect(afterInfo?.owner.toBase58()).toBe( + CTOKEN_PROGRAM_ID.toBase58(), + ); + // Parse hot balance + const hotBalance = afterInfo!.data.readBigUInt64LE(64); + expect(hotBalance).toBe(mintAmount); + + // Verify cold balance was consumed (loaded into hot) + const compressedAfter = await rpc.getCompressedTokenAccountsByOwner( + owner.publicKey, + { + mint: testMint, + }, + ); + // Cold accounts should be consumed (0 or empty) + const remainingCold = compressedAfter.items.reduce( + (sum, acc) => sum + BigInt(acc.parsed.amount.toString()), + BigInt(0), + ); + expect(remainingCold).toBe(0n); + }); + + it('should aggregate hot and cold balances', async () => { + // Create a fresh mint and owner + const mintSigner = Keypair.generate(); + const testMintAuth = Keypair.generate(); + const [testMint] = findMintAddress(mintSigner.publicKey); + const owner = Keypair.generate(); + + await createMintInterface( + rpc, + payer, + testMintAuth, + null, + 9, + mintSigner, + ); + + // Create hot ATA first + await createAtaInterfaceIdempotent( + rpc, + payer, + testMint, + owner.publicKey, + false, + undefined, + CTOKEN_PROGRAM_ID, + ); + + // Mint compressed tokens (creates cold balance) + const coldAmount = 500000n; + await mintToCompressed(rpc, payer, testMint, testMintAuth, [ + { recipient: owner.publicKey, amount: coldAmount }, + ]); + + // Call getOrCreateAtaInterface + const account = await getOrCreateAtaInterface( + rpc, + payer, + testMint, + owner.publicKey, + false, + undefined, + undefined, + CTOKEN_PROGRAM_ID, + ); + + // Verify aggregated balance (hot=0 + cold=coldAmount) + expect(account.parsed.amount).toBe(coldAmount); + }); + }); + + describe('default programId (TOKEN_PROGRAM_ID)', () => { + let splMint: PublicKey; + + beforeAll(async () => { + const mintAuthority = Keypair.generate(); + splMint = await createMint( + rpc, + payer, + mintAuthority.publicKey, + null, + 9, + undefined, + undefined, + TOKEN_PROGRAM_ID, + ); + }); + + it('should default to TOKEN_PROGRAM_ID when programId not specified', async () => { + const owner = Keypair.generate(); + + const expectedAddress = getAssociatedTokenAddressSync( + splMint, + owner.publicKey, + false, + TOKEN_PROGRAM_ID, + ASSOCIATED_TOKEN_PROGRAM_ID, + ); + + // Call without specifying programId + const account = await getOrCreateAtaInterface( + rpc, + payer, + splMint, + owner.publicKey, + ); + + expect(account.parsed.address.toBase58()).toBe( + expectedAddress.toBase58(), + ); + + // Verify it's owned by TOKEN_PROGRAM_ID + const info = await rpc.getAccountInfo(expectedAddress); + expect(info?.owner.toBase58()).toBe(TOKEN_PROGRAM_ID.toBase58()); + }); + }); + + describe('idempotency', () => { + it('should be idempotent - multiple calls return same account for SPL', async () => { + const mintAuthority = Keypair.generate(); + const owner = Keypair.generate(); + + const mint = await createMint( + rpc, + payer, + mintAuthority.publicKey, + null, + 9, + undefined, + undefined, + TOKEN_PROGRAM_ID, + ); + + // Call multiple times + const account1 = await getOrCreateAtaInterface( + rpc, + payer, + mint, + owner.publicKey, + false, + undefined, + undefined, + TOKEN_PROGRAM_ID, + ); + + const account2 = await getOrCreateAtaInterface( + rpc, + payer, + mint, + owner.publicKey, + false, + undefined, + undefined, + TOKEN_PROGRAM_ID, + ); + + const account3 = await getOrCreateAtaInterface( + rpc, + payer, + mint, + owner.publicKey, + false, + undefined, + undefined, + TOKEN_PROGRAM_ID, + ); + + expect(account1.parsed.address.toBase58()).toBe( + account2.parsed.address.toBase58(), + ); + expect(account2.parsed.address.toBase58()).toBe( + account3.parsed.address.toBase58(), + ); + }); + + it('should be idempotent for c-token', async () => { + const mintSigner = Keypair.generate(); + const testMintAuth = Keypair.generate(); + const [testMint] = findMintAddress(mintSigner.publicKey); + const owner = Keypair.generate(); + + await createMintInterface( + rpc, + payer, + testMintAuth, + null, + 9, + mintSigner, + ); + + const account1 = await getOrCreateAtaInterface( + rpc, + payer, + testMint, + owner.publicKey, + false, + undefined, + undefined, + CTOKEN_PROGRAM_ID, + ); + + const account2 = await getOrCreateAtaInterface( + rpc, + payer, + testMint, + owner.publicKey, + false, + undefined, + undefined, + CTOKEN_PROGRAM_ID, + ); + + expect(account1.parsed.address.toBase58()).toBe( + account2.parsed.address.toBase58(), + ); + }); + }); + + describe('cross-program verification', () => { + it('should produce different ATAs for same owner/mint with different programs', async () => { + const mintAuthority = Keypair.generate(); + const owner = Keypair.generate(); + + // Create SPL mint + const splMint = await createMint( + rpc, + payer, + mintAuthority.publicKey, + null, + 9, + undefined, + undefined, + TOKEN_PROGRAM_ID, + ); + + // Create T22 mint + const t22Mint = await createMint( + rpc, + payer, + mintAuthority.publicKey, + null, + 9, + undefined, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + + // Create c-token mint + const mintSigner = Keypair.generate(); + const [ctokenMint] = findMintAddress(mintSigner.publicKey); + await createMintInterface( + rpc, + payer, + mintAuthority, + null, + 9, + mintSigner, + ); + + // Get/Create ATAs for all programs + const splAccount = await getOrCreateAtaInterface( + rpc, + payer, + splMint, + owner.publicKey, + false, + undefined, + undefined, + TOKEN_PROGRAM_ID, + ); + + const t22Account = await getOrCreateAtaInterface( + rpc, + payer, + t22Mint, + owner.publicKey, + false, + undefined, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + + const ctokenAccount = await getOrCreateAtaInterface( + rpc, + payer, + ctokenMint, + owner.publicKey, + false, + undefined, + undefined, + CTOKEN_PROGRAM_ID, + ); + + // All addresses should be different (different mints) + expect(splAccount.parsed.address.toBase58()).not.toBe( + t22Account.parsed.address.toBase58(), + ); + expect(splAccount.parsed.address.toBase58()).not.toBe( + ctokenAccount.parsed.address.toBase58(), + ); + expect(t22Account.parsed.address.toBase58()).not.toBe( + ctokenAccount.parsed.address.toBase58(), + ); + + // Verify each account's mint matches + expect(splAccount.parsed.mint.toBase58()).toBe(splMint.toBase58()); + expect(t22Account.parsed.mint.toBase58()).toBe(t22Mint.toBase58()); + expect(ctokenAccount.parsed.mint.toBase58()).toBe( + ctokenMint.toBase58(), + ); + }); + }); + + describe('concurrent calls', () => { + it('should handle concurrent getOrCreate calls for same ATA', async () => { + const mintAuthority = Keypair.generate(); + const owner = Keypair.generate(); + + const mint = await createMint( + rpc, + payer, + mintAuthority.publicKey, + null, + 9, + undefined, + undefined, + TOKEN_PROGRAM_ID, + ); + + // Call concurrently + const results = await Promise.all([ + getOrCreateAtaInterface( + rpc, + payer, + mint, + owner.publicKey, + false, + undefined, + undefined, + TOKEN_PROGRAM_ID, + ), + getOrCreateAtaInterface( + rpc, + payer, + mint, + owner.publicKey, + false, + undefined, + undefined, + TOKEN_PROGRAM_ID, + ), + getOrCreateAtaInterface( + rpc, + payer, + mint, + owner.publicKey, + false, + undefined, + undefined, + TOKEN_PROGRAM_ID, + ), + ]); + + // All results should be the same account + expect(results[0].parsed.address.toBase58()).toBe( + results[1].parsed.address.toBase58(), + ); + expect(results[1].parsed.address.toBase58()).toBe( + results[2].parsed.address.toBase58(), + ); + }); + }); +}); diff --git a/js/compressed-token/tests/e2e/load-ata-combined.test.ts b/js/compressed-token/tests/e2e/load-ata-combined.test.ts new file mode 100644 index 0000000000..5230e6ad9d --- /dev/null +++ b/js/compressed-token/tests/e2e/load-ata-combined.test.ts @@ -0,0 +1,422 @@ +/** + * Load ATA - Combined Tests + * + * Tests combined scenarios, export path verification, payer handling, and idempotency. + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import { Keypair, Signer, PublicKey } from '@solana/web3.js'; +import { + Rpc, + bn, + newAccountWithLamports, + getTestRpc, + selectStateTreeInfo, + TreeInfo, + VERSION, + featureFlags, +} from '@lightprotocol/stateless.js'; +import { WasmFactory } from '@lightprotocol/hasher.rs'; +import { createAssociatedTokenAccount, getAccount } from '@solana/spl-token'; +import { createMint, mintTo, decompress } from '../../src/actions'; +import { + getTokenPoolInfos, + selectTokenPoolInfo, + selectTokenPoolInfosForDecompression, + TokenPoolInfo, +} from '../../src/utils/get-token-pool-infos'; + +import { loadAta as loadAtaStandard } from '../../src/v3/actions/load-ata'; +import { getAssociatedTokenAddressInterface } from '../../src/v3/get-associated-token-address-interface'; + +import { + loadAta as loadAtaUnified, + getAssociatedTokenAddressInterface as getAssociatedTokenAddressInterfaceUnified, +} from '../../src/v3/unified'; + +featureFlags.version = VERSION.V2; + +const TEST_TOKEN_DECIMALS = 9; + +async function getCTokenBalance(rpc: Rpc, address: PublicKey): Promise { + const accountInfo = await rpc.getAccountInfo(address); + if (!accountInfo) return BigInt(0); + return accountInfo.data.readBigUInt64LE(64); +} + +async function getCompressedBalance( + rpc: Rpc, + owner: PublicKey, + mint: PublicKey, +): Promise { + const result = await rpc.getCompressedTokenAccountsByOwner(owner, { mint }); + return result.items.reduce( + (sum, acc) => sum + BigInt(acc.parsed.amount.toString()), + BigInt(0), + ); +} + +describe('loadAta - All Sources Combined', () => { + let rpc: Rpc; + let payer: Signer; + let mint: PublicKey; + let mintAuthority: Keypair; + let stateTreeInfo: 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; + + stateTreeInfo = selectStateTreeInfo(await rpc.getStateTreeInfos()); + tokenPoolInfos = await getTokenPoolInfos(rpc, mint); + }, 60_000); + + it('should load SPL + ctoken-cold all at once', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + const splAta = await createAssociatedTokenAccount( + rpc, + payer, + mint, + owner.publicKey, + ); + + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(2000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + tokenPoolInfos = await getTokenPoolInfos(rpc, mint); + await decompress( + rpc, + payer, + mint, + bn(2000), + owner, + splAta, + selectTokenPoolInfosForDecompression(tokenPoolInfos, bn(2000)), + ); + + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(1000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const ctokenAta = getAssociatedTokenAddressInterfaceUnified( + mint, + owner.publicKey, + ); + await loadAtaUnified(rpc, ctokenAta, owner, mint); + + const hotBalance = await getCTokenBalance(rpc, ctokenAta); + expect(hotBalance).toBe(BigInt(3000)); + + const splBalance = (await getAccount(rpc, splAta)).amount; + expect(splBalance).toBe(BigInt(0)); + + const coldBalance = await getCompressedBalance( + rpc, + owner.publicKey, + mint, + ); + expect(coldBalance).toBe(BigInt(0)); + }); +}); + +describe('loadAta - Export Path Verification', () => { + let rpc: Rpc; + let payer: Signer; + let mint: PublicKey; + let mintAuthority: Keypair; + let stateTreeInfo: 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; + + stateTreeInfo = selectStateTreeInfo(await rpc.getStateTreeInfos()); + tokenPoolInfos = await getTokenPoolInfos(rpc, mint); + }, 60_000); + + it('standard export with wrap=true behaves like unified', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + const splAta = await createAssociatedTokenAccount( + rpc, + payer, + mint, + owner.publicKey, + ); + + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(1500), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + tokenPoolInfos = await getTokenPoolInfos(rpc, mint); + await decompress( + rpc, + payer, + mint, + bn(1500), + owner, + splAta, + selectTokenPoolInfosForDecompression(tokenPoolInfos, bn(1500)), + ); + + const ctokenAta = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); + const signature = await loadAtaStandard( + rpc, + ctokenAta, + owner, + mint, + undefined, + undefined, + undefined, + true, // wrap=true + ); + + expect(signature).not.toBeNull(); + + const splBalance = (await getAccount(rpc, splAta)).amount; + expect(splBalance).toBe(BigInt(0)); + + const hotBalance = await getCTokenBalance(rpc, ctokenAta); + expect(hotBalance).toBe(BigInt(1500)); + }); + + it('unified export always wraps (wrap=true default)', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + const splAta = await createAssociatedTokenAccount( + rpc, + payer, + mint, + owner.publicKey, + ); + + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(1200), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + tokenPoolInfos = await getTokenPoolInfos(rpc, mint); + await decompress( + rpc, + payer, + mint, + bn(1200), + owner, + splAta, + selectTokenPoolInfosForDecompression(tokenPoolInfos, bn(1200)), + ); + + const ctokenAta = getAssociatedTokenAddressInterfaceUnified( + mint, + owner.publicKey, + ); + await loadAtaUnified(rpc, ctokenAta, owner, mint); + + const splBalance = (await getAccount(rpc, splAta)).amount; + expect(splBalance).toBe(BigInt(0)); + + const hotBalance = await getCTokenBalance(rpc, ctokenAta); + expect(hotBalance).toBe(BigInt(1200)); + }); +}); + +describe('loadAta - Payer Handling', () => { + let rpc: Rpc; + let payer: Signer; + let mint: PublicKey; + let mintAuthority: Keypair; + let stateTreeInfo: 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; + + stateTreeInfo = selectStateTreeInfo(await rpc.getStateTreeInfos()); + tokenPoolInfos = await getTokenPoolInfos(rpc, mint); + }, 60_000); + + it('should use separate payer when provided', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + const separatePayer = await newAccountWithLamports(rpc, 1e9); + + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(1000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const ata = getAssociatedTokenAddressInterface(mint, owner.publicKey); + const signature = await loadAtaStandard( + rpc, + ata, + owner, + mint, + separatePayer, + ); + + expect(signature).not.toBeNull(); + + const hotBalance = await getCTokenBalance(rpc, ata); + expect(hotBalance).toBe(BigInt(1000)); + }); + + it('should default payer to owner when not provided', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(800), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const ata = getAssociatedTokenAddressInterface(mint, owner.publicKey); + const signature = await loadAtaStandard(rpc, ata, owner, mint); + + expect(signature).not.toBeNull(); + + const hotBalance = await getCTokenBalance(rpc, ata); + expect(hotBalance).toBe(BigInt(800)); + }); +}); + +describe('loadAta - Idempotency', () => { + let rpc: Rpc; + let payer: Signer; + let mint: PublicKey; + let mintAuthority: Keypair; + let stateTreeInfo: 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; + + stateTreeInfo = selectStateTreeInfo(await rpc.getStateTreeInfos()); + tokenPoolInfos = await getTokenPoolInfos(rpc, mint); + }, 60_000); + + it('should be idempotent - multiple loads do not fail', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(2000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const ata = getAssociatedTokenAddressInterface(mint, owner.publicKey); + + const sig1 = await loadAtaStandard(rpc, ata, owner, mint); + expect(sig1).not.toBeNull(); + + const sig2 = await loadAtaStandard(rpc, ata, owner, mint); + expect(sig2).toBeNull(); + + const sig3 = await loadAtaStandard(rpc, ata, owner, mint); + expect(sig3).toBeNull(); + + const hotBalance = await getCTokenBalance(rpc, ata); + expect(hotBalance).toBe(BigInt(2000)); + }); +}); diff --git a/js/compressed-token/tests/e2e/load-ata-spl-t22.test.ts b/js/compressed-token/tests/e2e/load-ata-spl-t22.test.ts new file mode 100644 index 0000000000..e7d906b078 --- /dev/null +++ b/js/compressed-token/tests/e2e/load-ata-spl-t22.test.ts @@ -0,0 +1,545 @@ +/** + * Load ATA - SPL/T22 Decompression (wrap=false) + * + * Tests decompressing compressed tokens to SPL/T22 ATAs via token pools. + * This is the standard path where compressed tokens are loaded into + * SPL or T22 ATAs rather than c-token ATAs. + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import { Keypair, Signer, PublicKey } from '@solana/web3.js'; +import { + Rpc, + bn, + newAccountWithLamports, + getTestRpc, + selectStateTreeInfo, + TreeInfo, + VERSION, + featureFlags, +} from '@lightprotocol/stateless.js'; +import { WasmFactory } from '@lightprotocol/hasher.rs'; +import { + TOKEN_PROGRAM_ID, + TOKEN_2022_PROGRAM_ID, + getAssociatedTokenAddressSync, + getAccount, + createAssociatedTokenAccount, + getOrCreateAssociatedTokenAccount, +} from '@solana/spl-token'; +import { createMint, mintTo } from '../../src/actions'; +import { + getTokenPoolInfos, + selectTokenPoolInfo, + TokenPoolInfo, +} from '../../src/utils/get-token-pool-infos'; +import { getAtaProgramId } from '../../src/v3/ata-utils'; + +import { + loadAta, + createLoadAtaInstructions, +} from '../../src/v3/actions/load-ata'; +import { validateAtaAddress } from '../../src/v3/ata-utils'; +import { getAssociatedTokenAddressInterface } from '../../src/v3/get-associated-token-address-interface'; + +featureFlags.version = VERSION.V2; + +const TEST_TOKEN_DECIMALS = 9; + +async function getCompressedBalance( + rpc: Rpc, + owner: PublicKey, + mint: PublicKey, +): Promise { + const result = await rpc.getCompressedTokenAccountsByOwner(owner, { mint }); + return result.items.reduce( + (sum, acc) => sum + BigInt(acc.parsed.amount.toString()), + BigInt(0), + ); +} + +describe('validateAtaAddress', () => { + it('should validate c-token ATA', () => { + const mint = Keypair.generate().publicKey; + const owner = Keypair.generate().publicKey; + + const ctokenAta = getAssociatedTokenAddressInterface(mint, owner); + const result = validateAtaAddress(ctokenAta, mint, owner); + expect(result.valid).toBe(true); + expect(result.type).toBe('ctoken'); + }); + + it('should validate SPL ATA', () => { + const mint = Keypair.generate().publicKey; + const owner = Keypair.generate().publicKey; + + const splAta = getAssociatedTokenAddressSync( + mint, + owner, + false, + TOKEN_PROGRAM_ID, + getAtaProgramId(TOKEN_PROGRAM_ID), + ); + const result = validateAtaAddress(splAta, mint, owner); + expect(result.valid).toBe(true); + expect(result.type).toBe('spl'); + }); + + it('should validate Token-2022 ATA', () => { + const mint = Keypair.generate().publicKey; + const owner = Keypair.generate().publicKey; + + const t22Ata = getAssociatedTokenAddressSync( + mint, + owner, + false, + TOKEN_2022_PROGRAM_ID, + getAtaProgramId(TOKEN_2022_PROGRAM_ID), + ); + const result = validateAtaAddress(t22Ata, mint, owner); + expect(result.valid).toBe(true); + expect(result.type).toBe('token2022'); + }); + + it('should throw on invalid ATA address', () => { + const mint = Keypair.generate().publicKey; + const owner = Keypair.generate().publicKey; + const wrongAta = Keypair.generate().publicKey; + + expect(() => validateAtaAddress(wrongAta, mint, owner)).toThrow( + 'ATA address does not match any valid derivation', + ); + }); + + it('should use hot path when programId is provided', () => { + const mint = Keypair.generate().publicKey; + const owner = Keypair.generate().publicKey; + + const splAta = getAssociatedTokenAddressSync( + mint, + owner, + false, + TOKEN_PROGRAM_ID, + getAtaProgramId(TOKEN_PROGRAM_ID), + ); + const result = validateAtaAddress( + splAta, + mint, + owner, + TOKEN_PROGRAM_ID, + ); + expect(result.valid).toBe(true); + expect(result.type).toBe('spl'); + }); +}); + +describe('loadAta - Decompress to SPL ATA', () => { + let rpc: Rpc; + let payer: Signer; + let mint: PublicKey; + let mintAuthority: Keypair; + let stateTreeInfo: 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; + + stateTreeInfo = selectStateTreeInfo(await rpc.getStateTreeInfos()); + tokenPoolInfos = await getTokenPoolInfos(rpc, mint); + }, 60_000); + + it('should decompress compressed tokens to SPL ATA via token pool', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + // Mint compressed tokens + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(3000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + // Verify compressed balance + const coldBefore = await getCompressedBalance( + rpc, + owner.publicKey, + mint, + ); + expect(coldBefore).toBe(BigInt(3000)); + + // Create SPL ATA + const splAta = await createAssociatedTokenAccount( + rpc, + payer, + mint, + owner.publicKey, + ); + + // Load to SPL ATA (not c-token ATA!) + const signature = await loadAta(rpc, splAta, owner, mint, payer); + + expect(signature).not.toBeNull(); + + // Verify SPL ATA has the balance + const splBalance = await getAccount(rpc, splAta); + expect(splBalance.amount).toBe(BigInt(3000)); + + // Verify compressed balance is gone + const coldAfter = await getCompressedBalance( + rpc, + owner.publicKey, + mint, + ); + expect(coldAfter).toBe(BigInt(0)); + }); + + it('should create SPL ATA if needed when decompressing', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + // Mint compressed tokens + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(2000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + // Derive SPL ATA (don't create it) + const splAta = getAssociatedTokenAddressSync( + mint, + owner.publicKey, + false, + TOKEN_PROGRAM_ID, + getAtaProgramId(TOKEN_PROGRAM_ID), + ); + + // Verify SPL ATA doesn't exist + const ataBefore = await rpc.getAccountInfo(splAta); + expect(ataBefore).toBeNull(); + + // Load to SPL ATA - should auto-create + const signature = await loadAta(rpc, splAta, owner, mint, payer); + + expect(signature).not.toBeNull(); + + // Verify SPL ATA was created with balance + const splBalance = await getAccount(rpc, splAta); + expect(splBalance.amount).toBe(BigInt(2000)); + }); + + it('should add to existing SPL ATA balance', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + // Create SPL ATA with initial balance + const splAta = await createAssociatedTokenAccount( + rpc, + payer, + mint, + owner.publicKey, + ); + + // Mint and decompress first batch directly to SPL + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(1000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + await loadAta(rpc, splAta, owner, mint, payer); + + const balanceAfterFirst = await getAccount(rpc, splAta); + expect(balanceAfterFirst.amount).toBe(BigInt(1000)); + + // Mint more compressed tokens + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(500), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + // Load second batch + await loadAta(rpc, splAta, owner, mint, payer); + + // Verify total balance + const balanceAfterSecond = await getAccount(rpc, splAta); + expect(balanceAfterSecond.amount).toBe(BigInt(1500)); + }); +}); + +describe('loadAta - Decompress to T22 ATA', () => { + let rpc: Rpc; + let payer: Signer; + let t22Mint: PublicKey; + let t22MintAuthority: Keypair; + let stateTreeInfo: TreeInfo; + let t22TokenPoolInfos: TokenPoolInfo[]; + + beforeAll(async () => { + const lightWasm = await WasmFactory.getInstance(); + rpc = await getTestRpc(lightWasm); + payer = await newAccountWithLamports(rpc, 10e9); + t22MintAuthority = Keypair.generate(); + const mintKeypair = Keypair.generate(); + + const result = await createMint( + rpc, + payer, + t22MintAuthority.publicKey, + TEST_TOKEN_DECIMALS, + mintKeypair, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + t22Mint = result.mint; + stateTreeInfo = selectStateTreeInfo(await rpc.getStateTreeInfos()); + t22TokenPoolInfos = await getTokenPoolInfos(rpc, t22Mint); + }, 60_000); + + it('should decompress compressed tokens to T22 ATA via token pool', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + // Mint compressed tokens + await mintTo( + rpc, + payer, + t22Mint, + owner.publicKey, + t22MintAuthority, + bn(2500), + stateTreeInfo, + selectTokenPoolInfo(t22TokenPoolInfos), + ); + + // Verify compressed balance + const coldBefore = await getCompressedBalance( + rpc, + owner.publicKey, + t22Mint, + ); + expect(coldBefore).toBe(BigInt(2500)); + + // Create T22 ATA + const t22AtaAccount = await getOrCreateAssociatedTokenAccount( + rpc, + payer as Keypair, + t22Mint, + owner.publicKey, + false, + 'confirmed', + undefined, + TOKEN_2022_PROGRAM_ID, + ); + const t22Ata = t22AtaAccount.address; + + // Load to T22 ATA + const signature = await loadAta(rpc, t22Ata, owner, t22Mint, payer); + + expect(signature).not.toBeNull(); + + // Verify T22 ATA has the balance + const t22Balance = await getAccount( + rpc, + t22Ata, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + expect(t22Balance.amount).toBe(BigInt(2500)); + + // Verify compressed balance is gone + const coldAfter = await getCompressedBalance( + rpc, + owner.publicKey, + t22Mint, + ); + expect(coldAfter).toBe(BigInt(0)); + }, 90_000); + + it('should add to existing T22 ATA balance', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + // Create T22 ATA + const t22AtaAccount = await getOrCreateAssociatedTokenAccount( + rpc, + payer as Keypair, + t22Mint, + owner.publicKey, + false, + 'confirmed', + undefined, + TOKEN_2022_PROGRAM_ID, + ); + const t22Ata = t22AtaAccount.address; + + // Mint and load first batch + await mintTo( + rpc, + payer, + t22Mint, + owner.publicKey, + t22MintAuthority, + bn(1000), + stateTreeInfo, + selectTokenPoolInfo(t22TokenPoolInfos), + ); + await loadAta(rpc, t22Ata, owner, t22Mint, payer); + + const balanceAfterFirst = await getAccount( + rpc, + t22Ata, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + expect(balanceAfterFirst.amount).toBe(BigInt(1000)); + + // Mint and load second batch + await mintTo( + rpc, + payer, + t22Mint, + owner.publicKey, + t22MintAuthority, + bn(800), + stateTreeInfo, + selectTokenPoolInfo(t22TokenPoolInfos), + ); + await loadAta(rpc, t22Ata, owner, t22Mint, payer); + + const balanceAfterSecond = await getAccount( + rpc, + t22Ata, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + expect(balanceAfterSecond.amount).toBe(BigInt(1800)); + }, 90_000); +}); + +describe('loadAta - Standard vs Unified Distinction', () => { + let rpc: Rpc; + let payer: Signer; + let mint: PublicKey; + let mintAuthority: Keypair; + let stateTreeInfo: 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; + + stateTreeInfo = selectStateTreeInfo(await rpc.getStateTreeInfos()); + tokenPoolInfos = await getTokenPoolInfos(rpc, mint); + }, 60_000); + + it('wrap=false with SPL ATA decompresses to SPL, not c-token', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + // Mint compressed tokens + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(1500), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + // Create SPL ATA + const splAta = await createAssociatedTokenAccount( + rpc, + payer, + mint, + owner.publicKey, + ); + + // Load to SPL ATA with wrap=false (default) + await loadAta(rpc, splAta, owner, mint, payer); + + // SPL ATA should have balance + const splBalance = await getAccount(rpc, splAta); + expect(splBalance.amount).toBe(BigInt(1500)); + + // c-token ATA should NOT exist (we didn't create it) + const ctokenAta = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); + const ctokenInfo = await rpc.getAccountInfo(ctokenAta); + expect(ctokenInfo).toBeNull(); + }); + + it('wrap=false with c-token ATA decompresses to c-token', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + // Mint compressed tokens + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(2000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + // Derive c-token ATA + const ctokenAta = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); + + // Load to c-token ATA with wrap=false + await loadAta(rpc, ctokenAta, owner, mint, payer); + + // c-token ATA should have balance + const ctokenInfo = await rpc.getAccountInfo(ctokenAta); + expect(ctokenInfo).not.toBeNull(); + const ctokenBalance = ctokenInfo!.data.readBigUInt64LE(64); + expect(ctokenBalance).toBe(BigInt(2000)); + }); +}); diff --git a/js/compressed-token/tests/e2e/load-ata-standard.test.ts b/js/compressed-token/tests/e2e/load-ata-standard.test.ts new file mode 100644 index 0000000000..0f8791cc24 --- /dev/null +++ b/js/compressed-token/tests/e2e/load-ata-standard.test.ts @@ -0,0 +1,418 @@ +/** + * Load ATA - Standard Path (wrap=false) + * + * Tests the standard load path which only decompresses ctoken-cold. + * SPL/T22 balances are NOT wrapped in this mode. + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import { Keypair, Signer, PublicKey } from '@solana/web3.js'; +import { + Rpc, + bn, + newAccountWithLamports, + getTestRpc, + selectStateTreeInfo, + TreeInfo, + CTOKEN_PROGRAM_ID, + VERSION, + featureFlags, +} from '@lightprotocol/stateless.js'; +import { WasmFactory } from '@lightprotocol/hasher.rs'; +import { + TOKEN_PROGRAM_ID, + createAssociatedTokenAccount, + getAccount, +} from '@solana/spl-token'; +import { createMint, mintTo, decompress } from '../../src/actions'; +import { + getTokenPoolInfos, + selectTokenPoolInfo, + selectTokenPoolInfosForDecompression, + TokenPoolInfo, +} from '../../src/utils/get-token-pool-infos'; + +import { + loadAta, + createLoadAtaInstructions, + createLoadAtaInstructionsFromInterface, +} from '../../src/v3/actions/load-ata'; +import { getAtaInterface } from '../../src/v3/get-account-interface'; +import { getAssociatedTokenAddressInterface } from '../../src/v3/get-associated-token-address-interface'; +import { createAtaInterfaceIdempotent } from '../../src/v3/actions/create-ata-interface'; + +featureFlags.version = VERSION.V2; + +const TEST_TOKEN_DECIMALS = 9; + +async function getCTokenBalance(rpc: Rpc, address: PublicKey): Promise { + const accountInfo = await rpc.getAccountInfo(address); + if (!accountInfo) return BigInt(0); + return accountInfo.data.readBigUInt64LE(64); +} + +async function getCompressedBalance( + rpc: Rpc, + owner: PublicKey, + mint: PublicKey, +): Promise { + const result = await rpc.getCompressedTokenAccountsByOwner(owner, { mint }); + return result.items.reduce( + (sum, acc) => sum + BigInt(acc.parsed.amount.toString()), + BigInt(0), + ); +} + +describe('loadAta - Standard Path (wrap=false)', () => { + let rpc: Rpc; + let payer: Signer; + let mint: PublicKey; + let mintAuthority: Keypair; + let stateTreeInfo: 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; + + stateTreeInfo = selectStateTreeInfo(await rpc.getStateTreeInfos()); + tokenPoolInfos = await getTokenPoolInfos(rpc, mint); + }, 60_000); + + describe('ctoken-cold only', () => { + it('should decompress cold balance to hot ATA', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(5000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const coldBefore = await getCompressedBalance( + rpc, + owner.publicKey, + mint, + ); + expect(coldBefore).toBe(BigInt(5000)); + + const ata = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); + const signature = await loadAta(rpc, ata, owner, mint); + + expect(signature).not.toBeNull(); + + const hotBalance = await getCTokenBalance(rpc, ata); + expect(hotBalance).toBe(BigInt(5000)); + + const coldAfter = await getCompressedBalance( + rpc, + owner.publicKey, + mint, + ); + expect(coldAfter).toBe(BigInt(0)); + }); + + it('should create ATA if it does not exist', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(1000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const ata = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); + + const ataBefore = await rpc.getAccountInfo(ata); + expect(ataBefore).toBeNull(); + + await loadAta(rpc, ata, owner, mint); + + const ataAfter = await rpc.getAccountInfo(ata); + expect(ataAfter).not.toBeNull(); + }); + }); + + describe('ctoken-hot exists', () => { + it('should return null when no cold balance (nothing to load)', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + await createAtaInterfaceIdempotent( + rpc, + payer, + mint, + owner.publicKey, + ); + + const ata = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); + + const signature = await loadAta(rpc, ata, owner, mint); + expect(signature).toBeNull(); + }); + + it('should decompress cold to existing hot ATA (additive)', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(3000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const ata = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); + await loadAta(rpc, ata, owner, mint); + + const hotBefore = await getCTokenBalance(rpc, ata); + expect(hotBefore).toBe(BigInt(3000)); + + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(2000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + await loadAta(rpc, ata, owner, mint); + + const hotAfter = await getCTokenBalance(rpc, ata); + expect(hotAfter).toBe(BigInt(5000)); + }); + }); + + describe('SPL/T22 balances (wrap=false)', () => { + it('should NOT wrap SPL balance when wrap=false', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + const splAta = await createAssociatedTokenAccount( + rpc, + payer, + mint, + owner.publicKey, + ); + + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(2000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + tokenPoolInfos = await getTokenPoolInfos(rpc, mint); + await decompress( + rpc, + payer, + mint, + bn(2000), + owner, + splAta, + selectTokenPoolInfosForDecompression(tokenPoolInfos, bn(2000)), + ); + + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(500), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const splBalanceBefore = await getAccount(rpc, splAta); + expect(splBalanceBefore.amount).toBe(BigInt(2000)); + + const ctokenAta = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); + const signature = await loadAta(rpc, ctokenAta, owner, mint); + + expect(signature).not.toBeNull(); + + const splBalanceAfter = await getAccount(rpc, splAta); + expect(splBalanceAfter.amount).toBe(BigInt(2000)); + + const hotBalance = await getCTokenBalance(rpc, ctokenAta); + expect(hotBalance).toBe(BigInt(500)); + }); + }); + + describe('createLoadAtaInstructions', () => { + it('should throw when no accounts exist', async () => { + const owner = Keypair.generate(); + const ata = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); + + await expect( + createLoadAtaInstructions( + rpc, + ata, + owner.publicKey, + mint, + payer.publicKey, + ), + ).rejects.toThrow('Token account not found'); + }); + + it('should return empty when hot exists but no cold', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + await createAtaInterfaceIdempotent( + rpc, + payer, + mint, + owner.publicKey, + ); + + const ata = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); + + const ixs = await createLoadAtaInstructions( + rpc, + ata, + owner.publicKey, + mint, + payer.publicKey, + ); + + expect(ixs.length).toBe(0); + }); + + it('should build instructions for cold balance', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(1500), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const ata = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); + const ixs = await createLoadAtaInstructions( + rpc, + ata, + owner.publicKey, + mint, + payer.publicKey, + ); + + expect(ixs.length).toBeGreaterThan(0); + }); + }); + + describe('createLoadAtaInstructionsFromInterface', () => { + it('should throw if AccountInterface not from getAtaInterface', async () => { + const fakeInterface = { + accountInfo: { data: Buffer.alloc(0) }, + parsed: {}, + isCold: false, + } as any; + + await expect( + createLoadAtaInstructionsFromInterface( + rpc, + payer.publicKey, + fakeInterface, + ), + ).rejects.toThrow('must be from getAtaInterface'); + }); + + it('should build instructions from valid AccountInterface', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(1200), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const ataAddress = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); + const ataInterface = await getAtaInterface( + rpc, + ataAddress, + owner.publicKey, + mint, + ); + + expect(ataInterface._isAta).toBe(true); + expect(ataInterface._owner?.equals(owner.publicKey)).toBe(true); + expect(ataInterface._mint?.equals(mint)).toBe(true); + + const ixs = await createLoadAtaInstructionsFromInterface( + rpc, + payer.publicKey, + ataInterface, + ); + + expect(ixs.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/js/compressed-token/tests/e2e/load-ata-unified.test.ts b/js/compressed-token/tests/e2e/load-ata-unified.test.ts new file mode 100644 index 0000000000..1cb71d3fd4 --- /dev/null +++ b/js/compressed-token/tests/e2e/load-ata-unified.test.ts @@ -0,0 +1,569 @@ +/** + * Load ATA - Unified Path (wrap=true) + * + * Tests the unified load path which wraps SPL/T22 AND decompresses ctoken-cold. + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import { Keypair, Signer, PublicKey } from '@solana/web3.js'; +import { + Rpc, + bn, + newAccountWithLamports, + getTestRpc, + selectStateTreeInfo, + TreeInfo, + VERSION, + featureFlags, +} from '@lightprotocol/stateless.js'; +import { WasmFactory } from '@lightprotocol/hasher.rs'; +import { + TOKEN_PROGRAM_ID, + TOKEN_2022_PROGRAM_ID, + createAssociatedTokenAccount, + getOrCreateAssociatedTokenAccount, + getAccount, +} from '@solana/spl-token'; +import { createMint, mintTo, decompress } from '../../src/actions'; +import { + getTokenPoolInfos, + selectTokenPoolInfo, + selectTokenPoolInfosForDecompression, + TokenPoolInfo, +} from '../../src/utils/get-token-pool-infos'; + +import { + loadAta as loadAtaUnified, + createLoadAtaInstructions as createLoadAtaInstructionsUnified, + getAssociatedTokenAddressInterface as getAssociatedTokenAddressInterfaceUnified, +} from '../../src/v3/unified'; + +featureFlags.version = VERSION.V2; + +const TEST_TOKEN_DECIMALS = 9; + +async function getCTokenBalance(rpc: Rpc, address: PublicKey): Promise { + const accountInfo = await rpc.getAccountInfo(address); + if (!accountInfo) return BigInt(0); + return accountInfo.data.readBigUInt64LE(64); +} + +async function getCompressedBalance( + rpc: Rpc, + owner: PublicKey, + mint: PublicKey, +): Promise { + const result = await rpc.getCompressedTokenAccountsByOwner(owner, { mint }); + return result.items.reduce( + (sum, acc) => sum + BigInt(acc.parsed.amount.toString()), + BigInt(0), + ); +} + +describe('loadAta - Unified Path (wrap=true)', () => { + let rpc: Rpc; + let payer: Signer; + let mint: PublicKey; + let mintAuthority: Keypair; + let stateTreeInfo: 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; + + stateTreeInfo = selectStateTreeInfo(await rpc.getStateTreeInfos()); + tokenPoolInfos = await getTokenPoolInfos(rpc, mint); + }, 60_000); + + describe('SPL only', () => { + it('should wrap SPL balance to ctoken ATA', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + const splAta = await createAssociatedTokenAccount( + rpc, + payer, + mint, + owner.publicKey, + ); + + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(3000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + tokenPoolInfos = await getTokenPoolInfos(rpc, mint); + await decompress( + rpc, + payer, + mint, + bn(3000), + owner, + splAta, + selectTokenPoolInfosForDecompression(tokenPoolInfos, bn(3000)), + ); + + const splBalanceBefore = await getAccount(rpc, splAta); + expect(splBalanceBefore.amount).toBe(BigInt(3000)); + + const ctokenAta = getAssociatedTokenAddressInterfaceUnified( + mint, + owner.publicKey, + ); + const signature = await loadAtaUnified(rpc, ctokenAta, owner, mint); + + expect(signature).not.toBeNull(); + + const splBalanceAfter = await getAccount(rpc, splAta); + expect(splBalanceAfter.amount).toBe(BigInt(0)); + + const ctokenBalance = await getCTokenBalance(rpc, ctokenAta); + expect(ctokenBalance).toBe(BigInt(3000)); + }); + }); + + describe('ctoken-cold only (unified)', () => { + it('should decompress cold balance via unified path', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(4000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const ctokenAta = getAssociatedTokenAddressInterfaceUnified( + mint, + owner.publicKey, + ); + const signature = await loadAtaUnified(rpc, ctokenAta, owner, mint); + + expect(signature).not.toBeNull(); + + const hotBalance = await getCTokenBalance(rpc, ctokenAta); + expect(hotBalance).toBe(BigInt(4000)); + }); + }); + + describe('SPL + ctoken-cold', () => { + it('should wrap SPL and decompress cold in single load', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + const splAta = await createAssociatedTokenAccount( + rpc, + payer, + mint, + owner.publicKey, + ); + + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(2000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + tokenPoolInfos = await getTokenPoolInfos(rpc, mint); + await decompress( + rpc, + payer, + mint, + bn(2000), + owner, + splAta, + selectTokenPoolInfosForDecompression(tokenPoolInfos, bn(2000)), + ); + + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(1500), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const splBefore = (await getAccount(rpc, splAta)).amount; + const coldBefore = await getCompressedBalance( + rpc, + owner.publicKey, + mint, + ); + expect(splBefore).toBe(BigInt(2000)); + expect(coldBefore).toBe(BigInt(1500)); + + const ctokenAta = getAssociatedTokenAddressInterfaceUnified( + mint, + owner.publicKey, + ); + await loadAtaUnified(rpc, ctokenAta, owner, mint); + + const splAfter = (await getAccount(rpc, splAta)).amount; + const coldAfter = await getCompressedBalance( + rpc, + owner.publicKey, + mint, + ); + const hotBalance = await getCTokenBalance(rpc, ctokenAta); + + expect(splAfter).toBe(BigInt(0)); + expect(coldAfter).toBe(BigInt(0)); + expect(hotBalance).toBe(BigInt(3500)); + }); + }); + + describe('ctoken-hot + cold', () => { + it('should decompress cold to existing hot (no ATA creation)', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(2000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const ctokenAta = getAssociatedTokenAddressInterfaceUnified( + mint, + owner.publicKey, + ); + await loadAtaUnified(rpc, ctokenAta, owner, mint); + + const hotBefore = await getCTokenBalance(rpc, ctokenAta); + expect(hotBefore).toBe(BigInt(2000)); + + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(1000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + await loadAtaUnified(rpc, ctokenAta, owner, mint); + + const hotAfter = await getCTokenBalance(rpc, ctokenAta); + expect(hotAfter).toBe(BigInt(3000)); + }); + }); + + describe('ctoken-hot + SPL', () => { + it('should wrap SPL to existing hot ATA', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(1000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const ctokenAta = getAssociatedTokenAddressInterfaceUnified( + mint, + owner.publicKey, + ); + await loadAtaUnified(rpc, ctokenAta, owner, mint); + + const hotBefore = await getCTokenBalance(rpc, ctokenAta); + expect(hotBefore).toBe(BigInt(1000)); + + const splAta = await createAssociatedTokenAccount( + rpc, + payer, + mint, + owner.publicKey, + ); + + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(500), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + tokenPoolInfos = await getTokenPoolInfos(rpc, mint); + await decompress( + rpc, + payer, + mint, + bn(500), + owner, + splAta, + selectTokenPoolInfosForDecompression(tokenPoolInfos, bn(500)), + ); + + await loadAtaUnified(rpc, ctokenAta, owner, mint); + + const hotAfter = await getCTokenBalance(rpc, ctokenAta); + expect(hotAfter).toBe(BigInt(1500)); + + const splAfter = (await getAccount(rpc, splAta)).amount; + expect(splAfter).toBe(BigInt(0)); + }); + }); + + describe('nothing to load', () => { + it('should throw when no balances exist at all', async () => { + const owner = Keypair.generate(); + const ctokenAta = getAssociatedTokenAddressInterfaceUnified( + mint, + owner.publicKey, + ); + + await expect( + loadAtaUnified( + rpc, + ctokenAta, + owner as unknown as Signer, + mint, + ), + ).rejects.toThrow('Token account not found'); + }); + + it('should return null when only hot balance exists', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(1000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const ctokenAta = getAssociatedTokenAddressInterfaceUnified( + mint, + owner.publicKey, + ); + await loadAtaUnified(rpc, ctokenAta, owner, mint); + + const signature = await loadAtaUnified(rpc, ctokenAta, owner, mint); + expect(signature).toBeNull(); + }); + }); + + describe('createLoadAtaInstructions unified', () => { + it('should throw when ATA not derived from c-token program', async () => { + const owner = Keypair.generate(); + const wrongAta = await import('@solana/spl-token').then(m => + m.getAssociatedTokenAddressSync( + mint, + owner.publicKey, + false, + TOKEN_PROGRAM_ID, + ), + ); + + await expect( + createLoadAtaInstructionsUnified( + rpc, + wrongAta, + owner.publicKey, + mint, + owner.publicKey, + ), + ).rejects.toThrow('For wrap=true, ata must be the c-token ATA'); + }); + + it('should build instructions for SPL + cold balance', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + const splAta = await createAssociatedTokenAccount( + rpc, + payer, + mint, + owner.publicKey, + ); + + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(1000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + tokenPoolInfos = await getTokenPoolInfos(rpc, mint); + await decompress( + rpc, + payer, + mint, + bn(1000), + owner, + splAta, + selectTokenPoolInfosForDecompression(tokenPoolInfos, bn(1000)), + ); + + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(500), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const ctokenAta = getAssociatedTokenAddressInterfaceUnified( + mint, + owner.publicKey, + ); + + const ixs = await createLoadAtaInstructionsUnified( + rpc, + ctokenAta, + owner.publicKey, + mint, + payer.publicKey, + ); + + expect(ixs.length).toBeGreaterThan(1); + }); + }); +}); + +describe('loadAta - T22 Only', () => { + let rpc: Rpc; + let payer: Signer; + let t22Mint: PublicKey; + let t22MintAuthority: Keypair; + let stateTreeInfo: TreeInfo; + let t22TokenPoolInfos: TokenPoolInfo[]; + + beforeAll(async () => { + const lightWasm = await WasmFactory.getInstance(); + rpc = await getTestRpc(lightWasm); + payer = await newAccountWithLamports(rpc, 10e9); + t22MintAuthority = Keypair.generate(); + const mintKeypair = Keypair.generate(); + + const result = await createMint( + rpc, + payer, + t22MintAuthority.publicKey, + TEST_TOKEN_DECIMALS, + mintKeypair, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + t22Mint = result.mint; + stateTreeInfo = selectStateTreeInfo(await rpc.getStateTreeInfos()); + t22TokenPoolInfos = await getTokenPoolInfos(rpc, t22Mint); + }, 60_000); + + it('should wrap T22 balance to ctoken ATA', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + const t22AtaAccount = await getOrCreateAssociatedTokenAccount( + rpc, + payer as Keypair, + t22Mint, + owner.publicKey, + false, + 'confirmed', + undefined, + TOKEN_2022_PROGRAM_ID, + ); + const t22Ata = t22AtaAccount.address; + + await mintTo( + rpc, + payer, + t22Mint, + owner.publicKey, + t22MintAuthority, + bn(2500), + stateTreeInfo, + selectTokenPoolInfo(t22TokenPoolInfos), + ); + + t22TokenPoolInfos = await getTokenPoolInfos(rpc, t22Mint); + await decompress( + rpc, + payer, + t22Mint, + bn(2500), + owner, + t22Ata, + selectTokenPoolInfosForDecompression(t22TokenPoolInfos, bn(2500)), + ); + + const t22BalanceBefore = await getAccount( + rpc, + t22Ata, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + expect(t22BalanceBefore.amount).toBe(BigInt(2500)); + + const ctokenAta = getAssociatedTokenAddressInterfaceUnified( + t22Mint, + owner.publicKey, + ); + const signature = await loadAtaUnified(rpc, ctokenAta, owner, t22Mint); + + expect(signature).not.toBeNull(); + + const t22BalanceAfter = await getAccount( + rpc, + t22Ata, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + expect(t22BalanceAfter.amount).toBe(BigInt(0)); + + const ctokenBalance = await getCTokenBalance(rpc, ctokenAta); + expect(ctokenBalance).toBe(BigInt(2500)); + }, 90_000); +}); diff --git a/js/compressed-token/tests/e2e/merge-token-accounts.test.ts b/js/compressed-token/tests/e2e/merge-token-accounts.test.ts index 9795c3f5c1..e63a4a7434 100644 --- a/js/compressed-token/tests/e2e/merge-token-accounts.test.ts +++ b/js/compressed-token/tests/e2e/merge-token-accounts.test.ts @@ -35,7 +35,6 @@ describe('mergeTokenAccounts', () => { rpc, payer, mintAuthority.publicKey, - null, 2, mintKeypair, ) diff --git a/js/compressed-token/tests/e2e/mint-to-compressed.test.ts b/js/compressed-token/tests/e2e/mint-to-compressed.test.ts index 08da1d78c2..da0fcba554 100644 --- a/js/compressed-token/tests/e2e/mint-to-compressed.test.ts +++ b/js/compressed-token/tests/e2e/mint-to-compressed.test.ts @@ -14,10 +14,10 @@ import { CTOKEN_PROGRAM_ID, selectStateTreeInfo, } from '@lightprotocol/stateless.js'; -import { createMintInterface } from '../../src/mint/actions/create-mint-interface'; -import { mintToCompressed } from '../../src/mint/actions/mint-to-compressed'; -import { getMintInterface } from '../../src/mint/helpers'; -import { findMintAddress } from '../../src/compressible/derivation'; +import { createMintInterface } from '../../src/v3/actions/create-mint-interface'; +import { mintToCompressed } from '../../src/v3/actions/mint-to-compressed'; +import { getMintInterface } from '../../src/v3/get-mint-interface'; +import { findMintAddress } from '../../src/v3/derivation'; featureFlags.version = VERSION.V2; @@ -46,9 +46,6 @@ describe('mintToCompressed', () => { null, decimals, mintSigner, - undefined, - undefined, - undefined, ); await rpc.confirmTransaction(result.transactionSignature, 'confirmed'); mint = result.mint; diff --git a/js/compressed-token/tests/e2e/mint-to-ctoken.test.ts b/js/compressed-token/tests/e2e/mint-to-ctoken.test.ts index a2ec6549eb..caa0aa3f27 100644 --- a/js/compressed-token/tests/e2e/mint-to-ctoken.test.ts +++ b/js/compressed-token/tests/e2e/mint-to-ctoken.test.ts @@ -13,14 +13,14 @@ import { featureFlags, CTOKEN_PROGRAM_ID, } from '@lightprotocol/stateless.js'; -import { createMintInterface } from '../../src/mint/actions/create-mint-interface'; -import { mintTo } from '../../src/mint/actions/mint-to'; -import { getMintInterface } from '../../src/mint/helpers'; -import { createAssociatedCTokenAccount } from '../../src/mint/actions/create-associated-ctoken'; +import { createMintInterface } from '../../src/v3/actions/create-mint-interface'; +import { mintTo } from '../../src/v3/actions/mint-to'; +import { getMintInterface } from '../../src/v3/get-mint-interface'; +import { createAssociatedCTokenAccount } from '../../src/v3/actions/create-associated-ctoken'; import { getAssociatedCTokenAddress, findMintAddress, -} from '../../src/compressible/derivation'; +} from '../../src/v3/derivation'; featureFlags.version = VERSION.V2; @@ -48,9 +48,6 @@ describe('mintTo (MintToCToken)', () => { null, decimals, mintSigner, - undefined, - undefined, - undefined, ); await rpc.confirmTransaction(result.transactionSignature, 'confirmed'); mint = result.mint; diff --git a/js/compressed-token/tests/e2e/mint-to-interface.test.ts b/js/compressed-token/tests/e2e/mint-to-interface.test.ts index a46c654f97..1f9c786d03 100644 --- a/js/compressed-token/tests/e2e/mint-to-interface.test.ts +++ b/js/compressed-token/tests/e2e/mint-to-interface.test.ts @@ -16,12 +16,12 @@ import { TOKEN_2022_PROGRAM_ID, } from '@solana/spl-token'; import { WasmFactory } from '@lightprotocol/hasher.rs'; -import { createMintInterface } from '../../src/mint/actions/create-mint-interface'; -import { mintToInterface } from '../../src/mint/actions/mint-to-interface'; +import { createMintInterface } from '../../src/v3/actions/create-mint-interface'; +import { mintToInterface } from '../../src/v3/actions/mint-to-interface'; import { createMint } from '../../src/actions/create-mint'; -import { createAssociatedCTokenAccount } from '../../src/mint/actions/create-associated-ctoken'; -import { getAssociatedCTokenAddress } from '../../src/compressible/derivation'; -import { getAccountInterface } from '../../src/mint/get-account-interface'; +import { createAssociatedCTokenAccount } from '../../src/v3/actions/create-associated-ctoken'; +import { getAssociatedCTokenAddress } from '../../src/v3/derivation'; +import { getAccountInterface } from '../../src/v3/get-account-interface'; featureFlags.version = VERSION.V2; @@ -45,7 +45,6 @@ describe('mintToInterface - SPL Mints', () => { rpc, payer, mintAuthority.publicKey, - null, TEST_TOKEN_DECIMALS, mintKeypair, ) @@ -194,9 +193,6 @@ describe('mintToInterface - Compressed Mints', () => { null, decimals, mintSigner, - undefined, - undefined, - undefined, ); await rpc.confirmTransaction(result.transactionSignature, 'confirmed'); mint = result.mint; @@ -342,6 +338,140 @@ describe('mintToInterface - Compressed Mints', () => { }); }); +describe('mintToInterface - Token-2022 Mints', () => { + let rpc: Rpc; + let payer: Signer; + let mint: PublicKey; + let mintAuthority: Keypair; + + beforeAll(async () => { + const lightWasm = await WasmFactory.getInstance(); + rpc = await getTestRpc(lightWasm); + payer = await newAccountWithLamports(rpc, 10e9); + mintAuthority = Keypair.generate(); + + const mintKeypair = Keypair.generate(); + const result = await createMintInterface( + rpc, + payer, + mintAuthority.publicKey, + null, + TEST_TOKEN_DECIMALS, + mintKeypair, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + await rpc.confirmTransaction(result.transactionSignature, 'confirmed'); + mint = result.mint; + }); + + it('should mint Token-2022 tokens', async () => { + const recipient = Keypair.generate(); + const amount = 3000; + + const ata = await getOrCreateAssociatedTokenAccount( + rpc, + payer as Keypair, + mint, + recipient.publicKey, + false, + 'confirmed', + undefined, + TOKEN_2022_PROGRAM_ID, + ); + + const txId = await mintToInterface( + rpc, + payer, + mint, + ata.address, + mintAuthority, + amount, + ); + + await rpc.confirmTransaction(txId, 'confirmed'); + + const accountInfo = await getAccount( + rpc, + ata.address, + 'confirmed', + TOKEN_2022_PROGRAM_ID, + ); + expect(accountInfo.amount).toBe(BigInt(amount)); + }); + + it('should mint Token-2022 tokens with bigint amount', async () => { + const recipient = Keypair.generate(); + const amount = 2000000000n; + + const ata = await getOrCreateAssociatedTokenAccount( + rpc, + payer as Keypair, + mint, + recipient.publicKey, + false, + 'confirmed', + undefined, + TOKEN_2022_PROGRAM_ID, + ); + + const txId = await mintToInterface( + rpc, + payer, + mint, + ata.address, + mintAuthority, + amount, + ); + + await rpc.confirmTransaction(txId, 'confirmed'); + + const accountInfo = await getAccount( + rpc, + ata.address, + 'confirmed', + TOKEN_2022_PROGRAM_ID, + ); + expect(accountInfo.amount).toBe(amount); + }); + + it('should auto-detect TOKEN_2022_PROGRAM_ID when programId not provided', async () => { + const recipient = Keypair.generate(); + const amount = 750; + + const ata = await getOrCreateAssociatedTokenAccount( + rpc, + payer as Keypair, + mint, + recipient.publicKey, + false, + 'confirmed', + undefined, + TOKEN_2022_PROGRAM_ID, + ); + + // Don't pass programId - should auto-detect Token-2022 + const txId = await mintToInterface( + rpc, + payer, + mint, + ata.address, + mintAuthority, + amount, + ); + + await rpc.confirmTransaction(txId, 'confirmed'); + + const accountInfo = await getAccount( + rpc, + ata.address, + 'confirmed', + TOKEN_2022_PROGRAM_ID, + ); + expect(accountInfo.amount).toBe(BigInt(amount)); + }); +}); + describe('mintToInterface - Edge Cases', () => { let rpc: Rpc; let payer: Signer; @@ -361,9 +491,6 @@ describe('mintToInterface - Edge Cases', () => { null, 6, mintSigner, - undefined, - undefined, - undefined, ); await rpc.confirmTransaction(result.transactionSignature, 'confirmed'); compressedMint = result.mint; @@ -412,9 +539,6 @@ describe('mintToInterface - Edge Cases', () => { null, 9, mintSigner, - undefined, - undefined, - undefined, ); await rpc.confirmTransaction(result.transactionSignature, 'confirmed'); diff --git a/js/compressed-token/tests/e2e/mint-to.test.ts b/js/compressed-token/tests/e2e/mint-to.test.ts index ad9a73edc8..62c9adc3ed 100644 --- a/js/compressed-token/tests/e2e/mint-to.test.ts +++ b/js/compressed-token/tests/e2e/mint-to.test.ts @@ -83,7 +83,6 @@ describe('mintTo', () => { rpc, payer, mintAuthority.publicKey, - null, TEST_TOKEN_DECIMALS, mintKeypair, ) diff --git a/js/compressed-token/tests/e2e/mint-workflow.test.ts b/js/compressed-token/tests/e2e/mint-workflow.test.ts index c60b1aadf7..d3165b7cdd 100644 --- a/js/compressed-token/tests/e2e/mint-workflow.test.ts +++ b/js/compressed-token/tests/e2e/mint-workflow.test.ts @@ -9,22 +9,20 @@ import { getDefaultAddressTreeInfo, CTOKEN_PROGRAM_ID, } from '@lightprotocol/stateless.js'; -import { createMintInterface } from '../../src/mint/actions'; -import { createTokenMetadata } from '../../src/mint/instructions'; +import { createMintInterface } from '../../src/v3/actions'; +import { createTokenMetadata } from '../../src/v3/instructions'; import { updateMintAuthority, updateFreezeAuthority, -} from '../../src/mint/actions/update-mint'; +} from '../../src/v3/actions/update-mint'; import { updateMetadataField, updateMetadataAuthority, -} from '../../src/mint/actions/update-metadata'; -import { - createATAInterfaceIdempotent, - getATAAddressInterface, -} from '../../src/mint/actions/create-ata-interface'; -import { getMintInterface } from '../../src/mint/helpers'; -import { findMintAddress } from '../../src/compressible/derivation'; +} from '../../src/v3/actions/update-metadata'; +import { createAtaInterfaceIdempotent } from '../../src/v3/actions/create-ata-interface'; +import { getAssociatedTokenAddressInterface } from '../../src/'; +import { getMintInterface } from '../../src/v3/get-mint-interface'; +import { findMintAddress } from '../../src/v3/derivation'; featureFlags.version = VERSION.V2; @@ -60,9 +58,9 @@ describe('Complete Mint Workflow', () => { initialFreezeAuthority.publicKey, decimals, mintSigner, - initialMetadata, - addressTreeInfo, undefined, + undefined, + initialMetadata, ); await rpc.confirmTransaction(createSig, 'confirmed'); @@ -87,7 +85,6 @@ describe('Complete Mint Workflow', () => { rpc, payer, mintPda, - mintSigner, initialMintAuthority, 'name', 'Workflow Token V2', @@ -106,7 +103,6 @@ describe('Complete Mint Workflow', () => { rpc, payer, mintPda, - mintSigner, initialMintAuthority, 'uri', 'https://workflow.com/updated', @@ -128,7 +124,6 @@ describe('Complete Mint Workflow', () => { rpc, payer, mintPda, - mintSigner, initialMintAuthority, newMetadataAuthority.publicKey, ); @@ -149,7 +144,6 @@ describe('Complete Mint Workflow', () => { rpc, payer, mintPda, - mintSigner, initialMintAuthority, newMintAuthority.publicKey, ); @@ -170,7 +164,6 @@ describe('Complete Mint Workflow', () => { rpc, payer, mintPda, - mintSigner, initialFreezeAuthority, newFreezeAuthority.publicKey, ); @@ -190,30 +183,39 @@ describe('Complete Mint Workflow', () => { const owner2 = Keypair.generate(); const owner3 = Keypair.generate(); - const { address: ata1 } = await createATAInterfaceIdempotent( + const { address: ata1 } = await createAtaInterfaceIdempotent( rpc, payer, mint, owner1.publicKey, ); - const { address: ata2 } = await createATAInterfaceIdempotent( + const { address: ata2 } = await createAtaInterfaceIdempotent( rpc, payer, mint, owner2.publicKey, ); - const { address: ata3 } = await createATAInterfaceIdempotent( + const { address: ata3 } = await createAtaInterfaceIdempotent( rpc, payer, mint, owner3.publicKey, ); - const expectedAta1 = getATAAddressInterface(mint, owner1.publicKey); - const expectedAta2 = getATAAddressInterface(mint, owner2.publicKey); - const expectedAta3 = getATAAddressInterface(mint, owner3.publicKey); + const expectedAta1 = getAssociatedTokenAddressInterface( + mint, + owner1.publicKey, + ); + const expectedAta2 = getAssociatedTokenAddressInterface( + mint, + owner2.publicKey, + ); + const expectedAta3 = getAssociatedTokenAddressInterface( + mint, + owner3.publicKey, + ); expect(ata1.toString()).toBe(expectedAta1.toString()); expect(ata2.toString()).toBe(expectedAta2.toString()); @@ -262,9 +264,9 @@ describe('Complete Mint Workflow', () => { freezeAuthority.publicKey, decimals, mintSigner, - metadata, - addressTreeInfo, undefined, + undefined, + metadata, ); await rpc.confirmTransaction(createSig, 'confirmed'); @@ -280,7 +282,6 @@ describe('Complete Mint Workflow', () => { rpc, payer, mintPda, - mintSigner, freezeAuthority, null, ); @@ -298,14 +299,14 @@ describe('Complete Mint Workflow', () => { ); const owner = Keypair.generate(); - const { address: ataAddress } = await createATAInterfaceIdempotent( + const { address: ataAddress } = await createAtaInterfaceIdempotent( rpc, payer, mintPda, owner.publicKey, ); - const expectedAddress = getATAAddressInterface( + const expectedAddress = getAssociatedTokenAddressInterface( mintPda, owner.publicKey, ); @@ -330,9 +331,6 @@ describe('Complete Mint Workflow', () => { null, decimals, mintSigner, - undefined, - addressTreeInfo, - undefined, ); await rpc.confirmTransaction(createSig, 'confirmed'); @@ -351,14 +349,14 @@ describe('Complete Mint Workflow', () => { ]; for (const owner of owners) { - const { address: ataAddress } = await createATAInterfaceIdempotent( + const { address: ataAddress } = await createAtaInterfaceIdempotent( rpc, payer, mint, owner.publicKey, ); - const expectedAddress = getATAAddressInterface( + const expectedAddress = getAssociatedTokenAddressInterface( mint, owner.publicKey, ); @@ -390,21 +388,21 @@ describe('Complete Mint Workflow', () => { null, decimals, mintSigner, - metadata, - addressTreeInfo, undefined, + undefined, + metadata, ); await rpc.confirmTransaction(createSig, 'confirmed'); const owner = Keypair.generate(); - const { address: ataAddress } = await createATAInterfaceIdempotent( + const { address: ataAddress } = await createAtaInterfaceIdempotent( rpc, payer, mintPda, owner.publicKey, ); - const expectedAddress = getATAAddressInterface( + const expectedAddress = getAssociatedTokenAddressInterface( mintPda, owner.publicKey, ); @@ -414,7 +412,6 @@ describe('Complete Mint Workflow', () => { rpc, payer, mintPda, - mintSigner, mintAuthority, 'name', 'After ATA', @@ -456,9 +453,9 @@ describe('Complete Mint Workflow', () => { freezeAuthority.publicKey, decimals, mintSigner, - metadata, - addressTreeInfo, undefined, + undefined, + metadata, ); await rpc.confirmTransaction(createSig, 'confirmed'); @@ -490,14 +487,14 @@ describe('Complete Mint Workflow', () => { const owner1 = Keypair.generate(); const owner2 = Keypair.generate(); - const { address: ata1 } = await createATAInterfaceIdempotent( + const { address: ata1 } = await createAtaInterfaceIdempotent( rpc, payer, mint, owner1.publicKey, ); - const { address: ata2 } = await createATAInterfaceIdempotent( + const { address: ata2 } = await createAtaInterfaceIdempotent( rpc, payer, mint, @@ -514,7 +511,6 @@ describe('Complete Mint Workflow', () => { rpc, payer, mintPda, - mintSigner, mintAuthority, newMintAuthority.publicKey, ); @@ -534,7 +530,6 @@ describe('Complete Mint Workflow', () => { rpc, payer, mintPda, - mintSigner, mintAuthority, 'symbol', 'FULL2', @@ -552,7 +547,7 @@ describe('Complete Mint Workflow', () => { newMintAuthority.publicKey.toString(), ); - const { address: ata1Again } = await createATAInterfaceIdempotent( + const { address: ata1Again } = await createAtaInterfaceIdempotent( rpc, payer, mint, @@ -576,9 +571,6 @@ describe('Complete Mint Workflow', () => { null, decimals, mintSigner, - undefined, - addressTreeInfo, - undefined, ); await rpc.confirmTransaction(createSig, 'confirmed'); @@ -592,14 +584,17 @@ describe('Complete Mint Workflow', () => { expect(mintInfo.tokenMetadata).toBeUndefined(); const owner = Keypair.generate(); - const { address: ataAddress } = await createATAInterfaceIdempotent( + const { address: ataAddress } = await createAtaInterfaceIdempotent( rpc, payer, mint, owner.publicKey, ); - const expectedAddress = getATAAddressInterface(mint, owner.publicKey); + const expectedAddress = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); expect(ataAddress.toString()).toBe(expectedAddress.toString()); const accountInfo = await rpc.getAccountInfo(ataAddress); @@ -611,7 +606,6 @@ describe('Complete Mint Workflow', () => { rpc, payer, mintPda, - mintSigner, mintAuthority, newMintAuthority.publicKey, ); @@ -645,25 +639,22 @@ describe('Complete Mint Workflow', () => { null, decimals, mintSigner, - undefined, - addressTreeInfo, - undefined, ); await rpc.confirmTransaction(createSig, 'confirmed'); - const derivedAddressBefore = getATAAddressInterface( + const derivedAddressBefore = getAssociatedTokenAddressInterface( mint, owner.publicKey, ); - const { address: ataAddress } = await createATAInterfaceIdempotent( + const { address: ataAddress } = await createAtaInterfaceIdempotent( rpc, payer, mint, owner.publicKey, ); - const derivedAddressAfter = getATAAddressInterface( + const derivedAddressAfter = getAssociatedTokenAddressInterface( mint, owner.publicKey, ); diff --git a/js/compressed-token/tests/e2e/multi-pool.test.ts b/js/compressed-token/tests/e2e/multi-pool.test.ts index 531e12ba68..2773c3ad14 100644 --- a/js/compressed-token/tests/e2e/multi-pool.test.ts +++ b/js/compressed-token/tests/e2e/multi-pool.test.ts @@ -123,7 +123,6 @@ describe('multi-pool', () => { rpc, payer, mintAuthority.publicKey, - null, TEST_TOKEN_DECIMALS, mintKeypair, ), diff --git a/js/compressed-token/tests/e2e/payment-flows.test.ts b/js/compressed-token/tests/e2e/payment-flows.test.ts index 650c315c4f..5f962c95b8 100644 --- a/js/compressed-token/tests/e2e/payment-flows.test.ts +++ b/js/compressed-token/tests/e2e/payment-flows.test.ts @@ -31,16 +31,16 @@ import { selectTokenPoolInfo, TokenPoolInfo, } from '../../src/utils/get-token-pool-infos'; -import { getATAInterface } from '../../src/mint/get-account-interface'; -import { getATAAddressInterface } from '../../src/mint/actions/create-ata-interface'; -import { getOrCreateATAInterface } from '../../src/mint/actions/get-or-create-ata-interface'; -import { transferInterface } from '../../src/mint/actions/transfer-interface'; +import { getAtaInterface } from '../../src/v3/get-account-interface'; +import { getAssociatedTokenAddressInterface } from '../../src/v3/get-associated-token-address-interface'; +import { getOrCreateAtaInterface } from '../../src/v3/actions/get-or-create-ata-interface'; +import { transferInterface } from '../../src/v3/actions/transfer-interface'; import { createLoadAccountsParams, - loadATA, -} from '../../src/compressible/unified-load'; -import { createTransferInterfaceInstruction } from '../../src/mint/instructions/transfer-interface'; -import { createAssociatedTokenAccountInterfaceIdempotentInstruction } from '../../src/mint/instructions/create-associated-ctoken'; + loadAta, +} from '../../src/v3/actions/load-ata'; +import { createTransferInterfaceInstruction } from '../../src/v3/instructions/transfer-interface'; +import { createAssociatedTokenAccountInterfaceIdempotentInstruction } from '../../src/v3/instructions/create-ata-interface'; featureFlags.version = VERSION.V2; @@ -66,7 +66,6 @@ describe('Payment Flows', () => { rpc, payer, mintAuthority.publicKey, - null, TEST_TOKEN_DECIMALS, mintKeypair, ) @@ -98,8 +97,8 @@ describe('Payment Flows', () => { selectTokenPoolInfo(tokenPoolInfos), ); - // STEP 1: getOrCreateATAInterface for recipient (like SPL's getOrCreateAssociatedTokenAccount) - const recipientAta = await getOrCreateATAInterface( + // STEP 1: getOrCreateAtaInterface for recipient (like SPL's getOrCreateAssociatedTokenAccount) + const recipientAta = await getOrCreateAtaInterface( rpc, payer, mint, @@ -107,25 +106,28 @@ describe('Payment Flows', () => { ); // STEP 2: transfer (auto-loads sender, destination must exist) - const sourceAta = getATAAddressInterface(mint, sender.publicKey); + const sourceAta = getAssociatedTokenAddressInterface( + mint, + sender.publicKey, + ); const signature = await transferInterface( rpc, payer, sourceAta, - recipientAta.address, - sender, mint, + recipientAta.parsed.address, + sender, amount, CTOKEN_PROGRAM_ID, undefined, - { tokenPoolInfos }, + { splInterfaceInfos: tokenPoolInfos }, ); expect(signature).toBeDefined(); // Verify const recipientBalance = (await rpc.getAccountInfo( - recipientAta.address, + recipientAta.parsed.address, ))!.data.readBigUInt64LE(64); expect(recipientBalance).toBe(amount); }); @@ -147,7 +149,7 @@ describe('Payment Flows', () => { ); // Create recipient ATA first - const recipientAta = await getOrCreateATAInterface( + const recipientAta = await getOrCreateAtaInterface( rpc, payer, mint, @@ -155,23 +157,26 @@ describe('Payment Flows', () => { ); // Transfer - auto-loads sender - const sourceAta = getATAAddressInterface(mint, sender.publicKey); + const sourceAta = getAssociatedTokenAddressInterface( + mint, + sender.publicKey, + ); await transferInterface( rpc, payer, sourceAta, - recipientAta.address, - sender, mint, + recipientAta.parsed.address, + sender, BigInt(2000), CTOKEN_PROGRAM_ID, undefined, - { tokenPoolInfos }, + { splInterfaceInfos: tokenPoolInfos }, ); // Verify const recipientBalance = (await rpc.getAccountInfo( - recipientAta.address, + recipientAta.parsed.address, ))!.data.readBigUInt64LE(64); expect(recipientBalance).toBe(BigInt(2000)); @@ -196,10 +201,11 @@ describe('Payment Flows', () => { stateTreeInfo, selectTokenPoolInfo(tokenPoolInfos), ); - const senderAta = getATAAddressInterface(mint, sender.publicKey); - await loadATA(rpc, payer, senderAta, sender, mint, undefined, { - tokenPoolInfos, - }); + const senderAta = getAssociatedTokenAddressInterface( + mint, + sender.publicKey, + ); + await loadAta(rpc, senderAta, sender, mint); await mintTo( rpc, @@ -211,24 +217,20 @@ describe('Payment Flows', () => { stateTreeInfo, selectTokenPoolInfo(tokenPoolInfos), ); - const recipientAta = getATAAddressInterface( + const recipientAta = getAssociatedTokenAddressInterface( mint, recipient.publicKey, ); - await loadATA( - rpc, - payer, - recipientAta, - recipient, + await loadAta(rpc, recipientAta, recipient, mint); + + const sourceAta = getAssociatedTokenAddressInterface( mint, - undefined, - { - tokenPoolInfos, - }, + sender.publicKey, + ); + const destAta = getAssociatedTokenAddressInterface( + mint, + recipient.publicKey, ); - - const sourceAta = getATAAddressInterface(mint, sender.publicKey); - const destAta = getATAAddressInterface(mint, recipient.publicKey); const recipientBefore = (await rpc.getAccountInfo( destAta, @@ -239,9 +241,9 @@ describe('Payment Flows', () => { rpc, payer, sourceAta, + mint, destAta, sender, - mint, BigInt(500), ); @@ -275,8 +277,13 @@ describe('Payment Flows', () => { ); // STEP 1: Fetch sender's ATA for loading - const senderAta = await getATAInterface( + const senderAtaAddress = getAssociatedTokenAddressInterface( + mint, + sender.publicKey, + ); + const senderAta = await getAtaInterface( rpc, + senderAtaAddress, sender.publicKey, mint, ); @@ -288,15 +295,10 @@ describe('Payment Flows', () => { CTOKEN_PROGRAM_ID, [], [senderAta], - { tokenPoolInfos }, + { splInterfaceInfos: tokenPoolInfos }, ); - // STEP 3: Derive addresses - const senderAtaAddress = getATAAddressInterface( - mint, - sender.publicKey, - ); - const recipientAtaAddress = getATAAddressInterface( + const recipientAtaAddress = getAssociatedTokenAddressInterface( mint, recipient.publicKey, ); @@ -352,25 +354,16 @@ describe('Payment Flows', () => { stateTreeInfo, selectTokenPoolInfo(tokenPoolInfos), ); - const senderAtaAddress = getATAAddressInterface( + const senderAtaAddress = getAssociatedTokenAddressInterface( mint, sender.publicKey, ); - await loadATA( - rpc, - payer, - senderAtaAddress, - sender, - mint, - undefined, - { - tokenPoolInfos, - }, - ); + await loadAta(rpc, senderAtaAddress, sender, mint); // Sender is hot - createLoadAccountsParams returns empty ataInstructions - const senderAta = await getATAInterface( + const senderAta = await getAtaInterface( rpc, + senderAtaAddress, sender.publicKey, mint, ); @@ -383,7 +376,7 @@ describe('Payment Flows', () => { ); expect(result.ataInstructions).toHaveLength(0); - const recipientAtaAddress = getATAAddressInterface( + const recipientAtaAddress = getAssociatedTokenAddressInterface( mint, recipient.publicKey, ); @@ -432,20 +425,21 @@ describe('Payment Flows', () => { stateTreeInfo, selectTokenPoolInfo(tokenPoolInfos), ); - const senderAta = getATAAddressInterface(mint, sender.publicKey); - await loadATA(rpc, payer, senderAta, sender, mint, undefined, { - tokenPoolInfos, - }); + const senderAta = getAssociatedTokenAddressInterface( + mint, + sender.publicKey, + ); + await loadAta(rpc, senderAta, sender, mint); - const senderAtaAddress = getATAAddressInterface( + const senderAtaAddress = getAssociatedTokenAddressInterface( mint, sender.publicKey, ); - const r1AtaAddress = getATAAddressInterface( + const r1AtaAddress = getAssociatedTokenAddressInterface( mint, recipient1.publicKey, ); - const r2AtaAddress = getATAAddressInterface( + const r2AtaAddress = getAssociatedTokenAddressInterface( mint, recipient2.publicKey, ); @@ -518,10 +512,11 @@ describe('Payment Flows', () => { stateTreeInfo, selectTokenPoolInfo(tokenPoolInfos), ); - const senderAta = getATAAddressInterface(mint, sender.publicKey); - await loadATA(rpc, payer, senderAta, sender, mint, undefined, { - tokenPoolInfos, - }); + const senderAta = getAssociatedTokenAddressInterface( + mint, + sender.publicKey, + ); + await loadAta(rpc, senderAta, sender, mint); await mintTo( rpc, @@ -533,27 +528,17 @@ describe('Payment Flows', () => { stateTreeInfo, selectTokenPoolInfo(tokenPoolInfos), ); - const recipientAta = getATAAddressInterface( + const recipientAta = getAssociatedTokenAddressInterface( mint, recipient.publicKey, ); - await loadATA( - rpc, - payer, - recipientAta, - recipient, - mint, - undefined, - { - tokenPoolInfos, - }, - ); + await loadAta(rpc, recipientAta, recipient, mint); - const senderAtaAddress = getATAAddressInterface( + const senderAtaAddress = getAssociatedTokenAddressInterface( mint, sender.publicKey, ); - const recipientAtaAddress = getATAAddressInterface( + const recipientAtaAddress = getAssociatedTokenAddressInterface( mint, recipient.publicKey, ); diff --git a/js/compressed-token/tests/e2e/rpc-multi-trees.test.ts b/js/compressed-token/tests/e2e/rpc-multi-trees.test.ts index 2ebcf4702e..254b777cfd 100644 --- a/js/compressed-token/tests/e2e/rpc-multi-trees.test.ts +++ b/js/compressed-token/tests/e2e/rpc-multi-trees.test.ts @@ -43,7 +43,6 @@ describe('rpc-multi-trees', () => { rpc, payer, mintAuthority.publicKey, - null, TEST_TOKEN_DECIMALS, mintKeypair, ) diff --git a/js/compressed-token/tests/e2e/rpc-token-interop.test.ts b/js/compressed-token/tests/e2e/rpc-token-interop.test.ts index d7eb5665e8..6bdcdfc7c1 100644 --- a/js/compressed-token/tests/e2e/rpc-token-interop.test.ts +++ b/js/compressed-token/tests/e2e/rpc-token-interop.test.ts @@ -46,7 +46,6 @@ describe('rpc-interop token', () => { rpc, payer, mintAuthority.publicKey, - null, TEST_TOKEN_DECIMALS, mintKeypair, ) @@ -260,7 +259,6 @@ describe('rpc-interop token', () => { rpc, payer, mintAuthority.publicKey, - null, TEST_TOKEN_DECIMALS, ) ).mint; diff --git a/js/compressed-token/tests/e2e/transfer-delegated.test.ts b/js/compressed-token/tests/e2e/transfer-delegated.test.ts index 518fa3dfa3..be2d66d2da 100644 --- a/js/compressed-token/tests/e2e/transfer-delegated.test.ts +++ b/js/compressed-token/tests/e2e/transfer-delegated.test.ts @@ -190,7 +190,6 @@ describe('transferDelegated', () => { rpc, payer, mintAuthority.publicKey, - null, TEST_TOKEN_DECIMALS, mintKeypair, ) @@ -252,7 +251,6 @@ describe('transferDelegated', () => { rpc, payer, mintAuthority.publicKey, - null, TEST_TOKEN_DECIMALS, newMintKeypair, ) @@ -327,7 +325,6 @@ describe('transferDelegated', () => { rpc, payer, mintAuthority.publicKey, - null, TEST_TOKEN_DECIMALS, newMintKeypair, ) diff --git a/js/compressed-token/tests/e2e/transfer-interface.test.ts b/js/compressed-token/tests/e2e/transfer-interface.test.ts index 193ea33f37..90e03a5266 100644 --- a/js/compressed-token/tests/e2e/transfer-interface.test.ts +++ b/js/compressed-token/tests/e2e/transfer-interface.test.ts @@ -18,17 +18,17 @@ import { selectTokenPoolInfo, TokenPoolInfo, } from '../../src/utils/get-token-pool-infos'; -import { getATAAddressInterface } from '../../src/mint/actions/create-ata-interface'; -import { getOrCreateATAInterface } from '../../src/mint/actions/get-or-create-ata-interface'; -import { transferInterface } from '../../src/mint/actions/transfer-interface'; +import { getAssociatedTokenAddressInterface } from '../../src/v3/get-associated-token-address-interface'; +import { getOrCreateAtaInterface } from '../../src/v3/actions/get-or-create-ata-interface'; +import { transferInterface } from '../../src/v3/actions/transfer-interface'; import { - loadATA, - createLoadATAInstructions, -} from '../../src/compressible/unified-load'; + loadAta, + createLoadAtaInstructions, +} from '../../src/v3/actions/load-ata'; import { createTransferInterfaceInstruction, createCTokenTransferInstruction, -} from '../../src/mint/instructions/transfer-interface'; +} from '../../src/v3/instructions/transfer-interface'; featureFlags.version = VERSION.V2; @@ -54,7 +54,6 @@ describe('transfer-interface', () => { rpc, payer, mintAuthority.publicKey, - null, TEST_TOKEN_DECIMALS, mintKeypair, ) @@ -122,17 +121,20 @@ describe('transfer-interface', () => { }); }); - describe('createLoadATAInstructions', () => { + describe('createLoadAtaInstructions', () => { it('should return empty when no balances to load (idempotent)', async () => { const owner = Keypair.generate(); - const ata = getATAAddressInterface(mint, owner.publicKey); + const ata = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); - const ixs = await createLoadATAInstructions( + const ixs = await createLoadAtaInstructions( rpc, - payer.publicKey, ata, owner.publicKey, mint, + payer.publicKey, ); expect(ixs.length).toBe(0); @@ -153,14 +155,15 @@ describe('transfer-interface', () => { selectTokenPoolInfo(tokenPoolInfos), ); - const ata = getATAAddressInterface(mint, owner.publicKey); - const ixs = await createLoadATAInstructions( + const ata = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); + const ixs = await createLoadAtaInstructions( rpc, - payer.publicKey, ata, - owner.publicKey, + payer.publicKey, mint, - { tokenPoolInfos }, ); expect(ixs.length).toBeGreaterThan(0); @@ -191,26 +194,30 @@ describe('transfer-interface', () => { selectTokenPoolInfo(tokenPoolInfos), ); - const ata = getATAAddressInterface(mint, owner.publicKey); - const ixs = await createLoadATAInstructions( + const ata = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); + const ixs = await createLoadAtaInstructions( rpc, - payer.publicKey, ata, - owner.publicKey, + payer.publicKey, mint, - { tokenPoolInfos }, ); expect(ixs.length).toBeGreaterThan(0); }); }); - describe('loadATA action', () => { + describe('loadAta action', () => { it('should return null when nothing to load (idempotent)', async () => { const owner = await newAccountWithLamports(rpc, 1e9); - const ata = getATAAddressInterface(mint, owner.publicKey); + const ata = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); - const signature = await loadATA(rpc, payer, ata, owner, mint); + const signature = await loadAta(rpc, ata, owner, mint); expect(signature).toBeNull(); }); @@ -230,24 +237,20 @@ describe('transfer-interface', () => { selectTokenPoolInfo(tokenPoolInfos), ); - const ata = getATAAddressInterface(mint, owner.publicKey); - const signature = await loadATA( - rpc, - payer, - ata, - owner, + const ata = getAssociatedTokenAddressInterface( mint, - undefined, - { - tokenPoolInfos, - }, + owner.publicKey, ); + const signature = await loadAta(rpc, ata, owner, mint); expect(signature).not.toBeNull(); expect(typeof signature).toBe('string'); // Verify hot balance increased - const ctokenAta = getATAAddressInterface(mint, owner.publicKey); + const ctokenAta = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); const ataInfo = await rpc.getAccountInfo(ctokenAta); expect(ataInfo).not.toBeNull(); const hotBalance = ataInfo!.data.readBigUInt64LE(64); @@ -271,29 +274,33 @@ describe('transfer-interface', () => { stateTreeInfo, selectTokenPoolInfo(tokenPoolInfos), ); - const senderAta = getATAAddressInterface(mint, sender.publicKey); - await loadATA(rpc, payer, senderAta, sender, mint, undefined, { - tokenPoolInfos, - }); + const senderAta = getAssociatedTokenAddressInterface( + mint, + sender.publicKey, + ); + await loadAta(rpc, senderAta, sender, mint); // Create recipient ATA first (like SPL Token flow) - const recipientAta = await getOrCreateATAInterface( + const recipientAta = await getOrCreateAtaInterface( rpc, payer, mint, recipient.publicKey, ); - const sourceAta = getATAAddressInterface(mint, sender.publicKey); + const sourceAta = getAssociatedTokenAddressInterface( + mint, + sender.publicKey, + ); // Transfer - destination is ATA address const signature = await transferInterface( rpc, payer, sourceAta, - recipientAta.address, - sender, mint, + recipientAta.parsed.address, + sender, BigInt(1000), ); @@ -305,7 +312,7 @@ describe('transfer-interface', () => { expect(senderBalance).toBe(BigInt(4000)); const recipientAtaInfo = await rpc.getAccountInfo( - recipientAta.address, + recipientAta.parsed.address, ); const recipientBalance = recipientAtaInfo!.data.readBigUInt64LE(64); expect(recipientBalance).toBe(BigInt(1000)); @@ -328,34 +335,37 @@ describe('transfer-interface', () => { ); // Create recipient ATA first - const recipientAta = await getOrCreateATAInterface( + const recipientAta = await getOrCreateAtaInterface( rpc, payer, mint, recipient.publicKey, ); - const sourceAta = getATAAddressInterface(mint, sender.publicKey); + const sourceAta = getAssociatedTokenAddressInterface( + mint, + sender.publicKey, + ); // Transfer should auto-load sender's cold balance const signature = await transferInterface( rpc, payer, sourceAta, - recipientAta.address, - sender, mint, + recipientAta.parsed.address, + sender, BigInt(2000), CTOKEN_PROGRAM_ID, undefined, - { tokenPoolInfos }, + { splInterfaceInfos: tokenPoolInfos }, ); expect(signature).toBeDefined(); // Verify recipient received tokens const recipientAtaInfo = await rpc.getAccountInfo( - recipientAta.address, + recipientAta.parsed.address, ); const recipientBalance = recipientAtaInfo!.data.readBigUInt64LE(64); expect(recipientBalance).toBe(BigInt(2000)); @@ -371,7 +381,7 @@ describe('transfer-interface', () => { const recipient = Keypair.generate(); const wrongSource = Keypair.generate().publicKey; - const recipientAta = await getOrCreateATAInterface( + const recipientAta = await getOrCreateAtaInterface( rpc, payer, mint, @@ -383,9 +393,9 @@ describe('transfer-interface', () => { rpc, payer, wrongSource, - recipientAta.address, - sender, mint, + recipientAta.parsed.address, + sender, BigInt(100), ), ).rejects.toThrow('Source mismatch'); @@ -407,27 +417,30 @@ describe('transfer-interface', () => { selectTokenPoolInfo(tokenPoolInfos), ); - const recipientAta = await getOrCreateATAInterface( + const recipientAta = await getOrCreateAtaInterface( rpc, payer, mint, recipient.publicKey, ); - const sourceAta = getATAAddressInterface(mint, sender.publicKey); + const sourceAta = getAssociatedTokenAddressInterface( + mint, + sender.publicKey, + ); await expect( transferInterface( rpc, payer, sourceAta, - recipientAta.address, - sender, mint, + recipientAta.parsed.address, + sender, BigInt(99999), CTOKEN_PROGRAM_ID, undefined, - { tokenPoolInfos }, + { splInterfaceInfos: tokenPoolInfos }, ), ).rejects.toThrow('Insufficient balance'); }); @@ -447,10 +460,11 @@ describe('transfer-interface', () => { stateTreeInfo, selectTokenPoolInfo(tokenPoolInfos), ); - const senderAta2 = getATAAddressInterface(mint, sender.publicKey); - await loadATA(rpc, payer, senderAta2, sender, mint, undefined, { - tokenPoolInfos, - }); + const senderAta2 = getAssociatedTokenAddressInterface( + mint, + sender.publicKey, + ); + await loadAta(rpc, senderAta2, sender, mint); // Setup recipient with existing ATA and balance await mintTo( @@ -463,24 +477,30 @@ describe('transfer-interface', () => { stateTreeInfo, selectTokenPoolInfo(tokenPoolInfos), ); - const recipientAta2 = getATAAddressInterface( + const recipientAta2 = getAssociatedTokenAddressInterface( mint, recipient.publicKey, ); - await loadATA( + await loadAta( rpc, - payer, recipientAta2, recipient, mint, undefined, + undefined, { - tokenPoolInfos, + splInterfaceInfos: tokenPoolInfos, }, ); - const sourceAta = getATAAddressInterface(mint, sender.publicKey); - const destAta = getATAAddressInterface(mint, recipient.publicKey); + const sourceAta = getAssociatedTokenAddressInterface( + mint, + sender.publicKey, + ); + const destAta = getAssociatedTokenAddressInterface( + mint, + recipient.publicKey, + ); const recipientBalanceBefore = (await rpc.getAccountInfo( destAta, @@ -491,9 +511,9 @@ describe('transfer-interface', () => { rpc, payer, sourceAta, + mint, destAta, sender, - mint, BigInt(500), ); diff --git a/js/compressed-token/tests/e2e/transfer.test.ts b/js/compressed-token/tests/e2e/transfer.test.ts index 69386d2b8f..98379e5174 100644 --- a/js/compressed-token/tests/e2e/transfer.test.ts +++ b/js/compressed-token/tests/e2e/transfer.test.ts @@ -114,7 +114,6 @@ describe('transfer', () => { rpc, payer, mintAuthority.publicKey, - null, TEST_TOKEN_DECIMALS, mintKeypair, ) @@ -153,7 +152,6 @@ describe('transfer', () => { bob, charlie.publicKey, ); - console.log('txid transfer ', txid); await assertTransfer( rpc, bobPreCompressedTokenAccounts, @@ -179,7 +177,6 @@ describe('transfer', () => { bob, charlie.publicKey, ); - console.log('txid transfer 2 ', txid2); await assertTransfer( rpc, bobPreCompressedTokenAccounts2.items, @@ -244,7 +241,6 @@ describe('transfer', () => { rpc, payer, mintAuthority.publicKey, - null, TEST_TOKEN_DECIMALS, mintKeypair, undefined, @@ -312,7 +308,6 @@ describe('e2e transfer with multiple accounts', () => { rpc, payer, mintAuthority.publicKey, - null, 9, mintKeypair, ) diff --git a/js/compressed-token/tests/e2e/unwrap.test.ts b/js/compressed-token/tests/e2e/unwrap.test.ts new file mode 100644 index 0000000000..6c0845202a --- /dev/null +++ b/js/compressed-token/tests/e2e/unwrap.test.ts @@ -0,0 +1,539 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { Keypair, Signer, PublicKey } from '@solana/web3.js'; +import { + Rpc, + bn, + newAccountWithLamports, + getTestRpc, + selectStateTreeInfo, + TreeInfo, + VERSION, + featureFlags, +} from '@lightprotocol/stateless.js'; +import { WasmFactory } from '@lightprotocol/hasher.rs'; +import { createMint, mintTo } from '../../src/actions'; +import { + getAssociatedTokenAddressSync, + TOKEN_PROGRAM_ID, + TOKEN_2022_PROGRAM_ID, + getAccount, + createAssociatedTokenAccount, +} from '@solana/spl-token'; +import { + getTokenPoolInfos, + selectTokenPoolInfo, + TokenPoolInfo, +} from '../../src/utils/get-token-pool-infos'; +import { createUnwrapInstruction } from '../../src/v3/instructions/unwrap'; +import { unwrap } from '../../src/v3/actions/unwrap'; +import { getAssociatedTokenAddressInterface } from '../../src'; +import { createAtaInterfaceIdempotent } from '../../src/v3/actions/create-ata-interface'; +import { getAtaProgramId } from '../../src/v3/ata-utils'; + +featureFlags.version = VERSION.V2; + +const TEST_TOKEN_DECIMALS = 9; + +async function getCTokenBalance(rpc: Rpc, address: PublicKey): Promise { + const accountInfo = await rpc.getAccountInfo(address); + if (!accountInfo) { + return BigInt(0); + } + return accountInfo.data.readBigUInt64LE(64); +} + +describe('createUnwrapInstruction', () => { + let rpc: Rpc; + let payer: Signer; + let mint: PublicKey; + let mintAuthority: Keypair; + let stateTreeInfo: 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; + + stateTreeInfo = selectStateTreeInfo(await rpc.getStateTreeInfos()); + tokenPoolInfos = await getTokenPoolInfos(rpc, mint); + }, 60_000); + + it('should create valid instruction with all required params', async () => { + const owner = Keypair.generate(); + const source = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); + const destination = getAssociatedTokenAddressSync( + mint, + owner.publicKey, + false, + TOKEN_PROGRAM_ID, + ); + + const tokenPoolInfo = tokenPoolInfos.find(info => info.isInitialized); + expect(tokenPoolInfo).toBeDefined(); + + const ix = createUnwrapInstruction( + source, + destination, + owner.publicKey, + mint, + BigInt(1000), + tokenPoolInfo!, + ); + + expect(ix).toBeDefined(); + expect(ix.programId).toBeDefined(); + expect(ix.keys.length).toBeGreaterThan(0); + expect(ix.data.length).toBeGreaterThan(0); + }); + + it('should create instruction with explicit payer', async () => { + const owner = Keypair.generate(); + const feePayer = Keypair.generate(); + const source = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); + const destination = getAssociatedTokenAddressSync( + mint, + owner.publicKey, + false, + TOKEN_PROGRAM_ID, + ); + + const tokenPoolInfo = tokenPoolInfos.find(info => info.isInitialized); + + const ix = createUnwrapInstruction( + source, + destination, + owner.publicKey, + mint, + BigInt(500), + tokenPoolInfo!, + feePayer.publicKey, + ); + + expect(ix).toBeDefined(); + const payerKey = ix.keys.find( + k => k.pubkey.equals(feePayer.publicKey) && k.isSigner, + ); + expect(payerKey).toBeDefined(); + }); +}); + +describe('unwrap action', () => { + let rpc: Rpc; + let payer: Signer; + let mint: PublicKey; + let mintAuthority: Keypair; + let stateTreeInfo: 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; + + stateTreeInfo = selectStateTreeInfo(await rpc.getStateTreeInfos()); + tokenPoolInfos = await getTokenPoolInfos(rpc, mint); + }, 60_000); + + it('should unwrap c-tokens to SPL ATA (from cold)', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + // Mint compressed tokens (cold) + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(1000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + // Create destination SPL ATA first (SPL pattern) + const splAta = await createAssociatedTokenAccount( + rpc, + payer, + mint, + owner.publicKey, + undefined, + TOKEN_PROGRAM_ID, + ); + + // Unwrap to SPL (should consolidate cold -> hot first, then unwrap) + const result = await unwrap( + rpc, + payer, + owner, + mint, + splAta, + BigInt(500), + ); + + expect(result.transactionSignature).toBeDefined(); + + // Check SPL ATA balance + const splBalance = await getAccount(rpc, splAta); + expect(splBalance.amount).toBe(BigInt(500)); + + // Check remaining c-token balance + const ctokenAta = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); + const ctokenBalance = await getCTokenBalance(rpc, ctokenAta); + expect(ctokenBalance).toBe(BigInt(500)); + }, 60_000); + + it('should unwrap c-tokens to SPL ATA (from hot)', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + // Create c-token ATA and mint to hot + const ctokenAta = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); + await createAtaInterfaceIdempotent(rpc, payer, mint, owner.publicKey); + + // Mint compressed tokens + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(800), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + // Load to hot first + const { loadAta } = await import('../../src/v3/actions/load-ata'); + await loadAta(rpc, ctokenAta, owner, mint, payer); + + // Verify hot balance + const hotBalanceBefore = await getCTokenBalance(rpc, ctokenAta); + expect(hotBalanceBefore).toBe(BigInt(800)); + + // Create destination SPL ATA first (SPL pattern) + const splAta = await createAssociatedTokenAccount( + rpc, + payer, + mint, + owner.publicKey, + undefined, + TOKEN_PROGRAM_ID, + ); + + // Unwrap partial + const result = await unwrap( + rpc, + payer, + owner, + mint, + splAta, + BigInt(300), + ); + + expect(result.transactionSignature).toBeDefined(); + + // Check SPL balance + const splBalance = await getAccount(rpc, splAta); + expect(splBalance.amount).toBe(BigInt(300)); + + // Check remaining c-token balance + const ctokenBalanceAfter = await getCTokenBalance(rpc, ctokenAta); + expect(ctokenBalanceAfter).toBe(BigInt(500)); + }, 60_000); + + it('should unwrap full balance when amount not specified', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + // Mint compressed tokens + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(600), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + // Create destination SPL ATA first (SPL pattern) + const splAta = await createAssociatedTokenAccount( + rpc, + payer, + mint, + owner.publicKey, + undefined, + TOKEN_PROGRAM_ID, + ); + + // Unwrap all (amount not specified) + const result = await unwrap(rpc, payer, owner, mint, splAta); + + expect(result.transactionSignature).toBeDefined(); + + // Check SPL balance + const splBalance = await getAccount(rpc, splAta); + expect(splBalance.amount).toBe(BigInt(600)); + + // c-token should be empty + const ctokenAta = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); + const ctokenBalance = await getCTokenBalance(rpc, ctokenAta); + expect(ctokenBalance).toBe(BigInt(0)); + }, 60_000); + + it('should auto-fetch SPL interface info when not provided', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + // Mint compressed tokens + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(400), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + // Create destination SPL ATA first (SPL pattern) + const splAta = await createAssociatedTokenAccount( + rpc, + payer, + mint, + owner.publicKey, + undefined, + TOKEN_PROGRAM_ID, + ); + + // Unwrap without providing splInterfaceInfo + const result = await unwrap( + rpc, + payer, + owner, + mint, + splAta, + BigInt(200), + ); + + expect(result.transactionSignature).toBeDefined(); + + const splBalance = await getAccount(rpc, splAta); + expect(splBalance.amount).toBe(BigInt(200)); + }, 60_000); + + it('should work with different owners and payers', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + const separatePayer = await newAccountWithLamports(rpc, 1e9); + + // Mint compressed tokens + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(500), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + // Create destination SPL ATA first (SPL pattern) + const splAta = await createAssociatedTokenAccount( + rpc, + payer, + mint, + owner.publicKey, + undefined, + TOKEN_PROGRAM_ID, + ); + + // Unwrap with separate payer + const result = await unwrap( + rpc, + separatePayer, + owner, + mint, + splAta, + BigInt(250), + ); + + expect(result.transactionSignature).toBeDefined(); + + const splBalance = await getAccount(rpc, splAta); + expect(splBalance.amount).toBe(BigInt(250)); + }, 60_000); + + it('should throw error when insufficient balance', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + // Mint small amount + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(100), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + // Create destination SPL ATA first (SPL pattern) + const splAta = await createAssociatedTokenAccount( + rpc, + payer, + mint, + owner.publicKey, + undefined, + TOKEN_PROGRAM_ID, + ); + + // Try to unwrap more than available + await expect( + unwrap(rpc, payer, owner, mint, splAta, BigInt(1000)), + ).rejects.toThrow(/Insufficient/); + }, 60_000); + + it('should throw error when destination does not exist', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + + // Mint compressed tokens + await mintTo( + rpc, + payer, + mint, + owner.publicKey, + mintAuthority, + bn(100), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + // Derive but don't create SPL ATA + const splAta = getAssociatedTokenAddressSync( + mint, + owner.publicKey, + false, + TOKEN_PROGRAM_ID, + ); + + // Try to unwrap to non-existent destination + await expect( + unwrap(rpc, payer, owner, mint, splAta, BigInt(50)), + ).rejects.toThrow(/does not exist/); + }, 60_000); +}); + +describe('unwrap Token-2022', () => { + let rpc: Rpc; + let payer: Signer; + let stateTreeInfo: TreeInfo; + + beforeAll(async () => { + const lightWasm = await WasmFactory.getInstance(); + rpc = await getTestRpc(lightWasm); + payer = await newAccountWithLamports(rpc, 10e9); + stateTreeInfo = selectStateTreeInfo(await rpc.getStateTreeInfos()); + }, 60_000); + + it('should unwrap c-tokens to Token-2022 ATA', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + const mintAuthority = Keypair.generate(); + + // Create T22 mint + const mintKeypair = Keypair.generate(); + const { mint: t22Mint } = await createMint( + rpc, + payer, + mintAuthority.publicKey, + TEST_TOKEN_DECIMALS, + mintKeypair, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + + const tokenPoolInfos = await getTokenPoolInfos(rpc, t22Mint); + + // Mint compressed tokens + await mintTo( + rpc, + payer, + t22Mint, + owner.publicKey, + mintAuthority, + bn(1000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + // Create destination T22 ATA first (SPL pattern) + const t22Ata = await createAssociatedTokenAccount( + rpc, + payer, + t22Mint, + owner.publicKey, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + + // Unwrap to T22 + const result = await unwrap( + rpc, + payer, + owner, + t22Mint, + t22Ata, + BigInt(500), + ); + + expect(result.transactionSignature).toBeDefined(); + + // Check T22 ATA balance + const t22Balance = await getAccount( + rpc, + t22Ata, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + expect(t22Balance.amount).toBe(BigInt(500)); + }, 90_000); +}); diff --git a/js/compressed-token/tests/e2e/update-metadata.test.ts b/js/compressed-token/tests/e2e/update-metadata.test.ts index 2c527cf6db..53d7192000 100644 --- a/js/compressed-token/tests/e2e/update-metadata.test.ts +++ b/js/compressed-token/tests/e2e/update-metadata.test.ts @@ -9,18 +9,15 @@ import { getDefaultAddressTreeInfo, CTOKEN_PROGRAM_ID, } from '@lightprotocol/stateless.js'; -import { - createMintInterface, - updateMintAuthority, -} from '../../src/mint/actions'; -import { createTokenMetadata } from '../../src/mint/instructions'; +import { createMintInterface, updateMintAuthority } from '../../src/v3/actions'; +import { createTokenMetadata } from '../../src/v3/instructions'; import { updateMetadataField, updateMetadataAuthority, removeMetadataKey, -} from '../../src/mint/actions/update-metadata'; -import { getMintInterface } from '../../src/mint/helpers'; -import { findMintAddress } from '../../src/compressible/derivation'; +} from '../../src/v3/actions/update-metadata'; +import { getMintInterface } from '../../src/v3/get-mint-interface'; +import { findMintAddress } from '../../src/v3/derivation'; featureFlags.version = VERSION.V2; @@ -54,9 +51,9 @@ describe('updateMetadata', () => { null, decimals, mintSigner, - initialMetadata, - addressTreeInfo, undefined, + undefined, + initialMetadata, ); await rpc.confirmTransaction(createSig, 'confirmed'); @@ -72,7 +69,6 @@ describe('updateMetadata', () => { rpc, payer, mintPda, - mintSigner, mintAuthority, 'name', 'Updated Token', @@ -113,9 +109,9 @@ describe('updateMetadata', () => { null, decimals, mintSigner, - initialMetadata, - addressTreeInfo, undefined, + undefined, + initialMetadata, ); await rpc.confirmTransaction(createSig, 'confirmed'); @@ -123,7 +119,6 @@ describe('updateMetadata', () => { rpc, payer, mintPda, - mintSigner, mintAuthority, 'symbol', 'UPDATED', @@ -161,9 +156,9 @@ describe('updateMetadata', () => { null, decimals, mintSigner, - initialMetadata, - addressTreeInfo, undefined, + undefined, + initialMetadata, ); await rpc.confirmTransaction(createSig, 'confirmed'); @@ -171,7 +166,6 @@ describe('updateMetadata', () => { rpc, payer, mintPda, - mintSigner, mintAuthority, 'uri', 'https://new.com/metadata', @@ -212,9 +206,9 @@ describe('updateMetadata', () => { null, decimals, mintSigner, - initialMetadata, - addressTreeInfo, undefined, + undefined, + initialMetadata, ); await rpc.confirmTransaction(createSig, 'confirmed'); @@ -232,7 +226,6 @@ describe('updateMetadata', () => { rpc, payer, mintPda, - mintSigner, initialMetadataAuthority, newMetadataAuthority.publicKey, ); @@ -270,9 +263,9 @@ describe('updateMetadata', () => { null, decimals, mintSigner, - initialMetadata, - addressTreeInfo, undefined, + undefined, + initialMetadata, ); await rpc.confirmTransaction(createSig, 'confirmed'); @@ -280,7 +273,6 @@ describe('updateMetadata', () => { rpc, payer, mintPda, - mintSigner, mintAuthority, 'name', 'New Name', @@ -299,7 +291,6 @@ describe('updateMetadata', () => { rpc, payer, mintPda, - mintSigner, mintAuthority, 'symbol', 'NEW', @@ -319,7 +310,6 @@ describe('updateMetadata', () => { rpc, payer, mintPda, - mintSigner, mintAuthority, 'uri', 'https://updated.com', @@ -359,9 +349,9 @@ describe('updateMetadata', () => { null, decimals, mintSigner, - initialMetadata, - addressTreeInfo, undefined, + undefined, + initialMetadata, ); await rpc.confirmTransaction(createSig, 'confirmed'); @@ -370,7 +360,6 @@ describe('updateMetadata', () => { rpc, payer, mintPda, - mintSigner, wrongAuthority, 'name', 'Hacked Name', @@ -394,9 +383,6 @@ describe('updateMetadata', () => { null, decimals, mintSigner, - undefined, - addressTreeInfo, - undefined, ); await rpc.confirmTransaction(createSig, 'confirmed'); @@ -405,7 +391,6 @@ describe('updateMetadata', () => { rpc, payer, mintPda, - mintSigner, wrongAuthority, newAuthority.publicKey, ), @@ -433,9 +418,9 @@ describe('updateMetadata', () => { null, decimals, mintSigner, - metadata, - addressTreeInfo, undefined, + undefined, + metadata, ); await rpc.confirmTransaction(createSig, 'confirmed'); @@ -443,7 +428,6 @@ describe('updateMetadata', () => { rpc, payer, mintPda, - mintSigner, mintAuthority, 'custom_key', true, @@ -480,9 +464,9 @@ describe('updateMetadata', () => { null, decimals, mintSigner, - metadata, - addressTreeInfo, undefined, + undefined, + metadata, ); await rpc.confirmTransaction(createSig, 'confirmed'); @@ -490,7 +474,6 @@ describe('updateMetadata', () => { rpc, payer, mintPda, - mintSigner, mintAuthority, 'name', 'Updated by Mint Authority', diff --git a/js/compressed-token/tests/e2e/update-mint.test.ts b/js/compressed-token/tests/e2e/update-mint.test.ts index e354f69389..27bc970a17 100644 --- a/js/compressed-token/tests/e2e/update-mint.test.ts +++ b/js/compressed-token/tests/e2e/update-mint.test.ts @@ -9,13 +9,13 @@ import { getDefaultAddressTreeInfo, CTOKEN_PROGRAM_ID, } from '@lightprotocol/stateless.js'; -import { createMintInterface } from '../../src/mint/actions'; +import { createMintInterface } from '../../src/v3/actions'; import { updateMintAuthority, updateFreezeAuthority, -} from '../../src/mint/actions/update-mint'; -import { getMintInterface } from '../../src/mint/helpers'; -import { findMintAddress } from '../../src/compressible/derivation'; +} from '../../src/v3/actions/update-mint'; +import { getMintInterface } from '../../src/v3/get-mint-interface'; +import { findMintAddress } from '../../src/v3/derivation'; featureFlags.version = VERSION.V2; @@ -43,9 +43,6 @@ describe('updateMint', () => { null, decimals, mintSigner, - undefined, - addressTreeInfo, - undefined, ); await rpc.confirmTransaction(createSig, 'confirmed'); @@ -63,7 +60,6 @@ describe('updateMint', () => { rpc, payer, mintPda, - mintSigner, initialMintAuthority, newMintAuthority.publicKey, ); @@ -96,9 +92,6 @@ describe('updateMint', () => { null, decimals, mintSigner, - undefined, - addressTreeInfo, - undefined, ); await rpc.confirmTransaction(createSig, 'confirmed'); @@ -106,7 +99,6 @@ describe('updateMint', () => { rpc, payer, mintPda, - mintSigner, mintAuthority, null, ); @@ -138,9 +130,6 @@ describe('updateMint', () => { initialFreezeAuthority.publicKey, decimals, mintSigner, - undefined, - addressTreeInfo, - undefined, ); await rpc.confirmTransaction(createSig, 'confirmed'); @@ -158,7 +147,6 @@ describe('updateMint', () => { rpc, payer, mintPda, - mintSigner, initialFreezeAuthority, newFreezeAuthority.publicKey, ); @@ -193,9 +181,6 @@ describe('updateMint', () => { freezeAuthority.publicKey, decimals, mintSigner, - undefined, - addressTreeInfo, - undefined, ); await rpc.confirmTransaction(createSig, 'confirmed'); @@ -203,7 +188,6 @@ describe('updateMint', () => { rpc, payer, mintPda, - mintSigner, freezeAuthority, null, ); @@ -238,9 +222,6 @@ describe('updateMint', () => { initialFreezeAuthority.publicKey, decimals, mintSigner, - undefined, - addressTreeInfo, - undefined, ); await rpc.confirmTransaction(createSig, 'confirmed'); @@ -248,7 +229,6 @@ describe('updateMint', () => { rpc, payer, mintPda, - mintSigner, initialMintAuthority, newMintAuthority.publicKey, ); @@ -268,7 +248,6 @@ describe('updateMint', () => { rpc, payer, mintPda, - mintSigner, initialFreezeAuthority, newFreezeAuthority.publicKey, ); diff --git a/js/compressed-token/tests/e2e/wrap.test.ts b/js/compressed-token/tests/e2e/wrap.test.ts index 7d7e592f70..8424317a0e 100644 --- a/js/compressed-token/tests/e2e/wrap.test.ts +++ b/js/compressed-token/tests/e2e/wrap.test.ts @@ -15,8 +15,10 @@ import { WasmFactory } from '@lightprotocol/hasher.rs'; import { createMint, mintTo, decompress } from '../../src/actions'; import { createAssociatedTokenAccount, + getOrCreateAssociatedTokenAccount, getAssociatedTokenAddressSync, TOKEN_PROGRAM_ID, + TOKEN_2022_PROGRAM_ID, getAccount, } from '@solana/spl-token'; @@ -35,12 +37,10 @@ import { selectTokenPoolInfosForDecompression, TokenPoolInfo, } from '../../src/utils/get-token-pool-infos'; -import { createWrapInstruction } from '../../src/mint/instructions/wrap'; -import { wrap } from '../../src/mint/actions/wrap'; -import { - getATAAddressInterface, - createATAInterfaceIdempotent, -} from '../../src/mint/actions/create-ata-interface'; +import { createWrapInstruction } from '../../src/v3/instructions/wrap'; +import { wrap } from '../../src/v3/actions/wrap'; +import { getAssociatedTokenAddressInterface } from '../../src'; +import { createAtaInterfaceIdempotent } from '../../src/v3/actions/create-ata-interface'; // Force V2 for CToken tests featureFlags.version = VERSION.V2; @@ -68,7 +68,6 @@ describe('createWrapInstruction', () => { rpc, payer, mintAuthority.publicKey, - null, TEST_TOKEN_DECIMALS, mintKeypair, ) @@ -86,7 +85,10 @@ describe('createWrapInstruction', () => { false, TOKEN_PROGRAM_ID, ); - const destination = getATAAddressInterface(mint, owner.publicKey); + const destination = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); const tokenPoolInfo = tokenPoolInfos.find(info => info.isInitialized); expect(tokenPoolInfo).toBeDefined(); @@ -115,7 +117,10 @@ describe('createWrapInstruction', () => { false, TOKEN_PROGRAM_ID, ); - const destination = getATAAddressInterface(mint, owner.publicKey); + const destination = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); const tokenPoolInfo = tokenPoolInfos.find(info => info.isInitialized); @@ -145,7 +150,10 @@ describe('createWrapInstruction', () => { false, TOKEN_PROGRAM_ID, ); - const destination = getATAAddressInterface(mint, owner.publicKey); + const destination = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); const tokenPoolInfo = tokenPoolInfos.find(info => info.isInitialized); @@ -189,7 +197,6 @@ describe('wrap action', () => { rpc, payer, mintAuthority.publicKey, - null, TEST_TOKEN_DECIMALS, mintKeypair, ) @@ -234,8 +241,11 @@ describe('wrap action', () => { ); // Create CToken ATA - const ctokenAta = getATAAddressInterface(mint, owner.publicKey); - await createATAInterfaceIdempotent(rpc, payer, mint, owner.publicKey); + const ctokenAta = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); + await createAtaInterfaceIdempotent(rpc, payer, mint, owner.publicKey); // Check initial balances const splBalanceBefore = await getAccount(rpc, splAta); @@ -300,8 +310,11 @@ describe('wrap action', () => { ); // Create CToken ATA - const ctokenAta = getATAAddressInterface(mint, owner.publicKey); - await createATAInterfaceIdempotent(rpc, payer, mint, owner.publicKey); + const ctokenAta = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); + await createAtaInterfaceIdempotent(rpc, payer, mint, owner.publicKey); // Wrap full balance tokenPoolInfos = await getTokenPoolInfos(rpc, mint); @@ -362,8 +375,11 @@ describe('wrap action', () => { selectTokenPoolInfosForDecompression(tokenPoolInfos, bn(200)), ); - const ctokenAta = getATAAddressInterface(mint, owner.publicKey); - await createATAInterfaceIdempotent(rpc, payer, mint, owner.publicKey); + const ctokenAta = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); + await createAtaInterfaceIdempotent(rpc, payer, mint, owner.publicKey); // Wrap without providing tokenPoolInfo - should fetch automatically const result = await wrap( @@ -430,8 +446,11 @@ describe('wrap action', () => { selectTokenPoolInfosForDecompression(tokenPoolInfos, bn(300)), ); - const ctokenAta = getATAAddressInterface(mint, owner.publicKey); - await createATAInterfaceIdempotent(rpc, payer, mint, owner.publicKey); + const ctokenAta = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); + await createAtaInterfaceIdempotent(rpc, payer, mint, owner.publicKey); // Wrap with separate payer tokenPoolInfos = await getTokenPoolInfos(rpc, mint); @@ -475,7 +494,6 @@ describe('wrap with non-ATA accounts', () => { rpc, payer, mintAuthority.publicKey, - null, TEST_TOKEN_DECIMALS, mintKeypair, ) @@ -490,18 +508,21 @@ describe('wrap with non-ATA accounts', () => { // Explicitly derive ATAs // Note: SPL ATAs use getAssociatedTokenAddressSync - // CToken ATAs use getATAAddressInterface (which defaults to CToken program) + // CToken ATAs use getAssociatedTokenAddressInterface (which defaults to CToken program) const source = getAssociatedTokenAddressSync( mint, owner.publicKey, false, TOKEN_PROGRAM_ID, ); - const destination = getATAAddressInterface(mint, owner.publicKey); + const destination = getAssociatedTokenAddressInterface( + mint, + owner.publicKey, + ); // Setup: Create both ATAs and fund source await createAssociatedTokenAccount(rpc, payer, mint, owner.publicKey); - await createATAInterfaceIdempotent(rpc, payer, mint, owner.publicKey); + await createAtaInterfaceIdempotent(rpc, payer, mint, owner.publicKey); await mintTo( rpc, @@ -546,3 +567,202 @@ describe('wrap with non-ATA accounts', () => { expect(destBalance).toBe(BigInt(200)); }, 60_000); }); + +describe('wrap Token-2022 to CToken', () => { + let rpc: Rpc; + let payer: Signer; + let stateTreeInfo: TreeInfo; + + beforeAll(async () => { + const lightWasm = await WasmFactory.getInstance(); + rpc = await getTestRpc(lightWasm); + payer = await newAccountWithLamports(rpc, 10e9); + stateTreeInfo = selectStateTreeInfo(await rpc.getStateTreeInfos()); + }, 60_000); + + it('should wrap Token-2022 tokens to CToken ATA', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + const mintAuthority = Keypair.generate(); + + // Create T22 mint with token pool via createMint action + const mintKeypair = Keypair.generate(); + const { mint: t22Mint } = await createMint( + rpc, + payer, + mintAuthority.publicKey, + TEST_TOKEN_DECIMALS, + mintKeypair, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + + // Create T22 ATA using getOrCreateAssociatedTokenAccount + const t22AtaAccount = await getOrCreateAssociatedTokenAccount( + rpc, + payer as Keypair, + t22Mint, + owner.publicKey, + false, + 'confirmed', + undefined, + TOKEN_2022_PROGRAM_ID, + ); + const t22Ata = t22AtaAccount.address; + + // Mint compressed then decompress to T22 ATA + const tokenPoolInfos = await getTokenPoolInfos(rpc, t22Mint); + await mintTo( + rpc, + payer, + t22Mint, + owner.publicKey, + mintAuthority, + bn(1000), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const updatedPoolInfos = await getTokenPoolInfos(rpc, t22Mint); + await decompress( + rpc, + payer, + t22Mint, + bn(1000), + owner, + t22Ata, + selectTokenPoolInfosForDecompression(updatedPoolInfos, bn(1000)), + ); + + // Create CToken ATA + const ctokenAta = getAssociatedTokenAddressInterface( + t22Mint, + owner.publicKey, + ); + await createAtaInterfaceIdempotent( + rpc, + payer, + t22Mint, + owner.publicKey, + ); + + // Check initial balances + const t22BalanceBefore = await getAccount( + rpc, + t22Ata, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + expect(t22BalanceBefore.amount).toBe(BigInt(1000)); + + // Wrap tokens + const finalPoolInfos = await getTokenPoolInfos(rpc, t22Mint); + const tokenPoolInfo = finalPoolInfos.find(info => info.isInitialized); + + const result = await wrap( + rpc, + payer, + t22Ata, + ctokenAta, + owner, + t22Mint, + BigInt(500), + tokenPoolInfo, + ); + + expect(result.transactionSignature).toBeDefined(); + + // Check balances after + const t22BalanceAfter = await getAccount( + rpc, + t22Ata, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + expect(t22BalanceAfter.amount).toBe(BigInt(500)); + + const ctokenBalanceAfter = await getCTokenBalance(rpc, ctokenAta); + expect(ctokenBalanceAfter).toBe(BigInt(500)); + }, 90_000); + + it('should auto-fetch SPL interface info for Token-2022 wrap', async () => { + const owner = await newAccountWithLamports(rpc, 1e9); + const mintAuthority = Keypair.generate(); + + // Create T22 mint with token pool + const mintKeypair = Keypair.generate(); + const { mint: t22Mint } = await createMint( + rpc, + payer, + mintAuthority.publicKey, + TEST_TOKEN_DECIMALS, + mintKeypair, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + + // Create T22 ATA using getOrCreateAssociatedTokenAccount + const t22AtaAccount = await getOrCreateAssociatedTokenAccount( + rpc, + payer as Keypair, + t22Mint, + owner.publicKey, + false, + 'confirmed', + undefined, + TOKEN_2022_PROGRAM_ID, + ); + const t22Ata = t22AtaAccount.address; + + const tokenPoolInfos = await getTokenPoolInfos(rpc, t22Mint); + await mintTo( + rpc, + payer, + t22Mint, + owner.publicKey, + mintAuthority, + bn(500), + stateTreeInfo, + selectTokenPoolInfo(tokenPoolInfos), + ); + + const updatedPoolInfos = await getTokenPoolInfos(rpc, t22Mint); + await decompress( + rpc, + payer, + t22Mint, + bn(500), + owner, + t22Ata, + selectTokenPoolInfosForDecompression(updatedPoolInfos, bn(500)), + ); + + // Create CToken ATA + const ctokenAta = getAssociatedTokenAddressInterface( + t22Mint, + owner.publicKey, + ); + await createAtaInterfaceIdempotent( + rpc, + payer, + t22Mint, + owner.publicKey, + ); + + // Wrap without providing tokenPoolInfo - should auto-fetch + const result = await wrap( + rpc, + payer, + t22Ata, + ctokenAta, + owner, + t22Mint, + BigInt(250), + // tokenPoolInfo not provided + ); + + expect(result.transactionSignature).toBeDefined(); + + const ctokenBalance = await getCTokenBalance(rpc, ctokenAta); + expect(ctokenBalance).toBe(BigInt(250)); + }, 90_000); +}); diff --git a/js/compressed-token/tests/unit/derive-token-pool-info.test.ts b/js/compressed-token/tests/unit/derive-token-pool-info.test.ts new file mode 100644 index 0000000000..0b0f4909cb --- /dev/null +++ b/js/compressed-token/tests/unit/derive-token-pool-info.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect } from 'vitest'; +import { Keypair } from '@solana/web3.js'; +import { TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID } from '@solana/spl-token'; +import { bn } from '@lightprotocol/stateless.js'; +import { + // New names + deriveSplInterfaceInfo, + SplInterfaceInfo, + // Deprecated aliases - should still work + deriveTokenPoolInfo, + TokenPoolInfo, +} from '../../src/utils/get-token-pool-infos'; +import { CompressedTokenProgram } from '../../src/program'; + +describe('deriveSplInterfaceInfo', () => { + const mint = Keypair.generate().publicKey; + + it('should derive SplInterfaceInfo for TOKEN_PROGRAM_ID', () => { + const result = deriveSplInterfaceInfo(mint, TOKEN_PROGRAM_ID); + + expect(result.mint.toBase58()).toBe(mint.toBase58()); + expect(result.tokenProgram.toBase58()).toBe( + TOKEN_PROGRAM_ID.toBase58(), + ); + expect(result.isInitialized).toBe(true); + expect(result.balance.eq(bn(0))).toBe(true); + expect(result.poolIndex).toBe(0); + expect(typeof result.bump).toBe('number'); + expect(result.splInterfacePda).toBeDefined(); + }); + + it('should derive SplInterfaceInfo for TOKEN_2022_PROGRAM_ID', () => { + const result = deriveSplInterfaceInfo(mint, TOKEN_2022_PROGRAM_ID); + + expect(result.mint.toBase58()).toBe(mint.toBase58()); + expect(result.tokenProgram.toBase58()).toBe( + TOKEN_2022_PROGRAM_ID.toBase58(), + ); + expect(result.isInitialized).toBe(true); + }); + + it('should derive correct PDA matching CompressedTokenProgram', () => { + const result = deriveSplInterfaceInfo(mint, TOKEN_PROGRAM_ID); + const [expectedPda, expectedBump] = + CompressedTokenProgram.deriveSplInterfacePdaWithIndex(mint, 0); + + expect(result.splInterfacePda.toBase58()).toBe(expectedPda.toBase58()); + expect(result.bump).toBe(expectedBump); + }); + + it('should support different pool indices', () => { + const result0 = deriveSplInterfaceInfo(mint, TOKEN_PROGRAM_ID, 0); + const result1 = deriveSplInterfaceInfo(mint, TOKEN_PROGRAM_ID, 1); + const result2 = deriveSplInterfaceInfo(mint, TOKEN_PROGRAM_ID, 2); + + expect(result0.poolIndex).toBe(0); + expect(result1.poolIndex).toBe(1); + expect(result2.poolIndex).toBe(2); + + // Different indices should produce different PDAs + expect(result0.splInterfacePda.toBase58()).not.toBe( + result1.splInterfacePda.toBase58(), + ); + expect(result1.splInterfacePda.toBase58()).not.toBe( + result2.splInterfacePda.toBase58(), + ); + }); + + it('should match PDA for non-zero pool indices', () => { + for (let i = 0; i < 4; i++) { + const result = deriveSplInterfaceInfo(mint, TOKEN_PROGRAM_ID, i); + const [expectedPda, expectedBump] = + CompressedTokenProgram.deriveSplInterfacePdaWithIndex(mint, i); + + expect(result.splInterfacePda.toBase58()).toBe( + expectedPda.toBase58(), + ); + expect(result.bump).toBe(expectedBump); + expect(result.poolIndex).toBe(i); + } + }); + + it('should be deterministic', () => { + const result1 = deriveSplInterfaceInfo(mint, TOKEN_PROGRAM_ID); + const result2 = deriveSplInterfaceInfo(mint, TOKEN_PROGRAM_ID); + + expect(result1.splInterfacePda.toBase58()).toBe( + result2.splInterfacePda.toBase58(), + ); + expect(result1.bump).toBe(result2.bump); + }); + + it('should produce different PDAs for different mints', () => { + const mint2 = Keypair.generate().publicKey; + + const result1 = deriveSplInterfaceInfo(mint, TOKEN_PROGRAM_ID); + const result2 = deriveSplInterfaceInfo(mint2, TOKEN_PROGRAM_ID); + + expect(result1.splInterfacePda.toBase58()).not.toBe( + result2.splInterfacePda.toBase58(), + ); + }); + + it('should have activity undefined', () => { + const result = deriveSplInterfaceInfo(mint, TOKEN_PROGRAM_ID); + expect(result.activity).toBeUndefined(); + }); +}); + +describe('deprecated aliases', () => { + const mint = Keypair.generate().publicKey; + + it('deriveTokenPoolInfo should work as alias for deriveSplInterfaceInfo', () => { + // Test that old function name still works + const result: TokenPoolInfo = deriveTokenPoolInfo( + mint, + TOKEN_PROGRAM_ID, + ); + + expect(result.mint.toBase58()).toBe(mint.toBase58()); + expect(result.isInitialized).toBe(true); + expect(result.balance.eq(bn(0))).toBe(true); + // splInterfacePda should be accessible (type is aliased) + expect(result.splInterfacePda).toBeDefined(); + }); + + it('TokenPoolInfo type should be alias for SplInterfaceInfo', () => { + // Both types should work for the same result + const newResult: SplInterfaceInfo = deriveSplInterfaceInfo( + mint, + TOKEN_PROGRAM_ID, + ); + const oldResult: TokenPoolInfo = deriveTokenPoolInfo( + mint, + TOKEN_PROGRAM_ID, + ); + + // Both should have same data + expect(newResult.mint.toBase58()).toBe(oldResult.mint.toBase58()); + expect(newResult.splInterfacePda.toBase58()).toBe( + oldResult.splInterfacePda.toBase58(), + ); + }); + + it('deprecated deriveTokenPoolPdaWithIndex should work', () => { + // Test the deprecated static method + const [pda, bump] = CompressedTokenProgram.deriveTokenPoolPdaWithIndex( + mint, + 0, + ); + const [newPda, newBump] = + CompressedTokenProgram.deriveSplInterfacePdaWithIndex(mint, 0); + + expect(pda.toBase58()).toBe(newPda.toBase58()); + expect(bump).toBe(newBump); + }); + + it('deprecated deriveTokenPoolPda should work', () => { + const pda = CompressedTokenProgram.deriveTokenPoolPda(mint); + const newPda = CompressedTokenProgram.deriveSplInterfacePda(mint); + + expect(pda.toBase58()).toBe(newPda.toBase58()); + }); +}); diff --git a/js/compressed-token/tests/unit/get-associated-token-address-interface.test.ts b/js/compressed-token/tests/unit/get-associated-token-address-interface.test.ts new file mode 100644 index 0000000000..56c8dc303d --- /dev/null +++ b/js/compressed-token/tests/unit/get-associated-token-address-interface.test.ts @@ -0,0 +1,332 @@ +import { describe, it, expect } from 'vitest'; +import { Keypair, PublicKey } from '@solana/web3.js'; +import { + TOKEN_PROGRAM_ID, + TOKEN_2022_PROGRAM_ID, + ASSOCIATED_TOKEN_PROGRAM_ID, + getAssociatedTokenAddressSync, +} from '@solana/spl-token'; +import { CTOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; +import { getAssociatedTokenAddressInterface } from '../../src/v3/get-associated-token-address-interface'; +import { getAtaProgramId } from '../../src/v3/ata-utils'; + +describe('getAssociatedTokenAddressInterface', () => { + const mint = Keypair.generate().publicKey; + const owner = Keypair.generate().publicKey; + + describe('default behavior (CTOKEN_PROGRAM_ID)', () => { + it('should derive ATA using CTOKEN_PROGRAM_ID by default', () => { + const result = getAssociatedTokenAddressInterface(mint, owner); + + const expected = getAssociatedTokenAddressSync( + mint, + owner, + false, + CTOKEN_PROGRAM_ID, + CTOKEN_PROGRAM_ID, + ); + + expect(result.toBase58()).toBe(expected.toBase58()); + }); + + it('should be deterministic - same inputs produce same output', () => { + const result1 = getAssociatedTokenAddressInterface(mint, owner); + const result2 = getAssociatedTokenAddressInterface(mint, owner); + + expect(result1.toBase58()).toBe(result2.toBase58()); + }); + + it('should produce different addresses for different mints', () => { + const mint2 = Keypair.generate().publicKey; + + const result1 = getAssociatedTokenAddressInterface(mint, owner); + const result2 = getAssociatedTokenAddressInterface(mint2, owner); + + expect(result1.toBase58()).not.toBe(result2.toBase58()); + }); + + it('should produce different addresses for different owners', () => { + const owner2 = Keypair.generate().publicKey; + + const result1 = getAssociatedTokenAddressInterface(mint, owner); + const result2 = getAssociatedTokenAddressInterface(mint, owner2); + + expect(result1.toBase58()).not.toBe(result2.toBase58()); + }); + }); + + describe('explicit TOKEN_PROGRAM_ID', () => { + it('should derive ATA using TOKEN_PROGRAM_ID', () => { + const result = getAssociatedTokenAddressInterface( + mint, + owner, + false, + TOKEN_PROGRAM_ID, + ); + + const expected = getAssociatedTokenAddressSync( + mint, + owner, + false, + TOKEN_PROGRAM_ID, + ASSOCIATED_TOKEN_PROGRAM_ID, + ); + + expect(result.toBase58()).toBe(expected.toBase58()); + }); + + it('should use ASSOCIATED_TOKEN_PROGRAM_ID for TOKEN_PROGRAM_ID', () => { + const result = getAssociatedTokenAddressInterface( + mint, + owner, + false, + TOKEN_PROGRAM_ID, + ); + + // Verify getAtaProgramId returns ASSOCIATED_TOKEN_PROGRAM_ID for TOKEN_PROGRAM_ID + expect(getAtaProgramId(TOKEN_PROGRAM_ID).toBase58()).toBe( + ASSOCIATED_TOKEN_PROGRAM_ID.toBase58(), + ); + + // Verify the derived address matches SPL's derivation + const splResult = getAssociatedTokenAddressSync( + mint, + owner, + false, + TOKEN_PROGRAM_ID, + ASSOCIATED_TOKEN_PROGRAM_ID, + ); + expect(result.toBase58()).toBe(splResult.toBase58()); + }); + }); + + describe('explicit TOKEN_2022_PROGRAM_ID', () => { + it('should derive ATA using TOKEN_2022_PROGRAM_ID', () => { + const result = getAssociatedTokenAddressInterface( + mint, + owner, + false, + TOKEN_2022_PROGRAM_ID, + ); + + const expected = getAssociatedTokenAddressSync( + mint, + owner, + false, + TOKEN_2022_PROGRAM_ID, + ASSOCIATED_TOKEN_PROGRAM_ID, + ); + + expect(result.toBase58()).toBe(expected.toBase58()); + }); + + it('should use ASSOCIATED_TOKEN_PROGRAM_ID for TOKEN_2022_PROGRAM_ID', () => { + expect(getAtaProgramId(TOKEN_2022_PROGRAM_ID).toBase58()).toBe( + ASSOCIATED_TOKEN_PROGRAM_ID.toBase58(), + ); + }); + }); + + describe('different programIds produce different ATAs', () => { + it('should produce different ATAs for CTOKEN vs TOKEN_PROGRAM_ID', () => { + const ctokenAta = getAssociatedTokenAddressInterface( + mint, + owner, + false, + CTOKEN_PROGRAM_ID, + ); + const splAta = getAssociatedTokenAddressInterface( + mint, + owner, + false, + TOKEN_PROGRAM_ID, + ); + + expect(ctokenAta.toBase58()).not.toBe(splAta.toBase58()); + }); + + it('should produce different ATAs for TOKEN_PROGRAM_ID vs TOKEN_2022_PROGRAM_ID', () => { + const splAta = getAssociatedTokenAddressInterface( + mint, + owner, + false, + TOKEN_PROGRAM_ID, + ); + const t22Ata = getAssociatedTokenAddressInterface( + mint, + owner, + false, + TOKEN_2022_PROGRAM_ID, + ); + + expect(splAta.toBase58()).not.toBe(t22Ata.toBase58()); + }); + }); + + describe('allowOwnerOffCurve parameter', () => { + it('should allow PDA owners when allowOwnerOffCurve is true', () => { + // Create a PDA (off-curve point) + const [pdaOwner] = PublicKey.findProgramAddressSync( + [Buffer.from('test-seed')], + CTOKEN_PROGRAM_ID, + ); + + // Should not throw with allowOwnerOffCurve = true + const result = getAssociatedTokenAddressInterface( + mint, + pdaOwner, + true, + CTOKEN_PROGRAM_ID, + ); + + expect(result).toBeInstanceOf(PublicKey); + }); + + it('should throw for PDA owners when allowOwnerOffCurve is false', () => { + const [pdaOwner] = PublicKey.findProgramAddressSync( + [Buffer.from('test-seed')], + CTOKEN_PROGRAM_ID, + ); + + expect(() => + getAssociatedTokenAddressInterface( + mint, + pdaOwner, + false, + CTOKEN_PROGRAM_ID, + ), + ).toThrow(); + }); + + it('should default allowOwnerOffCurve to false', () => { + const [pdaOwner] = PublicKey.findProgramAddressSync( + [Buffer.from('another-seed')], + TOKEN_PROGRAM_ID, + ); + + // Default behavior (no third param) should throw for PDA + expect(() => + getAssociatedTokenAddressInterface(mint, pdaOwner), + ).toThrow(); + }); + + it('should work with regular (on-curve) owner regardless of allowOwnerOffCurve', () => { + const regularOwner = Keypair.generate().publicKey; + + const result1 = getAssociatedTokenAddressInterface( + mint, + regularOwner, + false, + ); + const result2 = getAssociatedTokenAddressInterface( + mint, + regularOwner, + true, + ); + + // Both should succeed and produce the same address + expect(result1.toBase58()).toBe(result2.toBase58()); + }); + }); + + describe('explicit associatedTokenProgramId', () => { + it('should use explicit associatedTokenProgramId when provided', () => { + const customAssocProgram = Keypair.generate().publicKey; + + const result = getAssociatedTokenAddressInterface( + mint, + owner, + false, + TOKEN_PROGRAM_ID, + customAssocProgram, + ); + + const expected = getAssociatedTokenAddressSync( + mint, + owner, + false, + TOKEN_PROGRAM_ID, + customAssocProgram, + ); + + expect(result.toBase58()).toBe(expected.toBase58()); + }); + + it('should override auto-detected associatedTokenProgramId', () => { + // Force CTOKEN_PROGRAM_ID as associated program even for TOKEN_PROGRAM_ID + const result = getAssociatedTokenAddressInterface( + mint, + owner, + false, + TOKEN_PROGRAM_ID, + CTOKEN_PROGRAM_ID, + ); + + const autoDetected = getAssociatedTokenAddressInterface( + mint, + owner, + false, + TOKEN_PROGRAM_ID, + ); + + // Should be different because we overrode the associated program + expect(result.toBase58()).not.toBe(autoDetected.toBase58()); + }); + }); + + describe('getAtaProgramId helper', () => { + it('should return CTOKEN_PROGRAM_ID for CTOKEN_PROGRAM_ID', () => { + expect(getAtaProgramId(CTOKEN_PROGRAM_ID).toBase58()).toBe( + CTOKEN_PROGRAM_ID.toBase58(), + ); + }); + + it('should return ASSOCIATED_TOKEN_PROGRAM_ID for TOKEN_PROGRAM_ID', () => { + expect(getAtaProgramId(TOKEN_PROGRAM_ID).toBase58()).toBe( + ASSOCIATED_TOKEN_PROGRAM_ID.toBase58(), + ); + }); + + it('should return ASSOCIATED_TOKEN_PROGRAM_ID for TOKEN_2022_PROGRAM_ID', () => { + expect(getAtaProgramId(TOKEN_2022_PROGRAM_ID).toBase58()).toBe( + ASSOCIATED_TOKEN_PROGRAM_ID.toBase58(), + ); + }); + + it('should return ASSOCIATED_TOKEN_PROGRAM_ID for unknown program IDs', () => { + const unknownProgram = Keypair.generate().publicKey; + expect(getAtaProgramId(unknownProgram).toBase58()).toBe( + ASSOCIATED_TOKEN_PROGRAM_ID.toBase58(), + ); + }); + }); + + describe('edge cases', () => { + it('should handle PublicKey.default as mint', () => { + const result = getAssociatedTokenAddressInterface( + PublicKey.default, + owner, + ); + expect(result).toBeInstanceOf(PublicKey); + }); + + it('should handle well-known program IDs as mint', () => { + const result = getAssociatedTokenAddressInterface( + TOKEN_PROGRAM_ID, + owner, + ); + expect(result).toBeInstanceOf(PublicKey); + }); + + it('should handle system program as mint', () => { + const systemProgram = new PublicKey( + '11111111111111111111111111111111', + ); + const result = getAssociatedTokenAddressInterface( + systemProgram, + owner, + ); + expect(result).toBeInstanceOf(PublicKey); + }); + }); +}); diff --git a/js/compressed-token/tests/unit/layout-mint-action.test.ts b/js/compressed-token/tests/unit/layout-mint-action.test.ts new file mode 100644 index 0000000000..8f336faa67 --- /dev/null +++ b/js/compressed-token/tests/unit/layout-mint-action.test.ts @@ -0,0 +1,397 @@ +import { describe, it, expect } from 'vitest'; +import { PublicKey, Keypair } from '@solana/web3.js'; +import { + encodeMintActionInstructionData, + decodeMintActionInstructionData, + MintActionCompressedInstructionData, + Action, + MINT_ACTION_DISCRIMINATOR, +} from '../../src/v3/layout/layout-mint-action'; + +describe('layout-mint-action', () => { + describe('encodeMintActionInstructionData / decodeMintActionInstructionData', () => { + it('should encode and decode basic instruction data without actions', () => { + const mint = Keypair.generate().publicKey; + + const data: MintActionCompressedInstructionData = { + leafIndex: 100, + proveByIndex: true, + rootIndex: 5, + compressedAddress: Array(32).fill(1), + tokenPoolBump: 255, + tokenPoolIndex: 0, + maxTopUp: 1000, + createMint: null, + actions: [], + proof: null, + cpiContext: null, + mint: { + supply: 1000000n, + decimals: 9, + metadata: { + version: 1, + splMintInitialized: true, + mint, + }, + mintAuthority: mint, + freezeAuthority: null, + extensions: null, + }, + }; + + const encoded = encodeMintActionInstructionData(data); + + // Check discriminator + expect(encoded.subarray(0, 1)).toEqual(MINT_ACTION_DISCRIMINATOR); + + const decoded = decodeMintActionInstructionData(encoded); + + expect(decoded.leafIndex).toBe(100); + expect(decoded.proveByIndex).toBe(true); + expect(decoded.rootIndex).toBe(5); + expect(decoded.tokenPoolBump).toBe(255); + expect(decoded.maxTopUp).toBe(1000); + expect(decoded.actions.length).toBe(0); + expect(decoded.mint.decimals).toBe(9); + }); + + it('should encode and decode with mintToCompressed action', () => { + const mint = Keypair.generate().publicKey; + const recipient1 = Keypair.generate().publicKey; + const recipient2 = Keypair.generate().publicKey; + + const mintToCompressedAction: Action = { + mintToCompressed: { + tokenAccountVersion: 1, + recipients: [ + { recipient: recipient1, amount: 500n }, + { recipient: recipient2, amount: 1500n }, + ], + }, + }; + + const data: MintActionCompressedInstructionData = { + leafIndex: 50, + proveByIndex: false, + rootIndex: 10, + compressedAddress: Array(32).fill(2), + tokenPoolBump: 254, + tokenPoolIndex: 1, + maxTopUp: 500, + createMint: null, + actions: [mintToCompressedAction], + proof: null, + cpiContext: null, + mint: { + supply: 0n, + decimals: 6, + metadata: { + version: 1, + splMintInitialized: false, + mint, + }, + mintAuthority: mint, + freezeAuthority: null, + extensions: null, + }, + }; + + const encoded = encodeMintActionInstructionData(data); + const decoded = decodeMintActionInstructionData(encoded); + + expect(decoded.actions.length).toBe(1); + expect('mintToCompressed' in decoded.actions[0]).toBe(true); + + const action = decoded.actions[0] as { + mintToCompressed: { + tokenAccountVersion: number; + recipients: { recipient: PublicKey; amount: bigint }[]; + }; + }; + expect(action.mintToCompressed.tokenAccountVersion).toBe(1); + expect(action.mintToCompressed.recipients.length).toBe(2); + }); + + it('should encode and decode with mintToCToken action', () => { + const mint = Keypair.generate().publicKey; + + const mintToCTokenAction: Action = { + mintToCToken: { + accountIndex: 3, + amount: 1000000n, + }, + }; + + const data: MintActionCompressedInstructionData = { + leafIndex: 0, + proveByIndex: true, + rootIndex: 0, + compressedAddress: Array(32).fill(0), + tokenPoolBump: 253, + tokenPoolIndex: 0, + maxTopUp: 0, + createMint: null, + actions: [mintToCTokenAction], + proof: null, + cpiContext: null, + mint: { + supply: 1000000n, + decimals: 9, + metadata: { + version: 1, + splMintInitialized: true, + mint, + }, + mintAuthority: mint, + freezeAuthority: null, + extensions: null, + }, + }; + + const encoded = encodeMintActionInstructionData(data); + const decoded = decodeMintActionInstructionData(encoded); + + expect(decoded.actions.length).toBe(1); + expect('mintToCToken' in decoded.actions[0]).toBe(true); + + const action = decoded.actions[0] as { + mintToCToken: { accountIndex: number; amount: bigint }; + }; + expect(action.mintToCToken.accountIndex).toBe(3); + }); + + it('should encode and decode with updateMintAuthority action', () => { + const mint = Keypair.generate().publicKey; + const newAuthority = Keypair.generate().publicKey; + + const updateAction: Action = { + updateMintAuthority: { + newAuthority, + }, + }; + + const data: MintActionCompressedInstructionData = { + leafIndex: 10, + proveByIndex: true, + rootIndex: 2, + compressedAddress: Array(32).fill(5), + tokenPoolBump: 250, + tokenPoolIndex: 0, + maxTopUp: 100, + createMint: null, + actions: [updateAction], + proof: null, + cpiContext: null, + mint: { + supply: 500n, + decimals: 6, + metadata: { + version: 1, + splMintInitialized: true, + mint, + }, + mintAuthority: mint, + freezeAuthority: null, + extensions: null, + }, + }; + + const encoded = encodeMintActionInstructionData(data); + const decoded = decodeMintActionInstructionData(encoded); + + expect(decoded.actions.length).toBe(1); + expect('updateMintAuthority' in decoded.actions[0]).toBe(true); + }); + + it('should encode and decode with createSplMint action', () => { + const mint = Keypair.generate().publicKey; + + const createSplMintAction: Action = { + createSplMint: { + mintBump: 254, + }, + }; + + const data: MintActionCompressedInstructionData = { + leafIndex: 0, + proveByIndex: false, + rootIndex: 0, + compressedAddress: Array(32).fill(0), + tokenPoolBump: 255, + tokenPoolIndex: 0, + maxTopUp: 0, + createMint: { + readOnlyAddressTrees: [1, 2, 3, 4], + readOnlyAddressTreeRootIndices: [10, 20, 30, 40], + }, + actions: [createSplMintAction], + proof: { + a: Array(32).fill(1), + b: Array(64).fill(2), + c: Array(32).fill(3), + }, + cpiContext: null, + mint: { + supply: 0n, + decimals: 9, + metadata: { + version: 1, + splMintInitialized: false, + mint, + }, + mintAuthority: mint, + freezeAuthority: null, + extensions: null, + }, + }; + + const encoded = encodeMintActionInstructionData(data); + const decoded = decodeMintActionInstructionData(encoded); + + expect(decoded.actions.length).toBe(1); + expect('createSplMint' in decoded.actions[0]).toBe(true); + + const action = decoded.actions[0] as { + createSplMint: { mintBump: number }; + }; + expect(action.createSplMint.mintBump).toBe(254); + expect(decoded.createMint).not.toBe(null); + expect(decoded.proof).not.toBe(null); + }); + + it('should encode and decode with multiple actions', () => { + const mint = Keypair.generate().publicKey; + const recipient = Keypair.generate().publicKey; + + const actions: Action[] = [ + { + mintToCompressed: { + tokenAccountVersion: 1, + recipients: [{ recipient, amount: 1000n }], + }, + }, + { + updateMintAuthority: { + newAuthority: null, + }, + }, + ]; + + const data: MintActionCompressedInstructionData = { + leafIndex: 5, + proveByIndex: true, + rootIndex: 1, + compressedAddress: Array(32).fill(7), + tokenPoolBump: 200, + tokenPoolIndex: 2, + maxTopUp: 50, + createMint: null, + actions, + proof: null, + cpiContext: null, + mint: { + supply: 1000n, + decimals: 9, + metadata: { + version: 1, + splMintInitialized: true, + mint, + }, + mintAuthority: mint, + freezeAuthority: null, + extensions: null, + }, + }; + + const encoded = encodeMintActionInstructionData(data); + const decoded = decodeMintActionInstructionData(encoded); + + expect(decoded.actions.length).toBe(2); + }); + + it('should handle large supply values', () => { + const mint = Keypair.generate().publicKey; + const largeSupply = BigInt('18446744073709551615'); // max u64 + + const data: MintActionCompressedInstructionData = { + leafIndex: 0, + proveByIndex: true, + rootIndex: 0, + compressedAddress: Array(32).fill(0), + tokenPoolBump: 255, + tokenPoolIndex: 0, + maxTopUp: 0, + createMint: null, + actions: [], + proof: null, + cpiContext: null, + mint: { + supply: largeSupply, + decimals: 9, + metadata: { + version: 1, + splMintInitialized: true, + mint, + }, + mintAuthority: mint, + freezeAuthority: null, + extensions: null, + }, + }; + + const encoded = encodeMintActionInstructionData(data); + const decoded = decodeMintActionInstructionData(encoded); + + // BN returns bigint when converted, check as string to handle both types + expect(decoded.mint.supply.toString()).toBe(largeSupply.toString()); + }); + + it('should encode and decode with cpiContext', () => { + const mint = Keypair.generate().publicKey; + + const data: MintActionCompressedInstructionData = { + leafIndex: 0, + proveByIndex: true, + rootIndex: 0, + compressedAddress: Array(32).fill(0), + tokenPoolBump: 255, + tokenPoolIndex: 0, + maxTopUp: 0, + createMint: null, + actions: [], + proof: null, + cpiContext: { + setContext: true, + firstSetContext: true, + inTreeIndex: 1, + inQueueIndex: 2, + outQueueIndex: 3, + tokenOutQueueIndex: 4, + assignedAccountIndex: 5, + readOnlyAddressTrees: [6, 7, 8, 9], + addressTreePubkey: Array(32).fill(10), + }, + mint: { + supply: 0n, + decimals: 9, + metadata: { + version: 1, + splMintInitialized: true, + mint, + }, + mintAuthority: mint, + freezeAuthority: null, + extensions: null, + }, + }; + + const encoded = encodeMintActionInstructionData(data); + const decoded = decodeMintActionInstructionData(encoded); + + expect(decoded.cpiContext).not.toBe(null); + expect(decoded.cpiContext?.setContext).toBe(true); + expect(decoded.cpiContext?.firstSetContext).toBe(true); + expect(decoded.cpiContext?.inTreeIndex).toBe(1); + }); + }); +}); diff --git a/js/compressed-token/tests/unit/layout-mint.test.ts b/js/compressed-token/tests/unit/layout-mint.test.ts new file mode 100644 index 0000000000..05af92da6c --- /dev/null +++ b/js/compressed-token/tests/unit/layout-mint.test.ts @@ -0,0 +1,488 @@ +import { describe, it, expect } from 'vitest'; +import { PublicKey, Keypair } from '@solana/web3.js'; +import { + deserializeMint, + serializeMint, + decodeTokenMetadata, + encodeTokenMetadata, + extractTokenMetadata, + toMintInstructionData, + toMintInstructionDataWithMetadata, + CompressedMint, + BaseMint, + MintContext, + MintExtension, + TokenMetadata, + ExtensionType, +} from '../../src/v3/layout/layout-mint'; + +describe('layout-mint', () => { + describe('serializeMint / deserializeMint', () => { + it('should serialize and deserialize a basic mint without extensions', () => { + const mintAuthority = Keypair.generate().publicKey; + const splMint = Keypair.generate().publicKey; + + const mint: CompressedMint = { + base: { + mintAuthority, + supply: 1000000n, + decimals: 9, + isInitialized: true, + freezeAuthority: null, + }, + mintContext: { + version: 1, + splMintInitialized: true, + splMint, + }, + extensions: null, + }; + + const serialized = serializeMint(mint); + const deserialized = deserializeMint(serialized); + + expect(deserialized.base.mintAuthority?.toBase58()).toBe( + mintAuthority.toBase58(), + ); + expect(deserialized.base.supply).toBe(1000000n); + expect(deserialized.base.decimals).toBe(9); + expect(deserialized.base.isInitialized).toBe(true); + expect(deserialized.base.freezeAuthority).toBe(null); + expect(deserialized.mintContext.version).toBe(1); + expect(deserialized.mintContext.splMintInitialized).toBe(true); + expect(deserialized.mintContext.splMint.toBase58()).toBe( + splMint.toBase58(), + ); + expect(deserialized.extensions).toBe(null); + }); + + it('should serialize and deserialize a mint with freeze authority', () => { + const mintAuthority = Keypair.generate().publicKey; + const freezeAuthority = Keypair.generate().publicKey; + const splMint = Keypair.generate().publicKey; + + const mint: CompressedMint = { + base: { + mintAuthority, + supply: 500n, + decimals: 6, + isInitialized: true, + freezeAuthority, + }, + mintContext: { + version: 0, + splMintInitialized: false, + splMint, + }, + extensions: null, + }; + + const serialized = serializeMint(mint); + const deserialized = deserializeMint(serialized); + + expect(deserialized.base.freezeAuthority?.toBase58()).toBe( + freezeAuthority.toBase58(), + ); + expect(deserialized.mintContext.splMintInitialized).toBe(false); + }); + + it('should handle null mintAuthority', () => { + const splMint = Keypair.generate().publicKey; + + const mint: CompressedMint = { + base: { + mintAuthority: null, + supply: 0n, + decimals: 0, + isInitialized: true, + freezeAuthority: null, + }, + mintContext: { + version: 1, + splMintInitialized: true, + splMint, + }, + extensions: null, + }; + + const serialized = serializeMint(mint); + const deserialized = deserializeMint(serialized); + + expect(deserialized.base.mintAuthority).toBe(null); + }); + + it('should serialize and deserialize a mint with token metadata extension', () => { + const mintAuthority = Keypair.generate().publicKey; + const splMint = Keypair.generate().publicKey; + const updateAuthority = Keypair.generate().publicKey; + + // Create metadata + const metadata: TokenMetadata = { + updateAuthority, + mint: splMint, + name: 'Test Token', + symbol: 'TEST', + uri: 'https://example.com/metadata.json', + additionalMetadata: [{ key: 'version', value: '1.0' }], + }; + + const encodedMetadata = encodeTokenMetadata(metadata); + + const mint: CompressedMint = { + base: { + mintAuthority, + supply: 1000n, + decimals: 9, + isInitialized: true, + freezeAuthority: null, + }, + mintContext: { + version: 1, + splMintInitialized: true, + splMint, + }, + extensions: [ + { + extensionType: ExtensionType.TokenMetadata, + data: encodedMetadata, + }, + ], + }; + + const serialized = serializeMint(mint); + const deserialized = deserializeMint(serialized); + + expect(deserialized.extensions).not.toBe(null); + expect(deserialized.extensions?.length).toBe(1); + expect(deserialized.extensions?.[0].extensionType).toBe( + ExtensionType.TokenMetadata, + ); + }); + + it('should handle large supply values', () => { + const splMint = Keypair.generate().publicKey; + const largeSupply = BigInt('18446744073709551615'); // max u64 + + const mint: CompressedMint = { + base: { + mintAuthority: null, + supply: largeSupply, + decimals: 9, + isInitialized: true, + freezeAuthority: null, + }, + mintContext: { + version: 1, + splMintInitialized: true, + splMint, + }, + extensions: null, + }; + + const serialized = serializeMint(mint); + const deserialized = deserializeMint(serialized); + + expect(deserialized.base.supply).toBe(largeSupply); + }); + }); + + describe('encodeTokenMetadata / decodeTokenMetadata', () => { + it('should encode and decode token metadata', () => { + const mintPubkey = Keypair.generate().publicKey; + const updateAuthority = Keypair.generate().publicKey; + + const metadata: TokenMetadata = { + updateAuthority, + mint: mintPubkey, + name: 'My Token', + symbol: 'MTK', + uri: 'https://my-token.com/metadata.json', + }; + + const encoded = encodeTokenMetadata(metadata); + const decoded = decodeTokenMetadata(encoded); + + expect(decoded).not.toBe(null); + expect(decoded?.name).toBe('My Token'); + expect(decoded?.symbol).toBe('MTK'); + expect(decoded?.uri).toBe('https://my-token.com/metadata.json'); + expect(decoded?.mint.toBase58()).toBe(mintPubkey.toBase58()); + expect(decoded?.updateAuthority?.toBase58()).toBe( + updateAuthority.toBase58(), + ); + }); + + it('should handle metadata with additional metadata fields', () => { + const mintPubkey = Keypair.generate().publicKey; + const updateAuthority = Keypair.generate().publicKey; + + const metadata: TokenMetadata = { + updateAuthority, + mint: mintPubkey, + name: 'Extended Token', + symbol: 'EXT', + uri: 'https://example.com/extended.json', + additionalMetadata: [ + { key: 'version', value: '2.0' }, + { key: 'creator', value: 'Light Protocol' }, + { key: 'category', value: 'compressed' }, + ], + }; + + const encoded = encodeTokenMetadata(metadata); + const decoded = decodeTokenMetadata(encoded); + + expect(decoded?.additionalMetadata?.length).toBe(3); + expect(decoded?.additionalMetadata?.[0]).toEqual({ + key: 'version', + value: '2.0', + }); + expect(decoded?.additionalMetadata?.[1]).toEqual({ + key: 'creator', + value: 'Light Protocol', + }); + }); + + it('should handle null updateAuthority (zero pubkey)', () => { + const mintPubkey = Keypair.generate().publicKey; + + const metadata: TokenMetadata = { + updateAuthority: null, + mint: mintPubkey, + name: 'Immutable Token', + symbol: 'IMM', + uri: 'https://example.com/immutable.json', + }; + + const encoded = encodeTokenMetadata(metadata); + const decoded = decodeTokenMetadata(encoded); + + expect(decoded?.updateAuthority).toBeUndefined(); + }); + + it('should handle empty strings', () => { + const mintPubkey = Keypair.generate().publicKey; + + const metadata: TokenMetadata = { + mint: mintPubkey, + name: '', + symbol: '', + uri: '', + }; + + const encoded = encodeTokenMetadata(metadata); + const decoded = decodeTokenMetadata(encoded); + + expect(decoded?.name).toBe(''); + expect(decoded?.symbol).toBe(''); + expect(decoded?.uri).toBe(''); + }); + + it('should handle unicode characters', () => { + const mintPubkey = Keypair.generate().publicKey; + + const metadata: TokenMetadata = { + mint: mintPubkey, + name: 'Token', + symbol: '$TOKEN', + uri: 'https://example.com/metadata.json', + }; + + const encoded = encodeTokenMetadata(metadata); + const decoded = decodeTokenMetadata(encoded); + + expect(decoded?.name).toBe('Token'); + expect(decoded?.symbol).toBe('$TOKEN'); + }); + + it('should return null for invalid data', () => { + const invalidBuffer = Buffer.alloc(10); // Too small + const decoded = decodeTokenMetadata(invalidBuffer); + expect(decoded).toBe(null); + }); + }); + + describe('extractTokenMetadata', () => { + it('should extract token metadata from extensions array', () => { + const mintPubkey = Keypair.generate().publicKey; + const metadata: TokenMetadata = { + mint: mintPubkey, + name: 'Test', + symbol: 'TST', + uri: 'https://test.com', + }; + + const encoded = encodeTokenMetadata(metadata); + const extensions: MintExtension[] = [ + { extensionType: ExtensionType.TokenMetadata, data: encoded }, + ]; + + const extracted = extractTokenMetadata(extensions); + + expect(extracted).not.toBe(null); + expect(extracted?.name).toBe('Test'); + }); + + it('should return null for null extensions', () => { + const extracted = extractTokenMetadata(null); + expect(extracted).toBe(null); + }); + + it('should return null when no metadata extension exists', () => { + const extensions: MintExtension[] = [ + { extensionType: 99, data: Buffer.alloc(10) }, // Unknown extension + ]; + + const extracted = extractTokenMetadata(extensions); + expect(extracted).toBe(null); + }); + }); + + describe('toMintInstructionData', () => { + it('should convert CompressedMint to MintInstructionData', () => { + const mintAuthority = Keypair.generate().publicKey; + const splMint = Keypair.generate().publicKey; + + const compressedMint: CompressedMint = { + base: { + mintAuthority, + supply: 5000n, + decimals: 6, + isInitialized: true, + freezeAuthority: null, + }, + mintContext: { + version: 1, + splMintInitialized: true, + splMint, + }, + extensions: null, + }; + + const instructionData = toMintInstructionData(compressedMint); + + expect(instructionData.supply).toBe(5000n); + expect(instructionData.decimals).toBe(6); + expect(instructionData.mintAuthority?.toBase58()).toBe( + mintAuthority.toBase58(), + ); + expect(instructionData.freezeAuthority).toBe(null); + expect(instructionData.splMint.toBase58()).toBe(splMint.toBase58()); + expect(instructionData.splMintInitialized).toBe(true); + expect(instructionData.version).toBe(1); + expect(instructionData.metadata).toBeUndefined(); + }); + + it('should include metadata when extension exists', () => { + const mintAuthority = Keypair.generate().publicKey; + const splMint = Keypair.generate().publicKey; + const updateAuthority = Keypair.generate().publicKey; + + const metadata: TokenMetadata = { + updateAuthority, + mint: splMint, + name: 'Instruction Token', + symbol: 'INST', + uri: 'https://inst.com/meta.json', + }; + + const compressedMint: CompressedMint = { + base: { + mintAuthority, + supply: 1000n, + decimals: 9, + isInitialized: true, + freezeAuthority: null, + }, + mintContext: { + version: 1, + splMintInitialized: true, + splMint, + }, + extensions: [ + { + extensionType: ExtensionType.TokenMetadata, + data: encodeTokenMetadata(metadata), + }, + ], + }; + + const instructionData = toMintInstructionData(compressedMint); + + expect(instructionData.metadata).toBeDefined(); + expect(instructionData.metadata?.name).toBe('Instruction Token'); + expect(instructionData.metadata?.symbol).toBe('INST'); + expect(instructionData.metadata?.uri).toBe( + 'https://inst.com/meta.json', + ); + }); + }); + + describe('toMintInstructionDataWithMetadata', () => { + it('should return data with required metadata', () => { + const mintAuthority = Keypair.generate().publicKey; + const splMint = Keypair.generate().publicKey; + const updateAuthority = Keypair.generate().publicKey; + + const metadata: TokenMetadata = { + updateAuthority, + mint: splMint, + name: 'Required Meta', + symbol: 'REQ', + uri: 'https://req.com/meta.json', + }; + + const compressedMint: CompressedMint = { + base: { + mintAuthority, + supply: 1000n, + decimals: 9, + isInitialized: true, + freezeAuthority: null, + }, + mintContext: { + version: 1, + splMintInitialized: true, + splMint, + }, + extensions: [ + { + extensionType: ExtensionType.TokenMetadata, + data: encodeTokenMetadata(metadata), + }, + ], + }; + + const instructionData = + toMintInstructionDataWithMetadata(compressedMint); + + expect(instructionData.metadata.name).toBe('Required Meta'); + expect(instructionData.metadata.symbol).toBe('REQ'); + }); + + it('should throw when metadata extension is missing', () => { + const mintAuthority = Keypair.generate().publicKey; + const splMint = Keypair.generate().publicKey; + + const compressedMint: CompressedMint = { + base: { + mintAuthority, + supply: 1000n, + decimals: 9, + isInitialized: true, + freezeAuthority: null, + }, + mintContext: { + version: 1, + splMintInitialized: true, + splMint, + }, + extensions: null, + }; + + expect(() => + toMintInstructionDataWithMetadata(compressedMint), + ).toThrow('CompressedMint does not have TokenMetadata extension'); + }); + }); +}); diff --git a/js/compressed-token/tests/unit/layout-serde.test.ts b/js/compressed-token/tests/unit/layout-serde.test.ts new file mode 100644 index 0000000000..80dcbb3d4c --- /dev/null +++ b/js/compressed-token/tests/unit/layout-serde.test.ts @@ -0,0 +1,378 @@ +import { describe, it, expect } from 'vitest'; +import { struct, u8, u64 } from '@coral-xyz/borsh'; +import { bn } from '@lightprotocol/stateless.js'; +import { + createCompressedAccountDataLayout, + createDecompressAccountsIdempotentLayout, + serializeDecompressIdempotentInstructionData, + deserializeDecompressIdempotentInstructionData, + CompressedAccountMeta, + PackedStateTreeInfo, + CompressedAccountData, + DecompressAccountsIdempotentInstructionData, +} from '../../src/v3/layout/serde'; +import { DECOMPRESS_ACCOUNTS_IDEMPOTENT_DISCRIMINATOR } from '../../src/constants'; + +// Simple data layout for testing +const TestDataLayout = struct([u8('value'), u64('amount')]); + +// For encoding, Borsh requires BN not native BigInt +interface TestData { + value: number; + amount: any; // BN or bigint depending on encode/decode +} + +describe('layout-serde', () => { + describe('createCompressedAccountDataLayout', () => { + it('should create a layout for compressed account data', () => { + const layout = createCompressedAccountDataLayout(TestDataLayout); + + expect(layout).toBeDefined(); + expect(typeof layout.encode).toBe('function'); + expect(typeof layout.decode).toBe('function'); + }); + + it('should encode and decode compressed account data with empty seeds', () => { + const layout = createCompressedAccountDataLayout(TestDataLayout); + + const meta: CompressedAccountMeta = { + treeInfo: { + rootIndex: 10, + proveByIndex: true, + merkleTreePubkeyIndex: 1, + queuePubkeyIndex: 2, + leafIndex: 100, + }, + address: Array(32).fill(42), + lamports: bn(1000000), + outputStateTreeIndex: 3, + }; + + const testData: TestData = { + value: 255, + amount: bn(500), + }; + + const accountData: CompressedAccountData = { + meta, + data: testData, + seeds: [], // Empty seeds to avoid Buffer conversion issues + }; + + const buffer = Buffer.alloc(500); + const len = layout.encode(accountData, buffer); + const decoded = layout.decode(buffer.subarray(0, len)); + + expect(decoded.meta.treeInfo.rootIndex).toBe(10); + expect(decoded.meta.treeInfo.proveByIndex).toBe(true); + expect(decoded.meta.treeInfo.leafIndex).toBe(100); + expect(decoded.meta.address).toEqual(Array(32).fill(42)); + expect(decoded.meta.outputStateTreeIndex).toBe(3); + expect(decoded.data.value).toBe(255); + expect(decoded.seeds.length).toBe(0); + }); + + it('should handle null address and lamports', () => { + const layout = createCompressedAccountDataLayout(TestDataLayout); + + const meta: CompressedAccountMeta = { + treeInfo: { + rootIndex: 5, + proveByIndex: false, + merkleTreePubkeyIndex: 0, + queuePubkeyIndex: 1, + leafIndex: 50, + }, + address: null, + lamports: null, + outputStateTreeIndex: 0, + }; + + const accountData: CompressedAccountData = { + meta, + data: { value: 128, amount: bn(200) }, + seeds: [], + }; + + const buffer = Buffer.alloc(500); + const len = layout.encode(accountData, buffer); + const decoded = layout.decode(buffer.subarray(0, len)); + + expect(decoded.meta.address).toBe(null); + expect(decoded.meta.lamports).toBe(null); + }); + }); + + describe('createDecompressAccountsIdempotentLayout', () => { + it('should create a layout for decompress instruction', () => { + const layout = + createDecompressAccountsIdempotentLayout(TestDataLayout); + + expect(layout).toBeDefined(); + expect(typeof layout.encode).toBe('function'); + expect(typeof layout.decode).toBe('function'); + }); + }); + + describe('serializeDecompressIdempotentInstructionData / deserializeDecompressIdempotentInstructionData', () => { + it('should serialize and deserialize instruction data with empty seeds', () => { + const proof = { + a: Array(32).fill(1), + b: Array(64).fill(2), + c: Array(32).fill(3), + }; + + const meta: CompressedAccountMeta = { + treeInfo: { + rootIndex: 15, + proveByIndex: true, + merkleTreePubkeyIndex: 2, + queuePubkeyIndex: 3, + leafIndex: 200, + }, + address: Array(32).fill(99), + lamports: bn(5000000), + outputStateTreeIndex: 1, + }; + + const testData: TestData = { + value: 42, + amount: bn(1000), + }; + + const compressedAccount: CompressedAccountData = { + meta, + data: testData, + seeds: [], // Empty seeds to avoid Buffer issues + }; + + const data: DecompressAccountsIdempotentInstructionData = + { + proof, + compressedAccounts: [compressedAccount], + systemAccountsOffset: 5, + }; + + const serialized = serializeDecompressIdempotentInstructionData( + data, + TestDataLayout, + ); + + // Check discriminator + expect( + serialized.subarray( + 0, + DECOMPRESS_ACCOUNTS_IDEMPOTENT_DISCRIMINATOR.length, + ), + ).toEqual(DECOMPRESS_ACCOUNTS_IDEMPOTENT_DISCRIMINATOR); + + const deserialized = + deserializeDecompressIdempotentInstructionData( + serialized, + TestDataLayout, + ); + + expect(deserialized.proof.a).toEqual(Array(32).fill(1)); + expect(deserialized.proof.b).toEqual(Array(64).fill(2)); + expect(deserialized.proof.c).toEqual(Array(32).fill(3)); + expect(deserialized.compressedAccounts.length).toBe(1); + expect(deserialized.systemAccountsOffset).toBe(5); + + const decompressedAccount = deserialized.compressedAccounts[0]; + expect(decompressedAccount.meta.treeInfo.rootIndex).toBe(15); + expect(decompressedAccount.meta.treeInfo.leafIndex).toBe(200); + expect(decompressedAccount.data.value).toBe(42); + }); + + it('should handle multiple compressed accounts', () => { + const proof = { + a: Array(32).fill(0), + b: Array(64).fill(0), + c: Array(32).fill(0), + }; + + const accounts: CompressedAccountData[] = [ + { + meta: { + treeInfo: { + rootIndex: 1, + proveByIndex: true, + merkleTreePubkeyIndex: 0, + queuePubkeyIndex: 1, + leafIndex: 10, + }, + address: null, + lamports: null, + outputStateTreeIndex: 0, + }, + data: { value: 1, amount: bn(100) }, + seeds: [], + }, + { + meta: { + treeInfo: { + rootIndex: 2, + proveByIndex: false, + merkleTreePubkeyIndex: 1, + queuePubkeyIndex: 2, + leafIndex: 20, + }, + address: Array(32).fill(1), + lamports: bn(1000), + outputStateTreeIndex: 1, + }, + data: { value: 2, amount: bn(200) }, + seeds: [], + }, + { + meta: { + treeInfo: { + rootIndex: 3, + proveByIndex: true, + merkleTreePubkeyIndex: 2, + queuePubkeyIndex: 3, + leafIndex: 30, + }, + address: Array(32).fill(2), + lamports: bn(2000), + outputStateTreeIndex: 2, + }, + data: { value: 3, amount: bn(300) }, + seeds: [], + }, + ]; + + const data: DecompressAccountsIdempotentInstructionData = + { + proof, + compressedAccounts: accounts, + systemAccountsOffset: 10, + }; + + const serialized = serializeDecompressIdempotentInstructionData( + data, + TestDataLayout, + ); + + const deserialized = + deserializeDecompressIdempotentInstructionData( + serialized, + TestDataLayout, + ); + + expect(deserialized.compressedAccounts.length).toBe(3); + expect(deserialized.compressedAccounts[0].data.value).toBe(1); + expect(deserialized.compressedAccounts[1].data.value).toBe(2); + expect(deserialized.compressedAccounts[2].data.value).toBe(3); + expect( + deserialized.compressedAccounts[0].meta.treeInfo.leafIndex, + ).toBe(10); + expect( + deserialized.compressedAccounts[1].meta.treeInfo.leafIndex, + ).toBe(20); + expect( + deserialized.compressedAccounts[2].meta.treeInfo.leafIndex, + ).toBe(30); + }); + + it('should handle empty compressed accounts array', () => { + const proof = { + a: Array(32).fill(5), + b: Array(64).fill(6), + c: Array(32).fill(7), + }; + + const data: DecompressAccountsIdempotentInstructionData = + { + proof, + compressedAccounts: [], + systemAccountsOffset: 0, + }; + + const serialized = serializeDecompressIdempotentInstructionData( + data, + TestDataLayout, + ); + + const deserialized = + deserializeDecompressIdempotentInstructionData( + serialized, + TestDataLayout, + ); + + expect(deserialized.compressedAccounts.length).toBe(0); + expect(deserialized.systemAccountsOffset).toBe(0); + }); + + it('should handle various systemAccountsOffset values', () => { + const proof = { + a: Array(32).fill(0), + b: Array(64).fill(0), + c: Array(32).fill(0), + }; + + // Test boundary values + const offsets = [0, 1, 127, 255]; + + for (const offset of offsets) { + const data: DecompressAccountsIdempotentInstructionData = + { + proof, + compressedAccounts: [], + systemAccountsOffset: offset, + }; + + const serialized = serializeDecompressIdempotentInstructionData( + data, + TestDataLayout, + ); + + const deserialized = + deserializeDecompressIdempotentInstructionData( + serialized, + TestDataLayout, + ); + + expect(deserialized.systemAccountsOffset).toBe(offset); + } + }); + }); + + describe('PackedStateTreeInfo', () => { + it('should correctly handle all tree info fields', () => { + const layout = createCompressedAccountDataLayout(TestDataLayout); + + const treeInfo: PackedStateTreeInfo = { + rootIndex: 65535, // max u16 + proveByIndex: true, + merkleTreePubkeyIndex: 255, // max u8 + queuePubkeyIndex: 255, // max u8 + leafIndex: 4294967295, // max u32 + }; + + const meta: CompressedAccountMeta = { + treeInfo, + address: null, + lamports: null, + outputStateTreeIndex: 255, + }; + + const accountData: CompressedAccountData = { + meta, + data: { value: 0, amount: bn(0) }, + seeds: [], + }; + + const buffer = Buffer.alloc(500); + const len = layout.encode(accountData, buffer); + const decoded = layout.decode(buffer.subarray(0, len)); + + expect(decoded.meta.treeInfo.rootIndex).toBe(65535); + expect(decoded.meta.treeInfo.proveByIndex).toBe(true); + expect(decoded.meta.treeInfo.merkleTreePubkeyIndex).toBe(255); + expect(decoded.meta.treeInfo.queuePubkeyIndex).toBe(255); + expect(decoded.meta.treeInfo.leafIndex).toBe(4294967295); + expect(decoded.meta.outputStateTreeIndex).toBe(255); + }); + }); +}); diff --git a/js/compressed-token/tests/unit/layout-token-metadata.test.ts b/js/compressed-token/tests/unit/layout-token-metadata.test.ts new file mode 100644 index 0000000000..58f1bb09f9 --- /dev/null +++ b/js/compressed-token/tests/unit/layout-token-metadata.test.ts @@ -0,0 +1,201 @@ +import { describe, it, expect } from 'vitest'; +import { + toOffChainMetadataJson, + OffChainTokenMetadata, + OffChainTokenMetadataJson, +} from '../../src/v3/layout/layout-token-metadata'; + +describe('layout-token-metadata', () => { + describe('toOffChainMetadataJson', () => { + it('should convert basic metadata', () => { + const meta: OffChainTokenMetadata = { + name: 'My Token', + symbol: 'MTK', + }; + + const json = toOffChainMetadataJson(meta); + + expect(json.name).toBe('My Token'); + expect(json.symbol).toBe('MTK'); + expect(json.description).toBeUndefined(); + expect(json.image).toBeUndefined(); + expect(json.additionalMetadata).toBeUndefined(); + }); + + it('should include description when provided', () => { + const meta: OffChainTokenMetadata = { + name: 'Described Token', + symbol: 'DESC', + description: 'A token with a description', + }; + + const json = toOffChainMetadataJson(meta); + + expect(json.name).toBe('Described Token'); + expect(json.symbol).toBe('DESC'); + expect(json.description).toBe('A token with a description'); + }); + + it('should include image when provided', () => { + const meta: OffChainTokenMetadata = { + name: 'Image Token', + symbol: 'IMG', + image: 'https://example.com/token.png', + }; + + const json = toOffChainMetadataJson(meta); + + expect(json.image).toBe('https://example.com/token.png'); + }); + + it('should include additional metadata when provided', () => { + const meta: OffChainTokenMetadata = { + name: 'Extended Token', + symbol: 'EXT', + additionalMetadata: [ + { key: 'version', value: '1.0' }, + { key: 'creator', value: 'Light Protocol' }, + ], + }; + + const json = toOffChainMetadataJson(meta); + + expect(json.additionalMetadata).toBeDefined(); + expect(json.additionalMetadata?.length).toBe(2); + expect(json.additionalMetadata?.[0]).toEqual({ + key: 'version', + value: '1.0', + }); + expect(json.additionalMetadata?.[1]).toEqual({ + key: 'creator', + value: 'Light Protocol', + }); + }); + + it('should not include additionalMetadata when array is empty', () => { + const meta: OffChainTokenMetadata = { + name: 'No Extra', + symbol: 'NEX', + additionalMetadata: [], + }; + + const json = toOffChainMetadataJson(meta); + + expect(json.additionalMetadata).toBeUndefined(); + }); + + it('should include all fields when all provided', () => { + const meta: OffChainTokenMetadata = { + name: 'Complete Token', + symbol: 'COMP', + description: 'A complete token with all metadata fields', + image: 'https://example.com/complete.png', + additionalMetadata: [ + { key: 'website', value: 'https://complete.com' }, + ], + }; + + const json = toOffChainMetadataJson(meta); + + expect(json.name).toBe('Complete Token'); + expect(json.symbol).toBe('COMP'); + expect(json.description).toBe( + 'A complete token with all metadata fields', + ); + expect(json.image).toBe('https://example.com/complete.png'); + expect(json.additionalMetadata?.length).toBe(1); + }); + + it('should handle empty strings', () => { + const meta: OffChainTokenMetadata = { + name: '', + symbol: '', + description: '', + image: '', + }; + + const json = toOffChainMetadataJson(meta); + + expect(json.name).toBe(''); + expect(json.symbol).toBe(''); + // Empty strings are still included (not undefined) + expect(json.description).toBe(''); + expect(json.image).toBe(''); + }); + + it('should handle unicode characters', () => { + const meta: OffChainTokenMetadata = { + name: 'Token', + symbol: '$TKN', + description: 'Unicode: cafe, 100, ', + }; + + const json = toOffChainMetadataJson(meta); + + expect(json.name).toBe('Token'); + expect(json.symbol).toBe('$TKN'); + expect(json.description).toBe('Unicode: cafe, 100, '); + }); + + it('should handle long strings', () => { + const longName = 'A'.repeat(1000); + const longDescription = 'B'.repeat(5000); + const longUri = 'https://example.com/' + 'C'.repeat(500); + + const meta: OffChainTokenMetadata = { + name: longName, + symbol: 'LONG', + description: longDescription, + image: longUri, + }; + + const json = toOffChainMetadataJson(meta); + + expect(json.name.length).toBe(1000); + expect(json.description?.length).toBe(5000); + // 'https://example.com/' is 20 chars + 500 'C's = 520 + expect(json.image?.length).toBe(520); + }); + + it('should handle special characters in metadata', () => { + const meta: OffChainTokenMetadata = { + name: 'Token "special" chars & more', + symbol: 'SPC', + additionalMetadata: [ + { key: 'json-key', value: '{"nested": "json"}' }, + ], + }; + + const json = toOffChainMetadataJson(meta); + + expect(json.name).toBe('Token "special" chars & more'); + expect(json.additionalMetadata?.[0].value).toBe( + '{"nested": "json"}', + ); + }); + + it('should be JSON serializable', () => { + const meta: OffChainTokenMetadata = { + name: 'Serializable', + symbol: 'SER', + description: 'Can be converted to JSON string', + image: 'https://example.com/ser.png', + additionalMetadata: [{ key: 'test', value: 'value' }], + }; + + const json = toOffChainMetadataJson(meta); + + // Should not throw + const jsonString = JSON.stringify(json); + const parsed = JSON.parse(jsonString) as OffChainTokenMetadataJson; + + expect(parsed.name).toBe('Serializable'); + expect(parsed.symbol).toBe('SER'); + expect(parsed.description).toBe('Can be converted to JSON string'); + expect(parsed.additionalMetadata?.[0]).toEqual({ + key: 'test', + value: 'value', + }); + }); + }); +}); diff --git a/js/compressed-token/tests/unit/layout-transfer2.test.ts b/js/compressed-token/tests/unit/layout-transfer2.test.ts new file mode 100644 index 0000000000..6afa983a13 --- /dev/null +++ b/js/compressed-token/tests/unit/layout-transfer2.test.ts @@ -0,0 +1,449 @@ +import { describe, it, expect } from 'vitest'; +import { + encodeTransfer2InstructionData, + createCompressSpl, + createDecompressCtoken, + createDecompressSpl, + Transfer2InstructionData, + Compression, + TRANSFER2_DISCRIMINATOR, + COMPRESSION_MODE_COMPRESS, + COMPRESSION_MODE_DECOMPRESS, +} from '../../src/v3/layout/layout-transfer2'; + +describe('layout-transfer2', () => { + describe('encodeTransfer2InstructionData', () => { + it('should encode basic transfer instruction data', () => { + const data: Transfer2InstructionData = { + withTransactionHash: false, + withLamportsChangeAccountMerkleTreeIndex: false, + lamportsChangeAccountMerkleTreeIndex: 0, + lamportsChangeAccountOwnerIndex: 0, + outputQueue: 0, + maxTopUp: 0, + cpiContext: null, + compressions: null, + proof: null, + inTokenData: [], + outTokenData: [], + inLamports: null, + outLamports: null, + inTlv: null, + outTlv: null, + }; + + const encoded = encodeTransfer2InstructionData(data); + + // Check discriminator + expect(encoded.subarray(0, 1)).toEqual(TRANSFER2_DISCRIMINATOR); + expect(encoded.length).toBeGreaterThan(1); + }); + + it('should encode with compressions array', () => { + const compressions: Compression[] = [ + { + mode: COMPRESSION_MODE_COMPRESS, + amount: 1000n, + mint: 0, + sourceOrRecipient: 1, + authority: 2, + poolAccountIndex: 3, + poolIndex: 0, + bump: 255, + decimals: 9, + }, + ]; + + const data: Transfer2InstructionData = { + withTransactionHash: true, + withLamportsChangeAccountMerkleTreeIndex: true, + lamportsChangeAccountMerkleTreeIndex: 5, + lamportsChangeAccountOwnerIndex: 3, + outputQueue: 1, + maxTopUp: 100, + cpiContext: null, + compressions, + proof: null, + inTokenData: [], + outTokenData: [], + inLamports: null, + outLamports: null, + inTlv: null, + outTlv: null, + }; + + const encoded = encodeTransfer2InstructionData(data); + + expect(encoded.subarray(0, 1)).toEqual(TRANSFER2_DISCRIMINATOR); + // Encoding with compressions should produce larger buffer + expect(encoded.length).toBeGreaterThan(20); + }); + + it('should encode with input and output token data', () => { + const data: Transfer2InstructionData = { + withTransactionHash: false, + withLamportsChangeAccountMerkleTreeIndex: false, + lamportsChangeAccountMerkleTreeIndex: 0, + lamportsChangeAccountOwnerIndex: 0, + outputQueue: 0, + maxTopUp: 0, + cpiContext: null, + compressions: null, + proof: null, + inTokenData: [ + { + owner: 0, + amount: 500n, + hasDelegate: false, + delegate: 0, + mint: 1, + version: 1, + merkleContext: { + merkleTreePubkeyIndex: 2, + queuePubkeyIndex: 3, + leafIndex: 100, + proveByIndex: true, + }, + rootIndex: 5, + }, + ], + outTokenData: [ + { + owner: 1, + amount: 500n, + hasDelegate: false, + delegate: 0, + mint: 1, + version: 1, + }, + ], + inLamports: null, + outLamports: null, + inTlv: null, + outTlv: null, + }; + + const encoded = encodeTransfer2InstructionData(data); + + expect(encoded.subarray(0, 1)).toEqual(TRANSFER2_DISCRIMINATOR); + }); + + it('should encode with proof', () => { + const proof = { + a: Array(32).fill(1), + b: Array(64).fill(2), + c: Array(32).fill(3), + }; + + const data: Transfer2InstructionData = { + withTransactionHash: false, + withLamportsChangeAccountMerkleTreeIndex: false, + lamportsChangeAccountMerkleTreeIndex: 0, + lamportsChangeAccountOwnerIndex: 0, + outputQueue: 0, + maxTopUp: 0, + cpiContext: null, + compressions: null, + proof, + inTokenData: [], + outTokenData: [], + inLamports: null, + outLamports: null, + inTlv: null, + outTlv: null, + }; + + const encoded = encodeTransfer2InstructionData(data); + + expect(encoded.subarray(0, 1)).toEqual(TRANSFER2_DISCRIMINATOR); + // Proof adds 128 bytes + expect(encoded.length).toBeGreaterThan(128); + }); + + it('should encode with cpiContext', () => { + const data: Transfer2InstructionData = { + withTransactionHash: false, + withLamportsChangeAccountMerkleTreeIndex: false, + lamportsChangeAccountMerkleTreeIndex: 0, + lamportsChangeAccountOwnerIndex: 0, + outputQueue: 0, + maxTopUp: 0, + cpiContext: { + setContext: true, + firstSetContext: true, + cpiContextAccountIndex: 5, + }, + compressions: null, + proof: null, + inTokenData: [], + outTokenData: [], + inLamports: null, + outLamports: null, + inTlv: null, + outTlv: null, + }; + + const encoded = encodeTransfer2InstructionData(data); + + expect(encoded.subarray(0, 1)).toEqual(TRANSFER2_DISCRIMINATOR); + }); + + it('should encode with lamports arrays', () => { + const data: Transfer2InstructionData = { + withTransactionHash: false, + withLamportsChangeAccountMerkleTreeIndex: false, + lamportsChangeAccountMerkleTreeIndex: 0, + lamportsChangeAccountOwnerIndex: 0, + outputQueue: 0, + maxTopUp: 0, + cpiContext: null, + compressions: null, + proof: null, + inTokenData: [], + outTokenData: [], + inLamports: [1000000n, 2000000n], + outLamports: [3000000n], + inTlv: null, + outTlv: null, + }; + + const encoded = encodeTransfer2InstructionData(data); + + expect(encoded.subarray(0, 1)).toEqual(TRANSFER2_DISCRIMINATOR); + }); + + it('should handle large amount values', () => { + const largeAmount = BigInt('18446744073709551615'); // max u64 + + const compressions: Compression[] = [ + { + mode: COMPRESSION_MODE_COMPRESS, + amount: largeAmount, + mint: 0, + sourceOrRecipient: 1, + authority: 2, + poolAccountIndex: 3, + poolIndex: 0, + bump: 255, + decimals: 9, + }, + ]; + + const data: Transfer2InstructionData = { + withTransactionHash: false, + withLamportsChangeAccountMerkleTreeIndex: false, + lamportsChangeAccountMerkleTreeIndex: 0, + lamportsChangeAccountOwnerIndex: 0, + outputQueue: 0, + maxTopUp: 0, + cpiContext: null, + compressions, + proof: null, + inTokenData: [], + outTokenData: [], + inLamports: null, + outLamports: null, + inTlv: null, + outTlv: null, + }; + + // Should not throw + const encoded = encodeTransfer2InstructionData(data); + expect(encoded.length).toBeGreaterThan(1); + }); + }); + + describe('createCompressSpl', () => { + it('should create compression struct for SPL wrap', () => { + const compression = createCompressSpl( + 1000n, // amount + 0, // mintIndex + 1, // sourceIndex + 2, // authorityIndex + 3, // poolAccountIndex + 0, // poolIndex + 255, // bump + ); + + expect(compression.mode).toBe(COMPRESSION_MODE_COMPRESS); + expect(compression.amount).toBe(1000n); + expect(compression.mint).toBe(0); + expect(compression.sourceOrRecipient).toBe(1); + expect(compression.authority).toBe(2); + expect(compression.poolAccountIndex).toBe(3); + expect(compression.poolIndex).toBe(0); + expect(compression.bump).toBe(255); + expect(compression.decimals).toBe(0); + }); + + it('should handle different index values', () => { + const compression = createCompressSpl( + 500n, + 5, // mintIndex + 10, // sourceIndex + 15, // authorityIndex + 20, // poolAccountIndex + 3, // poolIndex + 254, // bump + ); + + expect(compression.mint).toBe(5); + expect(compression.sourceOrRecipient).toBe(10); + expect(compression.authority).toBe(15); + expect(compression.poolAccountIndex).toBe(20); + expect(compression.poolIndex).toBe(3); + expect(compression.bump).toBe(254); + }); + + it('should handle large amounts', () => { + const largeAmount = BigInt('18446744073709551615'); + const compression = createCompressSpl( + largeAmount, + 0, + 1, + 2, + 3, + 0, + 255, + ); + + expect(compression.amount).toBe(largeAmount); + }); + }); + + describe('createDecompressCtoken', () => { + it('should create decompression struct for CToken', () => { + const decompression = createDecompressCtoken( + 2000n, // amount + 0, // mintIndex + 1, // recipientIndex + 5, // tokenProgramIndex + ); + + expect(decompression.mode).toBe(COMPRESSION_MODE_DECOMPRESS); + expect(decompression.amount).toBe(2000n); + expect(decompression.mint).toBe(0); + expect(decompression.sourceOrRecipient).toBe(1); + expect(decompression.authority).toBe(0); + expect(decompression.poolAccountIndex).toBe(5); + expect(decompression.poolIndex).toBe(0); + expect(decompression.bump).toBe(0); + expect(decompression.decimals).toBe(0); + }); + + it('should use default tokenProgramIndex when not provided', () => { + const decompression = createDecompressCtoken( + 1000n, // amount + 0, // mintIndex + 1, // recipientIndex + // tokenProgramIndex not provided + ); + + expect(decompression.poolAccountIndex).toBe(0); + }); + + it('should handle different amounts', () => { + const decompression = createDecompressCtoken(1n, 0, 1); + expect(decompression.amount).toBe(1n); + + const largeDecompression = createDecompressCtoken( + BigInt('18446744073709551615'), + 0, + 1, + ); + expect(largeDecompression.amount).toBe( + BigInt('18446744073709551615'), + ); + }); + }); + + describe('createDecompressSpl', () => { + it('should create decompression struct for SPL', () => { + const decompression = createDecompressSpl( + 3000n, // amount + 0, // mintIndex + 1, // recipientIndex + 2, // poolAccountIndex + 0, // poolIndex + 253, // bump + ); + + expect(decompression.mode).toBe(COMPRESSION_MODE_DECOMPRESS); + expect(decompression.amount).toBe(3000n); + expect(decompression.mint).toBe(0); + expect(decompression.sourceOrRecipient).toBe(1); + expect(decompression.authority).toBe(0); + expect(decompression.poolAccountIndex).toBe(2); + expect(decompression.poolIndex).toBe(0); + expect(decompression.bump).toBe(253); + expect(decompression.decimals).toBe(0); + }); + + it('should handle different pool configurations', () => { + const decompression = createDecompressSpl( + 1000n, + 0, + 1, + 5, // poolAccountIndex + 2, // poolIndex + 200, // bump + ); + + expect(decompression.poolAccountIndex).toBe(5); + expect(decompression.poolIndex).toBe(2); + expect(decompression.bump).toBe(200); + }); + }); + + describe('compression modes', () => { + it('should have correct mode values', () => { + expect(COMPRESSION_MODE_COMPRESS).toBe(0); + expect(COMPRESSION_MODE_DECOMPRESS).toBe(1); + }); + + it('should set correct modes in factory functions', () => { + const compress = createCompressSpl(100n, 0, 1, 2, 3, 0, 255); + expect(compress.mode).toBe(COMPRESSION_MODE_COMPRESS); + + const decompressCtoken = createDecompressCtoken(100n, 0, 1); + expect(decompressCtoken.mode).toBe(COMPRESSION_MODE_DECOMPRESS); + + const decompressSpl = createDecompressSpl(100n, 0, 1, 2, 0, 255); + expect(decompressSpl.mode).toBe(COMPRESSION_MODE_DECOMPRESS); + }); + }); + + describe('encoding roundtrip integration', () => { + it('should encode complex wrap instruction correctly', () => { + const compressions = [ + createCompressSpl(1000n, 0, 2, 1, 4, 0, 255), + createDecompressCtoken(1000n, 0, 3, 6), + ]; + + const data: Transfer2InstructionData = { + withTransactionHash: false, + withLamportsChangeAccountMerkleTreeIndex: false, + lamportsChangeAccountMerkleTreeIndex: 0, + lamportsChangeAccountOwnerIndex: 0, + outputQueue: 0, + maxTopUp: 0, + cpiContext: null, + compressions, + proof: null, + inTokenData: [], + outTokenData: [], + inLamports: null, + outLamports: null, + inTlv: null, + outTlv: null, + }; + + const encoded = encodeTransfer2InstructionData(data); + + // Should have discriminator + expect(encoded.subarray(0, 1)).toEqual(TRANSFER2_DISCRIMINATOR); + // Should be reasonable size (compressions + header) + expect(encoded.length).toBeGreaterThan(30); + }); + }); +}); diff --git a/js/compressed-token/tests/unit/serde.test.ts b/js/compressed-token/tests/unit/serde.test.ts index 8e97dd208c..a4f8c0a8d2 100644 --- a/js/compressed-token/tests/unit/serde.test.ts +++ b/js/compressed-token/tests/unit/serde.test.ts @@ -21,7 +21,7 @@ import { ExtensionType, MINT_CONTEXT_SIZE, MintContextLayout, -} from '../../src/mint/serde'; +} from '../../src/v3'; import { MINT_SIZE } from '@solana/spl-token'; describe('serde', () => { diff --git a/js/compressed-token/tests/unit/unified-guards.test.ts b/js/compressed-token/tests/unit/unified-guards.test.ts new file mode 100644 index 0000000000..da122aca45 --- /dev/null +++ b/js/compressed-token/tests/unit/unified-guards.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'vitest'; +import { Keypair, PublicKey } from '@solana/web3.js'; +import { + TOKEN_PROGRAM_ID, + getAssociatedTokenAddressSync, +} from '@solana/spl-token'; +import { Rpc, CTOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; +import { getAtaProgramId } from '../../src/v3/ata-utils'; + +import { + getAssociatedTokenAddressInterface as unifiedGetAssociatedTokenAddressInterface, + createLoadAtaInstructions as unifiedCreateLoadAtaInstructions, +} from '../../src/v3/unified'; + +describe('unified guards', () => { + it('throws when unified getAssociatedTokenAddressInterface uses non c-token program', () => { + const mint = Keypair.generate().publicKey; + const owner = Keypair.generate().publicKey; + + expect(() => + unifiedGetAssociatedTokenAddressInterface( + mint, + owner, + false, + TOKEN_PROGRAM_ID, + ), + ).toThrow( + 'Please derive the unified ATA from the c-token program; balances across SPL, T22, and c-token are unified under the canonical c-token ATA.', + ); + }); + + it('allows unified getAssociatedTokenAddressInterface with c-token program', () => { + const mint = Keypair.generate().publicKey; + const owner = Keypair.generate().publicKey; + + expect(() => + unifiedGetAssociatedTokenAddressInterface( + mint, + owner, + false, + CTOKEN_PROGRAM_ID, + ), + ).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; + + // 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( + 'Unified loadAta expects ATA to be derived from c-token program. Derive it with getAssociatedTokenAddressInterface.', + ); + }); +}); diff --git a/js/compressed-token/tests/unit/upload.test.ts b/js/compressed-token/tests/unit/upload.test.ts index d6b8de98ed..402b7e6ec5 100644 --- a/js/compressed-token/tests/unit/upload.test.ts +++ b/js/compressed-token/tests/unit/upload.test.ts @@ -3,7 +3,7 @@ import { toOffChainMetadataJson, OffChainTokenMetadata, OffChainTokenMetadataJson, -} from '../../src/mint/upload'; +} from '../../src/v3'; describe('upload', () => { describe('toOffChainMetadataJson', () => { diff --git a/js/stateless.js/rollup.config.js b/js/stateless.js/rollup.config.js index 5dd5706cc6..00136d5037 100644 --- a/js/stateless.js/rollup.config.js +++ b/js/stateless.js/rollup.config.js @@ -14,6 +14,7 @@ const rolls = (fmt, env) => ({ dir: `dist/${fmt}/${env}`, format: fmt, entryFileNames: `[name].${fmt === 'cjs' ? 'cjs' : 'js'}`, + chunkFileNames: `[name]-[hash].${fmt === 'cjs' ? 'cjs' : 'js'}`, sourcemap: true, }, external: ['@solana/web3.js'], diff --git a/js/stateless.js/src/rpc-interface.ts b/js/stateless.js/src/rpc-interface.ts index 5ecedb788e..7fa907ad23 100644 --- a/js/stateless.js/src/rpc-interface.ts +++ b/js/stateless.js/src/rpc-interface.ts @@ -5,6 +5,9 @@ import { Commitment, GetAccountInfoConfig, AccountInfo, + ConfirmedSignatureInfo, + SignaturesForAddressOptions, + TokenAmount, } from '@solana/web3.js'; import { type as pick, @@ -893,6 +896,30 @@ export interface CompressionApiInterface { isCold: boolean; loadContext?: MerkleContext; } | null>; + + getSignaturesForAddressInterface( + address: PublicKey, + options?: SignaturesForAddressOptions, + compressedOptions?: PaginatedOptions, + ): Promise; + + getSignaturesForOwnerInterface( + owner: PublicKey, + options?: SignaturesForAddressOptions, + compressedOptions?: PaginatedOptions, + ): Promise; + + getTokenAccountBalanceInterface( + address: PublicKey, + owner: PublicKey, + mint: PublicKey, + commitment?: Commitment, + ): Promise; + + getBalanceInterface( + address: PublicKey, + commitment?: Commitment, + ): Promise; } // Public types for consumers @@ -913,3 +940,104 @@ export type RpcResultError = { }; export type RpcResult = RpcResultSuccess | RpcResultError; + +/** + * Source type for signature data. + */ +export const SignatureSource = { + /** From standard Solana RPC (getSignaturesForAddress) */ + Solana: 'solana', + /** From compression indexer (getCompressionSignaturesFor*) */ + Compressed: 'compressed', +} as const; + +export type SignatureSourceType = + (typeof SignatureSource)[keyof typeof SignatureSource]; + +/** + * Unified signature info combining data from both Solana RPC and compression indexer. + * + * Design rationale: + * - `sources` array indicates where this signature was found (can be both!) + * - Primary data comes from Solana RPC when available (richer: err, memo, confirmationStatus) + * - Compression-only signatures still included for complete transaction history + */ +export interface UnifiedSignatureInfo { + /** Transaction signature (base58) */ + signature: string; + /** Slot when the transaction was processed */ + slot: number; + /** Block time (unix timestamp), null if not available */ + blockTime: number | null; + /** Transaction error, null if successful. Only from Solana RPC. */ + err: any | null; + /** Memo data. Only from Solana RPC. */ + memo: string | null; + /** Confirmation status. Only from Solana RPC. */ + confirmationStatus?: string; + /** + * Sources where this signature was found. + * - ['solana'] = only in Solana RPC + * - ['compressed'] = only in compression indexer + * - ['solana', 'compressed'] = found in both (compression tx indexed by both) + */ + sources: SignatureSourceType[]; +} + +/** + * Result of getSignaturesForAddressInterface / getSignaturesForOwnerInterface. + * + * Design rationale: + * - `signatures`: Unified view, merged and deduplicated, sorted by slot desc + * - `solana` / `compressed`: Raw responses preserved for clients that need source-specific data + * - Allows callers to use the unified view OR drill into specific sources + */ +export interface SignaturesForAddressInterfaceResult { + /** Merged signatures from all sources, sorted by slot (descending) */ + signatures: UnifiedSignatureInfo[]; + /** Raw signatures from Solana RPC */ + solana: ConfirmedSignatureInfo[]; + /** Raw signatures from compression indexer */ + compressed: SignatureWithMetadata[]; +} + +/** + * Unified token balance combining on-chain and compressed balances. + * + * Design rationale: + * - `amount`: Total balance for display (what user cares about) + * - `onChainAmount` / `compressedAmount`: Breakdown for operations that need to know source + * - `solana`: Raw response preserved for clients needing full TokenAmount (uiAmount, etc) + */ +export interface UnifiedTokenBalance { + /** Total balance (on-chain + compressed) */ + amount: BN; + /** On-chain (hot) token balance */ + onChainAmount: BN; + /** Compressed (cold) token balance */ + compressedAmount: BN; + /** True if any compressed balance exists (signals need for decompress before transfer) */ + hasCompressedBalance: boolean; + /** Token decimals (from on-chain mint or 0 if unknown) */ + decimals: number; + /** Raw Solana RPC TokenAmount response, null if no on-chain account */ + solana: TokenAmount | null; +} + +/** + * Unified SOL balance combining on-chain and compressed balances. + * + * Design rationale: + * - Mirrors UnifiedTokenBalance structure for consistency + * - `hasCompressedBalance` signals to UI that decompress may be needed + */ +export interface UnifiedBalance { + /** Total balance (on-chain + compressed) in lamports */ + total: BN; + /** On-chain balance in lamports */ + onChain: BN; + /** Compressed balance in lamports */ + compressed: BN; + /** True if any compressed balance exists */ + hasCompressedBalance: boolean; +} diff --git a/js/stateless.js/src/rpc.ts b/js/stateless.js/src/rpc.ts index 0c556255fa..ac5daa94ac 100644 --- a/js/stateless.js/src/rpc.ts +++ b/js/stateless.js/src/rpc.ts @@ -6,6 +6,7 @@ import { GetAccountInfoConfig, PublicKey, SolanaJSONRPCError, + SignaturesForAddressOptions, } from '@solana/web3.js'; import { Buffer } from 'buffer'; import { @@ -58,6 +59,12 @@ import { HashWithTreeInfo, DerivationMode, AddressWithTreeInfoV2, + SignaturesForAddressInterfaceResult, + UnifiedSignatureInfo, + UnifiedTokenBalance, + UnifiedBalance, + SignatureSource, + SignatureSourceType, } from './rpc-interface'; import { MerkleContextWithMerkleProof, @@ -89,6 +96,7 @@ import { } from './constants'; import BN from 'bn.js'; import { toCamelCase, toHex } from './utils/conversion'; +import { ConfirmedSignatureInfo } from '@solana/web3.js'; import { proofFromJsonStruct, @@ -620,6 +628,63 @@ function buildCompressedAccountWithMaybeTokenData( return { account: compressedAccount, maybeTokenData: parsed }; } +/** + * Merge signatures from Solana RPC and compression indexer. + * Deduplicates by signature, tracking sources in the `sources` array. + * When a signature exists in both, uses Solana data (richer) but marks both sources. + * @internal + */ +function mergeSignatures( + solanaSignatures: ConfirmedSignatureInfo[], + compressedSignatures: SignatureWithMetadata[], +): UnifiedSignatureInfo[] { + const signatureMap = new Map(); + + // Process compressed signatures first + for (const sig of compressedSignatures) { + signatureMap.set(sig.signature, { + signature: sig.signature, + slot: sig.slot, + blockTime: sig.blockTime, + err: null, + memo: null, + confirmationStatus: undefined, + sources: [SignatureSource.Compressed], + }); + } + + // Process Solana signatures, merging sources if duplicate + for (const sig of solanaSignatures) { + const existing = signatureMap.get(sig.signature); + if (existing) { + // Found in both - use Solana data (richer), add both sources + signatureMap.set(sig.signature, { + signature: sig.signature, + slot: sig.slot, + blockTime: sig.blockTime ?? null, + err: sig.err, + memo: sig.memo ?? null, + confirmationStatus: sig.confirmationStatus, + sources: [SignatureSource.Solana, SignatureSource.Compressed], + }); + } else { + // Only in Solana + signatureMap.set(sig.signature, { + signature: sig.signature, + slot: sig.slot, + blockTime: sig.blockTime ?? null, + err: sig.err, + memo: sig.memo ?? null, + confirmationStatus: sig.confirmationStatus, + sources: [SignatureSource.Solana], + }); + } + } + + // Sort by slot descending (most recent first) + return Array.from(signatureMap.values()).sort((a, b) => b.slot - a.slot); +} + /** * */ @@ -2122,4 +2187,173 @@ export class Rpc extends Connection implements CompressionApiInterface { // account does not exist. return null; } + + /** + * Get signatures for an address from both Solana and compression indexer. + * Merges results by signature, tracking which sources each was found in. + * + * @param address Address to fetch signatures for. + * @param options Options for the Solana getSignaturesForAddress call. + * @param compressedOptions Options for the compression getCompressionSignaturesForAddress call. + * @returns Unified signatures from both sources. + */ + async getSignaturesForAddressInterface( + address: PublicKey, + options?: SignaturesForAddressOptions, + compressedOptions?: PaginatedOptions, + ): Promise { + const [solanaResult, compressedResult] = await Promise.allSettled([ + this.getSignaturesForAddress(address, options), + this.getCompressionSignaturesForAddress(address, compressedOptions), + ]); + + const solanaSignatures = + solanaResult.status === 'fulfilled' ? solanaResult.value : []; + const compressedSignatures = + compressedResult.status === 'fulfilled' + ? compressedResult.value.items + : []; + + const signatures = mergeSignatures( + solanaSignatures, + compressedSignatures, + ); + + return { + signatures, + solana: solanaSignatures, + compressed: compressedSignatures, + }; + } + + /** + * Get signatures for an owner from both Solana and compression indexer. + * Combines Solana getSignaturesForAddress with compression getCompressionSignaturesForOwner. + * This is the recommended method for wallet-style signature lookups. + * + * @param owner Owner address to fetch signatures for. + * @param options Options for the Solana getSignaturesForAddress call. + * @param compressedOptions Options for the compression getCompressionSignaturesForOwner call. + * @returns Unified signatures from both sources. + */ + async getSignaturesForOwnerInterface( + owner: PublicKey, + options?: SignaturesForAddressOptions, + compressedOptions?: PaginatedOptions, + ): Promise { + const [solanaResult, compressedResult] = await Promise.allSettled([ + this.getSignaturesForAddress(owner, options), + this.getCompressionSignaturesForOwner(owner, compressedOptions), + ]); + + const solanaSignatures = + solanaResult.status === 'fulfilled' ? solanaResult.value : []; + const compressedSignatures = + compressedResult.status === 'fulfilled' + ? compressedResult.value.items + : []; + + const signatures = mergeSignatures( + solanaSignatures, + compressedSignatures, + ); + + return { + signatures, + solana: solanaSignatures, + compressed: compressedSignatures, + }; + } + + /** + * Get token account balance from both on-chain and compressed sources. + * + * @param address Token account address (for on-chain lookup). + * @param owner Owner public key (for compressed token lookup). + * @param mint Mint public key (for compressed token lookup). + * @param commitment Commitment level for on-chain query. + * @returns Unified token balance from both sources. + */ + async getTokenAccountBalanceInterface( + address: PublicKey, + owner: PublicKey, + mint: PublicKey, + commitment?: Commitment, + ): Promise { + const [onChainResult, compressedResult] = await Promise.allSettled([ + this.getTokenAccountBalance(address, commitment), + this.getCompressedTokenBalancesByOwner(owner, { mint }), + ]); + + // Parse on-chain result + let onChainAmount = bn(0); + let decimals = 0; + let solanaTokenAmount = null; + + if (onChainResult.status === 'fulfilled' && onChainResult.value) { + const value = onChainResult.value.value; + onChainAmount = bn(value.amount); + decimals = value.decimals; + solanaTokenAmount = value; + } + + // Parse compressed result + let compressedAmount = bn(0); + if (compressedResult.status === 'fulfilled') { + const items = compressedResult.value.items; + // Filter by mint and sum up balances + const matchingBalances = items.filter(item => + item.mint.equals(mint), + ); + for (const balance of matchingBalances) { + compressedAmount = compressedAmount.add(balance.balance); + } + } + + const total = onChainAmount.add(compressedAmount); + + return { + amount: total, + onChainAmount, + compressedAmount, + hasCompressedBalance: !compressedAmount.isZero(), + decimals, + solana: solanaTokenAmount, + }; + } + + /** + * Get SOL balance from both on-chain and compressed sources. + * + * @param address Address to fetch balance for. + * @param commitment Commitment level for on-chain query. + * @returns Unified SOL balance from both sources. + */ + async getBalanceInterface( + address: PublicKey, + commitment?: Commitment, + ): Promise { + const [onChainResult, compressedResult] = await Promise.allSettled([ + this.getBalance(address, commitment), + this.getCompressedBalanceByOwner(address), + ]); + + const onChainBalance = + onChainResult.status === 'fulfilled' + ? bn(onChainResult.value) + : bn(0); + const compressedBalance = + compressedResult.status === 'fulfilled' + ? compressedResult.value + : bn(0); + + const total = onChainBalance.add(compressedBalance); + + return { + total, + onChain: onChainBalance, + compressed: compressedBalance, + hasCompressedBalance: !compressedBalance.isZero(), + }; + } } diff --git a/js/stateless.js/src/test-helpers/test-rpc/test-rpc.ts b/js/stateless.js/src/test-helpers/test-rpc/test-rpc.ts index ccbee7d71d..0686510f02 100644 --- a/js/stateless.js/src/test-helpers/test-rpc/test-rpc.ts +++ b/js/stateless.js/src/test-helpers/test-rpc/test-rpc.ts @@ -996,4 +996,42 @@ export class TestRpc extends Connection implements CompressionApiInterface { ): Promise { throw new Error('getAccountInfoInterface not implemented in TestRpc'); } + + async getSignaturesForAddressInterface( + _address: PublicKey, + _options?: any, + _compressedOptions?: PaginatedOptions, + ): Promise { + throw new Error( + 'getSignaturesForAddressInterface not implemented in TestRpc', + ); + } + + async getSignaturesForOwnerInterface( + _owner: PublicKey, + _options?: any, + _compressedOptions?: PaginatedOptions, + ): Promise { + throw new Error( + 'getSignaturesForOwnerInterface not implemented in TestRpc', + ); + } + + async getTokenAccountBalanceInterface( + _address: PublicKey, + _owner: PublicKey, + _mint: PublicKey, + _commitment?: any, + ): Promise { + throw new Error( + 'getTokenAccountBalanceInterface not implemented in TestRpc', + ); + } + + async getBalanceInterface( + _address: PublicKey, + _commitment?: any, + ): Promise { + throw new Error('getBalanceInterface not implemented in TestRpc'); + } } diff --git a/js/stateless.js/src/utils/get-state-tree-infos.ts b/js/stateless.js/src/utils/get-state-tree-infos.ts index f77794aecc..8c13115de2 100644 --- a/js/stateless.js/src/utils/get-state-tree-infos.ts +++ b/js/stateless.js/src/utils/get-state-tree-infos.ts @@ -1,7 +1,31 @@ import { Connection, PublicKey } from '@solana/web3.js'; import { TreeInfo, TreeType } from '../state/types'; +import { MerkleContext } from '../state/compressed-account'; import { featureFlags, StateTreeLUTPair } from '../constants'; +/** + * Get the currently active output queue from a merkle context. + * + * @param merkleContext The merkle context to get the output queue from + * @returns The output queue public key + */ +export function getOutputQueue(merkleContext: MerkleContext): PublicKey { + return ( + merkleContext.treeInfo.nextTreeInfo?.queue ?? + merkleContext.treeInfo.queue + ); +} + +/** + * Get the currently active output tree info from a merkle context. + * + * @param merkleContext The merkle context to get the output tree info from + * @returns The output tree info + */ +export function getOutputTreeInfo(merkleContext: MerkleContext): TreeInfo { + return merkleContext.treeInfo.nextTreeInfo ?? merkleContext.treeInfo; +} + /** * @deprecated use {@link getTreeInfoByPubkey} instead */ diff --git a/js/stateless.js/tests/e2e/interface-methods.test.ts b/js/stateless.js/tests/e2e/interface-methods.test.ts new file mode 100644 index 0000000000..d92c400d48 --- /dev/null +++ b/js/stateless.js/tests/e2e/interface-methods.test.ts @@ -0,0 +1,319 @@ +import { describe, it, assert, beforeAll, expect } from 'vitest'; +import { PublicKey, Signer } from '@solana/web3.js'; +import { newAccountWithLamports } from '../../src/test-helpers/test-utils'; +import { Rpc, createRpc } from '../../src/rpc'; +import { bn, compress, selectStateTreeInfo, sleep, TreeInfo } from '../../src'; +import { transfer } from '../../src/actions/transfer'; + +describe('interface-methods', () => { + let payer: Signer; + let bob: Signer; + let rpc: Rpc; + let stateTreeInfo: TreeInfo; + let transferSignature: string; + + beforeAll(async () => { + rpc = createRpc(); + + payer = await newAccountWithLamports(rpc, 10e9, 256); + bob = await newAccountWithLamports(rpc, 10e9, 256); + + const stateTreeInfos = await rpc.getStateTreeInfos(); + stateTreeInfo = selectStateTreeInfo(stateTreeInfos); + + // Create compressed SOL for testing + await compress(rpc, payer, 1e9, payer.publicKey, stateTreeInfo); + + // Perform a transfer to generate compression signatures + transferSignature = await transfer( + rpc, + payer, + 1e5, + payer, + bob.publicKey, + ); + }); + + describe('getBalanceInterface', () => { + it('should return unified balance with both on-chain and compressed', async () => { + const result = await rpc.getBalanceInterface(payer.publicKey); + + // Should have both on-chain and compressed components + assert.isTrue( + result.total.gt(bn(0)), + 'Total balance should be > 0', + ); + assert.isTrue( + result.onChain.gte(bn(0)), + 'On-chain balance should be >= 0', + ); + assert.isTrue( + result.compressed.gte(bn(0)), + 'Compressed balance should be >= 0', + ); + + // Total should equal sum of parts + assert.isTrue( + result.total.eq(result.onChain.add(result.compressed)), + 'Total should equal on-chain + compressed', + ); + + // After compress(), payer should have compressed balance + assert.isTrue( + result.hasCompressedBalance, + 'Should have compressed balance after compress()', + ); + }); + + it('should work for address with only on-chain balance', async () => { + // Create fresh account with only on-chain lamports + const freshAccount = await newAccountWithLamports(rpc, 1e9, 256); + + const result = await rpc.getBalanceInterface( + freshAccount.publicKey, + ); + + assert.isTrue(result.total.gt(bn(0))); + assert.isTrue(result.onChain.gt(bn(0))); + assert.isTrue(result.compressed.eq(bn(0))); + assert.isFalse(result.hasCompressedBalance); + }); + + it('should work for address with only compressed balance', async () => { + // Bob received compressed SOL via transfer + const result = await rpc.getBalanceInterface(bob.publicKey); + + // Bob has both on-chain (initial) and compressed (from transfer) + assert.isTrue(result.total.gt(bn(0))); + }); + }); + + describe('getSignaturesForAddressInterface', () => { + it('should return merged signatures from both sources', async () => { + // Wait for indexer to catch up + await sleep(2000); + + // Note: getCompressionSignaturesForAddress uses compressed account ADDRESS (not owner) + // For most practical use cases, compression sigs won't match regular address sigs + // unless the address has compressed accounts with that specific address field + const result = await rpc.getSignaturesForAddressInterface( + payer.publicKey, + ); + + // Should have merged signatures array + assert.isArray(result.signatures); + + // Should have separate arrays for each source + assert.isArray(result.solana); + assert.isArray(result.compressed); + + // The Solana RPC should return signatures for payer's regular transactions + assert.isAtLeast( + result.solana.length, + 1, + 'Should have at least one solana signature for payer', + ); + }); + + it('should have proper unified signature structure with sources array', async () => { + await sleep(2000); + + const result = await rpc.getSignaturesForAddressInterface( + payer.publicKey, + ); + + // Check structure of unified signatures + if (result.signatures.length > 0) { + const sig = result.signatures[0]; + assert.isString(sig.signature); + assert.isNumber(sig.slot); + assert.isDefined(sig.blockTime); + assert.isDefined(sig.err); + assert.isDefined(sig.memo); + // sources is an array of source types + assert.isArray(sig.sources); + assert.isAtLeast(sig.sources.length, 1); + // Each source should be 'solana' or 'compressed' + for (const source of sig.sources) { + assert.include(['solana', 'compressed'], source); + } + } + }); + + it('should sort signatures by slot descending', async () => { + await sleep(2000); + + const result = await rpc.getSignaturesForAddressInterface( + payer.publicKey, + ); + + // Verify descending order by slot + for (let i = 1; i < result.signatures.length; i++) { + assert.isTrue( + result.signatures[i - 1].slot >= result.signatures[i].slot, + `Signatures should be sorted by slot descending at index ${i}`, + ); + } + }); + + it('should deduplicate signatures preferring solana data', async () => { + await sleep(2000); + + const result = await rpc.getSignaturesForAddressInterface( + payer.publicKey, + ); + + // Check for duplicates + const sigSet = new Set(); + for (const sig of result.signatures) { + assert.isFalse( + sigSet.has(sig.signature), + `Duplicate signature found: ${sig.signature}`, + ); + sigSet.add(sig.signature); + } + }); + }); + + describe('getSignaturesForOwnerInterface', () => { + it('should return merged signatures from both sources by owner', async () => { + // Wait for indexer to catch up + await sleep(2000); + + const result = await rpc.getSignaturesForOwnerInterface( + payer.publicKey, + ); + + // Should have merged signatures array + assert.isArray(result.signatures); + + // Should have separate arrays for each source + assert.isArray(result.solana); + assert.isArray(result.compressed); + + // Solana should have signatures for payer + assert.isAtLeast( + result.solana.length, + 1, + 'Should have at least one solana signature', + ); + + // Compression should have signatures for owner who did compress/transfer + assert.isAtLeast( + result.compressed.length, + 1, + 'Should have at least one compressed signature for owner', + ); + }); + + it('should track sources correctly for compression signatures', async () => { + await sleep(2000); + + const result = await rpc.getSignaturesForOwnerInterface( + payer.publicKey, + ); + + // The raw compressed list should have entries (payer did compress/transfer) + assert.isAtLeast(result.compressed.length, 1); + + // Find signatures that include 'compressed' in their sources + const withCompressedSource = result.signatures.filter(sig => + sig.sources.includes('compressed'), + ); + + // Should have at least one signature from compression indexer + assert.isAtLeast( + withCompressedSource.length, + 1, + 'Should have signatures with compressed source', + ); + + // Signatures found in both should have both sources + const inBoth = result.signatures.filter( + sig => + sig.sources.includes('solana') && + sig.sources.includes('compressed'), + ); + // This is possible if same tx is indexed by both + assert.isArray(inBoth); + }); + + it('should sort by slot descending', async () => { + await sleep(2000); + + const result = await rpc.getSignaturesForOwnerInterface( + payer.publicKey, + ); + + for (let i = 1; i < result.signatures.length; i++) { + assert.isTrue( + result.signatures[i - 1].slot >= result.signatures[i].slot, + `Signatures should be sorted by slot descending`, + ); + } + }); + + it('should deduplicate signatures', async () => { + await sleep(2000); + + const result = await rpc.getSignaturesForOwnerInterface( + payer.publicKey, + ); + + const sigSet = new Set(); + for (const sig of result.signatures) { + assert.isFalse( + sigSet.has(sig.signature), + `Duplicate signature found: ${sig.signature}`, + ); + sigSet.add(sig.signature); + } + }); + }); + + describe('getTokenAccountBalanceInterface', () => { + it('should return zero balance for non-existent token account', async () => { + // Use a random mint that doesn't exist + const randomMint = PublicKey.unique(); + const randomAta = PublicKey.unique(); + + const result = await rpc.getTokenAccountBalanceInterface( + randomAta, + payer.publicKey, + randomMint, + ); + + // Both should be zero for non-existent accounts + assert.isTrue(result.amount.eq(bn(0))); + assert.isTrue(result.onChainAmount.eq(bn(0))); + assert.isTrue(result.compressedAmount.eq(bn(0))); + assert.isFalse(result.hasCompressedBalance); + assert.isNull(result.solana); + }); + + it('should have correct structure', async () => { + const randomMint = PublicKey.unique(); + const randomAta = PublicKey.unique(); + + const result = await rpc.getTokenAccountBalanceInterface( + randomAta, + payer.publicKey, + randomMint, + ); + + // Verify structure + assert.isDefined(result.amount); + assert.isDefined(result.onChainAmount); + assert.isDefined(result.compressedAmount); + assert.isDefined(result.hasCompressedBalance); + assert.isDefined(result.decimals); + // solana can be null + assert.isTrue('solana' in result); + + // Amount should be BN + assert.isTrue(result.amount instanceof bn(0).constructor); + assert.isTrue(result.onChainAmount instanceof bn(0).constructor); + assert.isTrue(result.compressedAmount instanceof bn(0).constructor); + }); + }); +}); diff --git a/js/stateless.js/tests/e2e/rpc-interop.test.ts b/js/stateless.js/tests/e2e/rpc-interop.test.ts index b706b9b527..f41f60a91c 100644 --- a/js/stateless.js/tests/e2e/rpc-interop.test.ts +++ b/js/stateless.js/tests/e2e/rpc-interop.test.ts @@ -502,6 +502,21 @@ describe('rpc-interop', () => { payer.publicKey, ); + console.log( + 'senderAccounts', + senderAccounts.items.map( + account => + account.hash.toString() + ' ' + account.lamports.toString(), + ), + ); + console.log( + 'senderAccountsTest', + senderAccountsTest.items.map( + account => + account.hash.toString() + ' ' + account.lamports.toString(), + ), + ); + assert.equal( senderAccounts.items.length, senderAccountsTest.items.length, @@ -610,25 +625,21 @@ describe('rpc-interop', () => { }); // Skip in V2: test depends on createAccount tests running before it (executedTxs count) - it.skipIf(featureFlags.isV2())( - '[test-rpc missing] getCompressionSignaturesForAccount should match', - async () => { - const senderAccounts = await rpc.getCompressedAccountsByOwner( - payer.publicKey, - ); + it('[test-rpc missing] getCompressionSignaturesForAccount should match', async () => { + const senderAccounts = await rpc.getCompressedAccountsByOwner( + payer.publicKey, + ); - await transfer(rpc, payer, 1, payer, bob.publicKey); + await transfer(rpc, payer, 1, payer, bob.publicKey); - executedTxs++; - const signaturesSpent = - await rpc.getCompressionSignaturesForAccount( - bn(senderAccounts.items[0].hash), - ); + executedTxs++; + const signaturesSpent = await rpc.getCompressionSignaturesForAccount( + bn(senderAccounts.items[0].hash), + ); - /// 1 spent account, so always 2 signatures. - assert.equal(signaturesSpent.length, 2); - }, - ); + /// 1 spent account, so always 2 signatures. + assert.equal(signaturesSpent.length, 2); + }); it('[test-rpc missing] getSignaturesForOwner should match', async () => { const signatures = await rpc.getCompressionSignaturesForOwner( @@ -672,28 +683,19 @@ describe('rpc-interop', () => { }); // Skip in V2: depends on getCompressionSignaturesForAccount having run a transfer - it.skipIf(featureFlags.isV2())( - '[test-rpc missing] getCompressedTransaction should match', - async () => { - const signatures = await rpc.getCompressionSignaturesForOwner( - payer.publicKey, - ); + it('[test-rpc missing] getCompressedTransaction should match', async () => { + const signatures = await rpc.getCompressionSignaturesForOwner( + payer.publicKey, + ); - const compressedTx = await rpc.getTransactionWithCompressionInfo( - signatures.items[0].signature, - ); + const compressedTx = await rpc.getTransactionWithCompressionInfo( + signatures.items[0].signature, + ); - /// is transfer - assert.equal( - compressedTx?.compressionInfo.closedAccounts.length, - 1, - ); - assert.equal( - compressedTx?.compressionInfo.openedAccounts.length, - 2, - ); - }, - ); + /// is transfer + assert.equal(compressedTx?.compressionInfo.closedAccounts.length, 1); + assert.equal(compressedTx?.compressionInfo.openedAccounts.length, 2); + }); // Skip in V2: createAccount is only supported via CPI in V2 it.skipIf(featureFlags.isV2())( diff --git a/scripts/devenv/versions.sh b/scripts/devenv/versions.sh index c714af38b0..af49212935 100755 --- a/scripts/devenv/versions.sh +++ b/scripts/devenv/versions.sh @@ -14,7 +14,7 @@ export ANCHOR_VERSION="0.31.1" export JQ_VERSION="1.8.0" export PHOTON_VERSION="0.51.2" export PHOTON_COMMIT="711c47b20330c6bb78feb0a2c15e8292fcd0a7b0" -#df1087d55a8ff237ff69495a48542461a972f4fe +#8bc3a8baeda38c62a7ac71be1d51d5c28e783842 export REDIS_VERSION="8.0.1" export ANCHOR_TAG="anchor-v${ANCHOR_VERSION}" From 20028722f7568421cd5dcecf56ff6f08c249a3fb Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Mon, 8 Dec 2025 07:53:53 -0500 Subject: [PATCH 03/13] fmt --- .../tests/e2e/create-ata-interface.test.ts | 4 +-- .../tests/e2e/create-mint-interface.test.ts | 36 +++++++++++++++---- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/js/compressed-token/tests/e2e/create-ata-interface.test.ts b/js/compressed-token/tests/e2e/create-ata-interface.test.ts index 08dced7edf..55c673b72a 100644 --- a/js/compressed-token/tests/e2e/create-ata-interface.test.ts +++ b/js/compressed-token/tests/e2e/create-ata-interface.test.ts @@ -676,8 +676,8 @@ describe('createAtaInterface', () => { expect(successful.length).toBeGreaterThan(0); // All successful results should have same address - const addresses = successful.map( - r => (r as PromiseFulfilledResult).value.address.toBase58(), + const addresses = successful.map(r => + (r as PromiseFulfilledResult).value.address.toBase58(), ); const uniqueAddresses = [...new Set(addresses)]; expect(uniqueAddresses.length).toBe(1); diff --git a/js/compressed-token/tests/e2e/create-mint-interface.test.ts b/js/compressed-token/tests/e2e/create-mint-interface.test.ts index e10e0e15a2..d55eedea8c 100644 --- a/js/compressed-token/tests/e2e/create-mint-interface.test.ts +++ b/js/compressed-token/tests/e2e/create-mint-interface.test.ts @@ -177,7 +177,12 @@ describe('createMintInterface', () => { expect(mint.toBase58()).toBe(mintKeypair.publicKey.toBase58()); - const fetchedMint = await getMint(rpc, mint, undefined, TOKEN_PROGRAM_ID); + const fetchedMint = await getMint( + rpc, + mint, + undefined, + TOKEN_PROGRAM_ID, + ); expect(fetchedMint.mintAuthority?.toBase58()).toBe( mintAuthority.publicKey.toBase58(), ); @@ -203,7 +208,12 @@ describe('createMintInterface', () => { await rpc.confirmTransaction(transactionSignature, 'confirmed'); - const fetchedMint = await getMint(rpc, mint, undefined, TOKEN_PROGRAM_ID); + const fetchedMint = await getMint( + rpc, + mint, + undefined, + TOKEN_PROGRAM_ID, + ); expect(fetchedMint.freezeAuthority?.toBase58()).toBe( freezeAuthority.publicKey.toBase58(), ); @@ -226,7 +236,12 @@ describe('createMintInterface', () => { await rpc.confirmTransaction(transactionSignature, 'confirmed'); - const fetchedMint = await getMint(rpc, mint, undefined, TOKEN_PROGRAM_ID); + const fetchedMint = await getMint( + rpc, + mint, + undefined, + TOKEN_PROGRAM_ID, + ); expect(fetchedMint.decimals).toBe(0); }); }); @@ -336,7 +351,12 @@ describe('createMintInterface', () => { await rpc.confirmTransaction(transactionSignature, 'confirmed'); - const fetchedMint = await getMint(rpc, mint, undefined, TOKEN_PROGRAM_ID); + const fetchedMint = await getMint( + rpc, + mint, + undefined, + TOKEN_PROGRAM_ID, + ); expect(fetchedMint.decimals).toBe(9); }); }); @@ -392,8 +412,12 @@ describe('createMintInterface', () => { expect(ctokenMint.toBase58()).toBe(ctokenMintPda.toBase58()); // SPL/T22 mints should be keypair pubkeys - expect(splMint.toBase58()).toBe(splMintKeypair.publicKey.toBase58()); - expect(t22Mint.toBase58()).toBe(t22MintKeypair.publicKey.toBase58()); + expect(splMint.toBase58()).toBe( + splMintKeypair.publicKey.toBase58(), + ); + expect(t22Mint.toBase58()).toBe( + t22MintKeypair.publicKey.toBase58(), + ); }); }); }); From e5c280e305a371dd529c02b976f2eef1a9c6b1a8 Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Mon, 8 Dec 2025 08:54:09 -0500 Subject: [PATCH 04/13] fix test --- cli/src/utils/constants.ts | 1 - .../src/utils/get-token-pool-infos.ts | 234 +++++++++++++----- js/compressed-token/tests/e2e/layout.test.ts | 76 +++++- .../tests/e2e/multi-pool.test.ts | 2 +- .../tests/unit/derive-token-pool-info.test.ts | 13 +- scripts/devenv/versions.sh | 1 - 6 files changed, 252 insertions(+), 75 deletions(-) diff --git a/cli/src/utils/constants.ts b/cli/src/utils/constants.ts index b298bc3798..e2f527654d 100644 --- a/cli/src/utils/constants.ts +++ b/cli/src/utils/constants.ts @@ -25,7 +25,6 @@ export const PHOTON_VERSION = "0.51.2"; export const USE_PHOTON_FROM_GIT = true; // If true, will show git install command instead of crates.io. export const PHOTON_GIT_REPO = "https://github.com/lightprotocol/photon.git"; export const PHOTON_GIT_COMMIT = "711c47b20330c6bb78feb0a2c15e8292fcd0a7b0"; // If empty, will use main branch. -//8bc3a8baeda38c62a7ac71be1d51d5c28e783842 export const LIGHT_PROTOCOL_PROGRAMS_DIR_ENV = "LIGHT_PROTOCOL_PROGRAMS_DIR"; export const BASE_PATH = "../../bin/"; diff --git a/js/compressed-token/src/utils/get-token-pool-infos.ts b/js/compressed-token/src/utils/get-token-pool-infos.ts index bcbdb22c14..df302af9db 100644 --- a/js/compressed-token/src/utils/get-token-pool-infos.ts +++ b/js/compressed-token/src/utils/get-token-pool-infos.ts @@ -4,6 +4,132 @@ import { CompressedTokenProgram } from '../program'; import { bn, Rpc } from '@lightprotocol/stateless.js'; import BN from 'bn.js'; +/** + * SPL interface PDA info. + */ +export type SplInterfaceInfo = { + /** + * The mint of the SPL interface + */ + mint: PublicKey; + /** + * The SPL interface address + */ + splInterfacePda: PublicKey; + /** + * The token program of the SPL interface + */ + tokenProgram: PublicKey; + /** + * count of txs and volume in the past 60 seconds. + */ + activity?: { + txs: number; + amountAdded: BN; + amountRemoved: BN; + }; + /** + * Whether the SPL interface is initialized + */ + isInitialized: boolean; + /** + * The balance of the SPL interface + */ + balance: BN; + /** + * The index of the SPL interface + */ + poolIndex: number; + /** + * The bump used to derive the SPL interface PDA + */ + bump: number; +}; + +/** + * @deprecated Use {@link SplInterfaceInfo} instead. + * This type maintains backward compatibility by including both tokenPoolPda and splInterfacePda. + * Both properties point to the same PublicKey value. + */ +export type TokenPoolInfo = { + /** + * The mint of the SPL interface + */ + mint: PublicKey; + /** + * @deprecated Use splInterfacePda instead. + */ + tokenPoolPda: PublicKey; + /** + * The SPL interface address (new name). + * For backward compatibility, tokenPoolPda is also available. + */ + splInterfacePda: PublicKey; + /** + * The token program of the SPL interface + */ + tokenProgram: PublicKey; + /** + * count of txs and volume in the past 60 seconds. + */ + activity?: { + txs: number; + amountAdded: BN; + amountRemoved: BN; + }; + /** + * Whether the SPL interface is initialized + */ + isInitialized: boolean; + /** + * The balance of the SPL interface + */ + balance: BN; + /** + * The index of the SPL interface + */ + poolIndex: number; + /** + * The bump used to derive the SPL interface PDA + */ + bump: number; +}; + +/** + * Convert SplInterfaceInfo to TokenPoolInfo for backward compatibility. + * @internal + */ +export function toTokenPoolInfo(info: SplInterfaceInfo): TokenPoolInfo { + return { + mint: info.mint, + tokenPoolPda: info.splInterfacePda, + splInterfacePda: info.splInterfacePda, + tokenProgram: info.tokenProgram, + activity: info.activity, + isInitialized: info.isInitialized, + balance: info.balance, + poolIndex: info.poolIndex, + bump: info.bump, + }; +} + +/** + * Convert TokenPoolInfo to SplInterfaceInfo. + * @internal + */ +function toSplInterfaceInfo(info: TokenPoolInfo): SplInterfaceInfo { + return { + mint: info.mint, + splInterfacePda: info.tokenPoolPda ?? info.splInterfacePda, + tokenProgram: info.tokenProgram, + activity: info.activity, + isInitialized: info.isInitialized, + balance: info.balance, + poolIndex: info.poolIndex, + bump: info.bump, + }; +} + /** * Derive SplInterfaceInfo for an SPL interface that will be initialized in the * same transaction. Use this when you need to create an SPL interface and @@ -38,18 +164,25 @@ export function deriveSplInterfaceInfo( /** * Check if the SPL interface info is initialized and has a balance. * @param mint The mint of the SPL interface - * @param splInterfaceInfo The SPL interface info + * @param splInterfaceInfo The SPL interface info (or TokenPoolInfo for backward compatibility) * @returns True if the SPL interface info is initialized and has a balance */ export function checkSplInterfaceInfo( - splInterfaceInfo: SplInterfaceInfo, + splInterfaceInfo: SplInterfaceInfo | TokenPoolInfo, mint: PublicKey, ): boolean { - if (!splInterfaceInfo.mint.equals(mint)) { + // Handle backward compatibility with TokenPoolInfo + // TokenPoolInfo has both tokenPoolPda and splInterfacePda, so we can use either + const info: SplInterfaceInfo = + 'tokenPoolPda' in splInterfaceInfo + ? toSplInterfaceInfo(splInterfaceInfo as TokenPoolInfo) + : (splInterfaceInfo as SplInterfaceInfo); + + if (!info.mint.equals(mint)) { throw new Error(`SplInterface mint does not match the provided mint.`); } - if (!splInterfaceInfo.isInitialized) { + if (!info.isInitialized) { throw new Error( `SplInterface is not initialized. Please create an SPL interface for mint: ${mint.toBase58()} via createSplInterface().`, ); @@ -129,48 +262,6 @@ export type SplInterfaceActivity = { action: Action; }; -/** - * SPL interface PDA info. - */ -export type SplInterfaceInfo = { - /** - * The mint of the SPL interface - */ - mint: PublicKey; - /** - * The SPL interface address - */ - splInterfacePda: PublicKey; - /** - * The token program of the SPL interface - */ - tokenProgram: PublicKey; - /** - * count of txs and volume in the past 60 seconds. - */ - activity?: { - txs: number; - amountAdded: BN; - amountRemoved: BN; - }; - /** - * Whether the SPL interface is initialized - */ - isInitialized: boolean; - /** - * The balance of the SPL interface - */ - balance: BN; - /** - * The index of the SPL interface - */ - poolIndex: number; - /** - * The bump used to derive the SPL interface PDA - */ - bump: number; -}; - /** * @internal */ @@ -262,14 +353,9 @@ export function selectSplInterfaceInfosForDecompression( } // ============================================================================= -// DEPRECATED ALIASES - Use the new SplInterface* names instead +// DEPRECATED TYPES AND FUNCTIONS - Use the new SplInterface* names instead // ============================================================================= -/** - * @deprecated Use {@link SplInterfaceInfo} instead. - */ -export type TokenPoolInfo = SplInterfaceInfo; - /** * @deprecated Use {@link SplInterfaceActivity} instead. */ @@ -278,25 +364,57 @@ export type TokenPoolActivity = SplInterfaceActivity; /** * @deprecated Use {@link deriveSplInterfaceInfo} instead. */ -export const deriveTokenPoolInfo = deriveSplInterfaceInfo; +export function deriveTokenPoolInfo( + mint: PublicKey, + tokenProgramId: PublicKey, + poolIndex = 0, +): TokenPoolInfo { + const info = deriveSplInterfaceInfo(mint, tokenProgramId, poolIndex); + return toTokenPoolInfo(info); +} /** * @deprecated Use {@link checkSplInterfaceInfo} instead. */ -export const checkTokenPoolInfo = checkSplInterfaceInfo; +export function checkTokenPoolInfo( + tokenPoolInfo: TokenPoolInfo, + mint: PublicKey, +): boolean { + return checkSplInterfaceInfo(toSplInterfaceInfo(tokenPoolInfo), mint); +} /** * @deprecated Use {@link getSplInterfaceInfos} instead. */ -export const getTokenPoolInfos = getSplInterfaceInfos; +export async function getTokenPoolInfos( + rpc: Rpc, + mint: PublicKey, + commitment?: Commitment, +): Promise { + const infos = await getSplInterfaceInfos(rpc, mint, commitment); + return infos.map(toTokenPoolInfo); +} /** * @deprecated Use {@link selectSplInterfaceInfo} instead. */ -export const selectTokenPoolInfo = selectSplInterfaceInfo; +export function selectTokenPoolInfo(infos: TokenPoolInfo[]): TokenPoolInfo { + const splInfos = infos.map(toSplInterfaceInfo); + const selected = selectSplInterfaceInfo(splInfos); + return toTokenPoolInfo(selected); +} /** * @deprecated Use {@link selectSplInterfaceInfosForDecompression} instead. */ -export const selectTokenPoolInfosForDecompression = - selectSplInterfaceInfosForDecompression; +export function selectTokenPoolInfosForDecompression( + infos: TokenPoolInfo[], + decompressAmount: number | BN, +): TokenPoolInfo[] { + const splInfos = infos.map(toSplInterfaceInfo); + const selected = selectSplInterfaceInfosForDecompression( + splInfos, + decompressAmount, + ); + return selected.map(toTokenPoolInfo); +} diff --git a/js/compressed-token/tests/e2e/layout.test.ts b/js/compressed-token/tests/e2e/layout.test.ts index 46a9bb32da..a2f6f14c27 100644 --- a/js/compressed-token/tests/e2e/layout.test.ts +++ b/js/compressed-token/tests/e2e/layout.test.ts @@ -33,6 +33,11 @@ import { PackedTokenTransferOutputData, selectTokenPoolInfo, selectTokenPoolInfosForDecompression, + SplInterfaceInfo, + selectSplInterfaceInfo, + selectSplInterfaceInfosForDecompression, + TokenPoolInfo, + toTokenPoolInfo, } from '../../src/'; import { Keypair } from '@solana/web3.js'; import { Connection } from '@solana/web3.js'; @@ -758,11 +763,11 @@ describe('layout', () => { }); }); -describe('selectTokenPoolInfo', () => { - const infos = [ +describe('selectSplInterfaceInfo', () => { + const infos: SplInterfaceInfo[] = [ { mint: new PublicKey('GyFUUg2iDsGZpaxceUNQAdXfFXzraekDzbBjhS7bkTA6'), - tokenPoolPda: new PublicKey( + splInterfacePda: new PublicKey( '5d77eGcKa1CDRJrHeohyT1igCCPX9SYWqBd6NZqsWMyt', ), tokenProgram: new PublicKey( @@ -776,7 +781,7 @@ describe('selectTokenPoolInfo', () => { }, { mint: new PublicKey('GyFUUg2iDsGZpaxceUNQAdXfFXzraekDzbBjhS7bkTA6'), - tokenPoolPda: new PublicKey( + splInterfacePda: new PublicKey( 'CqZ5Wv44cEn2R88hrftMdWowiyPhAuLLRzj1BXyq2Kz7', ), tokenProgram: new PublicKey( @@ -790,7 +795,7 @@ describe('selectTokenPoolInfo', () => { }, { mint: new PublicKey('GyFUUg2iDsGZpaxceUNQAdXfFXzraekDzbBjhS7bkTA6'), - tokenPoolPda: new PublicKey( + splInterfacePda: new PublicKey( '4ne3Bk9g8gKMWjTbDNc8Sigmec2FJWUjWAraMjJcQDTS', ), tokenProgram: new PublicKey( @@ -804,7 +809,7 @@ describe('selectTokenPoolInfo', () => { }, { mint: new PublicKey('GyFUUg2iDsGZpaxceUNQAdXfFXzraekDzbBjhS7bkTA6'), - tokenPoolPda: new PublicKey( + splInterfacePda: new PublicKey( 'Evr8a5qf2JSAf9DHF5L8qvmrdxtKWZJY9c61VkvfpTZA', ), tokenProgram: new PublicKey( @@ -818,7 +823,7 @@ describe('selectTokenPoolInfo', () => { }, { mint: new PublicKey('GyFUUg2iDsGZpaxceUNQAdXfFXzraekDzbBjhS7bkTA6'), - tokenPoolPda: new PublicKey( + splInterfacePda: new PublicKey( 'B6XrUD6K5VQZaG7m7fVwaf7JWbJXad8PTQdzzGcHdf7E', ), tokenProgram: new PublicKey( @@ -834,12 +839,13 @@ describe('selectTokenPoolInfo', () => { it('should return the correct token pool info', () => { for (let i = 0; i < 10000; i++) { - const tokenPoolInfo = selectTokenPoolInfo(infos); + const tokenPoolInfo: SplInterfaceInfo = + selectSplInterfaceInfo(infos); expect(tokenPoolInfo.poolIndex).not.toBe(4); expect(tokenPoolInfo.isInitialized).toBe(true); } - const decompressedInfos = selectTokenPoolInfosForDecompression( + const decompressedInfos = selectSplInterfaceInfosForDecompression( infos, new BN(1e9), ); @@ -848,7 +854,7 @@ describe('selectTokenPoolInfo', () => { expect(decompressedInfos[1].poolIndex).toBe(1); expect(decompressedInfos[2].poolIndex).toBe(2); expect(decompressedInfos[3].poolIndex).toBe(3); - const decompressedInfos2 = selectTokenPoolInfosForDecompression( + const decompressedInfos2 = selectSplInterfaceInfosForDecompression( infos, new BN(1.51e8), ); @@ -858,7 +864,7 @@ describe('selectTokenPoolInfo', () => { expect(decompressedInfos2[2].poolIndex).toBe(2); expect(decompressedInfos2[3].poolIndex).toBe(3); - const decompressedInfos3 = selectTokenPoolInfosForDecompression( + const decompressedInfos3 = selectSplInterfaceInfosForDecompression( infos, new BN(1.5e8), ); @@ -866,7 +872,7 @@ describe('selectTokenPoolInfo', () => { expect(decompressedInfos3[0].poolIndex).toBe(1); for (let i = 0; i < 1000; i++) { - const decompressedInfos4 = selectTokenPoolInfosForDecompression( + const decompressedInfos4 = selectSplInterfaceInfosForDecompression( infos, new BN(1), ); @@ -874,4 +880,50 @@ describe('selectTokenPoolInfo', () => { expect(decompressedInfos4[0].poolIndex).not.toBe(4); } }); + + const tokenPoolInfos: TokenPoolInfo[] = infos.map(toTokenPoolInfo); + + it('should return the correct legacy token pool info', () => { + for (let i = 0; i < 10000; i++) { + const tokenPoolInfo: TokenPoolInfo = + selectTokenPoolInfo(tokenPoolInfos); + expect(tokenPoolInfo.poolIndex).not.toBe(4); + expect(tokenPoolInfo.isInitialized).toBe(true); + } + + const decompressedInfos = selectTokenPoolInfosForDecompression( + tokenPoolInfos, + new BN(1e9), + ); + expect(decompressedInfos.length).toBe(4); + expect(decompressedInfos[0].poolIndex).toBe(0); + expect(decompressedInfos[1].poolIndex).toBe(1); + expect(decompressedInfos[2].poolIndex).toBe(2); + expect(decompressedInfos[3].poolIndex).toBe(3); + const decompressedInfos2 = selectTokenPoolInfosForDecompression( + tokenPoolInfos, + new BN(1.51e8), + ); + expect(decompressedInfos2.length).toBe(4); + expect(decompressedInfos2[0].poolIndex).toBe(0); + expect(decompressedInfos2[1].poolIndex).toBe(1); + expect(decompressedInfos2[2].poolIndex).toBe(2); + expect(decompressedInfos2[3].poolIndex).toBe(3); + + const decompressedInfos3 = selectTokenPoolInfosForDecompression( + tokenPoolInfos, + new BN(1.5e8), + ); + expect(decompressedInfos3.length).toBe(1); + expect(decompressedInfos3[0].poolIndex).toBe(1); + + for (let i = 0; i < 1000; i++) { + const decompressedInfos4 = selectTokenPoolInfosForDecompression( + tokenPoolInfos, + new BN(1), + ); + expect(decompressedInfos4.length).toBe(1); + expect(decompressedInfos4[0].poolIndex).not.toBe(4); + } + }); }); diff --git a/js/compressed-token/tests/e2e/multi-pool.test.ts b/js/compressed-token/tests/e2e/multi-pool.test.ts index 2773c3ad14..e25a52538e 100644 --- a/js/compressed-token/tests/e2e/multi-pool.test.ts +++ b/js/compressed-token/tests/e2e/multi-pool.test.ts @@ -188,7 +188,7 @@ describe('multi-pool', () => { expect(() => { selectTokenPoolInfosForDecompression(tokenPoolInfos4, 1); }).toThrowError( - 'All provided token pool balances are zero. Please pass recent token pool infos.', + 'All provided SPL interface balances are zero. Please pass recent SPL interface infos.', ); }); }); diff --git a/js/compressed-token/tests/unit/derive-token-pool-info.test.ts b/js/compressed-token/tests/unit/derive-token-pool-info.test.ts index 0b0f4909cb..e420a5221f 100644 --- a/js/compressed-token/tests/unit/derive-token-pool-info.test.ts +++ b/js/compressed-token/tests/unit/derive-token-pool-info.test.ts @@ -120,11 +120,16 @@ describe('deprecated aliases', () => { expect(result.mint.toBase58()).toBe(mint.toBase58()); expect(result.isInitialized).toBe(true); expect(result.balance.eq(bn(0))).toBe(true); - // splInterfacePda should be accessible (type is aliased) + // Both tokenPoolPda (deprecated) and splInterfacePda should be accessible + expect(result.tokenPoolPda).toBeDefined(); expect(result.splInterfacePda).toBeDefined(); + // Both should point to the same value + expect(result.tokenPoolPda.toBase58()).toBe( + result.splInterfacePda.toBase58(), + ); }); - it('TokenPoolInfo type should be alias for SplInterfaceInfo', () => { + it('TokenPoolInfo type should be compatible with SplInterfaceInfo', () => { // Both types should work for the same result const newResult: SplInterfaceInfo = deriveSplInterfaceInfo( mint, @@ -140,6 +145,10 @@ describe('deprecated aliases', () => { expect(newResult.splInterfacePda.toBase58()).toBe( oldResult.splInterfacePda.toBase58(), ); + // TokenPoolInfo should have tokenPoolPda for backward compatibility + expect(oldResult.tokenPoolPda.toBase58()).toBe( + oldResult.splInterfacePda.toBase58(), + ); }); it('deprecated deriveTokenPoolPdaWithIndex should work', () => { diff --git a/scripts/devenv/versions.sh b/scripts/devenv/versions.sh index af49212935..14a1c310f2 100755 --- a/scripts/devenv/versions.sh +++ b/scripts/devenv/versions.sh @@ -14,7 +14,6 @@ export ANCHOR_VERSION="0.31.1" export JQ_VERSION="1.8.0" export PHOTON_VERSION="0.51.2" export PHOTON_COMMIT="711c47b20330c6bb78feb0a2c15e8292fcd0a7b0" -#8bc3a8baeda38c62a7ac71be1d51d5c28e783842 export REDIS_VERSION="8.0.1" export ANCHOR_TAG="anchor-v${ANCHOR_VERSION}" From bb10ec7a21dc239017f27141239630361bfb769f Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Mon, 8 Dec 2025 09:16:39 -0500 Subject: [PATCH 05/13] fix build --- js/compressed-token/src/program.ts | 41 +++++++++++++------ .../v3/actions/get-or-create-ata-interface.ts | 6 ++- 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/js/compressed-token/src/program.ts b/js/compressed-token/src/program.ts index 25798bbc57..5e9b7e5be3 100644 --- a/js/compressed-token/src/program.ts +++ b/js/compressed-token/src/program.ts @@ -69,6 +69,17 @@ import { TokenPoolInfo, } from './utils/get-token-pool-infos'; +/** + * Helper to get the PDA from either TokenPoolInfo or SplInterfaceInfo + * @internal + */ +function getTokenPoolPda(info: TokenPoolInfo | SplInterfaceInfo): PublicKey { + if ('tokenPoolPda' in info) { + return info.tokenPoolPda ?? info.splInterfacePda; + } + return info.splInterfacePda; +} + export type CompressParams = { /** * Fee payer @@ -101,7 +112,7 @@ export type CompressParams = { /** * Token pool */ - tokenPoolInfo: TokenPoolInfo; + tokenPoolInfo: TokenPoolInfo | SplInterfaceInfo; }; export type CompressSplTokenAccountParams = { @@ -132,7 +143,7 @@ export type CompressSplTokenAccountParams = { /** * Token pool */ - tokenPoolInfo: TokenPoolInfo; + tokenPoolInfo: TokenPoolInfo | SplInterfaceInfo; }; export type DecompressParams = { @@ -163,7 +174,11 @@ export type DecompressParams = { /** * Token pool(s) */ - tokenPoolInfos: TokenPoolInfo | TokenPoolInfo[]; + tokenPoolInfos: + | TokenPoolInfo + | TokenPoolInfo[] + | SplInterfaceInfo + | SplInterfaceInfo[]; }; export type TransferParams = { @@ -338,7 +353,7 @@ export type MintToParams = { /** * Token pool */ - tokenPoolInfo: TokenPoolInfo; + tokenPoolInfo: TokenPoolInfo | SplInterfaceInfo; }; /** @@ -424,7 +439,7 @@ export type ApproveAndMintToParams = { /** * Token pool */ - tokenPoolInfo: TokenPoolInfo; + tokenPoolInfo: TokenPoolInfo | SplInterfaceInfo; }; export type CreateTokenProgramLookupTableParams = { @@ -941,7 +956,7 @@ export class CompressedTokenProgram { authority, cpiAuthorityPda: this.deriveCpiAuthorityPda, tokenProgram, - tokenPoolPda: tokenPoolInfo.splInterfacePda, + tokenPoolPda: getTokenPoolPda(tokenPoolInfo), lightSystemProgram: LightSystemProgram.programId, registeredProgramPda: systemKeys.registeredProgramPda, noopProgram: systemKeys.noopProgram, @@ -1228,7 +1243,7 @@ export class CompressedTokenProgram { } if (featureFlags.isV2()) { const [index, bump] = this.findSplInterfaceIndexAndBump( - tokenPoolInfo.splInterfacePda, + getTokenPoolPda(tokenPoolInfo), mint, ); const rawData: BatchCompressInstructionData = { @@ -1250,7 +1265,7 @@ export class CompressedTokenProgram { authority: owner, cpiAuthorityPda: this.deriveCpiAuthorityPda, tokenProgram: tokenPoolInfo.tokenProgram, - tokenPoolPda: tokenPoolInfo.splInterfacePda, + tokenPoolPda: getTokenPoolPda(tokenPoolInfo), lightSystemProgram: LightSystemProgram.programId, ...defaultStaticAccountsStruct(), merkleTree: outputStateTreeInfo.queue, @@ -1315,7 +1330,7 @@ export class CompressedTokenProgram { lightSystemProgram: LightSystemProgram.programId, selfProgram: this.programId, systemProgram: SystemProgram.programId, - tokenPoolPda: tokenPoolInfo.splInterfacePda, + tokenPoolPda: getTokenPoolPda(tokenPoolInfo), compressOrDecompressTokenAccount: source, tokenProgram: tokenPoolInfo.tokenProgram, }); @@ -1371,7 +1386,9 @@ export class CompressedTokenProgram { tokenTransferOutputs: tokenTransferOutputs, remainingAccounts: tokenPoolInfosArray .slice(1) - .map(info => info.splInterfacePda), + .map(info => + getTokenPoolPda(info as TokenPoolInfo | SplInterfaceInfo), + ), }); const { mint } = parseTokenData(inputCompressedTokenAccounts); @@ -1411,7 +1428,7 @@ export class CompressedTokenProgram { accountCompressionAuthority: accountCompressionAuthority, accountCompressionProgram: accountCompressionProgram, selfProgram: this.programId, - tokenPoolPda: tokenPoolInfosArray[0].splInterfacePda, + tokenPoolPda: getTokenPoolPda(tokenPoolInfosArray[0]), compressOrDecompressTokenAccount: toAddress, tokenProgram, systemProgram: SystemProgram.programId, @@ -1523,7 +1540,7 @@ export class CompressedTokenProgram { accountCompressionAuthority: accountCompressionAuthority, accountCompressionProgram: accountCompressionProgram, selfProgram: this.programId, - tokenPoolPda: tokenPoolInfo.splInterfacePda, + tokenPoolPda: getTokenPoolPda(tokenPoolInfo), compressOrDecompressTokenAccount: tokenAccount, tokenProgram: tokenPoolInfo.tokenProgram, systemProgram: SystemProgram.programId, 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 96f21765c7..a3db6ada37 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 @@ -270,7 +270,9 @@ async function getOrCreateCTokenAta( // Load if: cold balance exists, or (wrap=true and SPL/T22 balance exists) const sources = accountInterface._sources ?? []; const hasCold = sources.some( - s => s.type === TokenAccountSourceType.CTokenCold && s.amount > 0n, + s => + s.type === TokenAccountSourceType.CTokenCold && + s.amount > BigInt(0), ); const hasSplToWrap = wrap && @@ -278,7 +280,7 @@ async function getOrCreateCTokenAta( s => (s.type === TokenAccountSourceType.Spl || s.type === TokenAccountSourceType.Token2022) && - s.amount > 0n, + s.amount > BigInt(0), ); if (hasCold || hasSplToWrap) { From d1ca4dcd04ad59b3ff24c968a61659c49abe2d77 Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Mon, 8 Dec 2025 09:45:01 -0500 Subject: [PATCH 06/13] fix sed-inplace --- scripts/devenv/install-photon.sh | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/scripts/devenv/install-photon.sh b/scripts/devenv/install-photon.sh index 61bcfe3dba..23d85f6336 100755 --- a/scripts/devenv/install-photon.sh +++ b/scripts/devenv/install-photon.sh @@ -3,6 +3,15 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "${SCRIPT_DIR}/shared.sh" +# Cross-platform sed in-place (macOS vs Linux) +sed_inplace() { + if [ "$OS" = "Darwin" ]; then + sed -i '' "$@" + else + sed -i "$@" + fi +} + install_photon() { local expected_version="${PHOTON_VERSION}" local expected_commit="${PHOTON_COMMIT}" @@ -21,15 +30,6 @@ install_photon() { mkdir -p "${PREFIX}/cargo/bin" touch "$INSTALL_LOG" - if [ "$photon_installed" = false ] || [ "$photon_correct_version" = false ]; then - echo "Installing Photon indexer (version $expected_version)..." - RUSTFLAGS="-A dead-code" cargo install --git https://github.com/lightprotocol/photon.git --rev ${PHOTON_COMMIT} --locked --force - log "photon" - else - sed -i "$@" - fi - } - # Check if exact version+commit combo is already installed if grep -q "^${install_marker}$" "$INSTALL_LOG" 2>/dev/null; then # Double-check binary actually exists From 80257442bbb7f7ed7b048d850eb901b3ecc3c6af Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Mon, 8 Dec 2025 10:40:56 -0500 Subject: [PATCH 07/13] fix wrap unwrap call sig --- js/compressed-token/src/index.ts | 2 -- js/compressed-token/src/v3/actions/unwrap.ts | 28 +++----------------- js/compressed-token/src/v3/actions/wrap.ts | 21 ++------------- js/compressed-token/src/v3/unified/index.ts | 4 --- js/compressed-token/tests/e2e/unwrap.test.ts | 28 ++++++++++---------- js/compressed-token/tests/e2e/wrap.test.ts | 14 +++++----- 6 files changed, 27 insertions(+), 70 deletions(-) diff --git a/js/compressed-token/src/index.ts b/js/compressed-token/src/index.ts index a973bea181..92c04cf1df 100644 --- a/js/compressed-token/src/index.ts +++ b/js/compressed-token/src/index.ts @@ -94,8 +94,6 @@ export { InterfaceOptions, LoadOptions, TransferInterfaceOptions, - WrapParams, - WrapResult, // Helpers getMintInterface, unpackMintInterface, diff --git a/js/compressed-token/src/v3/actions/unwrap.ts b/js/compressed-token/src/v3/actions/unwrap.ts index 7ef1faf21d..c0a53c9a66 100644 --- a/js/compressed-token/src/v3/actions/unwrap.ts +++ b/js/compressed-token/src/v3/actions/unwrap.ts @@ -20,21 +20,6 @@ import { import { getAssociatedTokenAddressInterface } from '../get-associated-token-address-interface'; import { loadAta as _loadAta } from './load-ata'; -export interface UnwrapParams { - rpc: Rpc; - payer: Signer; - owner: Signer; - mint: PublicKey; - destination: PublicKey; - amount?: number | bigint | BN; - splInterfaceInfo?: SplInterfaceInfo; - confirmOptions?: ConfirmOptions; -} - -export interface UnwrapResult { - transactionSignature: TransactionSignature; -} - /** * Unwrap c-tokens to SPL tokens. * @@ -47,30 +32,25 @@ export interface UnwrapResult { * * @param rpc RPC connection * @param payer Fee payer + * @param destination Destination SPL/T22 token account (must exist) * @param owner Owner of the c-token (signer) * @param mint Mint address - * @param destination Destination SPL/T22 token account (must exist) * @param amount Optional: specific amount to unwrap (defaults to all) * @param splInterfaceInfo Optional: SPL interface info (will be fetched if not provided) * @param confirmOptions Optional: confirm options * - * @example - * // Unwrap to existing SPL ATA - * const splAta = getAssociatedTokenAddressSync(mint, owner.publicKey); - * await unwrap(rpc, payer, owner, mint, splAta, 1000n); - * * @returns Transaction signature */ export async function unwrap( rpc: Rpc, payer: Signer, + destination: PublicKey, owner: Signer, mint: PublicKey, - destination: PublicKey, amount?: number | bigint | BN, splInterfaceInfo?: SplInterfaceInfo, confirmOptions?: ConfirmOptions, -): Promise { +): Promise { // 1. Get SPL interface info if not provided let resolvedSplInterfaceInfo = splInterfaceInfo; if (!resolvedSplInterfaceInfo) { @@ -147,5 +127,5 @@ export async function unwrap( const txId = await sendAndConfirmTx(rpc, tx, confirmOptions); - return { transactionSignature: txId }; + return txId; } diff --git a/js/compressed-token/src/v3/actions/wrap.ts b/js/compressed-token/src/v3/actions/wrap.ts index 7d0db1d0b8..0025deb035 100644 --- a/js/compressed-token/src/v3/actions/wrap.ts +++ b/js/compressed-token/src/v3/actions/wrap.ts @@ -17,23 +17,6 @@ import { SplInterfaceInfo, } from '../../utils/get-token-pool-infos'; -// Keep old interface type for backwards compatibility export -export interface WrapParams { - rpc: Rpc; - payer: Signer; - source: PublicKey; - destination: PublicKey; - owner: Signer; - mint: PublicKey; - amount: bigint; - splInterfaceInfo?: SplInterfaceInfo; - confirmOptions?: ConfirmOptions; -} - -export interface WrapResult { - transactionSignature: TransactionSignature; -} - /** * Wrap tokens from an SPL/T22 account to a c-token account. * @@ -76,7 +59,7 @@ export async function wrap( amount: bigint, splInterfaceInfo?: SplInterfaceInfo, confirmOptions?: ConfirmOptions, -): Promise { +): Promise { // Get SPL interface info if not provided let resolvedSplInterfaceInfo = splInterfaceInfo; if (!resolvedSplInterfaceInfo) { @@ -118,5 +101,5 @@ export async function wrap( const txId = await sendAndConfirmTx(rpc, tx, confirmOptions); - return { transactionSignature: txId }; + return txId; } diff --git a/js/compressed-token/src/v3/unified/index.ts b/js/compressed-token/src/v3/unified/index.ts index ffb02ddadf..13302a2446 100644 --- a/js/compressed-token/src/v3/unified/index.ts +++ b/js/compressed-token/src/v3/unified/index.ts @@ -325,10 +325,6 @@ export { // Action types CreateAtaInterfaceParams, CreateAtaInterfaceResult, - WrapParams, - WrapResult, - UnwrapParams, - UnwrapResult, // Helpers getMintInterface, unpackMintInterface, diff --git a/js/compressed-token/tests/e2e/unwrap.test.ts b/js/compressed-token/tests/e2e/unwrap.test.ts index 6c0845202a..7819948e5f 100644 --- a/js/compressed-token/tests/e2e/unwrap.test.ts +++ b/js/compressed-token/tests/e2e/unwrap.test.ts @@ -194,13 +194,13 @@ describe('unwrap action', () => { const result = await unwrap( rpc, payer, + splAta, owner, mint, - splAta, BigInt(500), ); - expect(result.transactionSignature).toBeDefined(); + expect(result).toBeDefined(); // Check SPL ATA balance const splBalance = await getAccount(rpc, splAta); @@ -259,13 +259,13 @@ describe('unwrap action', () => { const result = await unwrap( rpc, payer, + splAta, owner, mint, - splAta, BigInt(300), ); - expect(result.transactionSignature).toBeDefined(); + expect(result).toBeDefined(); // Check SPL balance const splBalance = await getAccount(rpc, splAta); @@ -302,9 +302,9 @@ describe('unwrap action', () => { ); // Unwrap all (amount not specified) - const result = await unwrap(rpc, payer, owner, mint, splAta); + const result = await unwrap(rpc, payer, splAta, owner, mint); - expect(result.transactionSignature).toBeDefined(); + expect(result).toBeDefined(); // Check SPL balance const splBalance = await getAccount(rpc, splAta); @@ -348,13 +348,13 @@ describe('unwrap action', () => { const result = await unwrap( rpc, payer, + splAta, owner, mint, - splAta, BigInt(200), ); - expect(result.transactionSignature).toBeDefined(); + expect(result).toBeDefined(); const splBalance = await getAccount(rpc, splAta); expect(splBalance.amount).toBe(BigInt(200)); @@ -390,13 +390,13 @@ describe('unwrap action', () => { const result = await unwrap( rpc, separatePayer, + splAta, owner, mint, - splAta, BigInt(250), ); - expect(result.transactionSignature).toBeDefined(); + expect(result).toBeDefined(); const splBalance = await getAccount(rpc, splAta); expect(splBalance.amount).toBe(BigInt(250)); @@ -429,7 +429,7 @@ describe('unwrap action', () => { // Try to unwrap more than available await expect( - unwrap(rpc, payer, owner, mint, splAta, BigInt(1000)), + unwrap(rpc, payer, splAta, owner, mint, BigInt(1000)), ).rejects.toThrow(/Insufficient/); }, 60_000); @@ -458,7 +458,7 @@ describe('unwrap action', () => { // Try to unwrap to non-existent destination await expect( - unwrap(rpc, payer, owner, mint, splAta, BigInt(50)), + unwrap(rpc, payer, splAta, owner, mint, BigInt(50)), ).rejects.toThrow(/does not exist/); }, 60_000); }); @@ -519,13 +519,13 @@ describe('unwrap Token-2022', () => { const result = await unwrap( rpc, payer, + t22Ata, owner, t22Mint, - t22Ata, BigInt(500), ); - expect(result.transactionSignature).toBeDefined(); + expect(result).toBeDefined(); // Check T22 ATA balance const t22Balance = await getAccount( diff --git a/js/compressed-token/tests/e2e/wrap.test.ts b/js/compressed-token/tests/e2e/wrap.test.ts index 8424317a0e..03638dfab4 100644 --- a/js/compressed-token/tests/e2e/wrap.test.ts +++ b/js/compressed-token/tests/e2e/wrap.test.ts @@ -266,7 +266,7 @@ describe('wrap action', () => { tokenPoolInfo, ); - expect(result.transactionSignature).toBeDefined(); + expect(result).toBeDefined(); // Check balances after const splBalanceAfter = await getAccount(rpc, splAta); @@ -331,7 +331,7 @@ describe('wrap action', () => { tokenPoolInfo, ); - expect(result.transactionSignature).toBeDefined(); + expect(result).toBeDefined(); // SPL should be empty const splBalanceAfter = await getAccount(rpc, splAta); @@ -393,7 +393,7 @@ describe('wrap action', () => { // tokenPoolInfo not provided ); - expect(result.transactionSignature).toBeDefined(); + expect(result).toBeDefined(); const ctokenBalance = await getCTokenBalance(rpc, ctokenAta); expect(ctokenBalance).toBe(BigInt(100)); @@ -467,7 +467,7 @@ describe('wrap action', () => { tokenPoolInfo, ); - expect(result.transactionSignature).toBeDefined(); + expect(result).toBeDefined(); const ctokenBalance = await getCTokenBalance(rpc, ctokenAta); expect(ctokenBalance).toBe(BigInt(150)); @@ -561,7 +561,7 @@ describe('wrap with non-ATA accounts', () => { tokenPoolInfo, ); - expect(result.transactionSignature).toBeDefined(); + expect(result).toBeDefined(); const destBalance = await getCTokenBalance(rpc, destination); expect(destBalance).toBe(BigInt(200)); @@ -669,7 +669,7 @@ describe('wrap Token-2022 to CToken', () => { tokenPoolInfo, ); - expect(result.transactionSignature).toBeDefined(); + expect(result).toBeDefined(); // Check balances after const t22BalanceAfter = await getAccount( @@ -760,7 +760,7 @@ describe('wrap Token-2022 to CToken', () => { // tokenPoolInfo not provided ); - expect(result.transactionSignature).toBeDefined(); + expect(result).toBeDefined(); const ctokenBalance = await getCTokenBalance(rpc, ctokenAta); expect(ctokenBalance).toBe(BigInt(250)); From ea58ed1a967712e7b8c18f6dcd7f009b8f439355 Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Mon, 8 Dec 2025 10:53:50 -0500 Subject: [PATCH 08/13] docstring cleansing --- js/compressed-token/src/index.ts | 4 +- .../v3/actions/create-associated-ctoken.ts | 2 +- .../src/v3/actions/create-ata-interface.ts | 63 +++++-------------- .../src/v3/actions/create-mint-interface.ts | 31 ++++----- .../src/v3/actions/decompress-interface.ts | 26 +++----- .../v3/actions/get-or-create-ata-interface.ts | 52 ++++++--------- .../src/v3/actions/transfer-interface.ts | 5 -- js/compressed-token/src/v3/actions/unwrap.ts | 28 +++------ .../src/v3/instructions/transfer-interface.ts | 57 +++-------------- .../src/v3/instructions/unwrap.ts | 6 +- .../src/v3/instructions/wrap.ts | 18 +++--- js/compressed-token/src/v3/unified/index.ts | 4 +- 12 files changed, 86 insertions(+), 210 deletions(-) diff --git a/js/compressed-token/src/index.ts b/js/compressed-token/src/index.ts index 92c04cf1df..cc379dc0da 100644 --- a/js/compressed-token/src/index.ts +++ b/js/compressed-token/src/index.ts @@ -92,8 +92,8 @@ export { CreateAtaInterfaceParams, CreateAtaInterfaceResult, InterfaceOptions, - LoadOptions, - TransferInterfaceOptions, + InterfaceOptions, + InterfaceOptions, // Helpers getMintInterface, unpackMintInterface, 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 2bf709fd82..78eef8eabd 100644 --- a/js/compressed-token/src/v3/actions/create-associated-ctoken.ts +++ b/js/compressed-token/src/v3/actions/create-associated-ctoken.ts @@ -18,7 +18,7 @@ import { import { getAssociatedCTokenAddress } from '../derivation'; /** - * Create an associated compressed token account. + * Create an associated c-token account. * * @param rpc RPC connection * @param payer Fee payer 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 49ff2d4251..595a84e761 100644 --- a/js/compressed-token/src/v3/actions/create-ata-interface.ts +++ b/js/compressed-token/src/v3/actions/create-ata-interface.ts @@ -48,14 +48,8 @@ export interface CreateAtaInterfaceResult { } /** - * Create an associated token account for SPL Token, Token-2022, or Compressed Token. - * Follows SPL Token createAssociatedTokenAccount signature. - * Defaults to c-token program. - * - * Dispatches to the appropriate program based on `programId`: - * - `CTOKEN_PROGRAM_ID` -> Compressed Token ATA (default) - * - `TOKEN_PROGRAM_ID` -> SPL Token ATA - * - `TOKEN_2022_PROGRAM_ID` -> Token-2022 ATA + * Create an associated token account for SPL/T22/c-token. Follows SPL Token + * createAssociatedTokenAccount signature. Defaults to c-token program. * * @param rpc RPC connection * @param payer Fee payer and transaction signer @@ -64,29 +58,10 @@ export interface CreateAtaInterfaceResult { * @param allowOwnerOffCurve Allow owner to be a PDA (default: false) * @param confirmOptions Options for confirming the transaction * @param programId Token program ID (default: CTOKEN_PROGRAM_ID) - * @param associatedTokenProgramId Associated token program ID (auto-derived if not provided) - * @param ctokenConfig Optional c-token-specific configuration - * - * @example - * // Create Compressed Token ATA (default) - * const { address } = await createAtaInterface( - * rpc, - * payer, - * mint, - * wallet.publicKey, - * ); - * - * @example - * // Create SPL Token ATA - * const { address } = await createAtaInterface( - * rpc, - * payer, - * splMint, - * wallet.publicKey, - * false, - * undefined, - * TOKEN_PROGRAM_ID, - * ); + * @param associatedTokenProgramId Associated token program ID (auto-derived if + * not provided) + * @param ctokenConfig c-token-specific configuration + * @returns Object with token account address and transaction signature */ export async function createAtaInterface( rpc: Rpc, @@ -147,16 +122,12 @@ export async function createAtaInterface( } /** - * Create an associated token account idempotently for SPL Token, Token-2022, or Compressed Token. - * Follows SPL Token createAssociatedTokenAccountIdempotent signature. - * Defaults to c-token program. - * - * This is idempotent - if the account already exists, the instruction succeeds without error. + * Create an associated token account idempotently for SPL/T22/c-token. Follows + * SPL Token createAssociatedTokenAccountIdempotent signature. Defaults to + * c-token program. * - * Dispatches to the appropriate program based on `programId`: - * - `CTOKEN_PROGRAM_ID` -> Compressed Token ATA (default, idempotent) - * - `TOKEN_PROGRAM_ID` -> SPL Token ATA (idempotent) - * - `TOKEN_2022_PROGRAM_ID` -> Token-2022 ATA (idempotent) + * This is idempotent: if the account already exists, the instruction succeeds + * without error. * * @param rpc RPC connection * @param payer Fee payer and transaction signer @@ -165,17 +136,11 @@ export async function createAtaInterface( * @param allowOwnerOffCurve Allow owner to be a PDA (default: false) * @param confirmOptions Options for confirming the transaction * @param programId Token program ID (default: CTOKEN_PROGRAM_ID) - * @param associatedTokenProgramId Associated token program ID (auto-derived if not provided) + * @param associatedTokenProgramId Associated token program ID (auto-derived if + * not provided) * @param ctokenConfig Optional c-token-specific configuration * - * @example - * // Create or get existing c-token ATA (default) - * const { address } = await createAtaInterfaceIdempotent( - * rpc, - * payer, - * mint, - * wallet.publicKey, - * ); + * @returns Object with token account address and transaction signature */ export async function createAtaInterfaceIdempotent( rpc: Rpc, 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 aa23c506df..81f7c0a0c2 100644 --- a/js/compressed-token/src/v3/actions/create-mint-interface.ts +++ b/js/compressed-token/src/v3/actions/create-mint-interface.ts @@ -29,26 +29,21 @@ import { createMint } from '../../actions/create-mint'; export { TokenMetadataInstructionData }; /** - * Create and initialize a new mint (SPL, Token-2022, or Compressed Token). + * Create and initialize a new mint for SPL/T22/c-token. * - * This is a unified interface that dispatches to either: - * - SPL/Token-2022 mint creation when `programId` is TOKEN_PROGRAM_ID or TOKEN_2022_PROGRAM_ID - * - Compressed token mint creation when `programId` is CTOKEN_PROGRAM_ID (default) + * @param rpc RPC connection to use + * @param payer Fee payer + * @param mintAuthority Account that will control minting (signer for c-token mints) + * @param freezeAuthority Account that will control freeze and thaw (optional) + * @param decimals Location of the decimal place + * @param keypair Mint keypair (defaults to a random keypair) + * @param confirmOptions Confirm options + * @param programId Token program ID (defaults to CTOKEN_PROGRAM_ID) + * @param tokenMetadata Optional token metadata (c-token mints only) + * @param outputStateTreeInfo Optional output state tree info (c-token mints only) + * @param addressTreeInfo Optional address tree info (c-token mints only) * - * @param rpc RPC connection to use - * @param payer Fee payer - * @param mintAuthority Account that will control minting (must be Signer for compressed mints) - * @param freezeAuthority Optional: Account that will control freeze and thaw. - * @param decimals Location of the decimal place - * @param keypair Optional: Mint keypair. Defaults to a random keypair. - * @param confirmOptions Optional: Options for confirming the transaction - * @param programId Optional: Token program ID. Defaults to CTOKEN_PROGRAM_ID (compressed). - * Set to TOKEN_PROGRAM_ID or TOKEN_2022_PROGRAM_ID for SPL mints. - * @param tokenMetadata Optional: Token metadata (only used for compressed mints) - * @param outputStateTreeInfo Optional: Output state tree info (only used for compressed mints) - * @param addressTreeInfo Optional: Address tree info (only used for compressed mints) - * - * @return Object with mint address and transaction signature + * @returns Object with mint address and transaction signature */ export async function createMintInterface( rpc: Rpc, diff --git a/js/compressed-token/src/v3/actions/decompress-interface.ts b/js/compressed-token/src/v3/actions/decompress-interface.ts index 0f3b11b2a5..b62be19cd3 100644 --- a/js/compressed-token/src/v3/actions/decompress-interface.ts +++ b/js/compressed-token/src/v3/actions/decompress-interface.ts @@ -21,33 +21,23 @@ import { createDecompressInterfaceInstruction } from '../instructions/create-dec import { createAssociatedTokenAccountInterfaceIdempotentInstruction } from '../instructions/create-ata-interface'; import { getAssociatedTokenAddressInterface } from '../get-associated-token-address-interface'; import { CTOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; -import { - SplInterfaceInfo, - getSplInterfaceInfos, - selectSplInterfaceInfosForDecompression, -} from '../../utils/get-token-pool-infos'; +import { SplInterfaceInfo } from '../../utils/get-token-pool-infos'; /** * Decompress compressed (cold) tokens to an on-chain token account. * - * Low-level primitive for decompressing tokens. Destination type is determined - * by `splInterfaceInfo`: - * - undefined: Decompress to c-token ATA (default) - * - provided: Decompress to SPL/T22 ATA via token pool - * - * For unified loading, use - * {@link loadAta} instead. + * For unified loading, use {@link loadAta} instead. * * @param rpc RPC connection * @param payer Fee payer (signer) * @param owner Owner of the compressed tokens (signer) * @param mint Mint address - * @param amount Optional: specific amount to decompress (defaults to all) - * @param destinationAta Optional: destination token account address - * @param destinationOwner Optional: owner of the destination ATA - * @param splInterfaceInfo Optional: SPL interface info for SPL/T22 destinations - * @param confirmOptions Optional: confirm options - * @returns Transaction signature, or null if no compressed tokens to decompress + * @param amount Amount to decompress (defaults to all) + * @param destinationAta Destination token account address + * @param destinationOwner Owner of the destination ATA + * @param splInterfaceInfo SPL interface info for SPL/T22 destinations + * @param confirmOptions Confirm options + * @returns Transaction signature, null if nothing to load. */ export async function decompressInterface( rpc: Rpc, 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 a3db6ada37..905400638d 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 @@ -39,41 +39,25 @@ import { loadAta } from './load-ata'; /** * Retrieve the associated token account, or create it if it doesn't exist. * - * For c-token with Signer owner: - * - Creates hot ATA if it doesn't exist - * - Loads cold (compressed) tokens into hot ATA if any exist - * - Returns account with all tokens ready to use + * @param rpc Connection to use + * @param payer Payer of the transaction and initialization + * fees. + * @param mint Mint associated with the account to set or + * verify. + * @param owner Owner of the account. Pass Signer to + * auto-load cold (compressed) tokens, or + * PublicKey for read-only. + * @param allowOwnerOffCurve Allow the owner account to be a PDA (Program + * Derived Address). + * @param commitment Desired level of commitment for querying the + * state. + * @param confirmOptions Options for confirming the transaction + * @param programId Token program ID (defaults to + * CTOKEN_PROGRAM_ID) + * @param associatedTokenProgramId Associated token program ID (auto-derived if + * not provided) * - * For c-token with PublicKey owner: - * - Creates hot ATA if it doesn't exist - * - Returns aggregated balance but does NOT auto-load (can't sign) - * - Use loadAta() separately to consolidate - * - * For SPL/T22: standard behavior (create ATA if needed). - * - * Returns AccountInterface with: - * - `parsed.amount`: aggregated balance (hot + cold for c-token) - * - `_sources`: breakdown by source type (hot, cold, spl, token2022) - * - `_needsConsolidation`: true if loadAta() should be called before writes - * - * @param rpc Connection to use - * @param payer Payer of the transaction and initialization - * fees. - * @param mint Mint associated with the account to set or - * verify. - * @param owner Owner of the account. Pass Signer to auto-load - * cold tokens, or PublicKey for read-only. - * @param allowOwnerOffCurve Allow the owner account to be a PDA (Program - * Derived Address). - * @param commitment Desired level of commitment for querying the - * state. - * @param confirmOptions Options for confirming the transaction - * @param programId SPL Token program account or c-token program - * account. - * @param associatedTokenProgramId SPL Associated Token program account or c- - * token program account. - * - * @return AccountInterface with aggregated balance and source breakdown + * @returns AccountInterface with aggregated balance and source breakdown */ export async function getOrCreateAtaInterface( rpc: Rpc, diff --git a/js/compressed-token/src/v3/actions/transfer-interface.ts b/js/compressed-token/src/v3/actions/transfer-interface.ts index d780a51604..4675f584ca 100644 --- a/js/compressed-token/src/v3/actions/transfer-interface.ts +++ b/js/compressed-token/src/v3/actions/transfer-interface.ts @@ -33,7 +33,6 @@ import { } from '../../utils/get-token-pool-infos'; import { createWrapInstruction } from '../instructions/wrap'; import { createDecompressInterfaceInstruction } from '../instructions/create-decompress-interface-instruction'; -import { getAtaInterface } from '../get-account-interface'; /** * Options for interface operations (load, transfer) @@ -355,7 +354,3 @@ export async function transferInterface( return sendAndConfirmTx(rpc, tx, confirmOptions); } - -// Re-export old names for backwards compatibility -export type LoadOptions = InterfaceOptions; -export type TransferInterfaceOptions = InterfaceOptions; diff --git a/js/compressed-token/src/v3/actions/unwrap.ts b/js/compressed-token/src/v3/actions/unwrap.ts index c0a53c9a66..3a1bb0541d 100644 --- a/js/compressed-token/src/v3/actions/unwrap.ts +++ b/js/compressed-token/src/v3/actions/unwrap.ts @@ -23,21 +23,14 @@ import { loadAta as _loadAta } from './load-ata'; /** * Unwrap c-tokens to SPL tokens. * - * This is the reverse of wrap: converts c-token balance to SPL/T22 balance. - * Destination SPL/T22 ATA must already exist (same as SPL token transfer pattern). - * - * Flow: - * 1. Consolidate all c-token balances (cold -> hot) via loadAta - * 2. Transfer from c-token hot ATA to SPL ATA via token pool - * * @param rpc RPC connection * @param payer Fee payer - * @param destination Destination SPL/T22 token account (must exist) + * @param destination Destination SPL/T22 token account * @param owner Owner of the c-token (signer) * @param mint Mint address - * @param amount Optional: specific amount to unwrap (defaults to all) - * @param splInterfaceInfo Optional: SPL interface info (will be fetched if not provided) - * @param confirmOptions Optional: confirm options + * @param amount Amount to unwrap (defaults to all) + * @param splInterfaceInfo SPL interface info + * @param confirmOptions Confirm options * * @returns Transaction signature */ @@ -67,7 +60,6 @@ export async function unwrap( } } - // 2. Verify destination exists (SPL token pattern - destination must exist) const destAtaInfo = await rpc.getAccountInfo(destination); if (!destAtaInfo) { throw new Error( @@ -76,17 +68,17 @@ export async function unwrap( ); } - // 3. Load all tokens to c-token hot ATA first (consolidate cold -> hot) + // Load all tokens to c-token hot ATA const ctokenAta = getAssociatedTokenAddressInterface(mint, owner.publicKey); await _loadAta(rpc, ctokenAta, owner, mint, payer, confirmOptions); - // 4. Check c-token hot balance + // Check c-token hot balance const ctokenAccountInfo = await rpc.getAccountInfo(ctokenAta); if (!ctokenAccountInfo) { throw new Error('No c-token ATA found after loading'); } - // Parse c-token account balance (offset 64 for amount in token account layout) + // Parse c-token account balance const data = ctokenAccountInfo.data; const ctokenBalance = data.readBigUInt64LE(64); @@ -94,7 +86,6 @@ export async function unwrap( throw new Error('No c-token balance to unwrap'); } - // 5. Determine amount to unwrap const unwrapAmount = amount ? BigInt(amount.toString()) : ctokenBalance; if (unwrapAmount > ctokenBalance) { @@ -103,7 +94,7 @@ export async function unwrap( ); } - // 6. Build unwrap instruction + // Build unwrap instruction const ix = createUnwrapInstruction( ctokenAta, destination, @@ -114,12 +105,11 @@ export async function unwrap( payer.publicKey, ); - // 7. Build and send transaction const { blockhash } = await rpc.getLatestBlockhash(); const additionalSigners = dedupeSigner(payer, [owner]); const tx = buildAndSignTx( - [ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), ix], + [ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }), ix], payer, blockhash, additionalSigners, diff --git a/js/compressed-token/src/v3/instructions/transfer-interface.ts b/js/compressed-token/src/v3/instructions/transfer-interface.ts index 586ea973b7..71aa2c57e1 100644 --- a/js/compressed-token/src/v3/instructions/transfer-interface.ts +++ b/js/compressed-token/src/v3/instructions/transfer-interface.ts @@ -7,26 +7,19 @@ import { } from '@solana/spl-token'; /** - * c-token Transfer discriminator (matches InstructionType::CTokenTransfer = 3) + * c-token transfer instruction discriminator */ const CTOKEN_TRANSFER_DISCRIMINATOR = 3; /** - * Create a c-token transfer instruction for hot (on-chain) accounts. - * Uses CTokenTransfer instruction (discriminator 3) which wraps SPL Token transfer. - * - * Accounts: - * 1. source (mutable) - Source c-token account - * 2. destination (mutable) - Destination c-token account - * 3. authority (signer) - Owner of source account - * 4. payer (optional, signer, mutable) - For compressible extension top-up + * Create a c-token transfer instruction. * * @param source Source c-token account * @param destination Destination c-token account * @param owner Owner of the source account (signer) * @param amount Amount to transfer - * @param payer Optional payer for compressible extension top-up - * @returns TransactionInstruction for c-token transfer + * @param payer Payer for compressible extension top-up (optional) + * @returns Transaction instruction for c-token transfer */ export function createCTokenTransferInstruction( source: PublicKey, @@ -64,47 +57,15 @@ export function createCTokenTransferInstruction( } /** - * Construct a transfer instruction for SPL Token, Token-2022, or c-token (hot accounts). - * Matches SPL Token createTransferInstruction signature exactly. - * Defaults to c-token program. - * - * Dispatches to the appropriate program based on `programId`: - * - `CTOKEN_PROGRAM_ID` -> c-token hot-to-hot transfer (default) - * - `TOKEN_PROGRAM_ID` -> SPL Token transfer - * - `TOKEN_2022_PROGRAM_ID` -> Token-2022 transfer - * - * Note: This is for on-chain (hot) token accounts only. - * For compressed (cold) token transfers, use the `transfer` action. - * For cross-program transfers (SPL <> c-token), use `wrap`/`unwrap`. + * Construct a transfer instruction for SPL/T22/c-token. Defaults to c-token + * program. For cross-program transfers (SPL <> c-token), use `wrap`/`unwrap`. * * @param source Source token account * @param destination Destination token account - * @param owner Owner of the source account + * @param owner Owner of the source account (signer) * @param amount Amount to transfer - * @param multiSigners Signing accounts if `owner` is a multisig (SPL only) - * @param programId Token program ID (default: CTOKEN_PROGRAM_ID) - * @param payer Fee payer for compressible top-up (c-token only) - * - * @example - * // c-token hot transfer (default) - same signature as SPL! - * const ix = createTransferInterfaceInstruction( - * sourceCtokenAccount, - * destCtokenAccount, - * owner, - * 1000000n, - * ); - * - * @example - * // SPL Token transfer - identical call, just change programId - * import { TOKEN_PROGRAM_ID } from '@solana/spl-token'; - * const ix = createTransferInterfaceInstruction( - * sourceAta, - * destAta, - * owner, - * 1000000n, - * [], - * TOKEN_PROGRAM_ID, - * ); + * @param payer Payer for compressible top-up (optional) + * @returns instruction for c-token transfer */ export function createTransferInterfaceInstruction( source: PublicKey, diff --git a/js/compressed-token/src/v3/instructions/unwrap.ts b/js/compressed-token/src/v3/instructions/unwrap.ts index 6becaa176c..2e2726720c 100644 --- a/js/compressed-token/src/v3/instructions/unwrap.ts +++ b/js/compressed-token/src/v3/instructions/unwrap.ts @@ -14,13 +14,11 @@ import { * Create an unwrap instruction that moves tokens from a c-token account to an * SPL/T22 account. * - * This is the reverse of wrap: c-token ATA -> pool -> SPL ATA - * * @param source Source c-token account * @param destination Destination SPL/T22 token account - * @param owner Owner/authority of the source account (must sign) + * @param owner Owner of the source account (signer) * @param mint Mint address - * @param amount Amount to unwrap + * @param amount Amount to unwrap, * @param splInterfaceInfo SPL interface info for the decompression * @param payer Fee payer (defaults to owner if not provided) * @returns TransactionInstruction to unwrap tokens diff --git a/js/compressed-token/src/v3/instructions/wrap.ts b/js/compressed-token/src/v3/instructions/wrap.ts index 19347892eb..208b454035 100644 --- a/js/compressed-token/src/v3/instructions/wrap.ts +++ b/js/compressed-token/src/v3/instructions/wrap.ts @@ -14,16 +14,14 @@ import { * Create a wrap instruction that moves tokens from an SPL/T22 account to a * c-token account. * - * @param source Source SPL/T22 token account (any token account, not - * just ATA) - * @param destination Destination c-token account (any c-token account, not - * just ATA) - * @param owner Owner/authority of the source account (must sign) - * @param mint Mint address - * @param amount Amount to wrap - * @param splInterfaceInfo SPL interface info for the compression - * @param payer Fee payer (defaults to owner if not provided) - * @returns TransactionInstruction to wrap tokens + * @param source Source SPL/T22 token account + * @param destination Destination c-token account + * @param owner Owner of the source account (signer) + * @param mint Mint address + * @param amount Amount to wrap, + * @param splInterfaceInfo SPL interface info for the compression + * @param payer Fee payer (defaults to owner) + * @returns Instruction to wrap tokens */ export function createWrapInstruction( source: PublicKey, diff --git a/js/compressed-token/src/v3/unified/index.ts b/js/compressed-token/src/v3/unified/index.ts index 13302a2446..fcb6138efa 100644 --- a/js/compressed-token/src/v3/unified/index.ts +++ b/js/compressed-token/src/v3/unified/index.ts @@ -265,8 +265,8 @@ export { } from '../actions/load-ata'; export { - LoadOptions, - TransferInterfaceOptions, + InterfaceOptions, + InterfaceOptions, InterfaceOptions, } from '../actions/transfer-interface'; From 0b77338df3d533a53aa1795cfa39b6bace2c515a Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Mon, 8 Dec 2025 11:05:05 -0500 Subject: [PATCH 09/13] fix: remove duplicate InterfaceOptions exports --- js/compressed-token/src/index.ts | 2 -- js/compressed-token/src/v3/unified/index.ts | 6 +----- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/js/compressed-token/src/index.ts b/js/compressed-token/src/index.ts index cc379dc0da..89a20a9e47 100644 --- a/js/compressed-token/src/index.ts +++ b/js/compressed-token/src/index.ts @@ -92,8 +92,6 @@ export { CreateAtaInterfaceParams, CreateAtaInterfaceResult, InterfaceOptions, - InterfaceOptions, - InterfaceOptions, // Helpers getMintInterface, unpackMintInterface, diff --git a/js/compressed-token/src/v3/unified/index.ts b/js/compressed-token/src/v3/unified/index.ts index fcb6138efa..b0e08e21d0 100644 --- a/js/compressed-token/src/v3/unified/index.ts +++ b/js/compressed-token/src/v3/unified/index.ts @@ -264,11 +264,7 @@ export { LoadResult, } from '../actions/load-ata'; -export { - InterfaceOptions, - InterfaceOptions, - InterfaceOptions, -} from '../actions/transfer-interface'; +export { InterfaceOptions } from '../actions/transfer-interface'; export * from '../../actions'; export * from '../../utils'; From bc50c798e6d4d404a5e69eaf8a88aa229fcfa14d Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Mon, 8 Dec 2025 23:44:35 +0400 Subject: [PATCH 10/13] wip --- .../src/v3/actions/load-ata.ts | 6 +-- js/compressed-token/src/v3/ata-utils.ts | 43 +++++++++++-------- js/compressed-token/src/v3/derivation.ts | 2 +- .../src/v3/get-account-interface.ts | 4 +- .../tests/e2e/load-ata-spl-t22.test.ts | 19 +++----- sdk-libs/macros/src/compressible/GUIDE.md | 2 +- .../macros/src/compressible/instructions.rs | 2 +- .../macros/src/compressible/seed_providers.rs | 4 +- 8 files changed, 42 insertions(+), 40 deletions(-) diff --git a/js/compressed-token/src/v3/actions/load-ata.ts b/js/compressed-token/src/v3/actions/load-ata.ts index 8109045903..d6a9f7868a 100644 --- a/js/compressed-token/src/v3/actions/load-ata.ts +++ b/js/compressed-token/src/v3/actions/load-ata.ts @@ -31,7 +31,7 @@ import { getSplInterfaceInfos, SplInterfaceInfo, } from '../../utils/get-token-pool-infos'; -import { getAtaProgramId, validateAtaAddress, AtaType } from '../ata-utils'; +import { getAtaProgramId, checkAtaAddress, AtaType } from '../ata-utils'; import { InterfaceOptions } from './transfer-interface'; // Re-export types moved to instructions @@ -74,7 +74,7 @@ export async function createLoadAtaInstructions( ): Promise { payer ??= owner; - // Validation happens inside getAtaInterface via validateAtaAddress helper: + // Validation happens inside getAtaInterface via checkAtaAddress helper: // - Always validates ata matches mint+owner derivation // - For wrap=true, additionally requires c-token ATA const ataInterface = await _getAtaInterface( @@ -156,7 +156,7 @@ export async function createLoadAtaInstructionsFromInterface( // If called directly, this validates the targetAta is correct. let ataType: AtaType = 'ctoken'; if (targetAta) { - const validation = validateAtaAddress(targetAta, mint, owner); + const validation = checkAtaAddress(targetAta, mint, owner); ataType = validation.type; // For wrap=true, must be c-token ATA diff --git a/js/compressed-token/src/v3/ata-utils.ts b/js/compressed-token/src/v3/ata-utils.ts index 68518bac93..482d75f5e2 100644 --- a/js/compressed-token/src/v3/ata-utils.ts +++ b/js/compressed-token/src/v3/ata-utils.ts @@ -8,9 +8,9 @@ import { CTOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js'; import { PublicKey } from '@solana/web3.js'; /** - * Get the appropriate ATA program ID for a given token program ID - * @param tokenProgramId - The token program ID - * @returns The associated token program ID + * Get ATA program ID for a token program ID + * @param tokenProgramId Token program ID + * @returns ATA program ID */ export function getAtaProgramId(tokenProgramId: PublicKey): PublicKey { if (tokenProgramId.equals(CTOKEN_PROGRAM_ID)) { @@ -30,24 +30,23 @@ export interface AtaValidationResult { } /** - * Validate that an ATA address matches the expected derivation from mint+owner. + * Check if an ATA address matches the expected derivation from mint+owner. * - * Performance: If programId is provided, only derives and checks that one ATA. - * Otherwise derives all three (SPL, T22, c-token) until a match is found. + * Pass programId for fast path. * - * @param ata The ATA address to validate + * @param ata ATA address to check * @param mint Mint address * @param owner Owner address * @param programId Optional: if known, only check this program's ATA - * @returns Validation result with detected type, or throws on mismatch + * @returns Result with detected type, or throws on mismatch */ -export function validateAtaAddress( +export function checkAtaAddress( ata: PublicKey, mint: PublicKey, owner: PublicKey, programId?: PublicKey, ): AtaValidationResult { - // Hot path: programId specified - only check that one + // fast path if (programId) { const expected = getAssociatedTokenAddressSync( mint, @@ -69,8 +68,12 @@ export function validateAtaAddress( ); } - // Check c-token first (most common for this codebase) - const ctokenExpected = getAssociatedTokenAddressSync( + let ctokenExpected: PublicKey; + let splExpected: PublicKey; + let t22Expected: PublicKey; + + // c-token + ctokenExpected = getAssociatedTokenAddressSync( mint, owner, false, @@ -78,11 +81,15 @@ export function validateAtaAddress( getAtaProgramId(CTOKEN_PROGRAM_ID), ); if (ata.equals(ctokenExpected)) { - return { valid: true, type: 'ctoken', programId: CTOKEN_PROGRAM_ID }; + return { + valid: true, + type: 'ctoken', + programId: CTOKEN_PROGRAM_ID, + }; } - // Check SPL - const splExpected = getAssociatedTokenAddressSync( + // SPL + splExpected = getAssociatedTokenAddressSync( mint, owner, false, @@ -93,14 +100,15 @@ export function validateAtaAddress( return { valid: true, type: 'spl', programId: TOKEN_PROGRAM_ID }; } - // Check T22 - const t22Expected = getAssociatedTokenAddressSync( + // T22 + t22Expected = getAssociatedTokenAddressSync( mint, owner, false, TOKEN_2022_PROGRAM_ID, getAtaProgramId(TOKEN_2022_PROGRAM_ID), ); + if (ata.equals(t22Expected)) { return { valid: true, @@ -109,7 +117,6 @@ export function validateAtaAddress( }; } - // No match - invalid ATA throw new Error( `ATA address does not match any valid derivation from mint+owner. ` + `Got: ${ata.toBase58()}, expected one of: ` + diff --git a/js/compressed-token/src/v3/derivation.ts b/js/compressed-token/src/v3/derivation.ts index 13b29f8ecf..86a7106bdc 100644 --- a/js/compressed-token/src/v3/derivation.ts +++ b/js/compressed-token/src/v3/derivation.ts @@ -9,7 +9,7 @@ import { Buffer } from 'buffer'; /** * Returns the compressed mint address as bytes. */ -export function deriveCompressedMintAddress( +export function deriveCTokenMintAddress( mintSeed: PublicKey, addressTreeInfo: TreeInfo, ) { diff --git a/js/compressed-token/src/v3/get-account-interface.ts b/js/compressed-token/src/v3/get-account-interface.ts index d160670570..c54c437410 100644 --- a/js/compressed-token/src/v3/get-account-interface.ts +++ b/js/compressed-token/src/v3/get-account-interface.ts @@ -19,7 +19,7 @@ import { } from '@lightprotocol/stateless.js'; import { Buffer } from 'buffer'; import BN from 'bn.js'; -import { getAtaProgramId, validateAtaAddress } from './ata-utils'; +import { getAtaProgramId, checkAtaAddress } from './ata-utils'; export { Account, AccountState } from '@solana/spl-token'; export { ParsedTokenAccount } from '@lightprotocol/stateless.js'; @@ -241,7 +241,7 @@ export async function getAtaInterface( // Invariant: ata MUST match a valid derivation from mint+owner. // Hot path: if programId provided, only validate against that program. // For wrap=true, additionally require c-token ATA. - const validation = validateAtaAddress(ata, mint, owner, programId); + const validation = checkAtaAddress(ata, mint, owner, programId); if (wrap && validation.type !== 'ctoken') { throw new Error( diff --git a/js/compressed-token/tests/e2e/load-ata-spl-t22.test.ts b/js/compressed-token/tests/e2e/load-ata-spl-t22.test.ts index e7d906b078..14198e3a5b 100644 --- a/js/compressed-token/tests/e2e/load-ata-spl-t22.test.ts +++ b/js/compressed-token/tests/e2e/load-ata-spl-t22.test.ts @@ -38,7 +38,7 @@ import { loadAta, createLoadAtaInstructions, } from '../../src/v3/actions/load-ata'; -import { validateAtaAddress } from '../../src/v3/ata-utils'; +import { checkAtaAddress } from '../../src/v3/ata-utils'; import { getAssociatedTokenAddressInterface } from '../../src/v3/get-associated-token-address-interface'; featureFlags.version = VERSION.V2; @@ -57,13 +57,13 @@ async function getCompressedBalance( ); } -describe('validateAtaAddress', () => { +describe('checkAtaAddress', () => { it('should validate c-token ATA', () => { const mint = Keypair.generate().publicKey; const owner = Keypair.generate().publicKey; const ctokenAta = getAssociatedTokenAddressInterface(mint, owner); - const result = validateAtaAddress(ctokenAta, mint, owner); + const result = checkAtaAddress(ctokenAta, mint, owner); expect(result.valid).toBe(true); expect(result.type).toBe('ctoken'); }); @@ -79,7 +79,7 @@ describe('validateAtaAddress', () => { TOKEN_PROGRAM_ID, getAtaProgramId(TOKEN_PROGRAM_ID), ); - const result = validateAtaAddress(splAta, mint, owner); + const result = checkAtaAddress(splAta, mint, owner); expect(result.valid).toBe(true); expect(result.type).toBe('spl'); }); @@ -95,7 +95,7 @@ describe('validateAtaAddress', () => { TOKEN_2022_PROGRAM_ID, getAtaProgramId(TOKEN_2022_PROGRAM_ID), ); - const result = validateAtaAddress(t22Ata, mint, owner); + const result = checkAtaAddress(t22Ata, mint, owner); expect(result.valid).toBe(true); expect(result.type).toBe('token2022'); }); @@ -105,7 +105,7 @@ describe('validateAtaAddress', () => { const owner = Keypair.generate().publicKey; const wrongAta = Keypair.generate().publicKey; - expect(() => validateAtaAddress(wrongAta, mint, owner)).toThrow( + expect(() => checkAtaAddress(wrongAta, mint, owner)).toThrow( 'ATA address does not match any valid derivation', ); }); @@ -121,12 +121,7 @@ describe('validateAtaAddress', () => { TOKEN_PROGRAM_ID, getAtaProgramId(TOKEN_PROGRAM_ID), ); - const result = validateAtaAddress( - splAta, - mint, - owner, - TOKEN_PROGRAM_ID, - ); + const result = checkAtaAddress(splAta, mint, owner, TOKEN_PROGRAM_ID); expect(result.valid).toBe(true); expect(result.type).toBe('spl'); }); diff --git a/sdk-libs/macros/src/compressible/GUIDE.md b/sdk-libs/macros/src/compressible/GUIDE.md index 366ff4e788..d12b356eca 100644 --- a/sdk-libs/macros/src/compressible/GUIDE.md +++ b/sdk-libs/macros/src/compressible/GUIDE.md @@ -66,7 +66,7 @@ pub mod my_program {} // Program‑owned ctoken PDA (must provide authority seeds) TreasuryCtoken = (is_token, "treasury_ctoken", ctx.fee_payer, authority = (ctx.treasury)), // User ATA variant (no seeds, derived from owner+mint) - UserATA = (is_token, is_ata) + UserAta = (is_token, is_ata) )] #[program] pub mod my_program {} diff --git a/sdk-libs/macros/src/compressible/instructions.rs b/sdk-libs/macros/src/compressible/instructions.rs index e8fad9d77b..6469d22290 100644 --- a/sdk-libs/macros/src/compressible/instructions.rs +++ b/sdk-libs/macros/src/compressible/instructions.rs @@ -1550,7 +1550,7 @@ fn generate_error_codes(variant: InstructionVariant) -> Result { #[msg("Missing seed account")] MissingSeedAccount, #[msg("ATA uses SPL ATA derivation")] - ATADoesNotUseSeedDerivation, + AtaDoesNotUseSeedDerivation, }; let variant_specific_errors = match variant { diff --git a/sdk-libs/macros/src/compressible/seed_providers.rs b/sdk-libs/macros/src/compressible/seed_providers.rs index 0848122d47..0b55225fed 100644 --- a/sdk-libs/macros/src/compressible/seed_providers.rs +++ b/sdk-libs/macros/src/compressible/seed_providers.rs @@ -37,7 +37,7 @@ pub fn generate_ctoken_seed_provider_implementation( let get_seeds_arm = quote! { CTokenAccountVariant::#variant_name => { Err(anchor_lang::prelude::ProgramError::Custom( - CompressibleInstructionError::ATADoesNotUseSeedDerivation.into() + CompressibleInstructionError::AtaDoesNotUseSeedDerivation.into() ).into()) } }; @@ -46,7 +46,7 @@ pub fn generate_ctoken_seed_provider_implementation( let authority_arm = quote! { CTokenAccountVariant::#variant_name => { Err(anchor_lang::prelude::ProgramError::Custom( - CompressibleInstructionError::ATADoesNotUseSeedDerivation.into() + CompressibleInstructionError::AtaDoesNotUseSeedDerivation.into() ).into()) } }; From e5f623e8e505385d205404b8e32cdb623a3f6e25 Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Mon, 8 Dec 2025 23:45:52 +0400 Subject: [PATCH 11/13] wip --- js/compressed-token/src/v3/derivation.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/js/compressed-token/src/v3/derivation.ts b/js/compressed-token/src/v3/derivation.ts index 86a7106bdc..4232569bd5 100644 --- a/js/compressed-token/src/v3/derivation.ts +++ b/js/compressed-token/src/v3/derivation.ts @@ -9,12 +9,12 @@ import { Buffer } from 'buffer'; /** * Returns the compressed mint address as bytes. */ -export function deriveCTokenMintAddress( +export function deriveCMintAddress( mintSeed: PublicKey, addressTreeInfo: TreeInfo, ) { - // find_spl_mint_address returns [splMint, bump], we want splMint - // In JS, just use the mintSeed directly as the SPL mint address + // find_cmint_address returns [CMint, bump], we want CMint + // In JS, just use the mintSeed directly as the CMint address const address = deriveAddressV2( findMintAddress(mintSeed)[0].toBytes(), addressTreeInfo.tree, From b6272260d452286091bfe6a994535627eb5ae701 Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Tue, 9 Dec 2025 00:13:01 +0400 Subject: [PATCH 12/13] cleaning --- js/compressed-token/src/index.ts | 2 - .../v3/actions/create-associated-ctoken.ts | 17 +- .../src/v3/actions/create-ata-interface.ts | 110 +++++------- js/compressed-token/src/v3/unified/index.ts | 3 - .../e2e/create-associated-ctoken.test.ts | 157 ++++++++---------- .../tests/e2e/create-ata-interface.test.ts | 54 +++--- .../tests/e2e/mint-to-ctoken.test.ts | 3 +- .../tests/e2e/mint-to-interface.test.ts | 18 +- .../tests/e2e/mint-workflow.test.ts | 22 +-- js/stateless.js/src/programs/system/layout.ts | 3 +- 10 files changed, 159 insertions(+), 230 deletions(-) diff --git a/js/compressed-token/src/index.ts b/js/compressed-token/src/index.ts index 89a20a9e47..4552e61466 100644 --- a/js/compressed-token/src/index.ts +++ b/js/compressed-token/src/index.ts @@ -89,8 +89,6 @@ export { updateMetadataAuthority, removeMetadataKey, // Action types - CreateAtaInterfaceParams, - CreateAtaInterfaceResult, InterfaceOptions, // Helpers getMintInterface, 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 78eef8eabd..acf830e6ca 100644 --- a/js/compressed-token/src/v3/actions/create-associated-ctoken.ts +++ b/js/compressed-token/src/v3/actions/create-associated-ctoken.ts @@ -3,7 +3,6 @@ import { ConfirmOptions, PublicKey, Signer, - TransactionSignature, } from '@solana/web3.js'; import { Rpc, @@ -28,6 +27,7 @@ import { getAssociatedCTokenAddress } from '../derivation'; * @param configAccount Optional config account * @param rentPayerPda Optional rent payer PDA * @param confirmOptions Optional confirm options + * @returns Address of the new associated token account */ export async function createAssociatedCTokenAccount( rpc: Rpc, @@ -38,7 +38,7 @@ export async function createAssociatedCTokenAccount( configAccount?: PublicKey, rentPayerPda?: PublicKey, confirmOptions?: ConfirmOptions, -): Promise<{ address: PublicKey; transactionSignature: TransactionSignature }> { +): Promise { const ix = createAssociatedCTokenAccountInstruction( payer.publicKey, owner, @@ -56,10 +56,9 @@ export async function createAssociatedCTokenAccount( [], ); - const txId = await sendAndConfirmTx(rpc, tx, confirmOptions); - const address = getAssociatedCTokenAddress(owner, mint); + await sendAndConfirmTx(rpc, tx, confirmOptions); - return { address, transactionSignature: txId }; + return getAssociatedCTokenAddress(owner, mint); } /** @@ -73,6 +72,7 @@ export async function createAssociatedCTokenAccount( * @param configAccount Optional config account * @param rentPayerPda Optional rent payer PDA * @param confirmOptions Optional confirm options + * @returns Address of the associated token account */ export async function createAssociatedCTokenAccountIdempotent( rpc: Rpc, @@ -83,7 +83,7 @@ export async function createAssociatedCTokenAccountIdempotent( configAccount?: PublicKey, rentPayerPda?: PublicKey, confirmOptions?: ConfirmOptions, -): Promise<{ address: PublicKey; transactionSignature: TransactionSignature }> { +): Promise { const ix = createAssociatedCTokenAccountIdempotentInstruction( payer.publicKey, owner, @@ -101,8 +101,7 @@ export async function createAssociatedCTokenAccountIdempotent( [], ); - const txId = await sendAndConfirmTx(rpc, tx, confirmOptions); - const address = getAssociatedCTokenAddress(owner, mint); + await sendAndConfirmTx(rpc, tx, confirmOptions); - return { address, transactionSignature: txId }; + return getAssociatedCTokenAddress(owner, mint); } 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 595a84e761..0324832e9a 100644 --- a/js/compressed-token/src/v3/actions/create-ata-interface.ts +++ b/js/compressed-token/src/v3/actions/create-ata-interface.ts @@ -4,7 +4,6 @@ import { PublicKey, Signer, Transaction, - TransactionSignature, sendAndConfirmTransaction, } from '@solana/web3.js'; import { @@ -13,11 +12,6 @@ import { buildAndSignTx, sendAndConfirmTx, } from '@lightprotocol/stateless.js'; -import { - TOKEN_PROGRAM_ID, - TOKEN_2022_PROGRAM_ID, - getAssociatedTokenAddressSync, -} from '@solana/spl-token'; import { createAssociatedTokenAccountInterfaceInstruction, createAssociatedTokenAccountInterfaceIdempotentInstruction, @@ -29,39 +23,22 @@ import { getAssociatedTokenAddressInterface } from '../get-associated-token-addr // Re-export types for backwards compatibility export type { CTokenConfig }; -// Keep old interface type for backwards compatibility export -export interface CreateAtaInterfaceParams { - rpc: Rpc; - payer: Signer; - owner: PublicKey; - mint: PublicKey; - allowOwnerOffCurve?: boolean; - confirmOptions?: ConfirmOptions; - programId?: PublicKey; - associatedTokenProgramId?: PublicKey; - ctokenConfig?: CTokenConfig; -} - -export interface CreateAtaInterfaceResult { - address: PublicKey; - transactionSignature: TransactionSignature; -} - /** - * Create an associated token account for SPL/T22/c-token. Follows SPL Token - * createAssociatedTokenAccount signature. Defaults to c-token program. + * Create an associated token account for SPL/T22/c-token. Defaults to c-token + * program. * - * @param rpc RPC connection - * @param payer Fee payer and transaction signer - * @param mint Mint address - * @param owner Owner of the associated token account - * @param allowOwnerOffCurve Allow owner to be a PDA (default: false) - * @param confirmOptions Options for confirming the transaction - * @param programId Token program ID (default: CTOKEN_PROGRAM_ID) - * @param associatedTokenProgramId Associated token program ID (auto-derived if - * not provided) - * @param ctokenConfig c-token-specific configuration - * @returns Object with token account address and transaction signature + * @param rpc RPC connection + * @param payer Fee payer and transaction signer + * @param mint Mint address + * @param owner Owner of the associated token account + * @param allowOwnerOffCurve Allow owner to be a PDA (default: false) + * @param confirmOptions Options for confirming the transaction + * @param programId Token program ID (default: + * CTOKEN_PROGRAM_ID) + * @param associatedTokenProgramId ATA program ID (auto-derived if not + * provided) + * @param ctokenConfig Optional rent config + * @returns Address of the new associated token account */ export async function createAtaInterface( rpc: Rpc, @@ -73,7 +50,7 @@ export async function createAtaInterface( programId: PublicKey = CTOKEN_PROGRAM_ID, associatedTokenProgramId?: PublicKey, ctokenConfig?: CTokenConfig, -): Promise { +): Promise { const effectiveAtaProgramId = associatedTokenProgramId ?? getAtaProgramId(programId); @@ -95,22 +72,18 @@ export async function createAtaInterface( ctokenConfig, ); - let txId: TransactionSignature; - if (programId.equals(CTOKEN_PROGRAM_ID)) { - // c-token uses Light protocol transaction handling const { blockhash } = await rpc.getLatestBlockhash(); const tx = buildAndSignTx( - [ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }), ix], + [ComputeBudgetProgram.setComputeUnitLimit({ units: 30_000 }), ix], payer, blockhash, [], ); - txId = await sendAndConfirmTx(rpc, tx, confirmOptions); + await sendAndConfirmTx(rpc, tx, confirmOptions); } else { - // SPL Token / Token-2022 use standard transaction const transaction = new Transaction().add(ix); - txId = await sendAndConfirmTransaction( + await sendAndConfirmTransaction( rpc, transaction, [payer], @@ -118,29 +91,28 @@ export async function createAtaInterface( ); } - return { address: associatedToken, transactionSignature: txId }; + return associatedToken; } /** - * Create an associated token account idempotently for SPL/T22/c-token. Follows - * SPL Token createAssociatedTokenAccountIdempotent signature. Defaults to - * c-token program. + * Create an associated token account idempotently for SPL/T22/c-token. Defaults + * to c-token program. * - * This is idempotent: if the account already exists, the instruction succeeds - * without error. + * If the account already exists, the instruction succeeds without error. * - * @param rpc RPC connection - * @param payer Fee payer and transaction signer - * @param mint Mint address - * @param owner Owner of the associated token account - * @param allowOwnerOffCurve Allow owner to be a PDA (default: false) - * @param confirmOptions Options for confirming the transaction - * @param programId Token program ID (default: CTOKEN_PROGRAM_ID) - * @param associatedTokenProgramId Associated token program ID (auto-derived if - * not provided) - * @param ctokenConfig Optional c-token-specific configuration + * @param rpc RPC connection + * @param payer Fee payer and transaction signer + * @param mint Mint address + * @param owner Owner of the associated token account + * @param allowOwnerOffCurve Allow owner to be a PDA (default: false) + * @param confirmOptions Options for confirming the transaction + * @param programId Token program ID (default: + * CTOKEN_PROGRAM_ID) + * @param associatedTokenProgramId ATA program ID (auto-derived if not + * provided) + * @param ctokenConfig Optional c-token-specific configuration * - * @returns Object with token account address and transaction signature + * @returns Address of the associated token account */ export async function createAtaInterfaceIdempotent( rpc: Rpc, @@ -152,7 +124,7 @@ export async function createAtaInterfaceIdempotent( programId: PublicKey = CTOKEN_PROGRAM_ID, associatedTokenProgramId?: PublicKey, ctokenConfig?: CTokenConfig, -): Promise { +): Promise { const effectiveAtaProgramId = associatedTokenProgramId ?? getAtaProgramId(programId); @@ -174,22 +146,18 @@ export async function createAtaInterfaceIdempotent( ctokenConfig, ); - let txId: TransactionSignature; - if (programId.equals(CTOKEN_PROGRAM_ID)) { - // c-token uses Light protocol transaction handling const { blockhash } = await rpc.getLatestBlockhash(); const tx = buildAndSignTx( - [ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }), ix], + [ComputeBudgetProgram.setComputeUnitLimit({ units: 30_000 }), ix], payer, blockhash, [], ); - txId = await sendAndConfirmTx(rpc, tx, confirmOptions); + await sendAndConfirmTx(rpc, tx, confirmOptions); } else { - // SPL Token / Token-2022 use standard transaction const transaction = new Transaction().add(ix); - txId = await sendAndConfirmTransaction( + await sendAndConfirmTransaction( rpc, transaction, [payer], @@ -197,5 +165,5 @@ export async function createAtaInterfaceIdempotent( ); } - return { address: associatedToken, transactionSignature: txId }; + return associatedToken; } diff --git a/js/compressed-token/src/v3/unified/index.ts b/js/compressed-token/src/v3/unified/index.ts index b0e08e21d0..5571c1f918 100644 --- a/js/compressed-token/src/v3/unified/index.ts +++ b/js/compressed-token/src/v3/unified/index.ts @@ -318,9 +318,6 @@ export { updateMetadataField, updateMetadataAuthority, removeMetadataKey, - // Action types - CreateAtaInterfaceParams, - CreateAtaInterfaceResult, // Helpers getMintInterface, unpackMintInterface, diff --git a/js/compressed-token/tests/e2e/create-associated-ctoken.test.ts b/js/compressed-token/tests/e2e/create-associated-ctoken.test.ts index 05ab953eca..312d43d4f4 100644 --- a/js/compressed-token/tests/e2e/create-associated-ctoken.test.ts +++ b/js/compressed-token/tests/e2e/create-associated-ctoken.test.ts @@ -47,14 +47,12 @@ describe('createAssociatedCTokenAccount', () => { ); await rpc.confirmTransaction(createMintSig, 'confirmed'); - const { address: ataAddress, transactionSignature: createAtaSig } = - await createAssociatedCTokenAccount( - rpc, - payer, - owner.publicKey, - mintPda, - ); - await rpc.confirmTransaction(createAtaSig, 'confirmed'); + const ataAddress = await createAssociatedCTokenAccount( + rpc, + payer, + owner.publicKey, + mintPda, + ); const expectedAddress = getAssociatedCTokenAddress( owner.publicKey, @@ -88,14 +86,12 @@ describe('createAssociatedCTokenAccount', () => { ); await rpc.confirmTransaction(createMintSig, 'confirmed'); - const { transactionSignature: createAtaSig } = - await createAssociatedCTokenAccount( - rpc, - payer, - owner.publicKey, - mintPda, - ); - await rpc.confirmTransaction(createAtaSig, 'confirmed'); + await createAssociatedCTokenAccount( + rpc, + payer, + owner.publicKey, + mintPda, + ); await expect( createAssociatedCTokenAccount(rpc, payer, owner.publicKey, mintPda), @@ -121,14 +117,12 @@ describe('createAssociatedCTokenAccount', () => { ); await rpc.confirmTransaction(createMintSig, 'confirmed'); - const { address: ataAddress1, transactionSignature: createAtaSig1 } = - await createAssociatedCTokenAccountIdempotent( - rpc, - payer, - owner.publicKey, - mintPda, - ); - await rpc.confirmTransaction(createAtaSig1, 'confirmed'); + const ataAddress1 = await createAssociatedCTokenAccountIdempotent( + rpc, + payer, + owner.publicKey, + mintPda, + ); const expectedAddress = getAssociatedCTokenAddress( owner.publicKey, @@ -136,14 +130,12 @@ describe('createAssociatedCTokenAccount', () => { ); expect(ataAddress1.toString()).toBe(expectedAddress.toString()); - const { address: ataAddress2, transactionSignature: createAtaSig2 } = - await createAssociatedCTokenAccountIdempotent( - rpc, - payer, - owner.publicKey, - mintPda, - ); - await rpc.confirmTransaction(createAtaSig2, 'confirmed'); + const ataAddress2 = await createAssociatedCTokenAccountIdempotent( + rpc, + payer, + owner.publicKey, + mintPda, + ); expect(ataAddress2.toString()).toBe(ataAddress1.toString()); @@ -172,21 +164,21 @@ describe('createAssociatedCTokenAccount', () => { ); await rpc.confirmTransaction(createMintSig, 'confirmed'); - const { address: ata1 } = await createAssociatedCTokenAccount( + const ata1 = await createAssociatedCTokenAccount( rpc, payer, owner1.publicKey, mintPda, ); - const { address: ata2 } = await createAssociatedCTokenAccount( + const ata2 = await createAssociatedCTokenAccount( rpc, payer, owner2.publicKey, mintPda, ); - const { address: ata3 } = await createAssociatedCTokenAccount( + const ata3 = await createAssociatedCTokenAccount( rpc, payer, owner3.publicKey, @@ -258,9 +250,11 @@ describe('createAssociatedCTokenAccount', () => { owner.publicKey, mintPda, ); - expect(successfulResults[0].value.address.toString()).toBe( - expectedAddress.toString(), - ); + expect( + ( + successfulResults[0] as PromiseFulfilledResult + ).value.toString(), + ).toBe(expectedAddress.toString()); } }); }); @@ -307,23 +301,19 @@ describe('createMint -> createAssociatedCTokenAccount flow', () => { const owner1 = Keypair.generate(); const owner2 = Keypair.generate(); - const { address: ata1, transactionSignature: createAta1Sig } = - await createAssociatedCTokenAccount( - rpc, - payer, - owner1.publicKey, - mint, - ); - await rpc.confirmTransaction(createAta1Sig, 'confirmed'); + const ata1 = await createAssociatedCTokenAccount( + rpc, + payer, + owner1.publicKey, + mint, + ); - const { address: ata2, transactionSignature: createAta2Sig } = - await createAssociatedCTokenAccount( - rpc, - payer, - owner2.publicKey, - mint, - ); - await rpc.confirmTransaction(createAta2Sig, 'confirmed'); + const ata2 = await createAssociatedCTokenAccount( + rpc, + payer, + owner2.publicKey, + mint, + ); const expectedAta1 = getAssociatedCTokenAddress(owner1.publicKey, mint); const expectedAta2 = getAssociatedCTokenAddress(owner2.publicKey, mint); @@ -364,14 +354,12 @@ describe('createMint -> createAssociatedCTokenAccount flow', () => { ); await rpc.confirmTransaction(createMintSig, 'confirmed'); - const { address: ataAddress, transactionSignature: createAtaSig } = - await createAssociatedCTokenAccountIdempotent( - rpc, - payer, - owner.publicKey, - mint, - ); - await rpc.confirmTransaction(createAtaSig, 'confirmed'); + const ataAddress = await createAssociatedCTokenAccountIdempotent( + rpc, + payer, + owner.publicKey, + mint, + ); const expectedAddress = getAssociatedCTokenAddress( owner.publicKey, @@ -415,14 +403,14 @@ describe('createMint -> createAssociatedCTokenAccount flow', () => { ); await rpc.confirmTransaction(createMint2Sig, 'confirmed'); - const { address: ata1 } = await createAssociatedCTokenAccount( + const ata1 = await createAssociatedCTokenAccount( rpc, payer, owner.publicKey, mintPda1, ); - const { address: ata2 } = await createAssociatedCTokenAccount( + const ata2 = await createAssociatedCTokenAccount( rpc, payer, owner.publicKey, @@ -465,7 +453,7 @@ describe('createMint -> createAssociatedCTokenAccount flow', () => { await new Promise(resolve => setTimeout(resolve, 1000)); const owner = Keypair.generate(); - const { address: ataAddress } = await createAssociatedCTokenAccount( + const ataAddress = await createAssociatedCTokenAccount( rpc, payer, owner.publicKey, @@ -498,29 +486,26 @@ describe('createMint -> createAssociatedCTokenAccount flow', () => { ); await rpc.confirmTransaction(createMintSig, 'confirmed'); - const { address: ataAddress1 } = - await createAssociatedCTokenAccountIdempotent( - rpc, - payer, - owner.publicKey, - mintPda, - ); + const ataAddress1 = await createAssociatedCTokenAccountIdempotent( + rpc, + payer, + owner.publicKey, + mintPda, + ); - const { address: ataAddress2 } = - await createAssociatedCTokenAccountIdempotent( - rpc, - payer, - owner.publicKey, - mintPda, - ); + const ataAddress2 = await createAssociatedCTokenAccountIdempotent( + rpc, + payer, + owner.publicKey, + mintPda, + ); - const { address: ataAddress3 } = - await createAssociatedCTokenAccountIdempotent( - rpc, - payer, - owner.publicKey, - mintPda, - ); + const ataAddress3 = await createAssociatedCTokenAccountIdempotent( + rpc, + payer, + owner.publicKey, + mintPda, + ); expect(ataAddress1.toString()).toBe(ataAddress2.toString()); expect(ataAddress2.toString()).toBe(ataAddress3.toString()); diff --git a/js/compressed-token/tests/e2e/create-ata-interface.test.ts b/js/compressed-token/tests/e2e/create-ata-interface.test.ts index 55c673b72a..f923c10252 100644 --- a/js/compressed-token/tests/e2e/create-ata-interface.test.ts +++ b/js/compressed-token/tests/e2e/create-ata-interface.test.ts @@ -51,15 +51,13 @@ describe('createAtaInterface', () => { mintSigner, ); - const { address, transactionSignature } = await createAtaInterface( + const address = await createAtaInterface( rpc, payer, mintPda, owner.publicKey, ); - await rpc.confirmTransaction(transactionSignature, 'confirmed'); - const expectedAddress = getAssociatedTokenAddressInterface( mintPda, owner.publicKey, @@ -88,7 +86,7 @@ describe('createAtaInterface', () => { mintSigner, ); - const { address, transactionSignature } = await createAtaInterface( + const address = await createAtaInterface( rpc, payer, mintPda, @@ -98,8 +96,6 @@ describe('createAtaInterface', () => { CTOKEN_PROGRAM_ID, ); - await rpc.confirmTransaction(transactionSignature, 'confirmed'); - const expectedAddress = getAssociatedTokenAddressInterface( mintPda, owner.publicKey, @@ -146,21 +142,21 @@ describe('createAtaInterface', () => { mintSigner, ); - const { address: addr1 } = await createAtaInterfaceIdempotent( + const addr1 = await createAtaInterfaceIdempotent( rpc, payer, mintPda, owner.publicKey, ); - const { address: addr2 } = await createAtaInterfaceIdempotent( + const addr2 = await createAtaInterfaceIdempotent( rpc, payer, mintPda, owner.publicKey, ); - const { address: addr3 } = await createAtaInterfaceIdempotent( + const addr3 = await createAtaInterfaceIdempotent( rpc, payer, mintPda, @@ -187,14 +183,14 @@ describe('createAtaInterface', () => { mintSigner, ); - const { address: addr1 } = await createAtaInterface( + const addr1 = await createAtaInterface( rpc, payer, mintPda, owner1.publicKey, ); - const { address: addr2 } = await createAtaInterface( + const addr2 = await createAtaInterface( rpc, payer, mintPda, @@ -233,7 +229,7 @@ describe('createAtaInterface', () => { TOKEN_PROGRAM_ID, ); - const { address, transactionSignature } = await createAtaInterface( + const address = await createAtaInterface( rpc, payer, mint, @@ -243,8 +239,6 @@ describe('createAtaInterface', () => { TOKEN_PROGRAM_ID, ); - await rpc.confirmTransaction(transactionSignature, 'confirmed'); - const expectedAddress = getAssociatedTokenAddressSync( mint, owner.publicKey, @@ -276,7 +270,7 @@ describe('createAtaInterface', () => { TOKEN_PROGRAM_ID, ); - const { address: addr1 } = await createAtaInterfaceIdempotent( + const addr1 = await createAtaInterfaceIdempotent( rpc, payer, mint, @@ -286,7 +280,7 @@ describe('createAtaInterface', () => { TOKEN_PROGRAM_ID, ); - const { address: addr2 } = await createAtaInterfaceIdempotent( + const addr2 = await createAtaInterfaceIdempotent( rpc, payer, mint, @@ -354,7 +348,7 @@ describe('createAtaInterface', () => { TOKEN_2022_PROGRAM_ID, ); - const { address, transactionSignature } = await createAtaInterface( + const address = await createAtaInterface( rpc, payer, mint, @@ -364,8 +358,6 @@ describe('createAtaInterface', () => { TOKEN_2022_PROGRAM_ID, ); - await rpc.confirmTransaction(transactionSignature, 'confirmed'); - const expectedAddress = getAssociatedTokenAddressSync( mint, owner.publicKey, @@ -397,7 +389,7 @@ describe('createAtaInterface', () => { TOKEN_2022_PROGRAM_ID, ); - const { address: addr1 } = await createAtaInterfaceIdempotent( + const addr1 = await createAtaInterfaceIdempotent( rpc, payer, mint, @@ -407,7 +399,7 @@ describe('createAtaInterface', () => { TOKEN_2022_PROGRAM_ID, ); - const { address: addr2 } = await createAtaInterfaceIdempotent( + const addr2 = await createAtaInterfaceIdempotent( rpc, payer, mint, @@ -442,7 +434,7 @@ describe('createAtaInterface', () => { mintSigner, ); - const { address, transactionSignature } = await createAtaInterface( + const address = await createAtaInterface( rpc, payer, mintPda, @@ -450,8 +442,6 @@ describe('createAtaInterface', () => { true, // allowOwnerOffCurve ); - await rpc.confirmTransaction(transactionSignature, 'confirmed'); - const expectedAddress = getAssociatedTokenAddressInterface( mintPda, pdaOwner, @@ -480,7 +470,7 @@ describe('createAtaInterface', () => { TOKEN_PROGRAM_ID, ); - const { address, transactionSignature } = await createAtaInterface( + const address = await createAtaInterface( rpc, payer, mint, @@ -490,8 +480,6 @@ describe('createAtaInterface', () => { TOKEN_PROGRAM_ID, ); - await rpc.confirmTransaction(transactionSignature, 'confirmed'); - const expectedAddress = getAssociatedTokenAddressSync( mint, pdaOwner, @@ -533,7 +521,7 @@ describe('createAtaInterface', () => { ); // Create ATAs for both - const { address: splAta } = await createAtaInterfaceIdempotent( + const splAta = await createAtaInterfaceIdempotent( rpc, payer, splMint, @@ -543,7 +531,7 @@ describe('createAtaInterface', () => { TOKEN_PROGRAM_ID, ); - const { address: ctokenAta } = await createAtaInterfaceIdempotent( + const ctokenAta = await createAtaInterfaceIdempotent( rpc, payer, ctokenMint, @@ -569,7 +557,7 @@ describe('createAtaInterface', () => { undefined, TOKEN_PROGRAM_ID, ); - const { address: splAta } = await createAtaInterfaceIdempotent( + const splAta = await createAtaInterfaceIdempotent( rpc, payer, splMint, @@ -599,7 +587,7 @@ describe('createAtaInterface', () => { undefined, TOKEN_2022_PROGRAM_ID, ); - const { address: t22Ata } = await createAtaInterfaceIdempotent( + const t22Ata = await createAtaInterfaceIdempotent( rpc, payer, t22Mint, @@ -629,7 +617,7 @@ describe('createAtaInterface', () => { 9, mintSigner, ); - const { address: ctokenAta } = await createAtaInterfaceIdempotent( + const ctokenAta = await createAtaInterfaceIdempotent( rpc, payer, ctokenMint, @@ -677,7 +665,7 @@ describe('createAtaInterface', () => { // All successful results should have same address const addresses = successful.map(r => - (r as PromiseFulfilledResult).value.address.toBase58(), + (r as PromiseFulfilledResult).value.toBase58(), ); const uniqueAddresses = [...new Set(addresses)]; expect(uniqueAddresses.length).toBe(1); diff --git a/js/compressed-token/tests/e2e/mint-to-ctoken.test.ts b/js/compressed-token/tests/e2e/mint-to-ctoken.test.ts index caa0aa3f27..18dca890f5 100644 --- a/js/compressed-token/tests/e2e/mint-to-ctoken.test.ts +++ b/js/compressed-token/tests/e2e/mint-to-ctoken.test.ts @@ -52,13 +52,12 @@ describe('mintTo (MintToCToken)', () => { await rpc.confirmTransaction(result.transactionSignature, 'confirmed'); mint = result.mint; - const { transactionSignature } = await createAssociatedCTokenAccount( + await createAssociatedCTokenAccount( rpc, payer, recipient.publicKey, mint, ); - await rpc.confirmTransaction(transactionSignature, 'confirmed'); recipientCToken = getAssociatedCTokenAddress(recipient.publicKey, mint); }); diff --git a/js/compressed-token/tests/e2e/mint-to-interface.test.ts b/js/compressed-token/tests/e2e/mint-to-interface.test.ts index 1f9c786d03..697a09cea2 100644 --- a/js/compressed-token/tests/e2e/mint-to-interface.test.ts +++ b/js/compressed-token/tests/e2e/mint-to-interface.test.ts @@ -200,13 +200,12 @@ describe('mintToInterface - Compressed Mints', () => { it('should mint compressed tokens to onchain ctoken account', async () => { const recipient = Keypair.generate(); - const { transactionSignature } = await createAssociatedCTokenAccount( + await createAssociatedCTokenAccount( rpc, payer, recipient.publicKey, mint, ); - await rpc.confirmTransaction(transactionSignature, 'confirmed'); const recipientCToken = getAssociatedCTokenAddress( recipient.publicKey, @@ -240,13 +239,12 @@ describe('mintToInterface - Compressed Mints', () => { it('should mint compressed tokens with bigint amount', async () => { const recipient = Keypair.generate(); - const { transactionSignature } = await createAssociatedCTokenAccount( + await createAssociatedCTokenAccount( rpc, payer, recipient.publicKey, mint, ); - await rpc.confirmTransaction(transactionSignature, 'confirmed'); const recipientCToken = getAssociatedCTokenAddress( recipient.publicKey, @@ -276,13 +274,12 @@ describe('mintToInterface - Compressed Mints', () => { it('should fail with wrong authority for compressed mint', async () => { const wrongAuthority = Keypair.generate(); const recipient = Keypair.generate(); - const { transactionSignature } = await createAssociatedCTokenAccount( + await createAssociatedCTokenAccount( rpc, payer, recipient.publicKey, mint, ); - await rpc.confirmTransaction(transactionSignature, 'confirmed'); const recipientCToken = getAssociatedCTokenAddress( recipient.publicKey, @@ -303,13 +300,12 @@ describe('mintToInterface - Compressed Mints', () => { it('should auto-detect CTOKEN_PROGRAM_ID when programId not provided', async () => { const recipient = Keypair.generate(); - const { transactionSignature } = await createAssociatedCTokenAccount( + await createAssociatedCTokenAccount( rpc, payer, recipient.publicKey, mint, ); - await rpc.confirmTransaction(transactionSignature, 'confirmed'); const recipientCToken = getAssociatedCTokenAddress( recipient.publicKey, @@ -498,13 +494,12 @@ describe('mintToInterface - Edge Cases', () => { it('should handle zero amount minting', async () => { const recipient = Keypair.generate(); - const { transactionSignature } = await createAssociatedCTokenAccount( + await createAssociatedCTokenAccount( rpc, payer, recipient.publicKey, compressedMint, ); - await rpc.confirmTransaction(transactionSignature, 'confirmed'); const recipientCToken = getAssociatedCTokenAddress( recipient.publicKey, @@ -543,13 +538,12 @@ describe('mintToInterface - Edge Cases', () => { await rpc.confirmTransaction(result.transactionSignature, 'confirmed'); const recipient = Keypair.generate(); - const { transactionSignature } = await createAssociatedCTokenAccount( + await createAssociatedCTokenAccount( rpc, payer, recipient.publicKey, result.mint, ); - await rpc.confirmTransaction(transactionSignature, 'confirmed'); const recipientCToken = getAssociatedCTokenAddress( recipient.publicKey, diff --git a/js/compressed-token/tests/e2e/mint-workflow.test.ts b/js/compressed-token/tests/e2e/mint-workflow.test.ts index d3165b7cdd..45e8adc48d 100644 --- a/js/compressed-token/tests/e2e/mint-workflow.test.ts +++ b/js/compressed-token/tests/e2e/mint-workflow.test.ts @@ -183,21 +183,21 @@ describe('Complete Mint Workflow', () => { const owner2 = Keypair.generate(); const owner3 = Keypair.generate(); - const { address: ata1 } = await createAtaInterfaceIdempotent( + const ata1 = await createAtaInterfaceIdempotent( rpc, payer, mint, owner1.publicKey, ); - const { address: ata2 } = await createAtaInterfaceIdempotent( + const ata2 = await createAtaInterfaceIdempotent( rpc, payer, mint, owner2.publicKey, ); - const { address: ata3 } = await createAtaInterfaceIdempotent( + const ata3 = await createAtaInterfaceIdempotent( rpc, payer, mint, @@ -299,7 +299,7 @@ describe('Complete Mint Workflow', () => { ); const owner = Keypair.generate(); - const { address: ataAddress } = await createAtaInterfaceIdempotent( + const ataAddress = await createAtaInterfaceIdempotent( rpc, payer, mintPda, @@ -349,7 +349,7 @@ describe('Complete Mint Workflow', () => { ]; for (const owner of owners) { - const { address: ataAddress } = await createAtaInterfaceIdempotent( + const ataAddress = await createAtaInterfaceIdempotent( rpc, payer, mint, @@ -395,7 +395,7 @@ describe('Complete Mint Workflow', () => { await rpc.confirmTransaction(createSig, 'confirmed'); const owner = Keypair.generate(); - const { address: ataAddress } = await createAtaInterfaceIdempotent( + const ataAddress = await createAtaInterfaceIdempotent( rpc, payer, mintPda, @@ -487,14 +487,14 @@ describe('Complete Mint Workflow', () => { const owner1 = Keypair.generate(); const owner2 = Keypair.generate(); - const { address: ata1 } = await createAtaInterfaceIdempotent( + const ata1 = await createAtaInterfaceIdempotent( rpc, payer, mint, owner1.publicKey, ); - const { address: ata2 } = await createAtaInterfaceIdempotent( + const ata2 = await createAtaInterfaceIdempotent( rpc, payer, mint, @@ -547,7 +547,7 @@ describe('Complete Mint Workflow', () => { newMintAuthority.publicKey.toString(), ); - const { address: ata1Again } = await createAtaInterfaceIdempotent( + const ata1Again = await createAtaInterfaceIdempotent( rpc, payer, mint, @@ -584,7 +584,7 @@ describe('Complete Mint Workflow', () => { expect(mintInfo.tokenMetadata).toBeUndefined(); const owner = Keypair.generate(); - const { address: ataAddress } = await createAtaInterfaceIdempotent( + const ataAddress = await createAtaInterfaceIdempotent( rpc, payer, mint, @@ -647,7 +647,7 @@ describe('Complete Mint Workflow', () => { owner.publicKey, ); - const { address: ataAddress } = await createAtaInterfaceIdempotent( + const ataAddress = await createAtaInterfaceIdempotent( rpc, payer, mint, diff --git a/js/stateless.js/src/programs/system/layout.ts b/js/stateless.js/src/programs/system/layout.ts index 5e9624ebc2..f6c28da689 100644 --- a/js/stateless.js/src/programs/system/layout.ts +++ b/js/stateless.js/src/programs/system/layout.ts @@ -556,7 +556,8 @@ export function convertToPublicTransactionEvent( convertByteArray( Buffer.from( new Uint8Array( - invokeData.outputCompressedAccounts[ + invokeData + .outputCompressedAccounts[ index ].compressedAccount.data.data, ), From 3c9cbf565fc0cba62171f1cd8d4d1d28661874dc Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Tue, 9 Dec 2025 01:30:05 +0400 Subject: [PATCH 13/13] housekeeping for new rpc methods --- js/stateless.js/src/rpc-interface.ts | 33 +- js/stateless.js/src/rpc.ts | 46 +- .../tests/e2e/interface-methods.test.ts | 47 +- .../tests/unit/rpc/merge-signatures.test.ts | 414 ++++++++++++++++++ 4 files changed, 450 insertions(+), 90 deletions(-) create mode 100644 js/stateless.js/tests/unit/rpc/merge-signatures.test.ts diff --git a/js/stateless.js/src/rpc-interface.ts b/js/stateless.js/src/rpc-interface.ts index 7fa907ad23..3bb246a8cf 100644 --- a/js/stateless.js/src/rpc-interface.ts +++ b/js/stateless.js/src/rpc-interface.ts @@ -1002,22 +1002,13 @@ export interface SignaturesForAddressInterfaceResult { } /** - * Unified token balance combining on-chain and compressed balances. - * - * Design rationale: - * - `amount`: Total balance for display (what user cares about) - * - `onChainAmount` / `compressedAmount`: Breakdown for operations that need to know source - * - `solana`: Raw response preserved for clients needing full TokenAmount (uiAmount, etc) + * Unified token balance combining hot and cold token balances. */ export interface UnifiedTokenBalance { - /** Total balance (on-chain + compressed) */ + /** Total balance (hot + cold) */ amount: BN; - /** On-chain (hot) token balance */ - onChainAmount: BN; - /** Compressed (cold) token balance */ - compressedAmount: BN; - /** True if any compressed balance exists (signals need for decompress before transfer) */ - hasCompressedBalance: boolean; + /** True if any cold balance exists - call load() before usage */ + hasColdBalance: boolean; /** Token decimals (from on-chain mint or 0 if unknown) */ decimals: number; /** Raw Solana RPC TokenAmount response, null if no on-chain account */ @@ -1025,19 +1016,11 @@ export interface UnifiedTokenBalance { } /** - * Unified SOL balance combining on-chain and compressed balances. - * - * Design rationale: - * - Mirrors UnifiedTokenBalance structure for consistency - * - `hasCompressedBalance` signals to UI that decompress may be needed + * Unified SOL balance combining hot and cold SOL balances. */ export interface UnifiedBalance { - /** Total balance (on-chain + compressed) in lamports */ + /** Total balance (hot + cold) in lamports */ total: BN; - /** On-chain balance in lamports */ - onChain: BN; - /** Compressed balance in lamports */ - compressed: BN; - /** True if any compressed balance exists */ - hasCompressedBalance: boolean; + /** True if any cold balance exists - call load() before usage */ + hasColdBalance: boolean; } diff --git a/js/stateless.js/src/rpc.ts b/js/stateless.js/src/rpc.ts index ac5daa94ac..0acbb0b1bd 100644 --- a/js/stateless.js/src/rpc.ts +++ b/js/stateless.js/src/rpc.ts @@ -78,9 +78,7 @@ import { ValidityProof, TreeType, AddressTreeInfo, - CompressedAccount, MerkleContext, - CompressedAccountData, } from './state'; import { array, create, nullable } from 'superstruct'; import { @@ -632,9 +630,8 @@ function buildCompressedAccountWithMaybeTokenData( * Merge signatures from Solana RPC and compression indexer. * Deduplicates by signature, tracking sources in the `sources` array. * When a signature exists in both, uses Solana data (richer) but marks both sources. - * @internal */ -function mergeSignatures( +export function mergeSignatures( solanaSignatures: ConfirmedSignatureInfo[], compressedSignatures: SignatureWithMetadata[], ): UnifiedSignatureInfo[] { @@ -2083,8 +2080,8 @@ export class Rpc extends Connection implements CompressionApiInterface { } /** - * Fetch all the account info for the specified public key. Returns metadata - * to to load in case the account is cold. + * Fetch the account info for the specified public key. Returns metadata + * to load in case the account is cold. * @param address The account address to fetch. * @param programId The owner program ID. * @param commitmentOrConfig Optional. The commitment or config to use @@ -2110,9 +2107,7 @@ export class Rpc extends Connection implements CompressionApiInterface { loadContext?: MerkleContext; } | null> { if (!featureFlags.isV2()) { - throw new Error( - 'getAccountInfoInterfacea requires feature flag V2', - ); + throw new Error('getAccountInfoInterface requires feature flag V2'); } addressSpace = addressSpace ?? getDefaultAddressSpace(); @@ -2190,7 +2185,6 @@ export class Rpc extends Connection implements CompressionApiInterface { /** * Get signatures for an address from both Solana and compression indexer. - * Merges results by signature, tracking which sources each was found in. * * @param address Address to fetch signatures for. * @param options Options for the Solana getSignaturesForAddress call. @@ -2228,8 +2222,6 @@ export class Rpc extends Connection implements CompressionApiInterface { /** * Get signatures for an owner from both Solana and compression indexer. - * Combines Solana getSignaturesForAddress with compression getCompressionSignaturesForOwner. - * This is the recommended method for wallet-style signature lookups. * * @param owner Owner address to fetch signatures for. * @param options Options for the Solana getSignaturesForAddress call. @@ -2266,11 +2258,12 @@ export class Rpc extends Connection implements CompressionApiInterface { } /** - * Get token account balance from both on-chain and compressed sources. + * Get token account balance for an address, regardless of whether the token + * account is hot or cold. * - * @param address Token account address (for on-chain lookup). - * @param owner Owner public key (for compressed token lookup). - * @param mint Mint public key (for compressed token lookup). + * @param address Token account address. + * @param owner Owner public key. + * @param mint Mint public key. * @param commitment Commitment level for on-chain query. * @returns Unified token balance from both sources. */ @@ -2310,24 +2303,20 @@ export class Rpc extends Connection implements CompressionApiInterface { } } - const total = onChainAmount.add(compressedAmount); - return { - amount: total, - onChainAmount, - compressedAmount, - hasCompressedBalance: !compressedAmount.isZero(), + amount: onChainAmount.add(compressedAmount), + hasColdBalance: !compressedAmount.isZero(), decimals, solana: solanaTokenAmount, }; } /** - * Get SOL balance from both on-chain and compressed sources. + * Get SOL balance for an address, regardless of whether the account is hot or cold. * * @param address Address to fetch balance for. * @param commitment Commitment level for on-chain query. - * @returns Unified SOL balance from both sources. + * @returns Unified SOL balance. */ async getBalanceInterface( address: PublicKey, @@ -2347,13 +2336,14 @@ export class Rpc extends Connection implements CompressionApiInterface { ? compressedResult.value : bn(0); - const total = onChainBalance.add(compressedBalance); + const total = !onChainBalance.isZero() + ? onChainBalance + : compressedBalance; return { total, - onChain: onChainBalance, - compressed: compressedBalance, - hasCompressedBalance: !compressedBalance.isZero(), + + hasColdBalance: !compressedBalance.isZero(), }; } } diff --git a/js/stateless.js/tests/e2e/interface-methods.test.ts b/js/stateless.js/tests/e2e/interface-methods.test.ts index d92c400d48..4df96c7317 100644 --- a/js/stateless.js/tests/e2e/interface-methods.test.ts +++ b/js/stateless.js/tests/e2e/interface-methods.test.ts @@ -35,38 +35,22 @@ describe('interface-methods', () => { }); describe('getBalanceInterface', () => { - it('should return unified balance with both on-chain and compressed', async () => { + it('should return unified balance', async () => { const result = await rpc.getBalanceInterface(payer.publicKey); - // Should have both on-chain and compressed components assert.isTrue( result.total.gt(bn(0)), 'Total balance should be > 0', ); - assert.isTrue( - result.onChain.gte(bn(0)), - 'On-chain balance should be >= 0', - ); - assert.isTrue( - result.compressed.gte(bn(0)), - 'Compressed balance should be >= 0', - ); - - // Total should equal sum of parts - assert.isTrue( - result.total.eq(result.onChain.add(result.compressed)), - 'Total should equal on-chain + compressed', - ); - // After compress(), payer should have compressed balance + // After compress(), payer should have cold balance assert.isTrue( - result.hasCompressedBalance, - 'Should have compressed balance after compress()', + result.hasColdBalance, + 'Should have cold balance after compress()', ); }); - it('should work for address with only on-chain balance', async () => { - // Create fresh account with only on-chain lamports + it('should work for address with only hot balance', async () => { const freshAccount = await newAccountWithLamports(rpc, 1e9, 256); const result = await rpc.getBalanceInterface( @@ -74,16 +58,12 @@ describe('interface-methods', () => { ); assert.isTrue(result.total.gt(bn(0))); - assert.isTrue(result.onChain.gt(bn(0))); - assert.isTrue(result.compressed.eq(bn(0))); - assert.isFalse(result.hasCompressedBalance); + assert.isFalse(result.hasColdBalance); }); - it('should work for address with only compressed balance', async () => { - // Bob received compressed SOL via transfer + it('should work for address with cold balance', async () => { const result = await rpc.getBalanceInterface(bob.publicKey); - // Bob has both on-chain (initial) and compressed (from transfer) assert.isTrue(result.total.gt(bn(0))); }); }); @@ -283,11 +263,9 @@ describe('interface-methods', () => { randomMint, ); - // Both should be zero for non-existent accounts + // Should be zero for non-existent accounts assert.isTrue(result.amount.eq(bn(0))); - assert.isTrue(result.onChainAmount.eq(bn(0))); - assert.isTrue(result.compressedAmount.eq(bn(0))); - assert.isFalse(result.hasCompressedBalance); + assert.isFalse(result.hasColdBalance); assert.isNull(result.solana); }); @@ -303,17 +281,12 @@ describe('interface-methods', () => { // Verify structure assert.isDefined(result.amount); - assert.isDefined(result.onChainAmount); - assert.isDefined(result.compressedAmount); - assert.isDefined(result.hasCompressedBalance); + assert.isDefined(result.hasColdBalance); assert.isDefined(result.decimals); - // solana can be null assert.isTrue('solana' in result); // Amount should be BN assert.isTrue(result.amount instanceof bn(0).constructor); - assert.isTrue(result.onChainAmount instanceof bn(0).constructor); - assert.isTrue(result.compressedAmount instanceof bn(0).constructor); }); }); }); diff --git a/js/stateless.js/tests/unit/rpc/merge-signatures.test.ts b/js/stateless.js/tests/unit/rpc/merge-signatures.test.ts new file mode 100644 index 0000000000..6f54f4b599 --- /dev/null +++ b/js/stateless.js/tests/unit/rpc/merge-signatures.test.ts @@ -0,0 +1,414 @@ +import { describe, it, expect } from 'vitest'; +import { mergeSignatures } from '../../../src/rpc'; +import { SignatureSource } from '../../../src/rpc-interface'; +import type { ConfirmedSignatureInfo } from '@solana/web3.js'; +import type { SignatureWithMetadata } from '../../../src/rpc-interface'; + +describe('mergeSignatures', () => { + describe('empty inputs', () => { + it('should return empty array when both inputs are empty', () => { + const result = mergeSignatures([], []); + expect(result).toEqual([]); + }); + }); + + describe('solana-only signatures', () => { + it('should return solana signatures with solana source', () => { + const solanaSignatures: ConfirmedSignatureInfo[] = [ + { + signature: 'sig1', + slot: 100, + blockTime: 1700000000, + err: null, + memo: 'test memo', + confirmationStatus: 'finalized', + }, + ]; + + const result = mergeSignatures(solanaSignatures, []); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + signature: 'sig1', + slot: 100, + blockTime: 1700000000, + err: null, + memo: 'test memo', + confirmationStatus: 'finalized', + sources: [SignatureSource.Solana], + }); + }); + + it('should handle null blockTime from solana', () => { + const solanaSignatures: ConfirmedSignatureInfo[] = [ + { + signature: 'sig1', + slot: 100, + blockTime: null, + err: null, + memo: null, + confirmationStatus: 'confirmed', + }, + ]; + + const result = mergeSignatures(solanaSignatures, []); + + expect(result[0].blockTime).toBeNull(); + }); + + it('should handle undefined memo from solana', () => { + const solanaSignatures: ConfirmedSignatureInfo[] = [ + { + signature: 'sig1', + slot: 100, + blockTime: 1700000000, + err: null, + memo: undefined as unknown as string | null, + confirmationStatus: 'confirmed', + }, + ]; + + const result = mergeSignatures(solanaSignatures, []); + + expect(result[0].memo).toBeNull(); + }); + + it('should preserve error info from solana', () => { + const errorObj = { InstructionError: [0, 'Custom'] }; + const solanaSignatures: ConfirmedSignatureInfo[] = [ + { + signature: 'sig1', + slot: 100, + blockTime: 1700000000, + err: errorObj, + memo: null, + confirmationStatus: 'finalized', + }, + ]; + + const result = mergeSignatures(solanaSignatures, []); + + expect(result[0].err).toEqual(errorObj); + }); + }); + + describe('compressed-only signatures', () => { + it('should return compressed signatures with compressed source', () => { + const compressedSignatures: SignatureWithMetadata[] = [ + { + signature: 'csig1', + slot: 200, + blockTime: 1700001000, + }, + ]; + + const result = mergeSignatures([], compressedSignatures); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + signature: 'csig1', + slot: 200, + blockTime: 1700001000, + err: null, + memo: null, + confirmationStatus: undefined, + sources: [SignatureSource.Compressed], + }); + }); + + it('should handle multiple compressed signatures', () => { + const compressedSignatures: SignatureWithMetadata[] = [ + { signature: 'csig1', slot: 200, blockTime: 1700001000 }, + { signature: 'csig2', slot: 150, blockTime: 1700000500 }, + { signature: 'csig3', slot: 250, blockTime: 1700001500 }, + ]; + + const result = mergeSignatures([], compressedSignatures); + + expect(result).toHaveLength(3); + result.forEach(sig => { + expect(sig.sources).toEqual([SignatureSource.Compressed]); + }); + }); + }); + + describe('deduplication', () => { + it('should dedupe signature found in both sources and mark both sources', () => { + const solanaSignatures: ConfirmedSignatureInfo[] = [ + { + signature: 'shared_sig', + slot: 100, + blockTime: 1700000000, + err: null, + memo: 'solana memo', + confirmationStatus: 'finalized', + }, + ]; + const compressedSignatures: SignatureWithMetadata[] = [ + { + signature: 'shared_sig', + slot: 100, + blockTime: 1700000000, + }, + ]; + + const result = mergeSignatures( + solanaSignatures, + compressedSignatures, + ); + + expect(result).toHaveLength(1); + expect(result[0].signature).toBe('shared_sig'); + expect(result[0].sources).toEqual([ + SignatureSource.Solana, + SignatureSource.Compressed, + ]); + }); + + it('should use solana data (richer) when signature exists in both', () => { + const solanaSignatures: ConfirmedSignatureInfo[] = [ + { + signature: 'shared_sig', + slot: 100, + blockTime: 1700000000, + err: { SomeError: 'test' }, + memo: 'important memo', + confirmationStatus: 'finalized', + }, + ]; + const compressedSignatures: SignatureWithMetadata[] = [ + { + signature: 'shared_sig', + slot: 100, + blockTime: 1700000000, + }, + ]; + + const result = mergeSignatures( + solanaSignatures, + compressedSignatures, + ); + + expect(result[0].memo).toBe('important memo'); + expect(result[0].err).toEqual({ SomeError: 'test' }); + expect(result[0].confirmationStatus).toBe('finalized'); + }); + + it('should not create duplicates when same signature appears in both', () => { + const sig = 'duplicate_test_sig'; + const solanaSignatures: ConfirmedSignatureInfo[] = [ + { + signature: sig, + slot: 500, + blockTime: 1700005000, + err: null, + memo: null, + confirmationStatus: 'confirmed', + }, + ]; + const compressedSignatures: SignatureWithMetadata[] = [ + { signature: sig, slot: 500, blockTime: 1700005000 }, + ]; + + const result = mergeSignatures( + solanaSignatures, + compressedSignatures, + ); + + const matching = result.filter(r => r.signature === sig); + expect(matching).toHaveLength(1); + }); + }); + + describe('mixed sources', () => { + it('should correctly merge signatures from both sources', () => { + const solanaSignatures: ConfirmedSignatureInfo[] = [ + { + signature: 'solana_only', + slot: 300, + blockTime: 1700003000, + err: null, + memo: null, + confirmationStatus: 'finalized', + }, + { + signature: 'shared', + slot: 200, + blockTime: 1700002000, + err: null, + memo: 'shared memo', + confirmationStatus: 'confirmed', + }, + ]; + const compressedSignatures: SignatureWithMetadata[] = [ + { + signature: 'compressed_only', + slot: 400, + blockTime: 1700004000, + }, + { signature: 'shared', slot: 200, blockTime: 1700002000 }, + ]; + + const result = mergeSignatures( + solanaSignatures, + compressedSignatures, + ); + + expect(result).toHaveLength(3); + + const solanaOnly = result.find(r => r.signature === 'solana_only'); + expect(solanaOnly?.sources).toEqual([SignatureSource.Solana]); + + const compressedOnly = result.find( + r => r.signature === 'compressed_only', + ); + expect(compressedOnly?.sources).toEqual([ + SignatureSource.Compressed, + ]); + + const shared = result.find(r => r.signature === 'shared'); + expect(shared?.sources).toEqual([ + SignatureSource.Solana, + SignatureSource.Compressed, + ]); + expect(shared?.memo).toBe('shared memo'); + }); + }); + + describe('sorting', () => { + it('should sort by slot descending (most recent first)', () => { + const solanaSignatures: ConfirmedSignatureInfo[] = [ + { + signature: 'sig_slot_100', + slot: 100, + blockTime: 1700001000, + err: null, + memo: null, + confirmationStatus: 'finalized', + }, + { + signature: 'sig_slot_300', + slot: 300, + blockTime: 1700003000, + err: null, + memo: null, + confirmationStatus: 'finalized', + }, + ]; + const compressedSignatures: SignatureWithMetadata[] = [ + { signature: 'sig_slot_200', slot: 200, blockTime: 1700002000 }, + { signature: 'sig_slot_400', slot: 400, blockTime: 1700004000 }, + ]; + + const result = mergeSignatures( + solanaSignatures, + compressedSignatures, + ); + + expect(result.map(r => r.slot)).toEqual([400, 300, 200, 100]); + expect(result.map(r => r.signature)).toEqual([ + 'sig_slot_400', + 'sig_slot_300', + 'sig_slot_200', + 'sig_slot_100', + ]); + }); + + it('should maintain stable order for same slot', () => { + const solanaSignatures: ConfirmedSignatureInfo[] = [ + { + signature: 'sig_a', + slot: 100, + blockTime: 1700001000, + err: null, + memo: null, + confirmationStatus: 'finalized', + }, + ]; + const compressedSignatures: SignatureWithMetadata[] = [ + { signature: 'sig_b', slot: 100, blockTime: 1700001000 }, + ]; + + const result = mergeSignatures( + solanaSignatures, + compressedSignatures, + ); + + expect(result).toHaveLength(2); + expect(result.every(r => r.slot === 100)).toBe(true); + }); + }); + + describe('edge cases', () => { + it('should handle large number of signatures', () => { + const solanaSignatures: ConfirmedSignatureInfo[] = Array.from( + { length: 100 }, + (_, i) => ({ + signature: `solana_sig_${i}`, + slot: i * 10, + blockTime: 1700000000 + i, + err: null, + memo: null, + confirmationStatus: 'finalized' as const, + }), + ); + const compressedSignatures: SignatureWithMetadata[] = Array.from( + { length: 100 }, + (_, i) => ({ + signature: `compressed_sig_${i}`, + slot: i * 10 + 5, + blockTime: 1700000000 + i, + }), + ); + + const result = mergeSignatures( + solanaSignatures, + compressedSignatures, + ); + + expect(result).toHaveLength(200); + for (let i = 1; i < result.length; i++) { + expect(result[i - 1].slot).toBeGreaterThanOrEqual( + result[i].slot, + ); + } + }); + + it('should handle many duplicate signatures', () => { + const sharedSigs = Array.from( + { length: 50 }, + (_, i) => `shared_${i}`, + ); + + const solanaSignatures: ConfirmedSignatureInfo[] = sharedSigs.map( + (sig, i) => ({ + signature: sig, + slot: i * 10, + blockTime: 1700000000 + i, + err: null, + memo: `memo_${i}`, + confirmationStatus: 'finalized' as const, + }), + ); + const compressedSignatures: SignatureWithMetadata[] = + sharedSigs.map((sig, i) => ({ + signature: sig, + slot: i * 10, + blockTime: 1700000000 + i, + })); + + const result = mergeSignatures( + solanaSignatures, + compressedSignatures, + ); + + expect(result).toHaveLength(50); + result.forEach(r => { + expect(r.sources).toEqual([ + SignatureSource.Solana, + SignatureSource.Compressed, + ]); + }); + }); + }); +});