Skip to content

Commit 850ee62

Browse files
author
tilo-14
committed
refactor: clean up kora-light-client API surface
Add struct builders (Transfer2, TransferChecked, Decompress, Wrap, Unwrap) matching light-token-client's pattern. Remove internal type leakage from crate root. Dogfood builders in load_ata.rs. Update CLAUDE.md. Includes prior work: account selection fixes, packed accounts dedup, CLAUDE.md documentation.
1 parent 9cd39ca commit 850ee62

12 files changed

Lines changed: 894 additions & 366 deletions

File tree

kora-light-client/CLAUDE.md

Lines changed: 84 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -65,12 +65,12 @@ End-to-end flow for using this crate:
6565
find_spl_interface_pda(mint) → SPL pool PDA (for wrap/unwrap/SPL decompress)
6666
6767
5. Build instruction(s)
68-
create_transfer2_instruction(...) → compressed-to-compressed
69-
create_decompress_instruction(...) → compressed → on-chain account
70-
create_wrap_instruction(...) → SPL → light-token
71-
create_unwrap_instruction(...) → light-token → SPL
72-
create_ata_idempotent_instruction(...) → create ATA
73-
create_transfer_checked_instruction(...) → ATA-to-ATA
68+
Transfer2 { ... }.instruction() → compressed-to-compressed
69+
Decompress { ... }.instruction() → compressed → on-chain account
70+
Wrap { ... }.instruction() → SPL → light-token
71+
Unwrap { ... }.instruction() → light-token → SPL
72+
CreateAta::new(...).idempotent().instruction() → create ATA
73+
TransferChecked { ... }.instruction() → ATA-to-ATA
7474
7575
6. Set compute budget
7676
Use constants from load_ata.rs or create_load_ata_batches() for automatic estimation
@@ -136,121 +136,101 @@ Packed accounts for wrap/unwrap use fixed indices (not HashMap):
136136

137137
## Public API — Instruction builders
138138

139-
### create_transfer2_instruction
139+
All builders follow the same pattern: struct with named fields + `.instruction() -> Result<Instruction, KoraLightError>`.
140+
141+
### Transfer2
140142

141143
```rust
142-
fn create_transfer2_instruction(
143-
payer: &Pubkey, // fee payer (signer)
144-
authority: &Pubkey, // token owner or delegate (signer)
145-
mint: &Pubkey, // token mint
146-
inputs: &[CompressedTokenAccountInput], // source compressed accounts
147-
proof: &CompressedProof, // validity proof from RPC
148-
destination_owner: &Pubkey, // owner of destination compressed account
149-
amount: u64, // amount to transfer
150-
) -> Result<Instruction, KoraLightError>
144+
Transfer2 {
145+
payer, // fee payer (signer)
146+
authority, // token owner or delegate (signer)
147+
mint, // token mint
148+
inputs: &accounts, // source compressed accounts
149+
proof: &proof, // validity proof from RPC
150+
destination_owner, // owner of destination compressed account
151+
amount: 1_000,
152+
}.instruction()?
151153
```
152154

153155
- **discriminator:** 101 (`TRANSFER2_DISCRIMINATOR`)
154156
- **layout:** standard (7 static + packed)
155157
- **path:** `src/transfer.rs`
156158

157-
Builds a Transfer2 instruction for compressed-to-compressed token transfers. Automatically creates a change output if `amount < input_total`. Omits the proof from instruction data when all inputs use `prove_by_index`.
158-
159-
Signers: payer, authority.
159+
Compressed-to-compressed token transfer. Automatically creates a change output if `amount < input_total`. Omits the proof when all inputs use `prove_by_index`.
160160

161-
### create_decompress_instruction
161+
### Decompress
162162

163163
```rust
164-
fn create_decompress_instruction(
165-
payer: &Pubkey,
166-
owner: &Pubkey,
167-
mint: &Pubkey,
168-
inputs: &[CompressedTokenAccountInput],
169-
proof: &CompressedProof,
170-
destination: &Pubkey, // on-chain token account (light-token ATA or SPL ATA)
171-
amount: u64,
172-
decimals: u8,
173-
spl_interface: Option<&SplInterfaceInfo>, // None for light-token, Some for SPL
174-
) -> Result<Instruction, KoraLightError>
164+
Decompress {
165+
payer, owner, mint,
166+
inputs: &accounts,
167+
proof: &proof,
168+
destination, // light-token ATA or SPL ATA
169+
amount: 1_000,
170+
decimals: 6,
171+
spl_interface: None, // None for light-token, Some(&info) for SPL
172+
}.instruction()?
175173
```
176174

177175
- **discriminator:** 101 (Transfer2 with `Compression::Decompress`)
178176
- **layout:** standard (7 static + packed)
179177
- **path:** `src/decompress.rs`
180178

