diff --git a/kora-light-client/CLAUDE.md b/kora-light-client/CLAUDE.md new file mode 100644 index 0000000000..eb4325b2b9 --- /dev/null +++ b/kora-light-client/CLAUDE.md @@ -0,0 +1,498 @@ +# kora-light-client + +## Summary + +- Standalone Light Protocol instruction builders for solana-sdk 3.0 consumers (Kora) +- Zero `light-*` crate dependencies — all Borsh-serializable types are duplicated locally with byte-identical serialization to the on-chain program +- Builds Solana `Instruction` structs for Transfer2, Decompress, Wrap, Unwrap, CreateATA, and TransferChecked +- Uses packed account indices (u8) with HashMap-based deduplication for compact instruction data +- Golden byte tests verify serialization compatibility with the on-chain compressed token program + +## Used in + +- **Kora** (external) — Solana client using solana-sdk 3.0; consumes instruction builders for compressed token operations + +## Scope and limitations + +**Covers:** +- Compressed-to-compressed transfers (Transfer2) +- Decompress to light-token or SPL accounts +- Wrap (SPL → light-token) and Unwrap (light-token → SPL) +- Create associated token accounts with compressible config +- Decompressed ATA-to-ATA transfers (TransferChecked) +- Greedy account selection (max 8 inputs per transaction) +- Multi-transaction batch orchestration with compute budget estimation + +**Does not cover:** +- CreateMint, MintTo, MintToChecked, Freeze, Thaw, Approve, Revoke, CloseAccount, Burn +- RPC client for querying compressed accounts or fetching proofs +- Transaction signing, sending, or confirmation +- Address lookup table loading + +**Caller responsibilities:** +- Fetch compressed account data from Photon indexer/RPC → `CompressedTokenAccountInput` +- Fetch validity proofs from prover server → `CompressedProof` +- Derive PDAs for pools and ATAs as needed +- Assemble instructions into versioned transactions with LUTs +- Sign and submit transactions + +## Navigation + +This file contains all documentation for the crate. For on-chain instruction behavior, see: +- `programs/compressed-token/program/CLAUDE.md` — program overview and instruction index +- `programs/compressed-token/program/docs/compressed_token/TRANSFER2.md` — Transfer2 on-chain processing +- `programs/compressed-token/program/docs/ctoken/CREATE.md` — CreateAssociatedTokenAccount on-chain processing +- `programs/compressed-token/program/docs/ctoken/TRANSFER_CHECKED.md` — TransferChecked on-chain processing + +## Integration workflow + +End-to-end flow for using this crate: + +``` +1. Fetch compressed accounts → CompressedTokenAccountInput + Source: Photon indexer / RPC + Note: Kora implements TryFrom for this type + +2. Select input accounts → select_input_accounts(accounts, target_amount) + Returns up to 8 accounts using greedy descending selection + +3. Fetch validity proof → CompressedProof + Source: prover server via RPC + Note: proof is optional when all inputs use prove_by_index + +4. Derive PDAs if needed + get_associated_token_address(owner, mint) → light-token ATA + find_spl_interface_pda(mint) → SPL pool PDA (for wrap/unwrap/SPL decompress) + +5. Build instruction(s) + Transfer2 { ... }.instruction() → compressed-to-compressed + Decompress { ... }.instruction() → compressed → on-chain account + Wrap { ... }.instruction() → SPL → light-token + Unwrap { ... }.instruction() → light-token → SPL + CreateAta::new(...).idempotent().instruction() → create ATA + TransferChecked { ... }.instruction() → ATA-to-ATA + +6. Set compute budget + Use constants from load_ata.rs or create_load_ata_batches() for automatic estimation + +7. Assemble transaction + Use versioned transactions (V0) with LIGHT_LUT_MAINNET or LIGHT_LUT_DEVNET + +8. Sign and send + Caller's responsibility +``` + +## Address lookup tables + +`LIGHT_LUT_MAINNET` and `LIGHT_LUT_DEVNET` are exported for transaction assembly. All Transfer2/Decompress instructions include 7+ static program accounts (LightSystemProgram, CpiAuthorityPDA, RegisteredProgramPDA, etc.) that benefit from LUT compression. Callers should use versioned transactions (V0) and include the relevant LUT to keep transactions within the 1232-byte limit. + +Both mainnet and devnet currently point to the same address: `9NYFyEqPeWQHiS8Jv4VjZcjKBMPRCJ3KbEbaBcy4Mza`. + +## Design constraints + +- **Zero light-\* dependencies.** All types are ported with identical Borsh layout. This avoids version conflicts with Kora's solana-sdk 3.0 dependency tree. +- **solana-sdk 3.0 split crates.** Uses `solana-pubkey` 3.0, `solana-instruction` 3.0, `solana-system-interface` 2.0, `solana-compute-budget-interface` 3.0. +- **Borsh cross-version compatibility.** This crate uses borsh 1.5; the on-chain program uses borsh 0.10 via AnchorSerialize. The binary format is identical for these primitive types (verified by golden byte tests). +- **Packed u8 account indices.** Instruction data references accounts by u8 index into a deduplicated packed accounts array (see packed accounts scheme below). +- **Two Transfer2 layouts.** Standard (7 static accounts) for compressed inputs, decompressed-only (2 static accounts) for wrap/unwrap. + +## Packed accounts scheme + +All instruction builders (except CreateAta and TransferChecked) use the same pattern: + +1. **Static prefix.** Fixed accounts at the start of the accounts array. +2. **Packed suffix.** Remaining accounts are deduplicated via `HashMap` and appended. +3. **Index references.** Instruction data uses u8 indices into the packed portion. +4. **Flag upgrades.** When a pubkey is inserted twice, `is_signer` and `is_writable` flags are OR'd (upgraded, never downgraded). + +**Insert order for packed accounts:** +trees (writable) → queues (writable) → mint → authority/owner (signer) → destination → [delegates] → [pool (writable), token_program] + +### Standard layout (Transfer2, Decompress) + +| Index | Account | Signer | Writable | +|-------|---------|--------|----------| +| 0 | LightSystemProgram | | | +| 1 | payer | yes | yes | +| 2 | CpiAuthorityPDA | | | +| 3 | RegisteredProgramPDA | | | +| 4 | AccountCompressionAuthorityPDA | | | +| 5 | AccountCompressionProgram | | | +| 6 | SystemProgram | | | +| 7+ | packed accounts... | varies | varies | + +### Decompressed-only layout (Wrap, Unwrap) + +| Index | Account | Signer | Writable | +|-------|---------|--------|----------| +| 0 | CpiAuthorityPDA | | | +| 1 | payer | yes | yes | +| 2+ | packed accounts... | varies | varies | +| N-2 | LightTokenProgram | | | +| N-1 | SystemProgram | | | + +Packed accounts for wrap/unwrap use fixed indices (not HashMap): +0=mint, 1=owner(signer), 2=source(writable), 3=destination(writable), 4=pool(writable), 5=token_program. + +## Public API — Instruction builders + +All builders follow the same pattern: struct with named fields + `.instruction() -> Result`. + +### Transfer2 + +```rust +Transfer2 { + payer, // fee payer (signer) + authority, // token owner or delegate (signer) + mint, // token mint + inputs: &accounts, // source compressed accounts + proof: &proof, // validity proof from RPC + destination_owner, // owner of destination compressed account + amount: 1_000, +}.instruction()? +``` + +- **discriminator:** 101 (`TRANSFER2_DISCRIMINATOR`) +- **layout:** standard (7 static + packed) +- **path:** `src/transfer.rs` + +Compressed-to-compressed token transfer. Automatically creates a change output if `amount < input_total`. Omits the proof when all inputs use `prove_by_index`. + +### Decompress + +```rust +Decompress { + payer, owner, mint, + inputs: &accounts, + proof: &proof, + destination, // light-token ATA or SPL ATA + amount: 1_000, + decimals: 6, + spl_interface: None, // None for light-token, Some(&info) for SPL +}.instruction()? +``` + +- **discriminator:** 101 (Transfer2 with `Compression::Decompress`) +- **layout:** standard (7 static + packed) +- **path:** `src/decompress.rs` + +Routes between light-token decompress (`spl_interface=None`) and SPL decompress (with pool + token_program). Creates change output if `amount < input_total`. + +### Wrap + +```rust +Wrap { + source: spl_ata, + destination: light_token_ata, + owner, mint, + amount: 1_000, + decimals: 6, + payer, + spl_interface: &spl_info, +}.instruction()? +``` + +- **discriminator:** 101 (Transfer2 with two compressions) +- **layout:** decompressed-only (2 static + fixed packed) +- **path:** `src/wrap.rs` + +SPL → light-token via dual compression. Total accounts: 10. + +### Unwrap + +```rust +Unwrap { + source: light_token_ata, + destination: spl_ata, + owner, mint, + amount: 1_000, + decimals: 6, + payer, + spl_interface: &spl_info, +}.instruction()? +``` + +- **discriminator:** 101 (Transfer2 with two compressions) +- **layout:** decompressed-only (2 static + fixed packed) +- **path:** `src/unwrap.rs` + +Reverse of Wrap: light-token → SPL via dual compression. + +### CreateAta + +```rust +CreateAta::new(payer, owner, mint) + .idempotent() + .instruction()? +``` + +- **discriminator:** 100 (CreateATA) or 102 (idempotent) +- **path:** `src/create_ata.rs` + +Builder fields: `compressible_config`, `rent_sponsor`, `pre_pay_num_epochs`, `write_top_up`, `compression_only` all have sensible defaults. + +Accounts (7, fixed order): + +| Index | Account | Signer | Writable | +|-------|---------|--------|----------| +| 0 | owner | | | +| 1 | mint | | | +| 2 | payer | yes | yes | +| 3 | ATA (derived) | | yes | +| 4 | SystemProgram | | | +| 5 | compressible_config | | | +| 6 | rent_sponsor | | yes | + +### TransferChecked + +```rust +TransferChecked { + source_ata, + destination_ata, + mint, + owner, + amount: 1_000, + decimals: 6, + payer, +}.instruction()? +``` + +- **discriminator:** 12 +- **path:** `src/transfer.rs` + +Decompressed (on-chain) light-token ATA-to-ATA transfers. Not for compressed accounts. + +## Public API — Utilities + +### select_input_accounts + +```rust +fn select_input_accounts( + accounts: &[CompressedTokenAccountInput], + target_amount: u64, +) -> Result, KoraLightError> +``` + +- **path:** `src/account_select.rs` +- **constant:** `MAX_INPUT_ACCOUNTS = 8` + +Greedy descending selection: sorts accounts by amount (largest first), selects minimum accounts to meet target. Returns up to 8 accounts. Returns empty vec if `target_amount = 0`. Errors on empty input, insufficient balance, or arithmetic overflow. + +### create_load_ata_batches + +```rust +fn create_load_ata_batches(input: LoadAtaInput) -> Result, KoraLightError> +``` + +- **path:** `src/load_ata.rs` + +Orchestrates multi-transaction decompress flows. Chunks compressed accounts into batches of 8 (`MAX_INPUT_ACCOUNTS`). Each batch is one transaction containing: +- Compute budget instruction (auto-calculated) +- ATA creation (first batch, or idempotent in subsequent batches) +- Optional wrap instruction +- Decompress instruction for the chunk + +Input types: + +- `LoadAtaInput` — payer, owner, mint, decimals, destination, needs_ata_creation, compressed_accounts, proofs (one per chunk), spl_interface, spl_wrap +- `LoadBatch` — instructions, num_compressed_accounts, has_ata_creation, wrap_count +- `WrapSource` — source_ata, amount, spl_interface + +Validates that `proofs.len() == chunks.len()`. + +## Compute budget guidance + +For callers not using `create_load_ata_batches` (which handles this automatically): + +| Component | Compute units | +|-----------|--------------| +| ATA creation | 30,000 | +| Wrap operation | 50,000 | +| Decompress base | 50,000 | +| Full ZK proof verification | 100,000 | +| Per account (prove-by-index) | 10,000 | +| Per account (full proof) | 30,000 | + +**Formula:** `(base + per_account × N) × 1.3`, clamped to [50,000 .. 1,400,000]. + +Example: decompress 4 accounts with full proof = `(50K + 100K + 4 × 30K) × 1.3 = 351K CU`. + +Constants are defined in `src/load_ata.rs`. + +## PDA derivation + +**path:** `src/pda.rs` + +| Function | Seeds | Program | +|----------|-------|---------| +| `get_associated_token_address(owner, mint)` | [owner, LIGHT_TOKEN_PROGRAM_ID, mint] | LIGHT_TOKEN_PROGRAM_ID | +| `get_associated_token_address_and_bump(owner, mint)` | same as above | same | +| `find_spl_interface_pda(mint)` | [b"pool", mint, 0] | LIGHT_TOKEN_PROGRAM_ID | +| `find_spl_interface_pda_with_index(mint, pool_index)` | [b"pool", mint, pool_index] | LIGHT_TOKEN_PROGRAM_ID | +| `derive_cpi_authority_pda()` | [b"cpi_authority"] | LIGHT_TOKEN_PROGRAM_ID | + +`is_light_token_owner(owner)` — returns `Some(true)` for LIGHT_TOKEN_PROGRAM_ID, `Some(false)` for SPL Token or Token-2022, `None` otherwise. + +## Types + +### On-chain mirror types (Borsh-serializable) + +All types must remain byte-identical to the on-chain program. Verified by golden byte tests. + +| Type | Size (bytes) | Ported from | +|------|-------------|-------------| +| `CompressedProof` | 128 | `program-libs/compressed-account/src/instruction_data/compressed_proof.rs` | +| `PackedMerkleContext` | 7 | `program-libs/compressed-account/src/compressed_account.rs` | +| `CompressedCpiContext` | 2 | `program-libs/token-interface/src/instructions/transfer2/cpi_context.rs` | +| `CompressionMode` (enum) | 1 | `program-libs/token-interface/src/instructions/transfer2/compression.rs` | +| `Compression` | 16 | same as above | +| `MultiInputTokenDataWithContext` | 22 | `program-libs/token-interface/src/instructions/transfer2/instruction_data.rs` | +| `MultiTokenTransferOutputData` | 13 | same as above | +| `CompressedTokenInstructionDataTransfer2` | variable | same as above | +| `ExtensionInstructionData` (enum, 33 variants) | variable | `program-libs/token-interface/src/instructions/extensions/` | +| `TokenMetadataInstructionData` | variable | same as above (variant 19) | +| `CompressedOnlyExtensionInstructionData` | 21 | same as above (variant 31) | +| `CompressionInfo` | 96 | `program-libs/compressible/` (variant 32) | +| `CreateAssociatedTokenAccountInstructionData` | variable | `program-libs/token-interface/src/instructions/create_associated_token_account.rs` | +| `CompressibleExtensionInstructionData` | variable | `program-libs/token-interface/src/instructions/extensions/compressible.rs` | +| `CompressToPubkey` | variable | same as above | +| `AdditionalMetadata` | variable | key-value pair for token metadata | + +### Client-only types (not serialized on-chain) + +| Type | Purpose | +|------|---------| +| `CompressedTokenAccountInput` | Compressed account data from RPC, ready for instruction building. Kora implements `TryFrom`. | +| `SplInterfaceInfo` | SPL pool info (PDA, bump, pool_index, token_program) for compress/decompress via SPL. | +| `ValidityProofWithContext` | Proof + root indices from RPC. Root indices are per-input, same order. | +| `LoadAtaInput` | Pre-fetched data for `create_load_ata_batches`. | +| `LoadBatch` | One transaction's worth of instructions from batch orchestration. | +| `WrapSource` | SPL balance to wrap as part of a load operation. | + +## Constants + +**path:** `src/program_ids.rs` + +### Program IDs + +| Constant | Value | +|----------|-------| +| `LIGHT_TOKEN_PROGRAM_ID` | `cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m` | +| `LIGHT_SYSTEM_PROGRAM_ID` | `SySTEM1eSU2p4BGQfQpimFEWWSC1XDFeun3Nqzz3rT7` | +| `ACCOUNT_COMPRESSION_PROGRAM_ID` | `compr6CUsB5m2jS4Y3831ztGSTnDpnKJTKS95d64XVq` | +| `SPL_TOKEN_PROGRAM_ID` | `TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA` | +| `SPL_TOKEN_2022_PROGRAM_ID` | `TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb` | +| `SYSTEM_PROGRAM_ID` | `11111111111111111111111111111111` | +| `NOOP_PROGRAM_ID` | `noopb9bkMVfRPU8AsbpTUg8AQkHtKwMYZiFUjNRtMmV` | + +### Pre-derived PDAs + +| Constant | Value | +|----------|-------| +| `CPI_AUTHORITY_PDA` | `GXtd2izAiMJPwMEjfgTRH3d7k9mjn4Jq3JrWFv9gySYy` (bump 254) | +| `ACCOUNT_COMPRESSION_AUTHORITY_PDA` | `HwXnGK3tPkkVY6P439H2p68AxpeuWXd5PcrAxFpbmfbA` | +| `REGISTERED_PROGRAM_PDA` | `35hkDgaAKwMCaxRz2ocSZ6NaUrtKkyNqU6c4RV3tYJRh` | +| `LIGHT_TOKEN_CONFIG` | `ACXg8a7VaqecBWrSbdu73W4Pg9gsqXJ3EXAqkHyhvVXg` | +| `RENT_SPONSOR_V1` | `r18WwUxfG8kQ69bQPAB2jV6zGNKy3GosFGctjQoV4ti` | + +### Other constants + +| Constant | Value | Purpose | +|----------|-------|---------| +| `TRANSFER2_DISCRIMINATOR` | `101` | Transfer2 instruction discriminator | +| `DEFAULT_MAX_TOP_UP` | `u16::MAX` | Default max top-up for Transfer2 (no limit) | +| `WSOL_MINT` | `So11111111111111111111111111111111111111112` | Wrapped SOL mint | +| `CPI_AUTHORITY_PDA_SEED` | `b"cpi_authority"` | Seed for CPI authority derivation | +| `BUMP_CPI_AUTHORITY` | `254` | Known bump for CPI authority PDA | +| `POOL_SEED` | `b"pool"` | Seed for SPL pool PDA derivation | +| `LIGHT_LUT_MAINNET` | `9NYFyEqPeWQHiS8Jv4VjZcjKBMPRCJ3KbEbaBcy4Mza` | Mainnet address lookup table | +| `LIGHT_LUT_DEVNET` | `9NYFyEqPeWQHiS8Jv4VjZcjKBMPRCJ3KbEbaBcy4Mza` | Devnet address lookup table | + +## Errors + +**path:** `src/error.rs` + +| Variant | Description | Common cause | +|---------|-------------|--------------| +| `CannotDetermineAccountType` | Owner pubkey is not LIGHT_TOKEN_PROGRAM_ID, SPL Token, or Token-2022 | Passing an unknown program as account owner to `is_light_token_owner` | +| `InsufficientBalance { needed, available }` | Input accounts don't cover requested amount | `select_input_accounts` or builders with amount > sum of inputs | +| `NoCompressedAccounts` | Empty inputs slice passed to builder | Calling a builder or `select_input_accounts` with `&[]` | +| `BorshError(io::Error)` | Borsh serialization failure | Corrupted data or internal serialization bug | +| `ArithmeticOverflow` | Checked arithmetic failed | Extremely large token amounts that overflow u64 | +| `InvalidInput(String)` | General validation failure | `create_load_ata_batches` with mismatched proof/chunk count | + +## Source code structure + +### Instruction builders + +| File | Lines | Description | +|------|-------|-------------| +| `src/transfer.rs` | 448 | Transfer2 (compressed-to-compressed) and TransferChecked (ATA-to-ATA) | +| `src/decompress.rs` | 554 | Decompress via Transfer2 with Compression operation | +| `src/wrap.rs` | 150 | SPL → light-token via dual-compression Transfer2 (decompressed_accounts_only layout) | +| `src/unwrap.rs` | 184 | Light-token → SPL via dual-compression Transfer2 (decompressed_accounts_only layout) | +| `src/create_ata.rs` | 182 | CreateAssociatedTokenAccount builder with compressible config | + +### Utilities + +| File | Lines | Description | +|------|-------|-------------| +| `src/account_select.rs` | 160 | Greedy descending account selection (max 8, `MAX_INPUT_ACCOUNTS`) | +| `src/load_ata.rs` | 416 | Multi-transaction batch orchestration with compute budget estimation | + +### Core + +| File | Lines | Description | +|------|-------|-------------| +| `src/lib.rs` | 43 | Module declarations and re-exports | +| `src/types.rs` | 559 | All Borsh-serializable types (on-chain mirrors + client-only) | +| `src/program_ids.rs` | 78 | Constants (program IDs, PDAs, seeds, LUT addresses) | +| `src/pda.rs` | 77 | 6 PDA derivation functions | +| `src/error.rs` | 22 | `KoraLightError` enum (6 variants) | + +### Tests + +| File | Lines | Description | +|------|-------|-------------| +| `tests/golden_bytes.rs` | 381 | Borsh serialization cross-verification against on-chain format | +| `src/types.rs` (inline) | ~60 | Borsh verification gates (proof=128B, context=7B, compression=16B, input=22B, output=13B) | +| `src/` (inline per module) | ~200 | Unit tests per module (account order, deduplication, error paths, round-trips) | + +## Testing + +```bash +# Run from kora-light-client/ directory (crate is excluded from workspace) +cd kora-light-client && cargo test +``` + +### Golden byte tests (`tests/golden_bytes.rs`) + +8 tests that verify byte-identical serialization with the on-chain program: + +1. `test_transfer2_header_matches_kora_format` — header serialization (150 bytes with empty vecs) +2. `test_input_token_data_matches_kora_format` — 22 bytes per input +3. `test_output_token_data_on_chain_format` — 13 bytes per output (see compatibility note below) +4. `test_full_instruction_data_format` — discriminator + complete struct +5. `test_compression_serialization` — 16 bytes per Compression struct +6. `test_compressed_only_extension_serialization` — 21 bytes +7. `test_extension_enum_discriminators` — variants 0, 31, 32 +8. `test_transfer2_roundtrip` — serialize → deserialize identity + +### Borsh verification gates (`src/types.rs`) + +6 inline tests verifying individual type sizes match on-chain expectations. + +## Compatibility and version pinning + +**Source version:** `types.rs` header says "Source commit: HEAD of main branch at time of porting" — no pinned commit hash. Golden byte tests are the primary drift detection mechanism. + +**12 → 13 byte output format change:** Kora's existing raw-byte builder (`instruction_builder.rs`) uses a 12-byte output format per `MultiTokenTransferOutputData`: + +``` +Kora old format (12 bytes): owner(u8), amount(u64), lamports(Option=None), merkle_tree_index(u8), tlv(Option=None) +On-chain format (13 bytes): owner(u8), amount(u64), has_delegate(bool), delegate(u8), mint(u8), version(u8) +``` + +This crate uses the 13-byte on-chain format. When Kora adopts this crate, its output format will change. If the deployed on-chain program uses an older format, this needs investigation before deploying. + +**Verification:** Run `cd kora-light-client && cargo test` after any upstream changes to the on-chain types. Golden byte tests will fail if serialization drifts. diff --git a/kora-light-client/Cargo.lock b/kora-light-client/Cargo.lock new file mode 100644 index 0000000000..725dd48003 --- /dev/null +++ b/kora-light-client/Cargo.lock @@ -0,0 +1,714 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "borsh" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f" +dependencies = [ + "borsh-derive", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0686c856aa6aac0c4498f936d7d6a02df690f614c03e4d906d1018062b5c5e2c" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rand_core", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "five8" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f76610e969fa1784327ded240f1e28a3fd9520c9cec93b636fcf62dd37f772" +dependencies = [ + "five8_core", +] + +[[package]] +name = "five8_const" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a0f1728185f277989ca573a402716ae0beaaea3f76a8ff87ef9dd8fb19436c5" +dependencies = [ + "five8_core", +] + +[[package]] +name = "five8_core" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "059c31d7d36c43fe39d89e55711858b4da8be7eb6dabac23c7289b1a19489406" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "kora-light-client" +version = "0.1.0" +dependencies = [ + "borsh", + "solana-compute-budget-interface", + "solana-instruction", + "solana-pubkey 3.0.0", + "solana-system-interface", + "thiserror", +] + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pastey" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2-const-stable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f179d4e11094a893b82fff208f74d448a7512f99f5a0acbd5c679b705f83ed9" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "solana-address" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2ecac8e1b7f74c2baa9e774c42817e3e75b20787134b76cc4d45e8a604488f5" +dependencies = [ + "solana-address 2.3.0", +] + +[[package]] +name = "solana-address" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "500b83d41bda401b84ebff6033e2e7bc828870ea444805112d15fc0a3e470b9c" +dependencies = [ + "borsh", + "curve25519-dalek", + "five8", + "five8_const", + "serde", + "sha2-const-stable", + "solana-atomic-u64", + "solana-define-syscall 5.0.0", + "solana-program-error", + "solana-sanitize", + "solana-sha256-hasher", + "wincode", +] + +[[package]] +name = "solana-atomic-u64" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "085db4906d89324cef2a30840d59eaecf3d4231c560ec7c9f6614a93c652f501" +dependencies = [ + "parking_lot", +] + +[[package]] +name = "solana-compute-budget-interface" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8292c436b269ad23cecc8b24f7da3ab07ca111661e25e00ce0e1d22771951ab9" +dependencies = [ + "solana-instruction", + "solana-sdk-ids", +] + +[[package]] +name = "solana-define-syscall" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57e5b1c0bc1d4a4d10c88a4100499d954c09d3fecfae4912c1a074dff68b1738" + +[[package]] +name = "solana-define-syscall" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03aacdd7a61e2109887a7a7f046caebafce97ddf1150f33722eeac04f9039c73" + +[[package]] +name = "solana-hash" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8064ea1d591ec791be95245058ca40f4f5345d390c200069d0f79bbf55bfae55" + +[[package]] +name = "solana-instruction" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6a6d22d0a6fdf345be294bb9afdcd40c296cdc095e64e7ceaa3bb3c2f608c1c" +dependencies = [ + "borsh", + "serde", + "solana-define-syscall 5.0.0", + "solana-instruction-error", + "solana-pubkey 4.1.0", +] + +[[package]] +name = "solana-instruction-error" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d3d048edaaeef5a3dc8c01853e585539a74417e4c2d43a9e2c161270045b838" +dependencies = [ + "num-traits", + "solana-program-error", +] + +[[package]] +name = "solana-msg" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "726b7cbbc6be6f1c6f29146ac824343b9415133eee8cce156452ad1db93f8008" +dependencies = [ + "solana-define-syscall 5.0.0", +] + +[[package]] +name = "solana-program-error" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1af32c995a7b692a915bb7414d5f8e838450cf7c70414e763d8abcae7b51f28" + +[[package]] +name = "solana-pubkey" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8909d399deb0851aa524420beeb5646b115fd253ef446e35fe4504c904da3941" +dependencies = [ + "solana-address 1.1.0", +] + +[[package]] +name = "solana-pubkey" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b06bd918d60111ee1f97de817113e2040ca0cedb740099ee8d646233f6b906c" +dependencies = [ + "solana-address 2.3.0", +] + +[[package]] +name = "solana-sanitize" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcf09694a0fc14e5ffb18f9b7b7c0f15ecb6eac5b5610bf76a1853459d19daf9" + +[[package]] +name = "solana-sdk-ids" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "def234c1956ff616d46c9dd953f251fa7096ddbaa6d52b165218de97882b7280" +dependencies = [ + "solana-address 2.3.0", +] + +[[package]] +name = "solana-sha256-hasher" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db7dc3011ea4c0334aaaa7e7128cb390ecf546b28d412e9bf2064680f57f588f" +dependencies = [ + "sha2", + "solana-define-syscall 4.0.1", + "solana-hash", +] + +[[package]] +name = "solana-system-interface" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e1790547bfc3061f1ee68ea9d8dc6c973c02a163697b24263a8e9f2e6d4afa2" +dependencies = [ + "num-traits", + "solana-msg", + "solana-program-error", + "solana-pubkey 3.0.0", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml_datetime" +version = "1.0.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.9+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +dependencies = [ + "winnow", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wincode" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc91ddd8c932a38bbec58ed536d9e93ce9cd01b6af9b6de3c501132cf98ddec6" +dependencies = [ + "pastey", + "proc-macro2", + "quote", + "thiserror", + "wincode-derive", +] + +[[package]] +name = "wincode-derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fca057fc9a13dd19cdb64ef558635d43c42667c0afa1ae7915ea1fa66993fd1a" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" diff --git a/kora-light-client/Cargo.toml b/kora-light-client/Cargo.toml new file mode 100644 index 0000000000..1c4f8255ec --- /dev/null +++ b/kora-light-client/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "kora-light-client" +version = "0.1.0" +edition = "2021" +description = "Standalone Light Protocol instruction builders for solana-sdk 3.0 consumers" +license = "Apache-2.0" + +[dependencies] +solana-pubkey = { version = "3.0", features = ["std", "sha2", "curve25519"] } +solana-instruction = "3.0" +solana-system-interface = "2.0" +solana-compute-budget-interface = "3.0" +borsh = { version = "1.5", features = ["derive"] } +thiserror = "2.0" diff --git a/kora-light-client/src/account_select.rs b/kora-light-client/src/account_select.rs new file mode 100644 index 0000000000..0bf5969f23 --- /dev/null +++ b/kora-light-client/src/account_select.rs @@ -0,0 +1,189 @@ +//! Greedy descending account selection algorithm. +//! +//! Selects the minimum number of compressed token accounts to satisfy a target amount. + +use crate::{error::KoraLightError, types::CompressedTokenAccountInput}; + +/// Maximum number of compressed accounts per transaction. +pub const MAX_INPUT_ACCOUNTS: usize = 8; + +/// Select compressed token accounts to satisfy the given amount. +/// +/// Uses a greedy descending algorithm: sorts by amount (largest first), +/// then selects accounts until the cumulative amount meets or exceeds +/// the target. Returns up to `MAX_INPUT_ACCOUNTS` (8) accounts. +/// +/// If `target_amount` is 0, returns an empty vec. +/// If total available balance is insufficient, returns an error. +pub fn select_input_accounts( + accounts: &[CompressedTokenAccountInput], + target_amount: u64, +) -> Result, KoraLightError> { + if target_amount == 0 { + return Ok(Vec::new()); + } + + if accounts.is_empty() { + return Err(KoraLightError::NoCompressedAccounts); + } + + // Sort by amount descending (largest first) + let mut sorted: Vec<&CompressedTokenAccountInput> = accounts.iter().collect(); + sorted.sort_by(|a, b| b.amount.cmp(&a.amount)); + + // Greedy selection: take accounts until we have enough + let mut accumulated: u64 = 0; + let mut count_needed: usize = 0; + + for acc in &sorted { + count_needed += 1; + accumulated = accumulated + .checked_add(acc.amount) + .ok_or(KoraLightError::ArithmeticOverflow)?; + if accumulated >= target_amount { + break; + } + } + + // Check if we have enough + if accumulated < target_amount { + return Err(KoraLightError::InsufficientBalance { + needed: target_amount, + available: accumulated, + }); + } + + // Clamp to MAX_INPUT_ACCOUNTS + let select_count = count_needed.min(MAX_INPUT_ACCOUNTS).min(sorted.len()); + + // If we had to clamp, verify the top accounts still satisfy the target + if count_needed > MAX_INPUT_ACCOUNTS { + let top_sum: u64 = sorted[..select_count] + .iter() + .try_fold(0u64, |acc, a| acc.checked_add(a.amount)) + .ok_or(KoraLightError::ArithmeticOverflow)?; + if top_sum < target_amount { + return Err(KoraLightError::InsufficientBalance { + needed: target_amount, + available: top_sum, + }); + } + } + + Ok(sorted[..select_count] + .iter() + .map(|a| (*a).clone()) + .collect()) +} + +#[cfg(test)] +mod tests { + use solana_pubkey::Pubkey; + + use super::*; + + fn make_account(amount: u64) -> CompressedTokenAccountInput { + CompressedTokenAccountInput { + hash: [0u8; 32], + tree: Pubkey::default(), + queue: Pubkey::default(), + amount, + leaf_index: 0, + prove_by_index: false, + root_index: 0, + version: 0, + owner: Pubkey::default(), + mint: Pubkey::default(), + delegate: None, + } + } + + #[test] + fn test_select_exact_amount() { + let accounts = vec![make_account(500), make_account(300), make_account(200)]; + let selected = select_input_accounts(&accounts, 500).unwrap(); + assert_eq!(selected.len(), 1); + assert_eq!(selected[0].amount, 500); + } + + #[test] + fn test_select_multiple_accounts() { + let accounts = vec![make_account(300), make_account(200), make_account(100)]; + let selected = select_input_accounts(&accounts, 450).unwrap(); + assert_eq!(selected.len(), 2); + // Should pick largest first + assert_eq!(selected[0].amount, 300); + assert_eq!(selected[1].amount, 200); + } + + #[test] + fn test_select_all_accounts() { + let accounts = vec![make_account(100), make_account(100), make_account(100)]; + let selected = select_input_accounts(&accounts, 300).unwrap(); + assert_eq!(selected.len(), 3); + let total: u64 = selected.iter().map(|a| a.amount).sum(); + assert_eq!(total, 300); + } + + #[test] + fn test_select_insufficient_balance() { + let accounts = vec![make_account(100), make_account(50)]; + let result = select_input_accounts(&accounts, 200); + assert!(matches!( + result, + Err(KoraLightError::InsufficientBalance { .. }) + )); + } + + #[test] + fn test_select_zero_amount() { + let accounts = vec![make_account(100)]; + let selected = select_input_accounts(&accounts, 0).unwrap(); + assert!(selected.is_empty()); + } + + #[test] + fn test_select_empty_accounts() { + let result = select_input_accounts(&[], 100); + assert!(matches!(result, Err(KoraLightError::NoCompressedAccounts))); + } + + #[test] + fn test_select_respects_max_limit() { + // 10 accounts of 100 each, target 900: top 8 = 800 < 900 → InsufficientBalance + let accounts: Vec<_> = (0..10).map(|_| make_account(100)).collect(); + let result = select_input_accounts(&accounts, 900); + assert!(matches!( + result, + Err(KoraLightError::InsufficientBalance { + needed: 900, + available: 800, + }) + )); + } + + #[test] + fn test_select_max_limit_sufficient() { + // 10 accounts of 100 each, target 800: top 8 = 800 >= 800 → success + let accounts: Vec<_> = (0..10).map(|_| make_account(100)).collect(); + let selected = select_input_accounts(&accounts, 800).unwrap(); + assert_eq!(selected.len(), MAX_INPUT_ACCOUNTS); + let total: u64 = selected.iter().map(|a| a.amount).sum(); + assert_eq!(total, 800); + } + + #[test] + fn test_select_greedy_descending() { + let accounts = vec![ + make_account(10), + make_account(1000), + make_account(50), + make_account(500), + ]; + let selected = select_input_accounts(&accounts, 1200).unwrap(); + // Should pick 1000 + 500 = 1500 >= 1200 + assert_eq!(selected.len(), 2); + assert_eq!(selected[0].amount, 1000); + assert_eq!(selected[1].amount, 500); + } +} diff --git a/kora-light-client/src/create_ata.rs b/kora-light-client/src/create_ata.rs new file mode 100644 index 0000000000..bc218e119d --- /dev/null +++ b/kora-light-client/src/create_ata.rs @@ -0,0 +1,182 @@ +//! Create Light Token associated token account instruction builder. +//! +//! Ported from `sdk-libs/token-sdk/src/instruction/create_ata.rs`. + +use borsh::BorshSerialize; +use solana_instruction::{AccountMeta, Instruction}; +use solana_pubkey::Pubkey; + +use crate::{ + error::KoraLightError, + pda::get_associated_token_address, + program_ids::{LIGHT_TOKEN_CONFIG, LIGHT_TOKEN_PROGRAM_ID, RENT_SPONSOR_V1, SYSTEM_PROGRAM_ID}, + types::{CompressibleExtensionInstructionData, CreateAssociatedTokenAccountInstructionData}, +}; + +const CREATE_ATA_DISCRIMINATOR: u8 = 100; +const CREATE_ATA_IDEMPOTENT_DISCRIMINATOR: u8 = 102; + +/// Default pre-pay epochs for rent +const DEFAULT_PREPAY_EPOCHS: u8 = 16; +/// Default write top-up in lamports (covers ~3 hours of rent) +const DEFAULT_WRITE_TOP_UP: u32 = 766; + +/// Builder for CreateAssociatedTokenAccount instructions. +#[derive(Debug, Clone)] +pub struct CreateAta { + pub payer: Pubkey, + pub owner: Pubkey, + pub mint: Pubkey, + pub idempotent: bool, + /// Compressible config PDA (default: LIGHT_TOKEN_CONFIG) + pub compressible_config: Pubkey, + /// Rent sponsor PDA (default: RENT_SPONSOR_V1) + pub rent_sponsor: Pubkey, + /// Pre-pay rent epochs (default: 16) + pub pre_pay_num_epochs: u8, + /// Write top-up in lamports (default: 766) + pub write_top_up: u32, + /// Compression-only flag (default: true for ATAs) + pub compression_only: bool, +} + +impl CreateAta { + /// Create a new CreateAta builder with default rent-free settings. + pub fn new(payer: Pubkey, owner: Pubkey, mint: Pubkey) -> Self { + Self { + payer, + owner, + mint, + idempotent: false, + compressible_config: LIGHT_TOKEN_CONFIG, + rent_sponsor: RENT_SPONSOR_V1, + pre_pay_num_epochs: DEFAULT_PREPAY_EPOCHS, + write_top_up: DEFAULT_WRITE_TOP_UP, + compression_only: true, + } + } + + /// Make this an idempotent create (no-op if ATA already exists). + pub fn idempotent(mut self) -> Self { + self.idempotent = true; + self + } + + /// Build the instruction. + pub fn instruction(&self) -> Result { + let ata = get_associated_token_address(&self.owner, &self.mint); + + let instruction_data = CreateAssociatedTokenAccountInstructionData { + compressible_config: Some(CompressibleExtensionInstructionData { + token_account_version: 0, // ShaFlat + rent_payment: self.pre_pay_num_epochs, + compression_only: if self.compression_only { 1 } else { 0 }, + write_top_up: self.write_top_up, + compress_to_account_pubkey: None, + }), + }; + + let discriminator = if self.idempotent { + CREATE_ATA_IDEMPOTENT_DISCRIMINATOR + } else { + CREATE_ATA_DISCRIMINATOR + }; + + let mut data = Vec::new(); + data.push(discriminator); + instruction_data.serialize(&mut data)?; + + let accounts = vec![ + AccountMeta::new_readonly(self.owner, false), + AccountMeta::new_readonly(self.mint, false), + AccountMeta::new(self.payer, true), + AccountMeta::new(ata, false), + AccountMeta::new_readonly(SYSTEM_PROGRAM_ID, false), + AccountMeta::new_readonly(self.compressible_config, false), + AccountMeta::new(self.rent_sponsor, false), + ]; + + Ok(Instruction { + program_id: LIGHT_TOKEN_PROGRAM_ID, + accounts, + data, + }) + } +} + +/// Convenience function: build an idempotent CreateAta instruction with defaults. +pub fn create_ata_idempotent_instruction( + payer: &Pubkey, + owner: &Pubkey, + mint: &Pubkey, +) -> Result { + CreateAta::new(*payer, *owner, *mint) + .idempotent() + .instruction() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_create_ata_instruction_builds() { + let payer = Pubkey::new_unique(); + let owner = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + + let ix = CreateAta::new(payer, owner, mint).instruction().unwrap(); + + assert_eq!(ix.program_id, LIGHT_TOKEN_PROGRAM_ID); + assert_eq!(ix.accounts.len(), 7); + // First byte is discriminator + assert_eq!(ix.data[0], CREATE_ATA_DISCRIMINATOR); + + // Account order: owner, mint, payer, ata, system, config, sponsor + assert_eq!(ix.accounts[0].pubkey, owner); + assert!(!ix.accounts[0].is_signer); + assert_eq!(ix.accounts[1].pubkey, mint); + assert_eq!(ix.accounts[2].pubkey, payer); + assert!(ix.accounts[2].is_signer); + assert_eq!(ix.accounts[4].pubkey, SYSTEM_PROGRAM_ID); + assert_eq!(ix.accounts[5].pubkey, LIGHT_TOKEN_CONFIG); + assert_eq!(ix.accounts[6].pubkey, RENT_SPONSOR_V1); + } + + #[test] + fn test_create_ata_idempotent() { + let payer = Pubkey::new_unique(); + let owner = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + + let ix = CreateAta::new(payer, owner, mint) + .idempotent() + .instruction() + .unwrap(); + + assert_eq!(ix.data[0], CREATE_ATA_IDEMPOTENT_DISCRIMINATOR); + } + + #[test] + fn test_create_ata_ata_address_matches_pda() { + let owner = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + + let ix = CreateAta::new(Pubkey::new_unique(), owner, mint) + .instruction() + .unwrap(); + + let expected_ata = get_associated_token_address(&owner, &mint); + assert_eq!(ix.accounts[3].pubkey, expected_ata); + } + + #[test] + fn test_convenience_function() { + let payer = Pubkey::new_unique(); + let owner = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + + let ix = create_ata_idempotent_instruction(&payer, &owner, &mint).unwrap(); + assert_eq!(ix.data[0], CREATE_ATA_IDEMPOTENT_DISCRIMINATOR); + } +} diff --git a/kora-light-client/src/decompress.rs b/kora-light-client/src/decompress.rs new file mode 100644 index 0000000000..89a51530ca --- /dev/null +++ b/kora-light-client/src/decompress.rs @@ -0,0 +1,603 @@ +//! Decompress instruction builder — builds Transfer2 instructions to decompress +//! compressed tokens into light-token or SPL token accounts. +//! +//! Uses the packed accounts scheme where instruction data references accounts +//! by u8 index rather than full pubkey. +//! +//! Ported from TypeScript `create-decompress-interface-instruction.ts`. + +#[cfg(test)] +use borsh::BorshDeserialize; +use borsh::BorshSerialize; +use solana_instruction::{AccountMeta, Instruction}; +use solana_pubkey::Pubkey; + +use crate::{ + error::KoraLightError, + packed_accounts::PackedAccountsBuilder, + program_ids::{ + ACCOUNT_COMPRESSION_AUTHORITY_PDA, ACCOUNT_COMPRESSION_PROGRAM_ID, CPI_AUTHORITY_PDA, + DEFAULT_MAX_TOP_UP, LIGHT_SYSTEM_PROGRAM_ID, LIGHT_TOKEN_PROGRAM_ID, + REGISTERED_PROGRAM_PDA, SYSTEM_PROGRAM_ID, TRANSFER2_DISCRIMINATOR, + }, + types::{ + CompressedProof, CompressedTokenAccountInput, CompressedTokenInstructionDataTransfer2, + Compression, MultiInputTokenDataWithContext, MultiTokenTransferOutputData, + PackedMerkleContext, SplInterfaceInfo, + }, +}; + +/// Decompress compressed tokens to an on-chain account. +/// +/// Builds a Transfer2 instruction with a `Compression::Decompress` operation. +/// Routes between light-token decompress (`spl_interface = None`) and SPL +/// decompress (`spl_interface = Some(...)`). Creates a change output if +/// `amount < input_total`. +/// +/// # Example +/// ```rust,ignore +/// use kora_light_client::Decompress; +/// +/// let ix = Decompress { +/// payer, +/// owner, +/// mint, +/// inputs: &accounts, +/// proof: &proof, +/// destination, +/// amount: 1_000, +/// decimals: 6, +/// spl_interface: None, // light-token destination +/// }.instruction()?; +/// ``` +#[derive(Debug, Clone)] +pub struct Decompress<'a> { + /// Fee payer (signer). + pub payer: Pubkey, + /// Token account owner (signer). + pub owner: Pubkey, + /// Token mint. + pub mint: Pubkey, + /// Compressed token accounts to decompress. + pub inputs: &'a [CompressedTokenAccountInput], + /// Validity proof from the RPC. + pub proof: &'a CompressedProof, + /// Destination token account (light-token ATA or SPL ATA). + pub destination: Pubkey, + /// Amount to decompress. + pub amount: u64, + /// Token decimals. + pub decimals: u8, + /// SPL pool info. `None` for light-token, `Some` for SPL destinations. + pub spl_interface: Option<&'a SplInterfaceInfo>, +} + +impl<'a> Decompress<'a> { + /// Build the decompress instruction. + pub fn instruction(&self) -> Result { + create_decompress_instruction( + &self.payer, + &self.owner, + &self.mint, + self.inputs, + self.proof, + &self.destination, + self.amount, + self.decimals, + self.spl_interface, + ) + } +} + +/// Build a decompress instruction that moves compressed tokens to an on-chain +/// light-token or SPL token account. +#[allow(clippy::too_many_arguments)] +pub fn create_decompress_instruction( + payer: &Pubkey, + owner: &Pubkey, + mint: &Pubkey, + inputs: &[CompressedTokenAccountInput], + proof: &CompressedProof, + destination: &Pubkey, + amount: u64, + decimals: u8, + spl_interface: Option<&SplInterfaceInfo>, +) -> Result { + if inputs.is_empty() { + return Err(KoraLightError::NoCompressedAccounts); + } + + // Build packed accounts array — deduplicate pubkeys + let mut builder = PackedAccountsBuilder::new(); + + // 1. Add all unique merkle trees (writable) + for input in inputs { + builder.insert_or_get(input.tree, false, true); + } + + // 2. Add all unique queues (writable) + for input in inputs { + builder.insert_or_get(input.queue, false, true); + } + let first_queue_index = builder.get_index(&inputs[0].queue); + + // 3. Add mint (readonly) + let mint_index = builder.insert_or_get(*mint, false, false); + + // 4. Add owner (signer) + let owner_index = builder.insert_or_get(*owner, true, false); + + // 5. Add destination (writable) + let destination_index = builder.insert_or_get(*destination, false, true); + + // 6. Add delegates if any + for input in inputs { + if let Some(delegate) = &input.delegate { + builder.insert_or_get(*delegate, false, false); + } + } + + // 7. For SPL destinations: add pool and token program + let (pool_account_index, pool_index_val, bump_val) = if let Some(spl) = spl_interface { + let pool_idx = builder.insert_or_get(spl.spl_interface_pda, false, true); + let _token_prog_idx = builder.insert_or_get(spl.token_program, false, false); + (pool_idx, spl.pool_index, spl.bump) + } else { + (0u8, 0u8, 0u8) + }; + + // Build input token data + let in_token_data: Vec = inputs + .iter() + .map(|input| { + let tree_idx = builder.get_index(&input.tree); + let queue_idx = builder.get_index(&input.queue); + let delegate_idx = input.delegate.map(|d| builder.get_index(&d)).unwrap_or(0); + + MultiInputTokenDataWithContext { + owner: owner_index, + amount: input.amount, + has_delegate: input.delegate.is_some(), + delegate: delegate_idx, + mint: mint_index, + version: input.version, + merkle_context: PackedMerkleContext { + merkle_tree_pubkey_index: tree_idx, + queue_pubkey_index: queue_idx, + leaf_index: input.leaf_index, + prove_by_index: input.prove_by_index, + }, + root_index: input.root_index, + } + }) + .collect(); + + // Calculate change amount + let input_total: u64 = inputs + .iter() + .try_fold(0u64, |acc, i| acc.checked_add(i.amount)) + .ok_or(KoraLightError::ArithmeticOverflow)?; + let change_amount = + input_total + .checked_sub(amount) + .ok_or(KoraLightError::InsufficientBalance { + needed: amount, + available: input_total, + })?; + + // Build output data (change account if needed) + let out_token_data: Vec = if change_amount > 0 { + vec![MultiTokenTransferOutputData { + owner: owner_index, + amount: change_amount, + has_delegate: false, + delegate: 0, + mint: mint_index, + version: inputs[0].version, + }] + } else { + Vec::new() + }; + + // Build compression operation + let compression = if spl_interface.is_some() { + Compression::decompress_spl( + amount, + mint_index, + destination_index, + pool_account_index, + pool_index_val, + bump_val, + decimals, + ) + } else { + Compression::decompress(amount, mint_index, destination_index) + }; + + // Build Transfer2 instruction data + let transfer2_data = CompressedTokenInstructionDataTransfer2 { + with_transaction_hash: false, + with_lamports_change_account_merkle_tree_index: false, + lamports_change_account_merkle_tree_index: 0, + lamports_change_account_owner_index: 0, + output_queue: first_queue_index, + max_top_up: DEFAULT_MAX_TOP_UP, + cpi_context: None, + compressions: Some(vec![compression]), + proof: if inputs.iter().all(|i| i.prove_by_index) { + None + } else { + Some(*proof) + }, + in_token_data, + out_token_data, + in_lamports: None, + out_lamports: None, + in_tlv: None, + out_tlv: None, + }; + + // Serialize: discriminator + borsh data + let mut data = Vec::new(); + data.push(TRANSFER2_DISCRIMINATOR); + transfer2_data.serialize(&mut data)?; + + // Build account metas: static accounts + packed accounts + let mut accounts = vec![ + AccountMeta::new_readonly(LIGHT_SYSTEM_PROGRAM_ID, false), + AccountMeta::new(*payer, true), + AccountMeta::new_readonly(CPI_AUTHORITY_PDA, false), + AccountMeta::new_readonly(REGISTERED_PROGRAM_PDA, false), + AccountMeta::new_readonly(ACCOUNT_COMPRESSION_AUTHORITY_PDA, false), + AccountMeta::new_readonly(ACCOUNT_COMPRESSION_PROGRAM_ID, false), + AccountMeta::new_readonly(SYSTEM_PROGRAM_ID, false), + ]; + + // Append packed accounts + accounts.extend(builder.build_account_metas()); + + Ok(Instruction { + program_id: LIGHT_TOKEN_PROGRAM_ID, + accounts, + data, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_input( + amount: u64, + tree: Pubkey, + queue: Pubkey, + owner: Pubkey, + mint: Pubkey, + ) -> CompressedTokenAccountInput { + CompressedTokenAccountInput { + hash: [0u8; 32], + tree, + queue, + amount, + leaf_index: 42, + prove_by_index: false, + root_index: 0, + version: 0, + owner, + mint, + delegate: None, + } + } + + #[test] + fn test_decompress_basic() { + let payer = Pubkey::new_unique(); + let owner = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + let tree = Pubkey::new_unique(); + let queue = Pubkey::new_unique(); + let destination = Pubkey::new_unique(); + + let inputs = vec![make_input(1000, tree, queue, owner, mint)]; + let proof = CompressedProof::default(); + + let ix = create_decompress_instruction( + &payer, + &owner, + &mint, + &inputs, + &proof, + &destination, + 1000, + 6, + None, + ) + .unwrap(); + + assert_eq!(ix.program_id, LIGHT_TOKEN_PROGRAM_ID); + assert_eq!(ix.data[0], TRANSFER2_DISCRIMINATOR); + // 7 static accounts + packed accounts (tree, queue, mint, owner, destination) + assert_eq!(ix.accounts.len(), 7 + 5); + // Payer is signer + assert!(ix.accounts[1].is_signer); + assert_eq!(ix.accounts[1].pubkey, payer); + } + + #[test] + fn test_decompress_with_change() { + let payer = Pubkey::new_unique(); + let owner = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + let tree = Pubkey::new_unique(); + let queue = Pubkey::new_unique(); + let destination = Pubkey::new_unique(); + + let inputs = vec![make_input(1000, tree, queue, owner, mint)]; + let proof = CompressedProof::default(); + + // Decompress only 500 of 1000 + let ix = create_decompress_instruction( + &payer, + &owner, + &mint, + &inputs, + &proof, + &destination, + 500, + 6, + None, + ) + .unwrap(); + + // Should succeed — change of 500 goes to output account + assert_eq!(ix.program_id, LIGHT_TOKEN_PROGRAM_ID); + } + + #[test] + fn test_decompress_insufficient_balance() { + let payer = Pubkey::new_unique(); + let owner = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + let tree = Pubkey::new_unique(); + let queue = Pubkey::new_unique(); + let destination = Pubkey::new_unique(); + + let inputs = vec![make_input(100, tree, queue, owner, mint)]; + let proof = CompressedProof::default(); + + let result = create_decompress_instruction( + &payer, + &owner, + &mint, + &inputs, + &proof, + &destination, + 200, + 6, + None, + ); + + assert!(matches!( + result, + Err(KoraLightError::InsufficientBalance { .. }) + )); + } + + #[test] + fn test_decompress_deduplicates_trees() { + let payer = Pubkey::new_unique(); + let owner = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + let tree = Pubkey::new_unique(); // same tree for both + let queue = Pubkey::new_unique(); // same queue for both + let destination = Pubkey::new_unique(); + + let inputs = vec![ + make_input(500, tree, queue, owner, mint), + make_input(500, tree, queue, owner, mint), + ]; + let proof = CompressedProof::default(); + + let ix = create_decompress_instruction( + &payer, + &owner, + &mint, + &inputs, + &proof, + &destination, + 1000, + 6, + None, + ) + .unwrap(); + + // tree and queue deduplicated: 7 static + 5 packed (tree, queue, mint, owner, dest) + assert_eq!(ix.accounts.len(), 7 + 5); + } + + #[test] + fn test_decompress_with_spl_interface() { + let payer = Pubkey::new_unique(); + let owner = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + let tree = Pubkey::new_unique(); + let queue = Pubkey::new_unique(); + let destination = Pubkey::new_unique(); + let pool_pda = Pubkey::new_unique(); + let token_program = Pubkey::new_unique(); + + let inputs = vec![make_input(1000, tree, queue, owner, mint)]; + let proof = CompressedProof::default(); + + let spl = SplInterfaceInfo { + spl_interface_pda: pool_pda, + bump: 255, + pool_index: 0, + token_program, + }; + + let ix = create_decompress_instruction( + &payer, + &owner, + &mint, + &inputs, + &proof, + &destination, + 1000, + 6, + Some(&spl), + ) + .unwrap(); + + // 7 static + 7 packed (tree, queue, mint, owner, dest, pool, token_program) + assert_eq!(ix.accounts.len(), 7 + 7); + } + + #[test] + fn test_decompress_no_inputs() { + let payer = Pubkey::new_unique(); + let owner = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + let destination = Pubkey::new_unique(); + let proof = CompressedProof::default(); + + let result = create_decompress_instruction( + &payer, + &owner, + &mint, + &[], + &proof, + &destination, + 1000, + 6, + None, + ); + + assert!(matches!(result, Err(KoraLightError::NoCompressedAccounts))); + } + + #[test] + fn test_decompress_prove_by_index_no_proof() { + let payer = Pubkey::new_unique(); + let owner = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + let tree = Pubkey::new_unique(); + let queue = Pubkey::new_unique(); + let destination = Pubkey::new_unique(); + + let inputs = vec![CompressedTokenAccountInput { + prove_by_index: true, + ..make_input(1000, tree, queue, owner, mint) + }]; + let proof = CompressedProof::default(); + + let ix = create_decompress_instruction( + &payer, + &owner, + &mint, + &inputs, + &proof, + &destination, + 1000, + 6, + None, + ) + .unwrap(); + + // Deserialize and verify proof is None + let data = CompressedTokenInstructionDataTransfer2::try_from_slice(&ix.data[1..]).unwrap(); + assert!( + data.proof.is_none(), + "proof must be None when all inputs use prove_by_index" + ); + } + + #[test] + fn test_decompress_mixed_prove_by_index_has_proof() { + let payer = Pubkey::new_unique(); + let owner = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + let tree = Pubkey::new_unique(); + let queue = Pubkey::new_unique(); + let destination = Pubkey::new_unique(); + + let inputs = vec![ + CompressedTokenAccountInput { + prove_by_index: true, + ..make_input(500, tree, queue, owner, mint) + }, + CompressedTokenAccountInput { + prove_by_index: false, + ..make_input(500, tree, queue, owner, mint) + }, + ]; + let proof = CompressedProof { + a: [1; 32], + b: [2; 64], + c: [3; 32], + }; + + let ix = create_decompress_instruction( + &payer, + &owner, + &mint, + &inputs, + &proof, + &destination, + 1000, + 6, + None, + ) + .unwrap(); + + let data = CompressedTokenInstructionDataTransfer2::try_from_slice(&ix.data[1..]).unwrap(); + assert!( + data.proof.is_some(), + "proof must be Some when any input does not use prove_by_index" + ); + assert_eq!(data.proof.unwrap(), proof); + } + + #[test] + fn test_decompress_with_delegate() { + let payer = Pubkey::new_unique(); + let owner = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + let tree = Pubkey::new_unique(); + let queue = Pubkey::new_unique(); + let destination = Pubkey::new_unique(); + let delegate = Pubkey::new_unique(); + + let inputs = vec![CompressedTokenAccountInput { + delegate: Some(delegate), + ..make_input(1000, tree, queue, owner, mint) + }]; + let proof = CompressedProof::default(); + + let ix = create_decompress_instruction( + &payer, + &owner, + &mint, + &inputs, + &proof, + &destination, + 1000, + 6, + None, + ) + .unwrap(); + + // 7 static + packed (tree, queue, mint, owner, destination, delegate) + assert_eq!(ix.accounts.len(), 7 + 6); + + // Verify delegate is in packed accounts + let delegate_account = &ix.accounts[7 + 5]; + assert_eq!(delegate_account.pubkey, delegate); + assert!(!delegate_account.is_signer); + + // Verify instruction data has delegate set + let data = CompressedTokenInstructionDataTransfer2::try_from_slice(&ix.data[1..]).unwrap(); + assert!(data.in_token_data[0].has_delegate); + assert_eq!(data.in_token_data[0].delegate, 5); // delegate is 6th packed account + } +} diff --git a/kora-light-client/src/error.rs b/kora-light-client/src/error.rs new file mode 100644 index 0000000000..3461ef6376 --- /dev/null +++ b/kora-light-client/src/error.rs @@ -0,0 +1,22 @@ +//! Error types for kora-light-client. + +#[derive(Debug, thiserror::Error)] +pub enum KoraLightError { + #[error("Cannot determine account type from owner")] + CannotDetermineAccountType, + + #[error("Insufficient balance: need {needed}, have {available}")] + InsufficientBalance { needed: u64, available: u64 }, + + #[error("No compressed accounts provided")] + NoCompressedAccounts, + + #[error("Borsh serialization error: {0}")] + BorshError(#[from] std::io::Error), + + #[error("Arithmetic overflow")] + ArithmeticOverflow, + + #[error("Invalid input: {0}")] + InvalidInput(String), +} diff --git a/kora-light-client/src/lib.rs b/kora-light-client/src/lib.rs new file mode 100644 index 0000000000..7314e45915 --- /dev/null +++ b/kora-light-client/src/lib.rs @@ -0,0 +1,77 @@ +//! # kora-light-client +//! +//! Standalone Light Protocol instruction builders for solana-sdk 3.0 consumers. +//! +//! This crate has **zero `light-*` dependencies**. All types are duplicated +//! locally with byte-identical Borsh serialization to the on-chain program. +//! +//! | Builder | Description | +//! |---------|-------------| +//! | [`CreateAta`] | Create an associated light-token account | +//! | [`Transfer2`] | Compressed-to-compressed token transfer | +//! | [`TransferChecked`] | Decompressed ATA-to-ATA transfer | +//! | [`Decompress`] | Decompress compressed tokens to on-chain account | +//! | [`Wrap`] | Wrap SPL/T22 tokens to light-token account | +//! | [`Unwrap`] | Unwrap light-token to SPL/T22 account | +//! +//! ## Utilities +//! +//! | Function | Description | +//! |----------|-------------| +//! | [`select_input_accounts`] | Greedy account selection (max 8) | +//! | [`create_load_ata_batches`] | Multi-transaction batch orchestration | +//! | [`get_associated_token_address`] | Derive light-token ATA address | +//! | [`find_spl_interface_pda`] | Derive SPL pool PDA | +//! +//! ## Usage +//! +//! ```rust,ignore +//! use kora_light_client::{Transfer2, get_associated_token_address}; +//! +//! let ata = get_associated_token_address(&owner, &mint); +//! +//! let ix = Transfer2 { +//! payer, authority, mint, +//! inputs: &accounts, +//! proof: &proof, +//! destination_owner: recipient, +//! amount: 1_000, +//! }.instruction()?; +//! ``` + +pub mod account_select; +pub mod create_ata; +pub mod decompress; +pub mod error; +pub mod load_ata; +mod packed_accounts; +pub mod pda; +pub mod program_ids; +pub mod transfer; +pub mod types; +pub mod unwrap; +pub mod wrap; + +// Builder structs +// Utilities +pub use account_select::select_input_accounts; +pub use create_ata::CreateAta; +pub use decompress::Decompress; +pub use error::KoraLightError; +pub use load_ata::{create_load_ata_batches, LoadAtaInput, LoadBatch, WrapSource}; +// PDA helpers +pub use pda::{ + find_spl_interface_pda, find_spl_interface_pda_with_index, get_associated_token_address, + get_associated_token_address_and_bump, +}; +// Constants +pub use program_ids::{ + LIGHT_LUT_DEVNET, LIGHT_LUT_MAINNET, LIGHT_SYSTEM_PROGRAM_ID, LIGHT_TOKEN_PROGRAM_ID, +}; +pub use transfer::{Transfer2, TransferChecked}; +// Consumer-facing types +pub use types::{ + CompressedProof, CompressedTokenAccountInput, SplInterfaceInfo, ValidityProofWithContext, +}; +pub use unwrap::Unwrap; +pub use wrap::Wrap; diff --git a/kora-light-client/src/load_ata.rs b/kora-light-client/src/load_ata.rs new file mode 100644 index 0000000000..aedadf88b8 --- /dev/null +++ b/kora-light-client/src/load_ata.rs @@ -0,0 +1,423 @@ +//! Load ATA batch orchestration — decompress compressed tokens into an ATA. +//! +//! Ported from TypeScript `load-ata.ts` `_buildLoadBatches` function. +//! All RPC calls are Kora's responsibility — this function takes pre-fetched data. + +use solana_instruction::Instruction; +use solana_pubkey::Pubkey; + +use crate::{ + account_select::MAX_INPUT_ACCOUNTS, + create_ata::CreateAta, + decompress::Decompress, + error::KoraLightError, + types::{CompressedProof, CompressedTokenAccountInput, SplInterfaceInfo}, + wrap::Wrap, +}; + +/// Compute unit constants for load operations +const CU_ATA_CREATION: u32 = 30_000; +const CU_WRAP: u32 = 50_000; +const CU_DECOMPRESS_BASE: u32 = 50_000; +const CU_FULL_PROOF: u32 = 100_000; +const CU_PER_ACCOUNT_PROVE_BY_INDEX: u32 = 10_000; +const CU_PER_ACCOUNT_FULL_PROOF: u32 = 30_000; +const CU_BUFFER_FACTOR: f32 = 1.3; +const CU_MIN: u32 = 50_000; +const CU_MAX: u32 = 1_400_000; + +/// A batch of instructions representing one transaction in a load operation. +#[derive(Debug)] +pub struct LoadBatch { + /// All instructions for this transaction + pub instructions: Vec, + /// Number of compressed accounts being decompressed in this batch + pub num_compressed_accounts: usize, + /// Whether this batch includes ATA creation + pub has_ata_creation: bool, + /// Number of wrap operations in this batch + pub wrap_count: usize, +} + +/// Input for building load ATA instructions. +/// +/// All data must be pre-fetched by the caller (Kora). +#[derive(Debug)] +pub struct LoadAtaInput { + /// Fee payer + pub payer: Pubkey, + /// Token owner + pub owner: Pubkey, + /// Token mint + pub mint: Pubkey, + /// Token decimals + pub decimals: u8, + /// Destination ATA address + pub destination: Pubkey, + /// Whether the destination ATA needs to be created + pub needs_ata_creation: bool, + /// Compressed accounts to decompress, in order + pub compressed_accounts: Vec, + /// One validity proof per chunk (chunks of MAX_INPUT_ACCOUNTS) + pub proofs: Vec, + /// SPL interface info if decompressing to SPL (None for light-token) + pub spl_interface: Option, + /// Optional: SPL balance to wrap (source ATA → light-token destination) + pub spl_wrap: Option, +} + +/// SPL balance to wrap as part of load operation. +#[derive(Debug)] +pub struct WrapSource { + /// SPL token account to wrap from + pub source_ata: Pubkey, + /// Amount to wrap + pub amount: u64, + /// SPL interface info for the wrap + pub spl_interface: SplInterfaceInfo, +} + +/// Build load ATA instruction batches. +/// +/// Returns one `Vec` per transaction. Each inner vec is a complete +/// set of instructions for one transaction (compute budget + setup + decompress). +/// +/// # Arguments +/// * `input` - Pre-fetched data for the load operation +/// +/// # Returns +/// * `Vec` — each batch is one transaction +pub fn create_load_ata_batches(input: LoadAtaInput) -> Result, KoraLightError> { + let mut batches: Vec = Vec::new(); + + // If nothing to do, return empty + if input.compressed_accounts.is_empty() && input.spl_wrap.is_none() && !input.needs_ata_creation + { + return Ok(batches); + } + + // Build setup instructions (ATA creation + wraps) + let mut setup_instructions: Vec = Vec::new(); + let mut wrap_count = 0; + + if input.needs_ata_creation { + setup_instructions.push( + CreateAta::new(input.payer, input.owner, input.mint) + .idempotent() + .instruction()?, + ); + } + + if let Some(wrap) = &input.spl_wrap { + setup_instructions.push( + Wrap { + source: wrap.source_ata, + destination: input.destination, + owner: input.owner, + mint: input.mint, + amount: wrap.amount, + decimals: input.decimals, + payer: input.payer, + spl_interface: &wrap.spl_interface, + } + .instruction()?, + ); + wrap_count += 1; + } + + // If no compressed accounts to decompress, return setup-only batch + if input.compressed_accounts.is_empty() { + if !setup_instructions.is_empty() { + let cu = calculate_compute_units(0, input.needs_ata_creation, wrap_count, false); + let mut instructions = vec![compute_budget_instruction(cu)]; + instructions.extend(setup_instructions); + batches.push(LoadBatch { + instructions, + num_compressed_accounts: 0, + has_ata_creation: input.needs_ata_creation, + wrap_count, + }); + } + return Ok(batches); + } + + // Chunk compressed accounts into batches of MAX_INPUT_ACCOUNTS + let chunks: Vec<&[CompressedTokenAccountInput]> = input + .compressed_accounts + .chunks(MAX_INPUT_ACCOUNTS) + .collect(); + + if chunks.len() != input.proofs.len() { + return Err(KoraLightError::InvalidInput(format!( + "Expected {} proofs for {} chunks, got {}", + chunks.len(), + chunks.len(), + input.proofs.len(), + ))); + } + + for (i, (chunk, proof)) in chunks.iter().zip(input.proofs.iter()).enumerate() { + let mut batch_instructions: Vec = Vec::new(); + let mut batch_has_ata = false; + let mut batch_wrap_count = 0; + + // First batch gets setup instructions + if i == 0 { + batch_instructions.append(&mut setup_instructions); + batch_has_ata = input.needs_ata_creation; + batch_wrap_count = wrap_count; + } else if input.needs_ata_creation { + // Subsequent batches: idempotent ATA creation (no-op if exists) + batch_instructions.push( + CreateAta::new(input.payer, input.owner, input.mint) + .idempotent() + .instruction()?, + ); + batch_has_ata = true; + } + + // Calculate chunk amount + let chunk_amount: u64 = chunk + .iter() + .try_fold(0u64, |acc, a| acc.checked_add(a.amount)) + .ok_or(KoraLightError::ArithmeticOverflow)?; + + // Build decompress instruction for this chunk + let decompress_ix = Decompress { + payer: input.payer, + owner: input.owner, + mint: input.mint, + inputs: chunk, + proof, + destination: input.destination, + amount: chunk_amount, + decimals: input.decimals, + spl_interface: input.spl_interface.as_ref(), + } + .instruction()?; + batch_instructions.push(decompress_ix); + + // Check if any account needs full proof + let needs_full_proof = chunk.iter().any(|a| !a.prove_by_index); + + // Calculate and prepend compute budget + let cu = calculate_compute_units( + chunk.len(), + batch_has_ata, + batch_wrap_count, + needs_full_proof, + ); + + let mut final_instructions = vec![compute_budget_instruction(cu)]; + final_instructions.extend(batch_instructions); + + batches.push(LoadBatch { + instructions: final_instructions, + num_compressed_accounts: chunk.len(), + has_ata_creation: batch_has_ata, + wrap_count: batch_wrap_count, + }); + } + + Ok(batches) +} + +fn calculate_compute_units( + num_accounts: usize, + has_ata_creation: bool, + wrap_count: usize, + needs_full_proof: bool, +) -> u32 { + let mut cu: u32 = 0; + + if has_ata_creation { + cu += CU_ATA_CREATION; + } + cu += wrap_count as u32 * CU_WRAP; + + if num_accounts > 0 { + cu += CU_DECOMPRESS_BASE; + if needs_full_proof { + cu += CU_FULL_PROOF; + } + for _ in 0..num_accounts { + cu += if needs_full_proof { + CU_PER_ACCOUNT_FULL_PROOF + } else { + CU_PER_ACCOUNT_PROVE_BY_INDEX + }; + } + } + + let cu_buffered = (cu as f32 * CU_BUFFER_FACTOR).ceil() as u32; + cu_buffered.clamp(CU_MIN, CU_MAX) +} + +fn compute_budget_instruction(units: u32) -> Instruction { + solana_compute_budget_interface::ComputeBudgetInstruction::set_compute_unit_limit(units) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_account(amount: u64) -> CompressedTokenAccountInput { + CompressedTokenAccountInput { + hash: [0u8; 32], + tree: Pubkey::new_unique(), + queue: Pubkey::new_unique(), + amount, + leaf_index: 0, + prove_by_index: false, + root_index: 0, + version: 0, + owner: Pubkey::new_unique(), + mint: Pubkey::new_unique(), + delegate: None, + } + } + + #[test] + fn test_empty_load() { + let input = LoadAtaInput { + payer: Pubkey::new_unique(), + owner: Pubkey::new_unique(), + mint: Pubkey::new_unique(), + decimals: 6, + destination: Pubkey::new_unique(), + needs_ata_creation: false, + compressed_accounts: Vec::new(), + proofs: Vec::new(), + spl_interface: None, + spl_wrap: None, + }; + + let batches = create_load_ata_batches(input).unwrap(); + assert!(batches.is_empty()); + } + + #[test] + fn test_single_account_load() { + let input = LoadAtaInput { + payer: Pubkey::new_unique(), + owner: Pubkey::new_unique(), + mint: Pubkey::new_unique(), + decimals: 6, + destination: Pubkey::new_unique(), + needs_ata_creation: true, + compressed_accounts: vec![make_account(1000)], + proofs: vec![CompressedProof::default()], + spl_interface: None, + spl_wrap: None, + }; + + let batches = create_load_ata_batches(input).unwrap(); + assert_eq!(batches.len(), 1); + assert!(batches[0].has_ata_creation); + assert_eq!(batches[0].num_compressed_accounts, 1); + // compute_budget + ata_create + decompress = 3 instructions + assert_eq!(batches[0].instructions.len(), 3); + } + + #[test] + fn test_multi_batch_load() { + // 12 accounts = 2 batches (8 + 4) + let accounts: Vec<_> = (0..12).map(|_| make_account(100)).collect(); + let proofs = vec![CompressedProof::default(), CompressedProof::default()]; + + let input = LoadAtaInput { + payer: Pubkey::new_unique(), + owner: Pubkey::new_unique(), + mint: Pubkey::new_unique(), + decimals: 6, + destination: Pubkey::new_unique(), + needs_ata_creation: true, + compressed_accounts: accounts, + proofs, + spl_interface: None, + spl_wrap: None, + }; + + let batches = create_load_ata_batches(input).unwrap(); + assert_eq!(batches.len(), 2); + assert_eq!(batches[0].num_compressed_accounts, 8); + assert_eq!(batches[1].num_compressed_accounts, 4); + // Second batch also gets idempotent ATA creation + assert!(batches[1].has_ata_creation); + } + + #[test] + fn test_proof_count_mismatch() { + let input = LoadAtaInput { + payer: Pubkey::new_unique(), + owner: Pubkey::new_unique(), + mint: Pubkey::new_unique(), + decimals: 6, + destination: Pubkey::new_unique(), + needs_ata_creation: false, + compressed_accounts: vec![make_account(1000)], + proofs: Vec::new(), // Mismatch: 1 chunk but 0 proofs + spl_interface: None, + spl_wrap: None, + }; + + let result = create_load_ata_batches(input); + assert!(matches!(result, Err(KoraLightError::InvalidInput(_)))); + } + + #[test] + fn test_compute_units_calculation() { + // Basic: 1 account, no ATA, no wrap, full proof + let cu = calculate_compute_units(1, false, 0, true); + let expected = ((CU_DECOMPRESS_BASE + CU_FULL_PROOF + CU_PER_ACCOUNT_FULL_PROOF) as f32 + * CU_BUFFER_FACTOR) + .ceil() as u32; + assert_eq!(cu, expected.max(CU_MIN).min(CU_MAX)); + + // With ATA creation + let cu_with_ata = calculate_compute_units(1, true, 0, true); + assert!(cu_with_ata > cu); + } + + #[test] + fn test_load_with_wrap_and_decompress() { + // 10 compressed accounts (2 batches: 8 + 2) plus a wrap + let accounts: Vec<_> = (0..10).map(|_| make_account(100)).collect(); + let proofs = vec![CompressedProof::default(), CompressedProof::default()]; + + let spl_interface = SplInterfaceInfo { + spl_interface_pda: Pubkey::new_unique(), + bump: 255, + pool_index: 0, + token_program: Pubkey::new_unique(), + }; + + let input = LoadAtaInput { + payer: Pubkey::new_unique(), + owner: Pubkey::new_unique(), + mint: Pubkey::new_unique(), + decimals: 6, + destination: Pubkey::new_unique(), + needs_ata_creation: true, + compressed_accounts: accounts, + proofs, + spl_interface: None, + spl_wrap: Some(WrapSource { + source_ata: Pubkey::new_unique(), + amount: 500, + spl_interface, + }), + }; + + let batches = create_load_ata_batches(input).unwrap(); + assert_eq!(batches.len(), 2); + // First batch: ATA creation + wrap + decompress + assert!(batches[0].has_ata_creation); + assert_eq!(batches[0].wrap_count, 1); + assert_eq!(batches[0].num_compressed_accounts, 8); + // Second batch: idempotent ATA + decompress (no wrap) + assert!(batches[1].has_ata_creation); + assert_eq!(batches[1].wrap_count, 0); + assert_eq!(batches[1].num_compressed_accounts, 2); + } +} diff --git a/kora-light-client/src/packed_accounts.rs b/kora-light-client/src/packed_accounts.rs new file mode 100644 index 0000000000..db5cee4532 --- /dev/null +++ b/kora-light-client/src/packed_accounts.rs @@ -0,0 +1,162 @@ +//! Shared packed accounts builder for deduplicating pubkeys with flag upgrading. +//! +//! Used by Transfer2 and Decompress instruction builders to build the packed +//! accounts suffix in the accounts array. + +use std::collections::HashMap; + +use solana_instruction::AccountMeta; +use solana_pubkey::Pubkey; + +/// Builder that deduplicates pubkeys and upgrades `is_signer`/`is_writable` flags. +/// +/// Packed accounts are referenced by u8 index in instruction data. +pub(crate) struct PackedAccountsBuilder { + indices: HashMap, + accounts: Vec<(Pubkey, bool, bool)>, // (pubkey, is_signer, is_writable) +} + +impl PackedAccountsBuilder { + pub fn new() -> Self { + Self { + indices: HashMap::new(), + accounts: Vec::new(), + } + } + + /// Insert a pubkey or upgrade its flags if already present. Returns the u8 index. + pub fn insert_or_get(&mut self, pubkey: Pubkey, is_signer: bool, is_writable: bool) -> u8 { + if let Some(&idx) = self.indices.get(&pubkey) { + if is_writable { + self.accounts[idx as usize].2 = true; + } + if is_signer { + self.accounts[idx as usize].1 = true; + } + idx + } else { + let idx = self.accounts.len() as u8; + self.indices.insert(pubkey, idx); + self.accounts.push((pubkey, is_signer, is_writable)); + idx + } + } + + /// Get the index of a previously inserted pubkey. Panics if not found. + pub fn get_index(&self, pubkey: &Pubkey) -> u8 { + self.indices[pubkey] + } + + /// Build the final `AccountMeta` list for the packed accounts suffix. + pub fn build_account_metas(&self) -> Vec { + self.accounts + .iter() + .map(|(pubkey, is_signer, is_writable)| { + if *is_writable { + AccountMeta::new(*pubkey, *is_signer) + } else { + AccountMeta::new_readonly(*pubkey, *is_signer) + } + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_insert_new() { + let mut builder = PackedAccountsBuilder::new(); + let pk = Pubkey::new_unique(); + let idx = builder.insert_or_get(pk, false, true); + assert_eq!(idx, 0); + assert_eq!(builder.get_index(&pk), 0); + } + + #[test] + fn test_insert_duplicate_upgrades_flags() { + let mut builder = PackedAccountsBuilder::new(); + let pk = Pubkey::new_unique(); + + // Insert as readonly, non-signer + builder.insert_or_get(pk, false, false); + + // Re-insert as writable signer — flags should upgrade + let idx = builder.insert_or_get(pk, true, true); + assert_eq!(idx, 0); + + let metas = builder.build_account_metas(); + assert_eq!(metas.len(), 1); + assert!(metas[0].is_signer); + assert!(metas[0].is_writable); + } + + #[test] + fn test_insert_duplicate_no_downgrade() { + let mut builder = PackedAccountsBuilder::new(); + let pk = Pubkey::new_unique(); + + // Insert as writable signer + builder.insert_or_get(pk, true, true); + + // Re-insert as readonly non-signer — flags must NOT downgrade + builder.insert_or_get(pk, false, false); + + let metas = builder.build_account_metas(); + assert!(metas[0].is_signer); + assert!(metas[0].is_writable); + } + + #[test] + fn test_get_index() { + let mut builder = PackedAccountsBuilder::new(); + let pk1 = Pubkey::new_unique(); + let pk2 = Pubkey::new_unique(); + let pk3 = Pubkey::new_unique(); + + builder.insert_or_get(pk1, false, false); + builder.insert_or_get(pk2, false, false); + builder.insert_or_get(pk3, false, false); + + assert_eq!(builder.get_index(&pk1), 0); + assert_eq!(builder.get_index(&pk2), 1); + assert_eq!(builder.get_index(&pk3), 2); + } + + #[test] + #[should_panic] + fn test_get_index_panics_on_missing() { + let builder = PackedAccountsBuilder::new(); + let pk = Pubkey::new_unique(); + builder.get_index(&pk); + } + + #[test] + fn test_build_account_metas() { + let mut builder = PackedAccountsBuilder::new(); + let pk1 = Pubkey::new_unique(); + let pk2 = Pubkey::new_unique(); + let pk3 = Pubkey::new_unique(); + + builder.insert_or_get(pk1, true, false); // signer, readonly + builder.insert_or_get(pk2, false, true); // non-signer, writable + builder.insert_or_get(pk3, true, true); // signer, writable + + let metas = builder.build_account_metas(); + assert_eq!(metas.len(), 3); + + assert_eq!(metas[0].pubkey, pk1); + assert!(metas[0].is_signer); + assert!(!metas[0].is_writable); + + assert_eq!(metas[1].pubkey, pk2); + assert!(!metas[1].is_signer); + assert!(metas[1].is_writable); + + assert_eq!(metas[2].pubkey, pk3); + assert!(metas[2].is_signer); + assert!(metas[2].is_writable); + } +} diff --git a/kora-light-client/src/pda.rs b/kora-light-client/src/pda.rs new file mode 100644 index 0000000000..b4ecdd5acd --- /dev/null +++ b/kora-light-client/src/pda.rs @@ -0,0 +1,118 @@ +//! PDA derivation helpers for Light Protocol. +//! +//! Ported from `sdk-libs/token-sdk/src/utils.rs`. + +use solana_pubkey::Pubkey; + +use crate::program_ids::{ + LIGHT_TOKEN_PROGRAM_ID, POOL_SEED, SPL_TOKEN_2022_PROGRAM_ID, SPL_TOKEN_PROGRAM_ID, +}; + +/// Returns the Light Token associated token address for a given owner and mint. +pub fn get_associated_token_address(owner: &Pubkey, mint: &Pubkey) -> Pubkey { + get_associated_token_address_and_bump(owner, mint).0 +} + +/// Returns the Light Token associated token address and bump for a given owner and mint. +pub fn get_associated_token_address_and_bump(owner: &Pubkey, mint: &Pubkey) -> (Pubkey, u8) { + Pubkey::find_program_address( + &[ + &owner.to_bytes(), + &LIGHT_TOKEN_PROGRAM_ID.to_bytes(), + &mint.to_bytes(), + ], + &LIGHT_TOKEN_PROGRAM_ID, + ) +} + +/// Returns the SPL interface PDA, bump, and pool index for a given mint. +/// +/// Tries pool_index 0 first (most common). If the PDA derivation is needed +/// for other pool indices, use `find_spl_interface_pda_with_index`. +pub fn find_spl_interface_pda(mint: &Pubkey) -> (Pubkey, u8) { + find_spl_interface_pda_with_index(mint, 0) +} + +/// Returns the SPL interface PDA and bump for a given mint and pool index. +pub fn find_spl_interface_pda_with_index(mint: &Pubkey, pool_index: u8) -> (Pubkey, u8) { + Pubkey::find_program_address( + &[POOL_SEED, &mint.to_bytes(), &[pool_index]], + &LIGHT_TOKEN_PROGRAM_ID, + ) +} + +/// Derive the CPI authority PDA for the Light Token Program. +pub fn derive_cpi_authority_pda() -> (Pubkey, u8) { + Pubkey::find_program_address( + &[crate::program_ids::CPI_AUTHORITY_PDA_SEED], + &LIGHT_TOKEN_PROGRAM_ID, + ) +} + +/// Check if an account owner is a Light token program. +/// +/// Returns `true` if owner is `LIGHT_TOKEN_PROGRAM_ID`. +/// Returns `false` if owner is SPL Token or Token-2022. +/// Returns `None` if owner is unrecognized. +pub fn is_light_token_owner(owner: &Pubkey) -> Option { + if owner == &LIGHT_TOKEN_PROGRAM_ID { + Some(true) + } else if owner == &SPL_TOKEN_PROGRAM_ID || owner == &SPL_TOKEN_2022_PROGRAM_ID { + Some(false) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_cpi_authority_pda_matches_known() { + let (pda, bump) = derive_cpi_authority_pda(); + assert_eq!(pda, crate::program_ids::CPI_AUTHORITY_PDA); + assert_eq!(bump, crate::program_ids::BUMP_CPI_AUTHORITY); + } + + #[test] + fn test_find_spl_interface_pda_returns_valid_pubkey() { + let mint = Pubkey::new_unique(); + let (pda, bump) = find_spl_interface_pda(&mint); + // Verify it's a valid PDA (off the ed25519 curve) + assert_ne!(pda, Pubkey::default()); + let _ = bump; // u8, always valid + + // Same mint → same PDA (deterministic) + let (pda2, bump2) = find_spl_interface_pda(&mint); + assert_eq!(pda, pda2); + assert_eq!(bump, bump2); + + // Different pool index → different PDA + let (pda_idx1, _) = find_spl_interface_pda_with_index(&mint, 1); + assert_ne!(pda, pda_idx1); + } + + #[test] + fn test_is_light_token_owner_light_token() { + assert_eq!(is_light_token_owner(&LIGHT_TOKEN_PROGRAM_ID), Some(true)); + } + + #[test] + fn test_is_light_token_owner_spl_token() { + assert_eq!(is_light_token_owner(&SPL_TOKEN_PROGRAM_ID), Some(false)); + } + + #[test] + fn test_is_light_token_owner_token_2022() { + assert_eq!( + is_light_token_owner(&SPL_TOKEN_2022_PROGRAM_ID), + Some(false) + ); + } + + #[test] + fn test_is_light_token_owner_unknown() { + assert_eq!(is_light_token_owner(&Pubkey::new_unique()), None); + } +} diff --git a/kora-light-client/src/program_ids.rs b/kora-light-client/src/program_ids.rs new file mode 100644 index 0000000000..58adf0f3be --- /dev/null +++ b/kora-light-client/src/program_ids.rs @@ -0,0 +1,78 @@ +//! Program IDs and constants for Light Protocol. +//! +//! Ported from `light-token-types/src/constants.rs` and `light-compressed-token-sdk/src/constants.rs`. + +use solana_pubkey::Pubkey; + +/// Light Compressed Token Program ID +pub const LIGHT_TOKEN_PROGRAM_ID: Pubkey = + Pubkey::from_str_const("cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m"); + +/// Light System Program ID +pub const LIGHT_SYSTEM_PROGRAM_ID: Pubkey = + Pubkey::from_str_const("SySTEM1eSU2p4BGQfQpimFEWWSC1XDFeun3Nqzz3rT7"); + +/// Account Compression Program ID +pub const ACCOUNT_COMPRESSION_PROGRAM_ID: Pubkey = + Pubkey::from_str_const("compr6CUsB5m2jS4Y3831ztGSTnDpnKJTKS95d64XVq"); + +/// Account Compression Authority PDA +pub const ACCOUNT_COMPRESSION_AUTHORITY_PDA: Pubkey = + Pubkey::from_str_const("HwXnGK3tPkkVY6P439H2p68AxpeuWXd5PcrAxFpbmfbA"); + +/// Noop Program ID (used for logging) +pub const NOOP_PROGRAM_ID: Pubkey = + Pubkey::from_str_const("noopb9bkMVfRPU8AsbpTUg8AQkHtKwMYZiFUjNRtMmV"); + +/// CPI Authority PDA for the Light Token Program +pub const CPI_AUTHORITY_PDA: Pubkey = + Pubkey::from_str_const("GXtd2izAiMJPwMEjfgTRH3d7k9mjn4Jq3JrWFv9gySYy"); + +/// SPL Token Program ID +pub const SPL_TOKEN_PROGRAM_ID: Pubkey = + Pubkey::from_str_const("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"); + +/// SPL Token 2022 Program ID +pub const SPL_TOKEN_2022_PROGRAM_ID: Pubkey = + Pubkey::from_str_const("TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"); + +/// Default compressible config PDA (V1) +pub const LIGHT_TOKEN_CONFIG: Pubkey = + Pubkey::from_str_const("ACXg8a7VaqecBWrSbdu73W4Pg9gsqXJ3EXAqkHyhvVXg"); + +/// Default rent sponsor PDA (V1) +pub const RENT_SPONSOR_V1: Pubkey = + Pubkey::from_str_const("r18WwUxfG8kQ69bQPAB2jV6zGNKy3GosFGctjQoV4ti"); + +/// CPI Authority PDA seed +pub const CPI_AUTHORITY_PDA_SEED: &[u8] = b"cpi_authority"; + +/// CPI Authority bump +pub const BUMP_CPI_AUTHORITY: u8 = 254; + +/// Pool seed for SPL token pool accounts +pub const POOL_SEED: &[u8] = b"pool"; + +/// Transfer2 instruction discriminator +pub const TRANSFER2_DISCRIMINATOR: u8 = 101; + +/// Default max top-up (u16::MAX = no limit) +pub const DEFAULT_MAX_TOP_UP: u16 = u16::MAX; + +/// Wrapped SOL mint +pub const WSOL_MINT: Pubkey = Pubkey::from_str_const("So11111111111111111111111111111111111111112"); + +/// System program ID +pub const SYSTEM_PROGRAM_ID: Pubkey = Pubkey::from_str_const("11111111111111111111111111111111"); + +/// Registered program PDA (from registry) +pub const REGISTERED_PROGRAM_PDA: Pubkey = + Pubkey::from_str_const("35hkDgaAKwMCaxRz2ocSZ6NaUrtKkyNqU6c4RV3tYJRh"); + +/// Light Token mainnet LUT address +pub const LIGHT_LUT_MAINNET: Pubkey = + Pubkey::from_str_const("9NYFyEqPeWQHiS8Jv4VjZcjKBMPRCJ3KbEbaBcy4Mza"); + +/// Light Token devnet LUT address (currently same as mainnet) +pub const LIGHT_LUT_DEVNET: Pubkey = + Pubkey::from_str_const("9NYFyEqPeWQHiS8Jv4VjZcjKBMPRCJ3KbEbaBcy4Mza"); diff --git a/kora-light-client/src/transfer.rs b/kora-light-client/src/transfer.rs new file mode 100644 index 0000000000..76f779d64f --- /dev/null +++ b/kora-light-client/src/transfer.rs @@ -0,0 +1,541 @@ +//! Transfer instruction builders for Light Protocol. +//! +//! Provides Transfer2 instruction building for compressed token transfers. +//! Routes between: +//! - Compressed-to-compressed (Transfer2 with compressed inputs) +//! - Light-token-to-light-token (TransferChecked via on-chain accounts) + +#[cfg(test)] +use borsh::BorshDeserialize; +use borsh::BorshSerialize; +use solana_instruction::{AccountMeta, Instruction}; +use solana_pubkey::Pubkey; + +use crate::{ + error::KoraLightError, + packed_accounts::PackedAccountsBuilder, + program_ids::{ + ACCOUNT_COMPRESSION_AUTHORITY_PDA, ACCOUNT_COMPRESSION_PROGRAM_ID, CPI_AUTHORITY_PDA, + DEFAULT_MAX_TOP_UP, LIGHT_SYSTEM_PROGRAM_ID, LIGHT_TOKEN_PROGRAM_ID, + REGISTERED_PROGRAM_PDA, SYSTEM_PROGRAM_ID, TRANSFER2_DISCRIMINATOR, + }, + types::{ + CompressedProof, CompressedTokenAccountInput, CompressedTokenInstructionDataTransfer2, + MultiInputTokenDataWithContext, MultiTokenTransferOutputData, PackedMerkleContext, + }, +}; + +/// Light Token TransferChecked discriminator +const TRANSFER_CHECKED_DISCRIMINATOR: u8 = 12; + +/// Compressed-to-compressed token transfer. +/// +/// Builds a Transfer2 instruction that moves tokens between compressed accounts. +/// Automatically creates a change output if `amount < input_total`. +/// Omits the proof when all inputs use `prove_by_index`. +/// +/// # Example +/// ```rust,ignore +/// use kora_light_client::Transfer2; +/// +/// let ix = Transfer2 { +/// payer, +/// authority, +/// mint, +/// inputs: &accounts, +/// proof: &proof, +/// destination_owner, +/// amount: 1_000, +/// }.instruction()?; +/// ``` +#[derive(Debug, Clone)] +pub struct Transfer2<'a> { + /// Fee payer (signer). + pub payer: Pubkey, + /// Token owner or delegate (signer). + pub authority: Pubkey, + /// Token mint. + pub mint: Pubkey, + /// Source compressed token accounts. + pub inputs: &'a [CompressedTokenAccountInput], + /// Validity proof from the RPC. + pub proof: &'a CompressedProof, + /// Owner of the destination compressed account. + pub destination_owner: Pubkey, + /// Amount to transfer. + pub amount: u64, +} + +impl<'a> Transfer2<'a> { + /// Build the Transfer2 instruction. + pub fn instruction(&self) -> Result { + create_transfer2_instruction( + &self.payer, + &self.authority, + &self.mint, + self.inputs, + self.proof, + &self.destination_owner, + self.amount, + ) + } +} + +/// Decompressed ATA-to-ATA token transfer. +/// +/// Builds a TransferChecked instruction for on-chain light-token accounts. +/// Not for compressed accounts. +/// +/// # Example +/// ```rust,ignore +/// use kora_light_client::TransferChecked; +/// +/// let ix = TransferChecked { +/// source_ata, +/// destination_ata, +/// mint, +/// owner, +/// amount: 1_000, +/// decimals: 6, +/// payer, +/// }.instruction()?; +/// ``` +#[derive(Debug, Clone)] +pub struct TransferChecked { + /// Source token account (writable). + pub source_ata: Pubkey, + /// Destination token account (writable). + pub destination_ata: Pubkey, + /// Token mint. + pub mint: Pubkey, + /// Token owner (signer). + pub owner: Pubkey, + /// Amount to transfer. + pub amount: u64, + /// Token decimals. + pub decimals: u8, + /// Fee payer (signer). Only added if different from owner. + pub payer: Pubkey, +} + +impl TransferChecked { + /// Build the TransferChecked instruction. + pub fn instruction(&self) -> Result { + create_transfer_checked_instruction( + &self.source_ata, + &self.destination_ata, + &self.mint, + &self.owner, + self.amount, + self.decimals, + &self.payer, + ) + } +} + +/// Build a Transfer2 instruction for compressed-to-compressed token transfers. +pub fn create_transfer2_instruction( + payer: &Pubkey, + authority: &Pubkey, + mint: &Pubkey, + inputs: &[CompressedTokenAccountInput], + proof: &CompressedProof, + destination_owner: &Pubkey, + amount: u64, +) -> Result { + if inputs.is_empty() { + return Err(KoraLightError::NoCompressedAccounts); + } + + // Build packed accounts array + let mut builder = PackedAccountsBuilder::new(); + + // 1. Trees (writable) + for input in inputs { + builder.insert_or_get(input.tree, false, true); + } + + // 2. Queues (writable) + for input in inputs { + builder.insert_or_get(input.queue, false, true); + } + let first_queue_index = builder.get_index(&inputs[0].queue); + + // 3. Mint (readonly) + let mint_index = builder.insert_or_get(*mint, false, false); + + // 4. Authority/owner (signer) + let authority_index = builder.insert_or_get(*authority, true, false); + + // 5. Destination owner (readonly) + let dest_owner_index = builder.insert_or_get(*destination_owner, false, false); + + // 6. Delegates if any + for input in inputs { + if let Some(delegate) = &input.delegate { + builder.insert_or_get(*delegate, false, false); + } + } + + // Build input token data + let in_token_data: Vec = inputs + .iter() + .map(|input| { + let tree_idx = builder.get_index(&input.tree); + let queue_idx = builder.get_index(&input.queue); + let delegate_idx = input.delegate.map(|d| builder.get_index(&d)).unwrap_or(0); + + MultiInputTokenDataWithContext { + owner: authority_index, + amount: input.amount, + has_delegate: input.delegate.is_some(), + delegate: delegate_idx, + mint: mint_index, + version: input.version, + merkle_context: PackedMerkleContext { + merkle_tree_pubkey_index: tree_idx, + queue_pubkey_index: queue_idx, + leaf_index: input.leaf_index, + prove_by_index: input.prove_by_index, + }, + root_index: input.root_index, + } + }) + .collect(); + + // Calculate change + let input_total: u64 = inputs + .iter() + .try_fold(0u64, |acc, i| acc.checked_add(i.amount)) + .ok_or(KoraLightError::ArithmeticOverflow)?; + let change_amount = + input_total + .checked_sub(amount) + .ok_or(KoraLightError::InsufficientBalance { + needed: amount, + available: input_total, + })?; + + // Build output: destination + optional change + let mut out_token_data = vec![MultiTokenTransferOutputData { + owner: dest_owner_index, + amount, + has_delegate: false, + delegate: 0, + mint: mint_index, + version: inputs[0].version, + }]; + + if change_amount > 0 { + out_token_data.push(MultiTokenTransferOutputData { + owner: authority_index, + amount: change_amount, + has_delegate: false, + delegate: 0, + mint: mint_index, + version: inputs[0].version, + }); + } + + // Build Transfer2 instruction data + let transfer2_data = CompressedTokenInstructionDataTransfer2 { + with_transaction_hash: false, + with_lamports_change_account_merkle_tree_index: false, + lamports_change_account_merkle_tree_index: 0, + lamports_change_account_owner_index: 0, + output_queue: first_queue_index, + max_top_up: DEFAULT_MAX_TOP_UP, + cpi_context: None, + compressions: None, + proof: if inputs.iter().all(|i| i.prove_by_index) { + None + } else { + Some(*proof) + }, + in_token_data, + out_token_data, + in_lamports: None, + out_lamports: None, + in_tlv: None, + out_tlv: None, + }; + + // Serialize + let mut data = Vec::new(); + data.push(TRANSFER2_DISCRIMINATOR); + transfer2_data.serialize(&mut data)?; + + // Build account metas: static + packed + let mut accounts = vec![ + AccountMeta::new_readonly(LIGHT_SYSTEM_PROGRAM_ID, false), + AccountMeta::new(*payer, true), + AccountMeta::new_readonly(CPI_AUTHORITY_PDA, false), + AccountMeta::new_readonly(REGISTERED_PROGRAM_PDA, false), + AccountMeta::new_readonly(ACCOUNT_COMPRESSION_AUTHORITY_PDA, false), + AccountMeta::new_readonly(ACCOUNT_COMPRESSION_PROGRAM_ID, false), + AccountMeta::new_readonly(SYSTEM_PROGRAM_ID, false), + ]; + + accounts.extend(builder.build_account_metas()); + + Ok(Instruction { + program_id: LIGHT_TOKEN_PROGRAM_ID, + accounts, + data, + }) +} + +/// Build a simple TransferChecked instruction for light-token ATA to ATA transfers. +/// +/// This is for decompressed (on-chain) light-token accounts, NOT compressed accounts. +pub fn create_transfer_checked_instruction( + source_ata: &Pubkey, + destination_ata: &Pubkey, + mint: &Pubkey, + owner: &Pubkey, + amount: u64, + decimals: u8, + payer: &Pubkey, +) -> Result { + let mut data = Vec::with_capacity(10); + data.push(TRANSFER_CHECKED_DISCRIMINATOR); + data.extend_from_slice(&amount.to_le_bytes()); + data.push(decimals); + + let mut accounts = vec![ + AccountMeta::new(*source_ata, false), + AccountMeta::new_readonly(*mint, false), + AccountMeta::new(*destination_ata, false), + AccountMeta::new_readonly(*owner, true), + AccountMeta::new_readonly(SYSTEM_PROGRAM_ID, false), + ]; + + // If payer != owner, add payer as signer + if payer != owner { + accounts.push(AccountMeta::new(*payer, true)); + } + + Ok(Instruction { + program_id: LIGHT_TOKEN_PROGRAM_ID, + accounts, + data, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_input(amount: u64, tree: Pubkey, queue: Pubkey) -> CompressedTokenAccountInput { + CompressedTokenAccountInput { + hash: [0u8; 32], + tree, + queue, + amount, + leaf_index: 42, + prove_by_index: false, + root_index: 0, + version: 0, + owner: Pubkey::new_unique(), + mint: Pubkey::new_unique(), + delegate: None, + } + } + + #[test] + fn test_transfer2_basic() { + let payer = Pubkey::new_unique(); + let authority = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + let tree = Pubkey::new_unique(); + let queue = Pubkey::new_unique(); + let dest_owner = Pubkey::new_unique(); + + let inputs = vec![make_input(1000, tree, queue)]; + let proof = CompressedProof::default(); + + let ix = create_transfer2_instruction( + &payer, + &authority, + &mint, + &inputs, + &proof, + &dest_owner, + 1000, + ) + .unwrap(); + + assert_eq!(ix.program_id, LIGHT_TOKEN_PROGRAM_ID); + assert_eq!(ix.data[0], TRANSFER2_DISCRIMINATOR); + // 7 static + packed (tree, queue, mint, authority, dest_owner) + assert_eq!(ix.accounts.len(), 7 + 5); + } + + #[test] + fn test_transfer2_with_change() { + let payer = Pubkey::new_unique(); + let authority = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + let tree = Pubkey::new_unique(); + let queue = Pubkey::new_unique(); + let dest_owner = Pubkey::new_unique(); + + let inputs = vec![make_input(1000, tree, queue)]; + let proof = CompressedProof::default(); + + let ix = create_transfer2_instruction( + &payer, + &authority, + &mint, + &inputs, + &proof, + &dest_owner, + 700, + ) + .unwrap(); + + // Should have both destination and change outputs in data + assert_eq!(ix.program_id, LIGHT_TOKEN_PROGRAM_ID); + } + + #[test] + fn test_transfer_checked() { + let source = Pubkey::new_unique(); + let dest = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + let owner = Pubkey::new_unique(); + let payer = Pubkey::new_unique(); + + let ix = create_transfer_checked_instruction(&source, &dest, &mint, &owner, 500, 6, &payer) + .unwrap(); + + assert_eq!(ix.program_id, LIGHT_TOKEN_PROGRAM_ID); + assert_eq!(ix.data[0], TRANSFER_CHECKED_DISCRIMINATOR); + // Amount in LE bytes + assert_eq!(&ix.data[1..9], &500u64.to_le_bytes()); + // Decimals + assert_eq!(ix.data[9], 6); + // 5 accounts + payer (since payer != owner) + assert_eq!(ix.accounts.len(), 6); + } + + #[test] + fn test_transfer2_prove_by_index_no_proof() { + let payer = Pubkey::new_unique(); + let authority = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + let tree = Pubkey::new_unique(); + let queue = Pubkey::new_unique(); + let dest_owner = Pubkey::new_unique(); + + let inputs = vec![CompressedTokenAccountInput { + prove_by_index: true, + ..make_input(1000, tree, queue) + }]; + let proof = CompressedProof::default(); + + let ix = create_transfer2_instruction( + &payer, + &authority, + &mint, + &inputs, + &proof, + &dest_owner, + 1000, + ) + .unwrap(); + + // Deserialize and verify proof is None + let data = CompressedTokenInstructionDataTransfer2::try_from_slice(&ix.data[1..]).unwrap(); + assert!( + data.proof.is_none(), + "proof must be None when all inputs use prove_by_index" + ); + } + + #[test] + fn test_transfer2_mixed_prove_by_index_has_proof() { + let payer = Pubkey::new_unique(); + let authority = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + let tree = Pubkey::new_unique(); + let queue = Pubkey::new_unique(); + let dest_owner = Pubkey::new_unique(); + + let inputs = vec![ + CompressedTokenAccountInput { + prove_by_index: true, + ..make_input(500, tree, queue) + }, + CompressedTokenAccountInput { + prove_by_index: false, + ..make_input(500, tree, queue) + }, + ]; + let proof = CompressedProof { + a: [1; 32], + b: [2; 64], + c: [3; 32], + }; + + let ix = create_transfer2_instruction( + &payer, + &authority, + &mint, + &inputs, + &proof, + &dest_owner, + 1000, + ) + .unwrap(); + + let data = CompressedTokenInstructionDataTransfer2::try_from_slice(&ix.data[1..]).unwrap(); + assert!( + data.proof.is_some(), + "proof must be Some when any input does not use prove_by_index" + ); + assert_eq!(data.proof.unwrap(), proof); + } + + #[test] + fn test_transfer2_with_delegate() { + let payer = Pubkey::new_unique(); + let authority = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + let tree = Pubkey::new_unique(); + let queue = Pubkey::new_unique(); + let dest_owner = Pubkey::new_unique(); + let delegate = Pubkey::new_unique(); + + let inputs = vec![CompressedTokenAccountInput { + delegate: Some(delegate), + ..make_input(1000, tree, queue) + }]; + let proof = CompressedProof::default(); + + let ix = create_transfer2_instruction( + &payer, + &authority, + &mint, + &inputs, + &proof, + &dest_owner, + 1000, + ) + .unwrap(); + + // 7 static + packed (tree, queue, mint, authority, dest_owner, delegate) + assert_eq!(ix.accounts.len(), 7 + 6); + + // Verify delegate is in packed accounts (readonly, not signer) + let delegate_account = &ix.accounts[7 + 5]; // last packed account + assert_eq!(delegate_account.pubkey, delegate); + assert!(!delegate_account.is_signer); + assert!(!delegate_account.is_writable); + + // Verify instruction data has delegate set + let data = CompressedTokenInstructionDataTransfer2::try_from_slice(&ix.data[1..]).unwrap(); + assert!(data.in_token_data[0].has_delegate); + assert_eq!(data.in_token_data[0].delegate, 5); // delegate is 6th packed account (index 5) + } +} diff --git a/kora-light-client/src/types.rs b/kora-light-client/src/types.rs new file mode 100644 index 0000000000..298a8680bf --- /dev/null +++ b/kora-light-client/src/types.rs @@ -0,0 +1,574 @@ +//! Borsh-serializable types for Light Protocol instruction data. +//! +//! These types MUST be byte-identical to the on-chain program's Borsh layout. +//! Ported from: +//! - `program-libs/compressed-account/src/instruction_data/compressed_proof.rs` +//! - `program-libs/compressed-account/src/compressed_account.rs` (PackedMerkleContext) +//! - `program-libs/token-interface/src/instructions/transfer2/instruction_data.rs` +//! - `program-libs/token-interface/src/instructions/transfer2/compression.rs` +//! - `program-libs/token-interface/src/instructions/transfer2/cpi_context.rs` +//! +//! Source commit: HEAD of main branch at time of porting. + +use borsh::{BorshDeserialize, BorshSerialize}; +use solana_pubkey::Pubkey; + +// --------------------------------------------------------------------------- +// Compressed Proof (from program-libs/compressed-account) +// --------------------------------------------------------------------------- + +/// ZK validity proof (a, b, c components = 128 bytes total). +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +pub struct CompressedProof { + pub a: [u8; 32], + pub b: [u8; 64], + pub c: [u8; 32], +} + +impl Default for CompressedProof { + fn default() -> Self { + Self { + a: [0; 32], + b: [0; 64], + c: [0; 32], + } + } +} + +// --------------------------------------------------------------------------- +// Packed Merkle Context (from program-libs/compressed-account) +// --------------------------------------------------------------------------- + +/// Merkle tree context using packed indices into the accounts array. +#[repr(C)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +pub struct PackedMerkleContext { + pub merkle_tree_pubkey_index: u8, + pub queue_pubkey_index: u8, + pub leaf_index: u32, + pub prove_by_index: bool, +} + +// --------------------------------------------------------------------------- +// CPI Context (from program-libs/token-interface/transfer2/cpi_context.rs) +// --------------------------------------------------------------------------- + +/// Compressed CPI context for cross-program invocations. +#[repr(C)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +pub struct CompressedCpiContext { + pub set_context: bool, + pub first_set_context: bool, +} + +// --------------------------------------------------------------------------- +// Compression (from program-libs/token-interface/transfer2/compression.rs) +// --------------------------------------------------------------------------- + +/// Compression mode for token operations. +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +pub enum CompressionMode { + /// SPL/T22 → compressed (add tokens to pool) + Compress, + /// Compressed → SPL/T22 or light-token (remove tokens from pool) + Decompress, + /// Compress token account and close it + CompressAndClose, +} + +/// A single compression/decompression operation within a Transfer2 instruction. +/// +/// All index fields (mint, source_or_recipient, authority, pool_account_index) +/// are u8 indices into the packed accounts array. +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +pub struct Compression { + pub mode: CompressionMode, + pub amount: u64, + /// Index of mint in packed accounts + pub mint: u8, + /// Index of source (compress) or recipient (decompress) in packed accounts + pub source_or_recipient: u8, + /// Index of owner or delegate account in packed accounts + pub authority: u8, + /// Pool account index for SPL token compression/decompression + pub pool_account_index: u8, + /// Pool index for SPL token compression/decompression + pub pool_index: u8, + /// Bump seed for SPL token pool PDA + pub bump: u8, + /// Decimals for SPL token transfer_checked + pub decimals: u8, +} + +impl Compression { + /// Create a decompress operation for light-token (no SPL pool involved). + pub fn decompress(amount: u64, mint: u8, recipient: u8) -> Self { + Self { + mode: CompressionMode::Decompress, + amount, + mint, + source_or_recipient: recipient, + authority: 0, + pool_account_index: 0, + pool_index: 0, + bump: 0, + decimals: 0, + } + } + + /// Create a decompress operation to SPL token account (uses pool). + pub fn decompress_spl( + amount: u64, + mint: u8, + recipient: u8, + pool_account_index: u8, + pool_index: u8, + bump: u8, + decimals: u8, + ) -> Self { + Self { + mode: CompressionMode::Decompress, + amount, + mint, + source_or_recipient: recipient, + authority: 0, + pool_account_index, + pool_index, + bump, + decimals, + } + } + + /// Create a compress operation from SPL token account (uses pool). + #[allow(clippy::too_many_arguments)] + pub fn compress_spl( + amount: u64, + mint: u8, + source: u8, + authority: u8, + pool_account_index: u8, + pool_index: u8, + bump: u8, + decimals: u8, + ) -> Self { + Self { + mode: CompressionMode::Compress, + amount, + mint, + source_or_recipient: source, + authority, + pool_account_index, + pool_index, + bump, + decimals, + } + } + + /// Create a compress operation for light-token (no SPL pool). + pub fn compress(amount: u64, mint: u8, source: u8, authority: u8) -> Self { + Self { + mode: CompressionMode::Compress, + amount, + mint, + source_or_recipient: source, + authority, + pool_account_index: 0, + pool_index: 0, + bump: 0, + decimals: 0, + } + } +} + +// --------------------------------------------------------------------------- +// Transfer2 instruction data types +// (from program-libs/token-interface/transfer2/instruction_data.rs) +// --------------------------------------------------------------------------- + +/// Input token data with merkle context for Transfer2. +/// +/// All pubkey fields are u8 indices into the packed accounts array. +#[repr(C)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +pub struct MultiInputTokenDataWithContext { + /// Index of owner in packed accounts + pub owner: u8, + pub amount: u64, + /// Whether a delegate is set + pub has_delegate: bool, + /// Index of delegate in packed accounts (only valid if has_delegate) + pub delegate: u8, + /// Index of mint in packed accounts + pub mint: u8, + /// Token data version + pub version: u8, + /// Merkle tree context with packed indices + pub merkle_context: PackedMerkleContext, + /// Index of the root used in inclusion validity proof + pub root_index: u16, +} + +/// Output token data for Transfer2. +/// +/// All pubkey fields are u8 indices into the packed accounts array. +#[repr(C)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +pub struct MultiTokenTransferOutputData { + /// Index of owner in packed accounts + pub owner: u8, + pub amount: u64, + /// Whether a delegate is set + pub has_delegate: bool, + /// Index of delegate in packed accounts (only valid if has_delegate) + pub delegate: u8, + /// Index of mint in packed accounts + pub mint: u8, + /// Token data version + pub version: u8, +} + +/// Full Transfer2 instruction data (Borsh-serialized). +/// +/// The discriminator byte (101) is prepended BEFORE this struct when building +/// the instruction — it is NOT part of this struct. +#[repr(C)] +#[derive(Debug, Clone, BorshSerialize, BorshDeserialize)] +pub struct CompressedTokenInstructionDataTransfer2 { + pub with_transaction_hash: bool, + pub with_lamports_change_account_merkle_tree_index: bool, + pub lamports_change_account_merkle_tree_index: u8, + pub lamports_change_account_owner_index: u8, + /// Index of output queue in packed accounts + pub output_queue: u8, + /// Maximum lamports for rent and top-up combined (u16::MAX = no limit, 0 = no top-ups) + pub max_top_up: u16, + pub cpi_context: Option, + pub compressions: Option>, + pub proof: Option, + pub in_token_data: Vec, + pub out_token_data: Vec, + pub in_lamports: Option>, + pub out_lamports: Option>, + /// Extensions for input compressed token accounts (one Vec per input account) + pub in_tlv: Option>>, + /// Extensions for output compressed token accounts (one Vec per output account) + pub out_tlv: Option>>, +} + +// --------------------------------------------------------------------------- +// Extension instruction data +// (from program-libs/token-interface/instructions/extensions/) +// +// This enum must match the on-chain variant ordering exactly for Borsh compat. +// --------------------------------------------------------------------------- + +/// Extension data for compressed token accounts. +/// +/// Variant ordering MUST match the on-chain enum exactly (33 variants, indices 0-32). +/// Only variants 19, 31, and 32 carry data; the rest are reserved placeholders. +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +pub enum ExtensionInstructionData { + Placeholder0, + Placeholder1, + Placeholder2, + Placeholder3, + Placeholder4, + Placeholder5, + Placeholder6, + Placeholder7, + Placeholder8, + Placeholder9, + Placeholder10, + Placeholder11, + Placeholder12, + Placeholder13, + Placeholder14, + Placeholder15, + Placeholder16, + Placeholder17, + Placeholder18, + /// Token metadata extension (index 19) + TokenMetadata(TokenMetadataInstructionData), + Placeholder20, + Placeholder21, + Placeholder22, + Placeholder23, + Placeholder24, + Placeholder25, + Placeholder26, + /// Reserved for PausableAccount extension (index 27) + Placeholder27, + /// Reserved for PermanentDelegateAccount extension (index 28) + Placeholder28, + Placeholder29, + Placeholder30, + /// CompressedOnly extension (index 31) — marks account as decompress-only + CompressedOnly(CompressedOnlyExtensionInstructionData), + /// Compressible extension (index 32) — compression info from light-compressible + Compressible(CompressionInfo), +} + +/// Token metadata for compressed token accounts (index 19). +/// Uses [u8; 32] for pubkey instead of Pubkey for version-agnosticism. +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +pub struct TokenMetadataInstructionData { + pub update_authority: Option<[u8; 32]>, + pub name: Vec, + pub symbol: Vec, + pub uri: Vec, + pub additional_metadata: Option>, +} + +/// Key-value metadata pair. +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +pub struct AdditionalMetadata { + pub key: Vec, + pub value: Vec, +} + +/// CompressedOnly extension data (index 31). +/// Marks a compressed account as decompress-only (cannot be transferred). +#[derive(Debug, Clone, Copy, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +pub struct CompressedOnlyExtensionInstructionData { + pub delegated_amount: u64, + pub withheld_transfer_fee: u64, + pub is_frozen: bool, + pub compression_index: u8, + pub is_ata: bool, + pub bump: u8, + pub owner_index: u8, +} + +/// Rent configuration parameters. +/// Ported from `program-libs/compressible/src/rent/config.rs`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, BorshSerialize, BorshDeserialize)] +pub struct RentConfig { + pub base_rent: u16, + pub compression_cost: u16, + pub lamports_per_byte_per_epoch: u8, + pub max_funded_epochs: u8, + pub max_top_up: u16, +} + +/// Compressible extension data (index 32) — compression info (96 bytes). +/// Ported from `program-libs/compressible/src/compression_info.rs`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +pub struct CompressionInfo { + pub config_account_version: u16, + pub compress_to_pubkey: u8, + pub account_version: u8, + pub lamports_per_write: u32, + pub compression_authority: [u8; 32], + pub rent_sponsor: [u8; 32], + pub last_claimed_slot: u64, + pub rent_exemption_paid: u32, + pub _reserved: u32, + pub rent_config: RentConfig, +} + +// --------------------------------------------------------------------------- +// Create ATA instruction data types +// (from program-libs/token-interface/instructions/create_associated_token_account.rs) +// (from program-libs/token-interface/instructions/extensions/compressible.rs) +// --------------------------------------------------------------------------- + +/// Instruction data for CreateAssociatedTokenAccount. +#[derive(Debug, Clone, BorshSerialize, BorshDeserialize)] +pub struct CreateAssociatedTokenAccountInstructionData { + pub compressible_config: Option, +} + +/// Compressible extension data for token accounts. +#[derive(Debug, Clone, BorshSerialize, BorshDeserialize)] +pub struct CompressibleExtensionInstructionData { + pub token_account_version: u8, + pub rent_payment: u8, + pub compression_only: u8, + pub write_top_up: u32, + pub compress_to_account_pubkey: Option, +} + +/// Destination pubkey specification for compress operations. +#[derive(Debug, Clone, BorshSerialize, BorshDeserialize)] +pub struct CompressToPubkey { + pub bump: u8, + pub program_id: [u8; 32], + pub seeds: Vec>, +} + +// --------------------------------------------------------------------------- +// Input types for instruction builders (not on-chain — crate-specific) +// --------------------------------------------------------------------------- + +/// A compressed token account as returned by the RPC, ready for instruction building. +/// +/// Kora implements `TryFrom` for this type. +#[derive(Debug, Clone)] +pub struct CompressedTokenAccountInput { + /// The hash of this compressed account + pub hash: [u8; 32], + /// The Merkle tree this account lives in + pub tree: Pubkey, + /// The nullifier queue for this tree + pub queue: Pubkey, + /// Token amount + pub amount: u64, + /// Leaf index in the Merkle tree + pub leaf_index: u32, + /// Whether this account can use prove-by-index optimization + pub prove_by_index: bool, + /// Root index for the validity proof + pub root_index: u16, + /// Token data version + pub version: u8, + /// Owner of this token account + pub owner: Pubkey, + /// Mint of this token account + pub mint: Pubkey, + /// Optional delegate + pub delegate: Option, +} + +/// SPL interface info for compress/decompress operations involving SPL token pools. +#[derive(Debug, Clone)] +pub struct SplInterfaceInfo { + /// SPL interface PDA (the token pool account) + pub spl_interface_pda: Pubkey, + /// Bump for the PDA + pub bump: u8, + /// Pool index (typically 0) + pub pool_index: u8, + /// The SPL token program (Token or Token-2022) + pub token_program: Pubkey, +} + +/// Validity proof with root indices from the RPC. +#[derive(Debug, Clone)] +pub struct ValidityProofWithContext { + pub compressed_proof: CompressedProof, + /// One root index per input account, in the same order + pub root_indices: Vec, +} + +// --------------------------------------------------------------------------- +// Borsh verification tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + /// BORSH VERIFICATION GATE + /// + /// Verify that borsh 1.5 produces the same bytes as the on-chain program + /// (which uses borsh 0.10 via AnchorSerialize). The binary format is the + /// same across borsh versions for these primitive types. + #[test] + fn borsh_gate_compressed_proof() { + let proof = CompressedProof { + a: [1u8; 32], + b: [2u8; 64], + c: [3u8; 32], + }; + + let bytes = borsh::to_vec(&proof).expect("serialize"); + assert_eq!( + bytes.len(), + 128, + "CompressedProof should be exactly 128 bytes" + ); + assert_eq!(&bytes[0..32], &[1u8; 32], "a field"); + assert_eq!(&bytes[32..96], &[2u8; 64], "b field"); + assert_eq!(&bytes[96..128], &[3u8; 32], "c field"); + } + + #[test] + fn borsh_gate_packed_merkle_context() { + let ctx = PackedMerkleContext { + merkle_tree_pubkey_index: 7, + queue_pubkey_index: 8, + leaf_index: 42, + prove_by_index: true, + }; + + let bytes = borsh::to_vec(&ctx).expect("serialize"); + // u8 + u8 + u32(LE) + bool = 1 + 1 + 4 + 1 = 7 bytes + assert_eq!(bytes.len(), 7); + assert_eq!(bytes[0], 7); // merkle_tree_pubkey_index + assert_eq!(bytes[1], 8); // queue_pubkey_index + assert_eq!(&bytes[2..6], &42u32.to_le_bytes()); // leaf_index + assert_eq!(bytes[6], 1); // prove_by_index = true + } + + #[test] + fn borsh_gate_compression_mode() { + // CompressionMode::Compress = variant 0 + let bytes = borsh::to_vec(&CompressionMode::Compress).unwrap(); + assert_eq!(bytes, vec![0]); + + // CompressionMode::Decompress = variant 1 + let bytes = borsh::to_vec(&CompressionMode::Decompress).unwrap(); + assert_eq!(bytes, vec![1]); + + // CompressionMode::CompressAndClose = variant 2 + let bytes = borsh::to_vec(&CompressionMode::CompressAndClose).unwrap(); + assert_eq!(bytes, vec![2]); + } + + #[test] + fn borsh_gate_compression_struct() { + let c = Compression::decompress(1000, 3, 5); + let bytes = borsh::to_vec(&c).expect("serialize"); + + // CompressionMode(1 byte) + amount(8) + mint(1) + source_or_recipient(1) + + // authority(1) + pool_account_index(1) + pool_index(1) + bump(1) + decimals(1) = 16 + assert_eq!(bytes.len(), 16); + assert_eq!(bytes[0], 1); // Decompress + assert_eq!(&bytes[1..9], &1000u64.to_le_bytes()); // amount + assert_eq!(bytes[9], 3); // mint index + assert_eq!(bytes[10], 5); // recipient index + } + + #[test] + fn borsh_gate_multi_input_token_data() { + let data = MultiInputTokenDataWithContext { + owner: 1, + amount: 500, + has_delegate: false, + delegate: 0, + mint: 2, + version: 0, + merkle_context: PackedMerkleContext { + merkle_tree_pubkey_index: 3, + queue_pubkey_index: 4, + leaf_index: 10, + prove_by_index: false, + }, + root_index: 5, + }; + + let bytes = borsh::to_vec(&data).expect("serialize"); + // u8 + u64 + bool + u8 + u8 + u8 + PMC(7) + u16 = 1+8+1+1+1+1+7+2 = 22 + assert_eq!(bytes.len(), 22); + assert_eq!(bytes[0], 1); // owner index + } + + #[test] + fn borsh_gate_output_data() { + let data = MultiTokenTransferOutputData { + owner: 1, + amount: 1000, + has_delegate: false, + delegate: 0, + mint: 2, + version: 0, + }; + + let bytes = borsh::to_vec(&data).expect("serialize"); + // u8 + u64 + bool + u8 + u8 + u8 = 1+8+1+1+1+1 = 13 + assert_eq!(bytes.len(), 13); + } +} diff --git a/kora-light-client/src/unwrap.rs b/kora-light-client/src/unwrap.rs new file mode 100644 index 0000000000..0cb45fb4a1 --- /dev/null +++ b/kora-light-client/src/unwrap.rs @@ -0,0 +1,324 @@ +//! Unwrap instruction: light-token account → SPL/T22 token account. +//! +//! Uses Transfer2 with two compressions (compress from light-token + decompress to SPL). +//! Uses `decompressed_accounts_only` layout. +//! +//! Ported from TypeScript `unwrap.ts`. + +use borsh::BorshSerialize; +use solana_instruction::{AccountMeta, Instruction}; +use solana_pubkey::Pubkey; + +use crate::{ + error::KoraLightError, + program_ids::{ + CPI_AUTHORITY_PDA, DEFAULT_MAX_TOP_UP, LIGHT_TOKEN_PROGRAM_ID, SYSTEM_PROGRAM_ID, + TRANSFER2_DISCRIMINATOR, + }, + types::{CompressedTokenInstructionDataTransfer2, Compression, SplInterfaceInfo}, +}; + +/// Unwrap light-token to SPL/T22 token account. +/// +/// Builds a Transfer2 instruction with two compression operations: +/// compress from light-token source, then decompress to SPL destination. +/// Uses the `decompressed_accounts_only` layout. Reverse of [`Wrap`](crate::Wrap). +/// +/// # Example +/// ```rust,ignore +/// use kora_light_client::Unwrap; +/// +/// let ix = Unwrap { +/// source: light_token_ata, +/// destination: spl_ata, +/// owner, +/// mint, +/// amount: 1_000, +/// decimals: 6, +/// payer, +/// spl_interface: &spl_info, +/// }.instruction()?; +/// ``` +#[derive(Debug, Clone)] +pub struct Unwrap<'a> { + /// Source light-token account (writable). + pub source: Pubkey, + /// Destination SPL token account (writable). + pub destination: Pubkey, + /// Token owner (signer). + pub owner: Pubkey, + /// Token mint. + pub mint: Pubkey, + /// Amount to unwrap. + pub amount: u64, + /// Token decimals. + pub decimals: u8, + /// Fee payer (signer). + pub payer: Pubkey, + /// SPL pool info for the decompress operation. + pub spl_interface: &'a SplInterfaceInfo, +} + +impl<'a> Unwrap<'a> { + /// Build the unwrap instruction. + pub fn instruction(&self) -> Result { + create_unwrap_instruction( + &self.source, + &self.destination, + &self.owner, + &self.mint, + self.amount, + self.decimals, + &self.payer, + self.spl_interface, + ) + } +} + +/// Build an unwrap instruction: light-token → SPL. +#[allow(clippy::too_many_arguments)] +pub fn create_unwrap_instruction( + source: &Pubkey, + destination: &Pubkey, + owner: &Pubkey, + mint: &Pubkey, + amount: u64, + decimals: u8, + payer: &Pubkey, + spl_interface: &SplInterfaceInfo, +) -> Result { + // Packed accounts for decompressed_accounts_only mode + let mint_index: u8 = 0; + let owner_index: u8 = 1; + let source_index: u8 = 2; + let destination_index: u8 = 3; + let pool_index: u8 = 4; + let _token_program_index: u8 = 5; + + // Two compressions (reverse of wrap): + // 1. Compress from light-token (source → pool equivalent) + let compress = Compression::compress(amount, mint_index, source_index, owner_index); + + // 2. Decompress to SPL (pool → destination) + let decompress = Compression::decompress_spl( + amount, + mint_index, + destination_index, + pool_index, + spl_interface.pool_index, + spl_interface.bump, + decimals, + ); + + let transfer2_data = CompressedTokenInstructionDataTransfer2 { + with_transaction_hash: false, + with_lamports_change_account_merkle_tree_index: false, + lamports_change_account_merkle_tree_index: 0, + lamports_change_account_owner_index: 0, + output_queue: 0, + max_top_up: DEFAULT_MAX_TOP_UP, + cpi_context: None, + compressions: Some(vec![compress, decompress]), + proof: None, + in_token_data: Vec::new(), + out_token_data: Vec::new(), + in_lamports: None, + out_lamports: None, + in_tlv: None, + out_tlv: None, + }; + + let mut data = Vec::new(); + data.push(TRANSFER2_DISCRIMINATOR); + transfer2_data.serialize(&mut data)?; + + // decompressed_accounts_only layout + let mut accounts = vec![ + AccountMeta::new_readonly(CPI_AUTHORITY_PDA, false), + AccountMeta::new(*payer, true), + ]; + + accounts.extend([ + AccountMeta::new_readonly(*mint, false), // 0: mint + AccountMeta::new_readonly(*owner, true), // 1: owner (signer) + AccountMeta::new(*source, false), // 2: source light-token + AccountMeta::new(*destination, false), // 3: destination SPL + AccountMeta::new(spl_interface.spl_interface_pda, false), // 4: pool + AccountMeta::new_readonly(spl_interface.token_program, false), // 5: token program + AccountMeta::new_readonly(LIGHT_TOKEN_PROGRAM_ID, false), + AccountMeta::new_readonly(SYSTEM_PROGRAM_ID, false), + ]); + + Ok(Instruction { + program_id: LIGHT_TOKEN_PROGRAM_ID, + accounts, + data, + }) +} + +#[cfg(test)] +mod tests { + use borsh::BorshDeserialize; + + use super::*; + use crate::types::{CompressedTokenInstructionDataTransfer2, CompressionMode}; + + fn make_unwrap_ix() -> Instruction { + let spl = SplInterfaceInfo { + spl_interface_pda: Pubkey::new_unique(), + bump: 255, + pool_index: 0, + token_program: Pubkey::new_unique(), + }; + create_unwrap_instruction( + &Pubkey::new_unique(), + &Pubkey::new_unique(), + &Pubkey::new_unique(), + &Pubkey::new_unique(), + 1000, + 6, + &Pubkey::new_unique(), + &spl, + ) + .unwrap() + } + + #[test] + fn test_unwrap_instruction_builds() { + let ix = make_unwrap_ix(); + assert_eq!(ix.program_id, LIGHT_TOKEN_PROGRAM_ID); + assert_eq!(ix.data[0], TRANSFER2_DISCRIMINATOR); + assert_eq!(ix.accounts.len(), 10); + assert_eq!(ix.accounts[0].pubkey, CPI_AUTHORITY_PDA); + } + + #[test] + fn test_unwrap_compression_payload() { + let ix = make_unwrap_ix(); + let data = CompressedTokenInstructionDataTransfer2::try_from_slice(&ix.data[1..]).unwrap(); + + let compressions = data.compressions.expect("unwrap must have compressions"); + assert_eq!(compressions.len(), 2, "unwrap needs compress + decompress"); + + // First: Compress from light-token (reverse of wrap) + assert_eq!(compressions[0].mode, CompressionMode::Compress); + assert_eq!(compressions[0].amount, 1000); + assert_eq!(compressions[0].source_or_recipient, 2); // source index + assert_eq!(compressions[0].authority, 1); // owner index + + // Second: Decompress to SPL + assert_eq!(compressions[1].mode, CompressionMode::Decompress); + assert_eq!(compressions[1].amount, 1000); + assert_eq!(compressions[1].source_or_recipient, 3); // destination index + + // No compressed inputs/outputs + assert!(data.in_token_data.is_empty()); + assert!(data.out_token_data.is_empty()); + assert!(data.proof.is_none()); + } + + #[test] + fn test_unwrap_is_reverse_of_wrap() { + // Verify unwrap produces different compressions than wrap + let source = Pubkey::new_unique(); + let destination = Pubkey::new_unique(); + let owner = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + let payer = Pubkey::new_unique(); + + let spl = SplInterfaceInfo { + spl_interface_pda: Pubkey::new_unique(), + bump: 255, + pool_index: 0, + token_program: Pubkey::new_unique(), + }; + + let wrap_ix = crate::wrap::create_wrap_instruction( + &source, + &destination, + &owner, + &mint, + 1000, + 6, + &payer, + &spl, + ) + .unwrap(); + + let unwrap_ix = + create_unwrap_instruction(&source, &destination, &owner, &mint, 1000, 6, &payer, &spl) + .unwrap(); + + // Both should have same program and account count but different data + assert_eq!(wrap_ix.program_id, unwrap_ix.program_id); + assert_eq!(wrap_ix.accounts.len(), unwrap_ix.accounts.len()); + // Data should differ (different compression modes) + assert_ne!(wrap_ix.data, unwrap_ix.data); + } + + #[test] + fn test_wrap_unwrap_roundtrip_compression_modes() { + let source = Pubkey::new_unique(); + let destination = Pubkey::new_unique(); + let owner = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + let payer = Pubkey::new_unique(); + let spl = SplInterfaceInfo { + spl_interface_pda: Pubkey::new_unique(), + bump: 255, + pool_index: 0, + token_program: Pubkey::new_unique(), + }; + let amount = 5000u64; + + let wrap_ix = crate::wrap::create_wrap_instruction( + &source, + &destination, + &owner, + &mint, + amount, + 6, + &payer, + &spl, + ) + .unwrap(); + let unwrap_ix = create_unwrap_instruction( + &source, + &destination, + &owner, + &mint, + amount, + 6, + &payer, + &spl, + ) + .unwrap(); + + let wrap_data = + CompressedTokenInstructionDataTransfer2::try_from_slice(&wrap_ix.data[1..]).unwrap(); + let unwrap_data = + CompressedTokenInstructionDataTransfer2::try_from_slice(&unwrap_ix.data[1..]).unwrap(); + + let wrap_c = wrap_data.compressions.unwrap(); + let unwrap_c = unwrap_data.compressions.unwrap(); + + // Wrap: Compress(SPL) → Decompress(light-token) + assert_eq!(wrap_c[0].mode, CompressionMode::Compress); + assert_eq!(wrap_c[1].mode, CompressionMode::Decompress); + + // Unwrap: Compress(light-token) → Decompress(SPL) + assert_eq!(unwrap_c[0].mode, CompressionMode::Compress); + assert_eq!(unwrap_c[1].mode, CompressionMode::Decompress); + + // Same amounts + assert_eq!(wrap_c[0].amount, amount); + assert_eq!(wrap_c[1].amount, amount); + assert_eq!(unwrap_c[0].amount, amount); + assert_eq!(unwrap_c[1].amount, amount); + + // Wrap has SPL pool info on decompress (pool_index, bump, decimals = 0 for light-token) + // Unwrap has SPL pool info on decompress (pool_index, bump, decimals set for SPL) + assert_eq!(wrap_c[1].pool_account_index, 0); // light-token decompress: no pool + assert_ne!(unwrap_c[1].pool_account_index, 0); // SPL decompress: has pool + } +} diff --git a/kora-light-client/src/wrap.rs b/kora-light-client/src/wrap.rs new file mode 100644 index 0000000000..9d9ec2acdc --- /dev/null +++ b/kora-light-client/src/wrap.rs @@ -0,0 +1,249 @@ +//! Wrap instruction: SPL/T22 token account → light-token account. +//! +//! Uses Transfer2 with two compressions (compress from SPL + decompress to light-token). +//! Uses `decompressed_accounts_only` layout (CPI authority first, no light-system-program). +//! +//! Ported from TypeScript `wrap.ts`. + +use borsh::BorshSerialize; +use solana_instruction::{AccountMeta, Instruction}; +use solana_pubkey::Pubkey; + +use crate::{ + error::KoraLightError, + program_ids::{ + CPI_AUTHORITY_PDA, DEFAULT_MAX_TOP_UP, LIGHT_TOKEN_PROGRAM_ID, SYSTEM_PROGRAM_ID, + TRANSFER2_DISCRIMINATOR, + }, + types::{CompressedTokenInstructionDataTransfer2, Compression, SplInterfaceInfo}, +}; + +/// Wrap SPL/T22 tokens into a light-token account. +/// +/// Builds a Transfer2 instruction with two compression operations: +/// compress from SPL source, then decompress to light-token destination. +/// Uses the `decompressed_accounts_only` layout. +/// +/// # Example +/// ```rust,ignore +/// use kora_light_client::Wrap; +/// +/// let ix = Wrap { +/// source: spl_ata, +/// destination: light_token_ata, +/// owner, +/// mint, +/// amount: 1_000, +/// decimals: 6, +/// payer, +/// spl_interface: &spl_info, +/// }.instruction()?; +/// ``` +#[derive(Debug, Clone)] +pub struct Wrap<'a> { + /// Source SPL token account (writable). + pub source: Pubkey, + /// Destination light-token account (writable). + pub destination: Pubkey, + /// Token owner (signer). + pub owner: Pubkey, + /// Token mint. + pub mint: Pubkey, + /// Amount to wrap. + pub amount: u64, + /// Token decimals. + pub decimals: u8, + /// Fee payer (signer). + pub payer: Pubkey, + /// SPL pool info for the compress operation. + pub spl_interface: &'a SplInterfaceInfo, +} + +impl<'a> Wrap<'a> { + /// Build the wrap instruction. + pub fn instruction(&self) -> Result { + create_wrap_instruction( + &self.source, + &self.destination, + &self.owner, + &self.mint, + self.amount, + self.decimals, + &self.payer, + self.spl_interface, + ) + } +} + +/// Build a wrap instruction: SPL → light-token. +#[allow(clippy::too_many_arguments)] +pub fn create_wrap_instruction( + source: &Pubkey, + destination: &Pubkey, + owner: &Pubkey, + mint: &Pubkey, + amount: u64, + decimals: u8, + payer: &Pubkey, + spl_interface: &SplInterfaceInfo, +) -> Result { + // Packed accounts for decompressed_accounts_only mode + // Order: mint, owner, source, destination, pool, token_program + let mint_index: u8 = 0; + let owner_index: u8 = 1; + let source_index: u8 = 2; + let destination_index: u8 = 3; + let pool_index: u8 = 4; + let _token_program_index: u8 = 5; + + // Two compressions: + // 1. Compress from SPL (source → pool) + let compress = Compression::compress_spl( + amount, + mint_index, + source_index, + owner_index, + pool_index, + spl_interface.pool_index, + spl_interface.bump, + decimals, + ); + + // 2. Decompress to light-token (pool → destination) + let decompress = Compression::decompress(amount, mint_index, destination_index); + + let transfer2_data = CompressedTokenInstructionDataTransfer2 { + with_transaction_hash: false, + with_lamports_change_account_merkle_tree_index: false, + lamports_change_account_merkle_tree_index: 0, + lamports_change_account_owner_index: 0, + output_queue: 0, + max_top_up: DEFAULT_MAX_TOP_UP, + cpi_context: None, + compressions: Some(vec![compress, decompress]), + proof: None, + in_token_data: Vec::new(), + out_token_data: Vec::new(), + in_lamports: None, + out_lamports: None, + in_tlv: None, + out_tlv: None, + }; + + let mut data = Vec::new(); + data.push(TRANSFER2_DISCRIMINATOR); + transfer2_data.serialize(&mut data)?; + + // decompressed_accounts_only layout: CPI authority first, then fee payer + let mut accounts = vec![ + AccountMeta::new_readonly(CPI_AUTHORITY_PDA, false), + AccountMeta::new(*payer, true), + ]; + + // Packed accounts + accounts.extend([ + AccountMeta::new_readonly(*mint, false), // 0: mint + AccountMeta::new_readonly(*owner, true), // 1: owner (signer) + AccountMeta::new(*source, false), // 2: source SPL account + AccountMeta::new(*destination, false), // 3: destination light-token + AccountMeta::new(spl_interface.spl_interface_pda, false), // 4: pool + AccountMeta::new_readonly(spl_interface.token_program, false), // 5: token program + AccountMeta::new_readonly(LIGHT_TOKEN_PROGRAM_ID, false), // light token program + AccountMeta::new_readonly(SYSTEM_PROGRAM_ID, false), // system program + ]); + + Ok(Instruction { + program_id: LIGHT_TOKEN_PROGRAM_ID, + accounts, + data, + }) +} + +#[cfg(test)] +mod tests { + use borsh::BorshDeserialize; + + use super::*; + use crate::types::CompressionMode; + + fn make_wrap_ix() -> Instruction { + let spl = SplInterfaceInfo { + spl_interface_pda: Pubkey::new_unique(), + bump: 255, + pool_index: 0, + token_program: Pubkey::new_unique(), + }; + create_wrap_instruction( + &Pubkey::new_unique(), + &Pubkey::new_unique(), + &Pubkey::new_unique(), + &Pubkey::new_unique(), + 1000, + 6, + &Pubkey::new_unique(), + &spl, + ) + .unwrap() + } + + #[test] + fn test_wrap_instruction_builds() { + let ix = make_wrap_ix(); + assert_eq!(ix.program_id, LIGHT_TOKEN_PROGRAM_ID); + assert_eq!(ix.data[0], TRANSFER2_DISCRIMINATOR); + assert_eq!(ix.accounts.len(), 10); + assert_eq!(ix.accounts[0].pubkey, CPI_AUTHORITY_PDA); + } + + #[test] + fn test_wrap_compression_payload() { + let ix = make_wrap_ix(); + let data = CompressedTokenInstructionDataTransfer2::try_from_slice(&ix.data[1..]).unwrap(); + + let compressions = data.compressions.expect("wrap must have compressions"); + assert_eq!(compressions.len(), 2, "wrap needs compress + decompress"); + + // First: Compress from SPL + assert_eq!(compressions[0].mode, CompressionMode::Compress); + assert_eq!(compressions[0].amount, 1000); + assert_eq!(compressions[0].source_or_recipient, 2); // source index + assert_eq!(compressions[0].authority, 1); // owner index + + // Second: Decompress to light-token + assert_eq!(compressions[1].mode, CompressionMode::Decompress); + assert_eq!(compressions[1].amount, 1000); + assert_eq!(compressions[1].source_or_recipient, 3); // destination index + + // No compressed inputs/outputs + assert!(data.in_token_data.is_empty()); + assert!(data.out_token_data.is_empty()); + assert!(data.proof.is_none()); + } + + #[test] + fn test_wrap_large_amount() { + let amount = u64::MAX - 1; + let spl = SplInterfaceInfo { + spl_interface_pda: Pubkey::new_unique(), + bump: 255, + pool_index: 0, + token_program: Pubkey::new_unique(), + }; + let ix = create_wrap_instruction( + &Pubkey::new_unique(), + &Pubkey::new_unique(), + &Pubkey::new_unique(), + &Pubkey::new_unique(), + amount, + 6, + &Pubkey::new_unique(), + &spl, + ) + .unwrap(); + + let data = CompressedTokenInstructionDataTransfer2::try_from_slice(&ix.data[1..]).unwrap(); + let compressions = data.compressions.unwrap(); + assert_eq!(compressions[0].amount, amount); + assert_eq!(compressions[1].amount, amount); + } +} diff --git a/kora-light-client/tests/golden_bytes.rs b/kora-light-client/tests/golden_bytes.rs new file mode 100644 index 0000000000..7db785b619 --- /dev/null +++ b/kora-light-client/tests/golden_bytes.rs @@ -0,0 +1,431 @@ +//! Golden byte cross-verification tests. +//! +//! These tests verify that kora-light-client produces byte-identical instruction +//! data to what the on-chain program expects, by comparing against the known +//! Borsh serialization format. +//! +//! IMPORTANT FINDING: +//! Kora's existing raw-byte builder (`instruction_builder.rs`) uses a DIFFERENT +//! output data format than the current on-chain `MultiTokenTransferOutputData`: +//! +//! Kora's format (12 bytes per output): +//! owner: u8, amount: u64, lamports: Option=None, merkle_tree_index: u8, tlv: Option=None +//! +//! On-chain format (13 bytes per output): +//! owner: u8, amount: u64, has_delegate: bool, delegate: u8, mint: u8, version: u8 +//! +//! This crate uses the on-chain format (13 bytes) which matches the source at +//! `program-libs/token-interface/src/instructions/transfer2/instruction_data.rs`. +//! When Kora integrates this crate, its output format will change to match the +//! current on-chain program. If the deployed program uses an older format, +//! this needs investigation. + +use borsh::BorshSerialize; +use kora_light_client::types::*; + +/// Verify Transfer2 header serialization matches Kora's byte layout. +/// +/// Kora builds the header as: +/// [discriminator(1), with_tx_hash(1), with_lamports_change(1), +/// lamports_change_tree(1), lamports_change_owner(1), output_queue(1), +/// max_top_up(2), cpi_context_option(1), compressions_option(1), +/// proof_option(1), proof_bytes(128)] +#[test] +fn test_transfer2_header_matches_kora_format() { + let proof = CompressedProof { + a: [0xAA; 32], + b: [0xBB; 64], + c: [0xCC; 32], + }; + + let transfer2 = CompressedTokenInstructionDataTransfer2 { + with_transaction_hash: false, + with_lamports_change_account_merkle_tree_index: false, + lamports_change_account_merkle_tree_index: 0, + lamports_change_account_owner_index: 0, + output_queue: 3, + max_top_up: 5000, + cpi_context: None, + compressions: None, + proof: Some(proof), + in_token_data: Vec::new(), + out_token_data: Vec::new(), + in_lamports: None, + out_lamports: None, + in_tlv: None, + out_tlv: None, + }; + + let data = borsh::to_vec(&transfer2).unwrap(); + + // Verify header bytes match Kora's manual serialization + assert_eq!(data[0], 0, "with_transaction_hash = false"); + assert_eq!(data[1], 0, "with_lamports_change = false"); + assert_eq!(data[2], 0, "lamports_change_tree_index = 0"); + assert_eq!(data[3], 0, "lamports_change_owner_index = 0"); + assert_eq!(data[4], 3, "output_queue = 3"); + assert_eq!(&data[5..7], &5000u16.to_le_bytes(), "max_top_up = 5000"); + assert_eq!(data[7], 0, "cpi_context = None"); + assert_eq!(data[8], 0, "compressions = None"); + assert_eq!(data[9], 1, "proof = Some"); + + // Proof bytes (128 bytes starting at offset 10) + assert_eq!(&data[10..42], &[0xAA; 32], "proof.a"); + assert_eq!(&data[42..106], &[0xBB; 64], "proof.b"); + assert_eq!(&data[106..138], &[0xCC; 32], "proof.c"); + + // Empty vecs for in_token_data and out_token_data + assert_eq!( + &data[138..142], + &0u32.to_le_bytes(), + "in_token_data len = 0" + ); + assert_eq!( + &data[142..146], + &0u32.to_le_bytes(), + "out_token_data len = 0" + ); + + // Trailing None options + assert_eq!(data[146], 0, "in_lamports = None"); + assert_eq!(data[147], 0, "out_lamports = None"); + assert_eq!(data[148], 0, "in_tlv = None"); + assert_eq!(data[149], 0, "out_tlv = None"); + + assert_eq!(data.len(), 150, "Total header size with empty vecs"); +} + +/// Verify MultiInputTokenDataWithContext serialization matches Kora's +/// `serialize_input_token_data` byte layout (22 bytes per input). +#[test] +fn test_input_token_data_matches_kora_format() { + let input = MultiInputTokenDataWithContext { + owner: 4, // owner_index + amount: 1_000_000, + has_delegate: false, + delegate: 0, + mint: 3, // mint_index + version: 0, + merkle_context: PackedMerkleContext { + merkle_tree_pubkey_index: 0, + queue_pubkey_index: 1, + leaf_index: 42, + prove_by_index: false, + }, + root_index: 7, + }; + + let bytes = borsh::to_vec(&input).unwrap(); + assert_eq!(bytes.len(), 22, "Input token data must be 22 bytes"); + + // Verify byte-by-byte against Kora's serialize_input_token_data + assert_eq!(bytes[0], 4, "owner index"); + assert_eq!(&bytes[1..9], &1_000_000u64.to_le_bytes(), "amount"); + assert_eq!(bytes[9], 0, "has_delegate = false"); + assert_eq!(bytes[10], 0, "delegate index"); + assert_eq!(bytes[11], 3, "mint index"); + assert_eq!(bytes[12], 0, "version"); + // Merkle context + assert_eq!(bytes[13], 0, "merkle_tree_pubkey_index"); + assert_eq!(bytes[14], 1, "queue_pubkey_index"); + assert_eq!(&bytes[15..19], &42u32.to_le_bytes(), "leaf_index"); + assert_eq!(bytes[19], 0, "prove_by_index = false"); + // Root index + assert_eq!(&bytes[20..22], &7u16.to_le_bytes(), "root_index"); +} + +/// Verify MultiTokenTransferOutputData serialization. +/// +/// NOTE: This is 13 bytes per output (on-chain format). +/// Kora's raw builder uses 12 bytes (different format — see module doc). +#[test] +fn test_output_token_data_on_chain_format() { + let output = MultiTokenTransferOutputData { + owner: 5, // destination_index + amount: 500_000, + has_delegate: false, + delegate: 0, + mint: 3, // mint_index + version: 0, + }; + + let bytes = borsh::to_vec(&output).unwrap(); + assert_eq!( + bytes.len(), + 13, + "Output token data must be 13 bytes (on-chain format)" + ); + + assert_eq!(bytes[0], 5, "owner index"); + assert_eq!(&bytes[1..9], &500_000u64.to_le_bytes(), "amount"); + assert_eq!(bytes[9], 0, "has_delegate = false"); + assert_eq!(bytes[10], 0, "delegate index"); + assert_eq!(bytes[11], 3, "mint index"); + assert_eq!(bytes[12], 0, "version"); +} + +/// Verify the full discriminator + struct serialization for Transfer2. +/// +/// This is how the instruction data is actually built: +/// [TRANSFER2_DISCRIMINATOR(1 byte)] + [borsh-serialized struct] +#[test] +fn test_full_instruction_data_format() { + let input = MultiInputTokenDataWithContext { + owner: 4, + amount: 1_000_000, + has_delegate: false, + delegate: 0, + mint: 3, + version: 0, + merkle_context: PackedMerkleContext { + merkle_tree_pubkey_index: 0, + queue_pubkey_index: 1, + leaf_index: 42, + prove_by_index: false, + }, + root_index: 7, + }; + + let output = MultiTokenTransferOutputData { + owner: 5, + amount: 1_000_000, + has_delegate: false, + delegate: 0, + mint: 3, + version: 0, + }; + + let proof = CompressedProof { + a: [1u8; 32], + b: [2u8; 64], + c: [3u8; 32], + }; + + let transfer2 = CompressedTokenInstructionDataTransfer2 { + with_transaction_hash: false, + with_lamports_change_account_merkle_tree_index: false, + lamports_change_account_merkle_tree_index: 0, + lamports_change_account_owner_index: 0, + output_queue: 0, + max_top_up: u16::MAX, + cpi_context: None, + compressions: None, + proof: Some(proof), + in_token_data: vec![input], + out_token_data: vec![output], + in_lamports: None, + out_lamports: None, + in_tlv: None, + out_tlv: None, + }; + + // Build as instruction does: discriminator + borsh + let mut data = vec![101u8]; // TRANSFER2_DISCRIMINATOR + transfer2.serialize(&mut data).unwrap(); + + assert_eq!(data[0], 101, "Discriminator"); + + // Header: 10 bytes (5 bools/u8s + u16 + 2 option tags) + // Proof: 1 (Some) + 128 bytes + // in_token_data: 4 (len) + 22 (one input) + // out_token_data: 4 (len) + 13 (one output) + // Trailing: 4 None option tags + // Total: 1 + 10 + 129 + 26 + 17 + 4 = 187 + let expected_len = 1 + 7 + 2 + 1 + 128 + 4 + 22 + 4 + 13 + 4; + assert_eq!(data.len(), expected_len, "Total instruction data length"); +} + +/// Verify Compression struct serialization (16 bytes). +#[test] +fn test_compression_serialization() { + let decompress = Compression::decompress_spl( + 500_000, // amount + 2, // mint index + 4, // recipient index + 5, // pool_account_index + 0, // pool_index + 254, // bump + 6, // decimals + ); + + let bytes = borsh::to_vec(&decompress).unwrap(); + assert_eq!(bytes.len(), 16, "Compression struct is 16 bytes"); + + assert_eq!(bytes[0], 1, "mode = Decompress (variant 1)"); + assert_eq!(&bytes[1..9], &500_000u64.to_le_bytes(), "amount"); + assert_eq!(bytes[9], 2, "mint index"); + assert_eq!(bytes[10], 4, "recipient index"); + assert_eq!(bytes[11], 0, "authority (not used for decompress)"); + assert_eq!(bytes[12], 5, "pool_account_index"); + assert_eq!(bytes[13], 0, "pool_index"); + assert_eq!(bytes[14], 254, "bump"); + assert_eq!(bytes[15], 6, "decimals"); +} + +/// Verify CompressedOnlyExtensionInstructionData serialization. +#[test] +fn test_compressed_only_extension_serialization() { + let ext = CompressedOnlyExtensionInstructionData { + delegated_amount: 0, + withheld_transfer_fee: 0, + is_frozen: false, + compression_index: 0, + is_ata: true, + bump: 255, + owner_index: 3, + }; + + let bytes = borsh::to_vec(&ext).unwrap(); + // u64 + u64 + bool + u8 + bool + u8 + u8 = 8+8+1+1+1+1+1 = 21 + assert_eq!(bytes.len(), 21); +} + +/// Verify ExtensionInstructionData enum discriminators. +#[test] +fn test_extension_enum_discriminators() { + // Placeholder0 = variant 0 + let bytes = borsh::to_vec(&ExtensionInstructionData::Placeholder0).unwrap(); + assert_eq!(bytes[0], 0, "Placeholder0 discriminator"); + + // CompressedOnly = variant 31 + let ext = ExtensionInstructionData::CompressedOnly(CompressedOnlyExtensionInstructionData { + delegated_amount: 0, + withheld_transfer_fee: 0, + is_frozen: false, + compression_index: 0, + is_ata: false, + bump: 0, + owner_index: 0, + }); + let bytes = borsh::to_vec(&ext).unwrap(); + assert_eq!(bytes[0], 31, "CompressedOnly discriminator = 31"); + + // Compressible = variant 32 + let ext = ExtensionInstructionData::Compressible(CompressionInfo { + config_account_version: 1, + compress_to_pubkey: 0, + account_version: 0, + lamports_per_write: 766, + compression_authority: [0u8; 32], + rent_sponsor: [0u8; 32], + last_claimed_slot: 0, + rent_exemption_paid: 0, + _reserved: 0, + rent_config: RentConfig { + base_rent: 0, + compression_cost: 0, + lamports_per_byte_per_epoch: 0, + max_funded_epochs: 0, + max_top_up: 0, + }, + }); + let bytes = borsh::to_vec(&ext).unwrap(); + assert_eq!(bytes[0], 32, "Compressible discriminator = 32"); +} + +/// Verify CompressionInfo serializes to 96 bytes (matching on-chain layout). +#[test] +fn test_compression_info_size() { + let info = CompressionInfo { + config_account_version: 1, + compress_to_pubkey: 0, + account_version: 0, + lamports_per_write: 766, + compression_authority: [0xAA; 32], + rent_sponsor: [0xBB; 32], + last_claimed_slot: 42, + rent_exemption_paid: 1_000_000, + _reserved: 0, + rent_config: RentConfig { + base_rent: 100, + compression_cost: 50, + lamports_per_byte_per_epoch: 1, + max_funded_epochs: 16, + max_top_up: 5000, + }, + }; + let bytes = borsh::to_vec(&info).unwrap(); + assert_eq!( + bytes.len(), + 96, + "CompressionInfo must be 96 bytes (matching on-chain)" + ); + + // Verify field layout: last 8 bytes = RentConfig + // rent_exemption_paid (u32 LE) at offset 80 + assert_eq!(&bytes[80..84], &1_000_000u32.to_le_bytes()); + // _reserved (u32 LE) at offset 84 + assert_eq!(&bytes[84..88], &0u32.to_le_bytes()); + // RentConfig starts at offset 88 + assert_eq!(&bytes[88..90], &100u16.to_le_bytes()); // base_rent + assert_eq!(&bytes[90..92], &50u16.to_le_bytes()); // compression_cost + assert_eq!(bytes[92], 1); // lamports_per_byte_per_epoch + assert_eq!(bytes[93], 16); // max_funded_epochs + assert_eq!(&bytes[94..96], &5000u16.to_le_bytes()); // max_top_up +} + +/// Verify the discriminator + Transfer2 data round-trip (serialize → deserialize). +#[test] +fn test_transfer2_roundtrip() { + let proof = CompressedProof { + a: [0x11; 32], + b: [0x22; 64], + c: [0x33; 32], + }; + + let original = CompressedTokenInstructionDataTransfer2 { + with_transaction_hash: false, + with_lamports_change_account_merkle_tree_index: false, + lamports_change_account_merkle_tree_index: 0, + lamports_change_account_owner_index: 0, + output_queue: 2, + max_top_up: 5000, + cpi_context: None, + compressions: Some(vec![Compression::decompress(1000, 1, 3)]), + proof: Some(proof), + in_token_data: vec![MultiInputTokenDataWithContext { + owner: 4, + amount: 2000, + has_delegate: false, + delegate: 0, + mint: 1, + version: 0, + merkle_context: PackedMerkleContext { + merkle_tree_pubkey_index: 0, + queue_pubkey_index: 2, + leaf_index: 100, + prove_by_index: true, + }, + root_index: 5, + }], + out_token_data: vec![MultiTokenTransferOutputData { + owner: 4, + amount: 1000, + has_delegate: false, + delegate: 0, + mint: 1, + version: 0, + }], + in_lamports: None, + out_lamports: None, + in_tlv: None, + out_tlv: None, + }; + + let bytes = borsh::to_vec(&original).unwrap(); + let deserialized: CompressedTokenInstructionDataTransfer2 = borsh::from_slice(&bytes).unwrap(); + + // Verify round-trip + assert_eq!(deserialized.output_queue, 2); + assert_eq!(deserialized.max_top_up, 5000); + assert!(deserialized.proof.is_some()); + assert_eq!(deserialized.in_token_data.len(), 1); + assert_eq!(deserialized.in_token_data[0].amount, 2000); + assert_eq!(deserialized.out_token_data.len(), 1); + assert_eq!(deserialized.out_token_data[0].amount, 1000); + assert!(deserialized.compressions.is_some()); + let compressions = deserialized.compressions.unwrap(); + assert_eq!(compressions.len(), 1); + assert_eq!(compressions[0].mode, CompressionMode::Decompress); + assert_eq!(compressions[0].amount, 1000); +}