Skip to content

Commit af1114d

Browse files
authored
Merge pull request #45 from lazor-kit/feat/optimize-txn-size
feat: odometer replay protection, Solita SDK, tx size optimization
2 parents 92d7fa0 + 9c7a7b2 commit af1114d

27 files changed

Lines changed: 323 additions & 441 deletions

CHANGELOG.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
88

99
### Added
1010

11-
- Odometer counter replay protection for Secp256r1 (monotonic u64 per authority)
11+
- Odometer counter replay protection for Secp256r1 (monotonic u32 per authority)
1212
- program_id included in challenge hash (cross-program replay prevention)
13+
- rpId stored on authority account at creation (saves ~14 bytes per transaction)
1314
- TypeScript SDK (`sdk/solita-client`) with Solita code generation
1415
- Integration test suite (`tests-sdk/`) with 28 tests across 7 files
1516
- Benchmark script for CU and transaction size measurements
@@ -26,12 +27,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
2627
### Changed
2728

2829
- Secp256r1 replay protection: primary mechanism changed from WebAuthn hardware counter to program-controlled odometer
29-
- Auth payload layout: added 8-byte counter field at offset 8 (all subsequent fields shifted)
30+
- Auth payload layout: added 4-byte counter field at offset 8 (all subsequent fields shifted)
3031
- Challenge hash: 5 elements -> 7 elements (added counter + program_id)
31-
- AuthorityAccountHeader: added `counter` (u64) and `version` (u8) fields
32+
- AuthorityAccountHeader: added `counter` (u32) and `version` (u8) fields
3233
- Secp256r1 pubkey storage: verified as 33-byte compressed format
3334
- Authenticator trait: added `program_id` parameter
3435
- Counter write timing: moved to after full signature verification
36+
- Slot freshness: replaced SlotHashes sysvar with `Clock::get()` (removes 1 account from transaction)
37+
- Counter size: u64 -> u32 (4 billion operations per authority is sufficient)
38+
- Execute Secp256r1 transaction size: 708 -> 658 bytes (50 bytes saved)
39+
- Execute Secp256r1 accounts: 8 -> 7 (SlotHashes sysvar removed)
3540

3641
### Fixed
3742

@@ -46,5 +51,5 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
4651
- Role-Based Access Control (Owner, Admin, Spender)
4752
- Ephemeral session keys with slot-based expiry
4853
- CompactInstructions for Execute
49-
- SlotHashes nonce for signature freshness
54+
- SlotHashes nonce for signature freshness (replaced by Clock::get() in v2)
5055
- Zero-copy serialization via pinocchio