181-
Routes between light-token decompress (no pool, `spl_interface=None`) and SPL decompress (with pool account and token program added to packed accounts). Creates a change output if `amount < input_total`.
179+
Routes between light-token decompress (`spl_interface=None`) and SPL decompress (with pool + token_program). Creates change output if `amount < input_total`.
182180

183-
Signers: payer, owner. Packed accounts include pool (writable) and token_program when `spl_interface` is provided.
184-
185-
### create_wrap_instruction
181+
### Wrap
186182

187183
```rust
188-
fn create_wrap_instruction(
189-
source: &Pubkey, // SPL token account (writable)
190-
destination: &Pubkey, // light-token account (writable)
191-
owner: &Pubkey, // token owner (signer)
192-
mint: &Pubkey,
193-
amount: u64,
194-
decimals: u8,
195-
payer: &Pubkey, // fee payer (signer)
196-
spl_interface: &SplInterfaceInfo,
197-
) -> Result<Instruction, KoraLightError>
184+
Wrap {
185+
source: spl_ata,
186+
destination: light_token_ata,
187+
owner, mint,
188+
amount: 1_000,
189+
decimals: 6,
190+
payer,
191+
spl_interface: &spl_info,
192+
}.instruction()?
198193
```
199194

200195
- **discriminator:** 101 (Transfer2 with two compressions)
201196
- **layout:** decompressed-only (2 static + fixed packed)
202197
- **path:** `src/wrap.rs`
203198

204-
Uses two compression operations: `Compress(SPL)` moves tokens from SPL source to pool, then `Decompress(light-token)` moves them from pool to light-token destination. No compressed inputs or outputs (empty vecs), no proof needed.
205-
206-
Signers: payer, owner. Total accounts: 10 (2 static + 6 packed + 2 appended programs).
199+
SPL → light-token via dual compression. Total accounts: 10.
207200

208-
### create_unwrap_instruction
201+
### Unwrap
209202

210203
```rust
211-
fn create_unwrap_instruction(
212-
source: &Pubkey, // light-token account (writable)
213-
destination: &Pubkey, // SPL token account (writable)
214-
owner: &Pubkey, // token owner (signer)
215-
mint: &Pubkey,
216-
amount: u64,
217-
decimals: u8,
218-
payer: &Pubkey, // fee payer (signer)
219-
spl_interface: &SplInterfaceInfo,
220-
) -> Result<Instruction, KoraLightError>
204+
Unwrap {
205+
source: light_token_ata,
206+
destination: spl_ata,
207+
owner, mint,
208+
amount: 1_000,
209+
decimals: 6,
210+
payer,
211+
spl_interface: &spl_info,
212+
}.instruction()?
221213
```
222214

223215
- **discriminator:** 101 (Transfer2 with two compressions)
224216
- **layout:** decompressed-only (2 static + fixed packed)
225217
- **path:** `src/unwrap.rs`
226218

227-
Reverse of wrap: `Compress(light-token)` then `Decompress(SPL)`. Same account layout and structure as wrap with different compression modes.
219+
Reverse of Wrap: light-tokenSPL via dual compression.
228220

229-
### CreateAta / create_ata_idempotent_instruction
221+
### CreateAta
230222

231223
```rust
232-
struct CreateAta {
233-
payer: Pubkey,
234-
owner: Pubkey,
235-
mint: Pubkey,
236-
idempotent: bool, // default: false
237-
compressible_config: Pubkey, // default: LIGHT_TOKEN_CONFIG
238-
rent_sponsor: Pubkey, // default: RENT_SPONSOR_V1
239-
pre_pay_num_epochs: u8, // default: 16
240-
write_top_up: u32, // default: 766 lamports
241-
compression_only: bool, // default: true
242-
}
243-
244-
// Builder usage
245-
CreateAta::new(payer, owner, mint).idempotent().instruction()
246-
247-
// Convenience function
248-
fn create_ata_idempotent_instruction(payer, owner, mint) -> Result<Instruction>
224+
CreateAta::new(payer, owner, mint)
225+
.idempotent()
226+
.instruction()?
249227
```
250228

251-
- **discriminator:** 100 (CreateATA) or 102 (CreateATA idempotent)
229+
- **discriminator:** 100 (CreateATA) or 102 (idempotent)
252230
- **path:** `src/create_ata.rs`
253231

232+
Builder fields: `compressible_config`, `rent_sponsor`, `pre_pay_num_epochs`, `write_top_up`, `compression_only` all have sensible defaults.
233+
254234
Accounts (7, fixed order):
255235

256236
| Index | Account | Signer | Writable |
@@ -263,28 +243,24 @@ Accounts (7, fixed order):
263243
| 5 | compressible_config | | |
264244
| 6 | rent_sponsor | | yes |
265245