README.md

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ A high-performance smart wallet program on Solana with passkey (WebAuthn/Secp256
1111
- **Multi-Protocol Authentication**: Ed25519 (native Solana) + Secp256r1 (WebAuthn/Passkeys/Apple Secure Enclave)
1212
- **Role-Based Access Control**: Owner / Admin / Spender with strict permission hierarchy
1313
- **Ephemeral Session Keys**: Time-bound keys with absolute slot-based expiry (max 30 days)
14-
- **Odometer Replay Protection**: Monotonic u64 counter per authority — works reliably with synced passkeys (iCloud, Google)
14+
- **Odometer Replay Protection**: Monotonic u32 counter per authority — works reliably with synced passkeys (iCloud, Google)
15+
- **Clock-Based Slot Freshness**: 150-slot window via `Clock::get()` — no SlotHashes sysvar needed
1516
- **Zero-Copy Serialization**: Raw byte casting via pinocchio, no Borsh overhead
1617
- **CompactInstructions**: Index-based instruction packing for multi-call payloads within Solana's 1,232-byte tx limit
1718
- **CPI Reentrancy Protection**: stack_height check prevents cross-program authentication attacks
@@ -22,9 +23,9 @@ A high-performance smart wallet program on Solana with passkey (WebAuthn/Secp256
2223

2324
| Metric | Normal Transfer | LazorKit (Secp256r1) | LazorKit (Session) |
2425
|---|---|---|---|
25-
| Compute Units | 150 | 9,316 | 7,483 |
26-
| Transaction Size | 215 bytes | 708 bytes | 452 bytes |
27-
| Accounts | 2 | 8 | 7 |
26+
| Compute Units | 150 | 10,941 | 4,483 |
27+
| Transaction Size | 215 bytes | 658 bytes | 452 bytes |
28+
| Accounts | 2 | 7 | 7 |
2829
| Instructions | 1 | 2 | 1 |
2930
| Transaction Fee | 0.000005 SOL | 0.000005 SOL | 0.000005 SOL |
3031

@@ -42,15 +43,15 @@ See [docs/Costs.md](docs/Costs.md) for full cost analysis, session key costs, an
4243
|---|---|---|
4344
| Wallet PDA | 8 bytes | 0.000947 |
4445
| Authority (Ed25519) | 80 bytes | 0.001448 |
45-
| Authority (Secp256r1) | 113 bytes | 0.001677 |
46+
| Authority (Secp256r1) | ~125 bytes | 0.001761 |
4647
| Session | 80 bytes | 0.001448 |
4748

4849
### Total Wallet Creation
4950

5051
| Auth Type | Total Cost | ~USD at $150/SOL |
5152
|---|---|---|
5253
| Ed25519 | 0.002399 SOL | $0.36 |
53-
| Secp256r1 (Passkey) | 0.002629 SOL | $0.39 |
54+
| Secp256r1 (Passkey) | 0.002713 SOL | $0.41 |
5455

5556
### Session Key Cost
5657

@@ -157,12 +158,13 @@ LazorKit V2 has been audited by **Accretion** (Solana Foundation funded).
157158
**Status**: 17/17 security issues resolved
158159

159160
Security features:
160-
- Odometer counter replay protection (per-authority monotonic u64)
161-
- SlotHashes liveness window (150 slots)
161+
- Odometer counter replay protection (per-authority monotonic u32)
162+
- Clock-based slot freshness window (150 slots via `Clock::get()`)
162163
- CPI reentrancy prevention (stack_height check)
163164
- Signature binding (payer, accounts hash, counter, program_id)
164165
- Self-removal and owner removal protection
165166
- Session expiry validation (future + 30-day max)
167+
- rpId stored on-chain (prevents cross-origin attacks)
166168

167169
Report vulnerabilities via [SECURITY.md](SECURITY.md).
168170

docs/Architecture.md

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ LazorKit is a high-performance smart wallet on Solana with passkey (WebAuthn) au
1616

1717
### Replay Protection
1818

19-
- **Secp256r1 (Primary: Odometer Counter)**: Program-controlled u64 counter per authority. Client submits `stored_counter + 1`. The WebAuthn hardware counter is intentionally NOT used -- synced passkeys (iCloud, Google) return unreliable values. Counter is committed only after successful signature verification.
20-
- **Secp256r1 (Secondary: SlotHashes Liveness)**: Slot from auth_payload must appear in SlotHashes sysvar (valid within 150 slots). Provides freshness without stateful nonces.
19+
- **Secp256r1 (Primary: Odometer Counter)**: Program-controlled u32 counter per authority. Client submits `stored_counter + 1`. The WebAuthn hardware counter is intentionally NOT used -- synced passkeys (iCloud, Google) return unreliable values. Counter is committed only after successful signature verification.
20+
- **Secp256r1 (Secondary: Clock-based Slot Freshness)**: Slot from auth_payload must be within 150 slots of `Clock::get()`. Provides freshness without stateful nonces or the SlotHashes sysvar.
2121
- **Secp256r1 (CPI Protection)**: stack_height check prevents authentication via CPI.
2222
- **Secp256r1 (Signature Binding)**: Challenge hash binds signature to specific instruction, payer, accounts, counter, and program_id.
2323
- **Ed25519**: Standard Solana runtime signer verification. No counter needed.
@@ -78,17 +78,18 @@ pub struct AuthorityAccountHeader {
7878
pub role: u8, // 0=Owner, 1=Admin, 2=Spender
7979
pub bump: u8,
8080
pub version: u8,
81-
pub _padding: [u8; 3],
82-
pub counter: u64, // Monotonic odometer for Secp256r1 replay protection
81+
pub _padding1: [u8; 3],
82+
pub counter: u32, // Monotonic u32 odometer for Secp256r1 replay protection
83+
pub _padding2: [u8; 4], // Alignment padding (wallet stays at offset 16)
8384
pub wallet: Pubkey, // 32 bytes
8485
}
85-
// Header: 1+1+1+1+1+3+8+32 = 48 bytes
86+
// Header: 1+1+1+1+1+3+4+4+32 = 48 bytes (same size, wallet at same offset)
8687
```
8788

8889
Variable data after header:
8990

9091
- **Ed25519**: `[pubkey: [u8; 32]]` -- total 80 bytes.
91-
- **Secp256r1**: `[credential_id_hash: [u8; 32]] [compressed_pubkey: [u8; 33]]` -- total 113 bytes.
92+
- **Secp256r1**: `[credential_id_hash: [u8; 32]] [compressed_pubkey: [u8; 33]] [rpIdLen: u8] [rpId: [u8; N]]` -- total 114+ bytes (rpId stored on-chain to avoid per-tx transmission).
9293

9394
### C. SessionAccount (80 bytes)
9495

@@ -127,7 +128,7 @@ No data allocated. Holds SOL. Program signs for it via PDA seeds during Execute.
127128
- Creates new Authority PDA.
128129
- Requires Admin or Owner authentication.
129130
- Owner can add any role; Admin can only add Spender.
130-
- Accounts: payer, wallet, admin_authority, new_authority, system_program, rent_sysvar [+ sysvar_instructions, sysvar_slothashes for Secp256r1].
131+
- Accounts: payer, wallet, admin_authority, new_authority, system_program, rent_sysvar [+ sysvar_instructions for Secp256r1].
131132

132133
### RemoveAuthority (discriminator: 2)
133134

@@ -177,16 +178,18 @@ For Secp256r1 Execute, the signed payload includes a SHA256 hash of all account
177178
## 7. Auth Payload Layout (Secp256r1)
178179

179180
```
180-
[slot: u64 LE] // 8 bytes -- SlotHashes liveness
181-
[counter: u64 LE] // 8 bytes -- odometer value (stored + 1)
181+
[slot: u64 LE] // 8 bytes -- Clock-based slot freshness
182+
[counter: u32 LE] // 4 bytes -- odometer value (stored + 1)
182183
[sysvar_ix_index: u8] // 1 byte -- index of sysvar_instructions in accounts
183-
[sysvar_slothashes_index: u8] // 1 byte
184184
[type_and_flags: u8] // 1 byte -- WebAuthn type + flags
185-
[rp_id_len: u8] // 1 byte
186-
[rp_id: u8[]] // N bytes -- e.g., "lazorkit.app"
187185
[authenticator_data: u8[]] // M bytes -- WebAuthn authenticator data (min 37 bytes)
188186
```
189187

188+
Compared to the previous layout, 3 optimizations reduce the per-transaction payload:
189+
- **Counter**: u64 -> u32 (saves 4 bytes; 4 billion ops per authority is sufficient)
190+
- **SlotHashes index**: removed (slot freshness via `Clock::get()` instead of sysvar lookup)
191+
- **rpId**: stored on the authority account at creation, not sent per-tx (saves ~12 bytes)
192+
190193
## 8. Project Structure
191194

192195
```
@@ -195,10 +198,8 @@ program/
195198
auth/
196199
ed25519.rs Native signer verification
197200
secp256r1/
198-
mod.rs Passkey authenticator with odometer
201+
mod.rs Passkey authenticator with odometer + Clock-based slot check
199202
introspection.rs Precompile instruction verification
200-
nonce.rs SlotHashes liveness validation
201-
slothashes.rs Sysvar memory parsing
202203
webauthn.rs ClientDataJSON reconstruction + AuthDataParser
203204
traits.rs Authenticator trait
204205
processor/

docs/Costs.md

Lines changed: 37 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,42 +11,56 @@ This document provides comprehensive cost data for the LazorKit smart wallet pro
1111
| Instruction | CU | Tx Size (bytes) | Ix Data (bytes) | Accounts | Instructions |
1212
|---|---|---|---|---|---|
1313
| Normal SOL Transfer (baseline) | 150 | 215 | 12 | 2 | 1 |
14-
| CreateWallet (Ed25519) | 13,687 | 408 | 73 | 6 | 1 |
15-
| CreateWallet (Secp256r1) | 12,185 | 441 | 106 | 6 | 1 |
16-
| AddAuthority (Ed25519 admin) | 7,342 | 473 | 41 | 7 | 1 |
17-
| Execute Secp256r1 (SOL transfer) | 9,316 | 708 | 271 | 8 | 2 |
18-
| Execute Session (SOL transfer) | 7,483 | 452 | 20 | 7 | 1 |
19-
| CreateSession (Ed25519) | 9,015 | 473 | 41 | 7 | 1 |
14+
| CreateWallet (Ed25519) | 15,187 | 408 | 73 | 6 | 1 |
15+
| CreateWallet (Secp256r1) | 13,687 | 453 | 118 | 6 | 1 |
16+
| AddAuthority (Ed25519 admin) | 5,846 | 473 | 41 | 7 | 1 |
17+
| Execute Secp256r1 (SOL transfer) | 10,941 | 658 | 254 | 7 | 2 |
18+
| Execute Session (SOL transfer) | 4,483 | 452 | 20 | 7 | 1 |
19+
| CreateSession (Ed25519) | 6,015 | 473 | 41 | 7 | 1 |
2020

2121
**Notes:**
2222
- CU values are from real transactions on a local validator
2323
- Secp256r1 Execute requires 2 instructions (precompile verification + execute)
24-
- Session Execute is cheaper only 1 instruction, no precompile, no auth payload
24+
- Session Execute is cheaper -- only 1 instruction, no precompile, no auth payload
2525
- All operations fit well within Solana's 200,000 CU default budget
2626
- Transaction sizes are well within Solana's 1,232-byte limit
2727

2828
---
2929

30+
## Transaction Size Optimization (v2)
31+
32+
The Secp256r1 Execute transaction was optimized from **708 bytes to 658 bytes** (50 bytes saved) via three changes:
33+
34+
| Optimization | Bytes Saved | Details |
35+
|---|---|---|
36+
| Drop SlotHashes sysvar | ~32 bytes | Use `Clock::get()` for slot freshness instead of SlotHashes sysvar lookup. Removes 1 account from the transaction. |
37+
| u32 counter (was u64) | ~4 bytes | 4 billion operations per authority is sufficient. Saves 4 bytes in auth payload + 4 bytes in challenge hash. |
38+
| rpId stored on-chain | ~14 bytes | rpId (e.g. "example.com") stored on authority account at creation time, no longer sent per-tx. |
39+
40+
**Security impact:** None. The odometer counter remains the primary replay protection. Slot freshness via `Clock::get()` provides the same age check (150-slot window) without requiring the SlotHashes sysvar account.
41+
42+
---
43+
3044
## LazorKit vs Normal SOL Transfer
3145

3246
| Metric | Normal Transfer | LazorKit Secp256r1 | LazorKit Session | Notes |
3347
|---|---|---|---|---|
34-
| Compute Units | 150 | 9,316 | 7,483 | Session uses Ed25519 signer (no precompile) |
35-
| Transaction Size | 215 bytes | 708 bytes | 452 bytes | Session tx is smaller (no precompile ix) |
36-
| Instruction Data | 12 bytes | 271 bytes | 20 bytes | Session has no auth payload |
37-
| Accounts | 2 | 8 | 7 | Session skips sysvar accounts |
48+
| Compute Units | 150 | 10,941 | 4,483 | Session uses Ed25519 signer (no precompile) |
49+
| Transaction Size | 215 bytes | 658 bytes | 452 bytes | Session tx is smaller (no precompile ix) |
50+
| Instruction Data | 12 bytes | 254 bytes | 20 bytes | Session has no auth payload |
51+
| Accounts | 2 | 7 | 7 | Secp256r1 uses sysvar_instructions only |
3852
| Instructions per Tx | 1 | 2 | 1 | Session needs only 1 instruction |
3953
| Transaction Fee | 0.000005 SOL | 0.000005 SOL | 0.000005 SOL | Same base fee |
4054

4155
**Why the overhead is acceptable:**
42-
- 9,316 CU (Secp256r1) is only **4.7%** of the 200,000 CU default budget
43-
- 7,483 CU (Session) is only **3.7%** of the 200,000 CU default budget
44-
- 708 bytes (Secp256r1) is **57%** of the 1,232-byte transaction limit
56+
- 10,941 CU (Secp256r1) is only **5.5%** of the 200,000 CU default budget
57+
- 4,483 CU (Session) is only **2.2%** of the 200,000 CU default budget
58+
- 658 bytes (Secp256r1) is **53%** of the 1,232-byte transaction limit, leaving **574 bytes** for inner instructions
4559
- 452 bytes (Session) is only **37%** of the 1,232-byte limit
4660
- Transaction fee is identical (base fee is per-signature, not per-CU)
4761
- The overhead buys: passkey auth, RBAC, replay protection, session keys, multi-sig
4862

49-
**Session keys** are ideal for frequent transactions (gaming, DeFi) they're faster, cheaper, and only need a one-time setup cost.
63+
**Session keys** are ideal for frequent transactions (gaming, DeFi) -- they're faster, cheaper, and only need a one-time setup cost.
5064

5165
---
5266

@@ -58,11 +72,13 @@ Solana requires accounts to maintain a minimum balance (rent-exempt) based on da
5872
|---|---|---|---|
5973
| Wallet PDA | 8 | 0.000946560 | 946,560 |
6074
| Authority (Ed25519) | 80 | 0.001447680 | 1,447,680 |
61-
| Authority (Secp256r1) | 113 | 0.001677360 | 1,677,360 |
75+
| Authority (Secp256r1) | ~125 | 0.001760880 | 1,760,880 |
6276
| Session | 80 | 0.001447680 | 1,447,680 |
6377
| Vault PDA | 0 | 0 | 0 |
6478

65-
**Vault PDA** is not initialized as a program-owned account. It simply receives SOL via transfer. No rent cost.
79+
**Notes:**
80+
- Secp256r1 authority size is variable: 48 (header) + 32 (cred hash) + 33 (pubkey) + 1 (rpIdLen) + N (rpId). For `rpId = "example.com"` (11 bytes), total = 125 bytes.
81+
- **Vault PDA** is not initialized as a program-owned account. It simply receives SOL via transfer. No rent cost.
6682

6783
---
6884

@@ -73,9 +89,9 @@ Creating a wallet involves allocating a Wallet PDA and the first Authority PDA.
7389
| Auth Type | Wallet Rent | Authority Rent | Tx Fee | Total |
7490
|---|---|---|---|---|
7591
| Ed25519 | 0.000947 SOL | 0.001448 SOL | 0.000005 SOL | **0.002399 SOL** |
76-
| Secp256r1 (Passkey) | 0.000947 SOL | 0.001677 SOL | 0.000005 SOL | **0.002629 SOL** |
92+
| Secp256r1 (Passkey) | 0.000947 SOL | 0.001761 SOL | 0.000005 SOL | **0.002713 SOL** |
7793

78-
At $150/SOL, wallet creation costs approximately **$0.36 - $0.39 USD**.
94+
At $150/SOL, wallet creation costs approximately **$0.36 - $0.41 USD**.
7995

8096
---
8197

@@ -120,13 +136,14 @@ At $150/SOL, session setup costs ~$0.22 USD. Each subsequent execute costs $0.00
120136
|---|---|---|---|
121137
| WalletAccount | 8 bytes | 0 | **8 bytes** |
122138
| Authority (Ed25519) | 48 bytes | 32 bytes (pubkey) | **80 bytes** |
123-
| Authority (Secp256r1) | 48 bytes | 65 bytes (cred_hash + compressed_pubkey) | **113 bytes** |
139+
| Authority (Secp256r1) | 48 bytes | 32 (cred_hash) + 33 (pubkey) + 1 (rpIdLen) + N (rpId) | **114+ bytes** |
124140
| SessionAccount | 80 bytes | 0 | **80 bytes** |
125141

126142
The compact data sizes are achieved through:
127143
- `#[repr(C, align(8))]` with `NoPadding` derive macro
128144
- 33-byte compressed Secp256r1 public keys (not 64-byte uncompressed)
129145
- No Borsh serialization overhead
146+
- rpId stored on authority account (saves per-tx payload bytes)
130147

131148
---
132149

0 commit comments

Comments
 (0)