266-
ATA address is derived from `get_associated_token_address(owner, mint)`.
267-
268-
### create_transfer_checked_instruction
246+
### TransferChecked
269247

270248
```rust
271-
fn create_transfer_checked_instruction(
272-
source_ata: &Pubkey,
273-
destination_ata: &Pubkey,
274-
mint: &Pubkey,
275-
owner: &Pubkey, // signer
276-
amount: u64,
277-
decimals: u8,
278-
payer: &Pubkey, // signer, only added if != owner
279-
) -> Result<Instruction, KoraLightError>
249+
TransferChecked {
250+
source_ata,
251+
destination_ata,
252+
mint,
253+
owner,
254+
amount: 1_000,
255+
decimals: 6,
256+
payer,
257+
}.instruction()?
280258
```
281259

282260
- **discriminator:** 12
283261
- **path:** `src/transfer.rs`
284262

285-
For decompressed (on-chain) light-token ATA-to-ATA transfers. Not for compressed accounts. Data format: discriminator(1) + amount(8 LE) + decimals(1) = 10 bytes.
286-
287-
Accounts: source(writable), mint, destination(writable), owner(signer), SystemProgram, [payer(signer) if payer != owner].
263+
Decompressed (on-chain) light-token ATA-to-ATA transfers. Not for compressed accounts.
288264

289265
## Public API — Utilities
290266

@@ -424,12 +400,11 @@ All types must remain byte-identical to the on-chain program. Verified by golden
424400
| Constant | Value | Purpose |
425401
|----------|-------|---------|
426402
| `TRANSFER2_DISCRIMINATOR` | `101` | Transfer2 instruction discriminator |
427-
| `TOKEN_COMPRESSED_ACCOUNT_DISCRIMINATOR` | `[2,0,0,0,0,0,0,0]` | Compressed token account discriminator |
403+
| `DEFAULT_MAX_TOP_UP` | `u16::MAX` | Default max top-up for Transfer2 (no limit) |
428404
| `WSOL_MINT` | `So11111111111111111111111111111111111111112` | Wrapped SOL mint |
429405
| `CPI_AUTHORITY_PDA_SEED` | `b"cpi_authority"` | Seed for CPI authority derivation |
430406
| `BUMP_CPI_AUTHORITY` | `254` | Known bump for CPI authority PDA |
431407
| `POOL_SEED` | `b"pool"` | Seed for SPL pool PDA derivation |
432-
| `NUM_MAX_POOL_ACCOUNTS` | `5` | Maximum pool accounts per mint |
433408
| `LIGHT_LUT_MAINNET` | `9NYFyEqPeWQHiS8Jv4VjZcjKBMPRCJ3KbEbaBcy4Mza` | Mainnet address lookup table |
434409
| `LIGHT_LUT_DEVNET` | `9NYFyEqPeWQHiS8Jv4VjZcjKBMPRCJ3KbEbaBcy4Mza` | Devnet address lookup table |
435410

@@ -452,34 +427,34 @@ All types must remain byte-identical to the on-chain program. Verified by golden
452427

453428
| File | Lines | Description |
454429
|------|-------|-------------|
455-
| `src/transfer.rs` | 416 | Transfer2 (compressed-to-compressed) and TransferChecked (ATA-to-ATA) |
456-
| `src/decompress.rs` | 523 | Decompress via Transfer2 with Compression operation |
457-
| `src/wrap.rs` | 153 | SPLlight-token via dual-compression Transfer2 (decompressed_accounts_only layout) |
458-
| `src/unwrap.rs` | 187 | Light-tokenSPL via dual-compression Transfer2 (decompressed_accounts_only layout) |
459-
| `src/create_ata.rs` | 183 | CreateAssociatedTokenAccount builder with compressible config |
430+
| `src/transfer.rs` | 448 | Transfer2 (compressed-to-compressed) and TransferChecked (ATA-to-ATA) |
431+
| `src/decompress.rs` | 554 | Decompress via Transfer2 with Compression operation |
432+
| `src/wrap.rs` | 150 | SPLlight-token via dual-compression Transfer2 (decompressed_accounts_only layout) |
433+
| `src/unwrap.rs` | 184 | Light-tokenSPL via dual-compression Transfer2 (decompressed_accounts_only layout) |
434+
| `src/create_ata.rs` | 182 | CreateAssociatedTokenAccount builder with compressible config |
460435

461436
### Utilities
462437

463438
| File | Lines | Description |
464439
|------|-------|-------------|
465-
| `src/account_select.rs` | 161 | Greedy descending account selection (max 8, `MAX_INPUT_ACCOUNTS`) |
466-
| `src/load_ata.rs` | 375 | Multi-transaction batch orchestration with compute budget estimation |
440+
| `src/account_select.rs` | 160 | Greedy descending account selection (max 8, `MAX_INPUT_ACCOUNTS`) |
441+
| `src/load_ata.rs` | 416 | Multi-transaction batch orchestration with compute budget estimation |
467442

468443
### Core
469444

470445
| File | Lines | Description |
471446
|------|-------|-------------|
472-
| `src/lib.rs` | 44 | Module declarations and re-exports |
473-
| `src/types.rs` | 560 | All Borsh-serializable types (on-chain mirrors + client-only) |
474-
| `src/program_ids.rs` | 82 | 31 constants (program IDs, PDAs, seeds, LUT addresses) |
475-
| `src/pda.rs` | 78 | 6 PDA derivation functions |
476-
| `src/error.rs` | 23 | `KoraLightError` enum (6 variants) |
447+
| `src/lib.rs` | 43 | Module declarations and re-exports |
448+
| `src/types.rs` | 559 | All Borsh-serializable types (on-chain mirrors + client-only) |
449+
| `src/program_ids.rs` | 78 | Constants (program IDs, PDAs, seeds, LUT addresses) |
450+
| `src/pda.rs` | 77 | 6 PDA derivation functions |
451+
| `src/error.rs` | 22 | `KoraLightError` enum (6 variants) |
477452

478453
### Tests
479454

480455
| File | Lines | Description |
481456
|------|-------|-------------|
482-
| `tests/golden_bytes.rs` | 382 | Borsh serialization cross-verification against on-chain format |
457+
| `tests/golden_bytes.rs` | 381 | Borsh serialization cross-verification against on-chain format |
483458
| `src/types.rs` (inline) | ~60 | Borsh verification gates (proof=128B, context=7B, compression=16B, input=22B, output=13B) |
484459
| `src/` (inline per module) | ~200 | Unit tests per module (account order, deduplication, error paths, round-trips) |
485460

kora-light-client/Cargo.lock

Lines changed: 0 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

kora-light-client/Cargo.toml

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,3 @@ solana-system-interface = "2.0"
1212
solana-compute-budget-interface = "3.0"
1313
borsh = { version = "1.5", features = ["derive"] }
1414
thiserror = "2.0"
15-
16-
[dev-dependencies]
17-
hex = "0.4"

kora-light-client/src/account_select.rs

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,23 @@ pub fn select_input_accounts(
5353
});
5454
}
5555

56-
// Select up to MAX_INPUT_ACCOUNTS, but at least count_needed
56+
// Clamp to MAX_INPUT_ACCOUNTS
5757
let select_count = count_needed.min(MAX_INPUT_ACCOUNTS).min(sorted.len());
5858

59+
// If we had to clamp, verify the top accounts still satisfy the target
60+
if count_needed > MAX_INPUT_ACCOUNTS {
61+
let top_sum: u64 = sorted[..select_count]
62+
.iter()
63+
.try_fold(0u64, |acc, a| acc.checked_add(a.amount))
64+
.ok_or(KoraLightError::ArithmeticOverflow)?;
65+
if top_sum < target_amount {
66+
return Err(KoraLightError::InsufficientBalance {
67+
needed: target_amount,
68+
available: top_sum,
69+
});
70+
}
71+
}
72+
5973
Ok(sorted[..select_count]
6074
.iter()
6175
.map(|a| (*a).clone())
@@ -136,11 +150,26 @@ mod tests {
136150

137151
#[test]
138152
fn test_select_respects_max_limit() {
139-
// Create 10 accounts of 100 each
153+
// 10 accounts of 100 each, target 900: top 8 = 800 < 900 → InsufficientBalance
154+
let accounts: Vec<_> = (0..10).map(|_| make_account(100)).collect();
155+
let result = select_input_accounts(&accounts, 900);
156+
assert!(matches!(
157+
result,
158+
Err(KoraLightError::InsufficientBalance {
159+
needed: 900,
160+
available: 800,
161+
})
162+
));
163+
}
164+
165+
#[test]
166+
fn test_select_max_limit_sufficient() {
167+
// 10 accounts of 100 each, target 800: top 8 = 800 >= 800 → success
140168
let accounts: Vec<_> = (0..10).map(|_| make_account(100)).collect();
141-
let selected = select_input_accounts(&accounts, 900).unwrap();
142-
// Should return at most MAX_INPUT_ACCOUNTS (8)
143-
assert!(selected.len() <= MAX_INPUT_ACCOUNTS);
169+
let selected = select_input_accounts(&accounts, 800).unwrap();
170+
assert_eq!(selected.len(), MAX_INPUT_ACCOUNTS);
171+
let total: u64 = selected.iter().map(|a| a.amount).sum();
172+
assert_eq!(total, 800);
144173
}
145174

146175
#[test]

0 commit comments

Comments
 (0)