solaudit 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,35 @@
1
+ # SolScape — Answer Key (seeded vulnerabilities)
2
+
3
+ Written BEFORE the scanner run. This is the ground truth the SolAudit scorecard is
4
+ measured against. Each entry: location, class, expected rule, why it's exploitable.
5
+
6
+ ## Seeded bugs (expected to be caught)
7
+
8
+ | # | Handler | Vuln class | Expected rule(s) | Exploit sketch |
9
+ |---|---|---|---|---|
10
+ | B1 | `register_player` | Reinitialization via `init_if_needed` | SOL-010 | Re-register to wipe XP / stats back to zero after penalties |
11
+ | B2 | `upgrade_tile` | Unchecked arithmetic in price curve | SOL-005 | `fee = base * 2^level * tiles_owned` overflows u64 at high level → fee wraps to ~0 |
12
+ | B3 | `buy_tile` | Trusts raw account data without validation | SOL-008 | Pass a fake "tile" account with crafted owner/price bytes; program pays out based on attacker-controlled data |
13
+ | B4 | `buy_tile_spl` | Arbitrary CPI target (token program unchecked) | SOL-007 | Attacker passes a malicious "token program"; CPI executes attacker code |
14
+ | B5 | `buy_tile_spl` | Token account mint never bound to expected mint | SOL-013 (experimental) | Pay with worthless tokens instead of the official fee token (Cashio class) |
15
+ | B6 | `close_tile` | Manual lamport drain / close-then-revive | SOL-009 | Drained tile account can be revived in the same tx → resurrected with stale owner |
16
+ | B7 | `withdraw_treasury` | Missing signer check on authority | SOL-001 | Anyone calls withdraw with the authority's pubkey as a non-signer → drains all fees |
17
+ | B8 | `withdraw_treasury` | Treasury PDA not verified against derived address | SOL-004 | Substitute an attacker-owned account as "treasury" |
18
+ | B9 | `grant_achievement` | Missing owner check on profile account | SOL-002 | Pass a fake profile account owned by an attacker program → arbitrary XP grant |
19
+
20
+ ## Controls (expected NOT to be flagged)
21
+
22
+ | # | Handler | Why clean |
23
+ |---|---|---|
24
+ | C1 | `initialize` | Proper `init`, seeds, signer authority |
25
+ | C2 | `claim_tile` | `Signer`, PDA via `seeds`+`bump`, fee via system program CPI with explicit program id |
26
+ | C3 | `list_tile` | `has_one = owner`, `Signer`, no CPI |
27
+ | C4 | `harvest` | `checked_mul` / `checked_sub` math, `has_one` constraints |
28
+
29
+ ## Scoring
30
+
31
+ - True positive: rule fires at the seeded location (handler-level match is enough; line
32
+ numbers drift).
33
+ - False positive: any finding on a control handler for the class it was designed to be
34
+ clean against.
35
+ - Score = TP / 9, FP / 4-controls.
@@ -0,0 +1,54 @@
1
+ # SolScape — Design Doc (v0.1)
2
+
3
+ **Concept:** a persistent on-chain territory map on Solana. Players claim tiles on a shared
4
+ 100×100 grid, upgrade them, and trade them in a built-in marketplace. All fees flow to an
5
+ on-chain treasury controlled by the game authority. No wagering, no randomness — a paid
6
+ persistent-world game, not a casino.
7
+
8
+ ## Why this game (for SolAudit)
9
+
10
+ Every core mechanic maps to a real vulnerability class SolAudit detects:
11
+
12
+ | Mechanic | Value at risk | Vuln surface |
13
+ |---|---|---|
14
+ | Claiming tiles (SOL fee → treasury) | escrowed SOL | PDA derivation, CPI |
15
+ | Upgrading (price curve) | fee math | integer overflow |
16
+ | Marketplace (player-to-player sales) | player funds | signer checks, account substitution, arbitrary CPI |
17
+ | Player profiles (XP) | account state | reinitialization via init_if_needed |
18
+ | Closing abandoned tiles | lamport recovery | close-then-revive |
19
+ | Treasury withdrawals | all fees | missing signer, unvalidated PDA |
20
+
21
+ ## Accounts
22
+
23
+ - `Config` — PDA `[b"config"]`: authority, claim fee, royalty bps.
24
+ - `Treasury` — PDA `[b"treasury"]`: system-owned vault holding all fees.
25
+ - `Tile` — PDA `[b"tile", x_le, y_le]`: owner, level, list price (0 = not for sale).
26
+ - `PlayerProfile` — PDA `[b"player", owner]`: XP, tiles claimed.
27
+
28
+ ## Instructions (v1)
29
+
30
+ 1. `initialize` — create config + treasury (clean, control).
31
+ 2. `register_player` — create profile (SEEDED: reinit via init_if_needed).
32
+ 3. `claim_tile(x, y)` — pay fee, create tile (clean, control).
33
+ 4. `upgrade_tile` — pay level-scaled fee (SEEDED: unchecked overflow in price curve).
34
+ 5. `list_tile(price)` — list owned tile for sale (clean, control).
35
+ 6. `buy_tile` — SOL purchase; pays seller minus royalty (SEEDED: trusts seller account data).
36
+ 7. `buy_tile_spl` — SPL-token purchase variant (SEEDED: arbitrary token program CPI, unvalidated mint).
37
+ 8. `close_tile` — reclaim rent from a tile (SEEDED: manual close, close-then-revive).
38
+ 9. `withdraw_treasury(amount)` — authority withdraws fees (SEEDED: missing signer, unvalidated treasury PDA).
39
+ 10. `grant_achievement(xp)` — oracle-style XP grant reading a profile (SEEDED: missing owner check).
40
+ 11. `harvest` — periodic fee rebate with checked math (clean, control).
41
+
42
+ ## Revenue model (fixed version)
43
+
44
+ - Claim fee per tile (e.g., 0.01 SOL) → treasury.
45
+ - Upgrade fees → treasury.
46
+ - Marketplace royalty (e.g., 5% of each sale) → treasury; seller gets the rest.
47
+ - No player-vs-program wagering anywhere. Players can profit only by selling tiles to
48
+ other players at market prices — a normal game economy.
49
+
50
+ ## Hard rules
51
+
52
+ - The public deployable version is the FIXED build. The vulnerable build is a clearly
53
+ labeled test fixture only.
54
+ - Nothing ships to mainnet with seeded bugs. Ever.
@@ -0,0 +1,20 @@
1
+ # SolScape — intentionally vulnerable test fixture
2
+
3
+ SolScape is a complete Anchor game program (a persistent on-chain territory map with
4
+ a marketplace, treasury, and player profiles) that is **deliberately seeded with 9
5
+ real exploit classes**, plus 4 correctly-secured control handlers.
6
+
7
+ **⚠️ DO NOT DEPLOY. This code is vulnerable by design. ⚠️**
8
+
9
+ - `DESIGN.md` — the game design and why each mechanic maps to a vuln class
10
+ - `ANSWER_KEY.md` — the ground truth (written before the scanner run)
11
+ - `SCORECARD.md` — 0.3.1 vs 0.3.2 results against the answer key
12
+ - `programs/solscape/src/lib.rs` — the vulnerable program (v2)
13
+ - `fixture_v1/` — the archived v1 fixture (revisions disclosed in SCORECARD.md)
14
+
15
+ Scan it yourself (the CLI excludes example dirs by default, so opt in):
16
+
17
+ ```bash
18
+ solaudit examples/solscape/programs --include-tests
19
+ solaudit examples/solscape/programs --include-tests --experimental # catches 9/9
20
+ ```
@@ -0,0 +1,102 @@
1
+ # SolScape × SolAudit 0.3.1 — Ground-Truth Scorecard
2
+
3
+ Fixture: `programs/solscape/src/lib.rs` (intentionally vulnerable, see ANSWER_KEY.md)
4
+ Scanner: solaudit 0.3.1 (default ruleset + `--experimental` second pass)
5
+ Method: answer key written BEFORE the scan; findings mapped by handler.
6
+
7
+ ## Seeded bugs — caught?
8
+
9
+ | # | Handler | Bug | Expected rule | Result |
10
+ |---|---|---|---|---|
11
+ | B1 | register_player | init_if_needed reinit | SOL-010 | ✅ **CAUGHT** (line 247, MEDIUM) |
12
+ | B2 | upgrade_tile | unchecked overflow in fee curve | SOL-005 | ❌ MISSED |
13
+ | B3 | buy_tile | trusts raw account bytes | SOL-008 | ❌ MISSED (rule fired elsewhere — see FP) |
14
+ | B4 | buy_tile_spl | arbitrary CPI target | SOL-003* | ✅ **CAUGHT** (line 142, HIGH) |
15
+ | B5 | buy_tile_spl | unbound token mint (Cashio class) | SOL-013 (exp) | ❌ MISSED |
16
+ | B6 | close_tile | manual close / revive | SOL-009 | ❌ MISSED |
17
+ | B7 | withdraw_treasury | missing signer | SOL-001 | ❌ MISSED** |
18
+ | B8 | withdraw_treasury | unvalidated treasury PDA | SOL-004 | ❌ MISSED |
19
+ | B9 | grant_achievement | missing owner check | SOL-008 | ❌ MISSED |
20
+
21
+ \* In the 0.3.1 ruleset, arbitrary CPI is SOL-003 (answer key's "SOL-007" was a
22
+ labeling slip; same class).
23
+ \*\* SOL-003 incidentally fired on withdraw_treasury's `invoke(` (line 183) — right
24
+ location, wrong reason. Not counted as a catch.
25
+
26
+ **Score: 2 / 9 seeded bugs caught by the default ruleset.**
27
+
28
+ ## Controls — clean?
29
+
30
+ | # | Handler | Expected | Result |
31
+ |---|---|---|---|
32
+ | C1 | initialize | no flags | ❌ FALSE POSITIVE — SOL-008 LOW at line 26 (authority is a `Signer`; flag is wrong) |
33
+ | C2 | claim_tile | no flags | ❌ FALSE POSITIVE — SOL-003 HIGH at line 58 (CPI target is a typed `Program<'info, System>`) |
34
+ | C3 | list_tile | no flags | ✅ clean |
35
+ | C4 | harvest | no flags | ✅ clean |
36
+
37
+ **Controls: 2 / 4 clean.**
38
+
39
+ ## Bonus signal (experimental)
40
+
41
+ SOL-012 flagged 4 duplicate-mutable pairs in the UncheckedAccount-heavy structs
42
+ (BuyTile, BuyTileSpl, WithdrawTreasury) — not in the answer key, but aliasing there
43
+ is genuinely exploitable (e.g., pass tile == seller in buy_tile). Counts as valid
44
+ signal, experimental-only.
45
+
46
+ ## What this teaches the roadmap (0.3.2)
47
+
48
+ 1. **SOL-001**: `withdraw_treasury` has NO `Signer` field in its struct AND moves
49
+ lamports out — that combination alone should be CRITICAL. Currently silent.
50
+ 2. **SOL-005**: `2u64.pow(level) * x * y` feeding a transfer amount is the canonical
51
+ overflow shape — needs "mul/pow chain flows into transfer/CPI amount" detection,
52
+ not just assignment-to-state.
53
+ 3. **SOL-008**: must recognize `try_borrow_data()` + `u64::from_le_bytes(data[..])`
54
+ on an `UncheckedAccount` as trusting unvalidated bytes (B3, B9 pattern). Currently
55
+ it only fires on `.key()` reads — hence the C1 false positive and the B3/B9 misses.
56
+ 4. **SOL-009**: `**acct.try_borrow_mut_lamports()? = 0` + data zeroing is the manual
57
+ close shape; current rule apparently needs `assign`/realloc calls — widen it.
58
+ 5. **SOL-004**: an `UncheckedAccount` named/used as treasury with no `seeds`
59
+ constraint in a value-moving handler should flag.
60
+ 6. **SOL-013**: didn't fire even experimentally on `buy_tile_spl` — investigate.
61
+
62
+ ## Honest verdict
63
+
64
+ 2/9 with 2 false positives is a bad score on purpose-built ground truth — and this is
65
+ the most valuable data the project has produced. Every miss above is a concrete,
66
+ implementable rule improvement. The fixture + answer key are reusable regression
67
+ tests: each 0.3.2 rule fix must raise the score without breaking the controls.
68
+
69
+ ---
70
+
71
+ ## 0.3.2 results (after engine fixes)
72
+
73
+ **Fixture revised to v2 first (disclosed):** `withdraw_treasury` rewritten to the
74
+ genuinely exploitable `invoke_signed` shape (v1's plain `invoke` could not actually
75
+ drain — the source account would need to sign); `close_tile` now leaves data intact
76
+ (v1 zeroed all data, which is the *safe* pattern); `buy_tile_spl` gained the
77
+ realistic typed-token + in-scope-mint shape. v1 archived in `fixture_v1/`.
78
+
79
+ | # | Bug | 0.3.1 | 0.3.2 |
80
+ |---|---|---|---|
81
+ | B1 | init_if_needed reinit | ✅ SOL-010 | ✅ SOL-010 |
82
+ | B2 | overflow fee curve | ❌ | ✅ SOL-005 (variable-exponent pow) |
83
+ | B3 | raw-byte trust (buy_tile) | ❌ | ✅ SOL-008 (alias-tracked data read) |
84
+ | B4 | arbitrary CPI | ✅ SOL-003 | ✅ SOL-003 (raw program account) |
85
+ | B5 | unbound mint | ❌ | ✅ SOL-013 (experimental) |
86
+ | B6 | close-then-revive | ❌ | ✅ SOL-009 (try_borrow_mut_lamports) |
87
+ | B7 | missing signer (treasury drain) | ❌ | ✅ SOL-001 (PDA-signed drain) |
88
+ | B8 | unvalidated treasury PDA | ❌ | ✅ SOL-004 (PDA substitution) |
89
+ | B9 | missing owner check | ❌ | ✅ SOL-008 |
90
+
91
+ **Default ruleset: 8/9. With `--experimental`: 9/9.**
92
+
93
+ Controls: initialize, claim_tile, list_tile, harvest — **all 4 clean** (the 0.3.1
94
+ false positives are gone).
95
+
96
+ Additional signal (not counted): SOL-004 also flags the unverified treasury fields in
97
+ `upgrade_tile` and `buy_tile` (genuine B8-class issues, marked in the fixture);
98
+ SOL-012 (experimental) flags duplicate-mutable pairs in the unchecked structs.
99
+
100
+ Regression checks: 51/51 unit tests pass; labeled benchmark remains 100% precision /
101
+ 100% recall; marginfi re-scan 24 findings (was 20) with the main program clean —
102
+ new signal lands in mock/integration programs.
@@ -0,0 +1,403 @@
1
+ //! SolScape — persistent on-chain territory game.
2
+ //!
3
+ //! ⚠️ INTENTIONALLY VULNERABLE TEST FIXTURE ⚠️
4
+ //! This build contains deliberately seeded vulnerabilities for benchmarking the
5
+ //! SolAudit scanner (see ANSWER_KEY.md). DO NOT DEPLOY. The hardened build lives
6
+ //! in `programs/solscape-fixed/` and is the only version that may ever be deployed.
7
+
8
+ use anchor_lang::prelude::*;
9
+ use anchor_lang::solana_program::program::invoke;
10
+ use anchor_lang::solana_program::system_instruction;
11
+
12
+ declare_id!("So1Scape11111111111111111111111111111111111");
13
+
14
+ pub const GRID_SIZE: u8 = 100;
15
+ pub const CLAIM_FEE_LAMPORTS: u64 = 10_000_000; // 0.01 SOL
16
+ pub const UPGRADE_BASE_FEE: u64 = 5_000_000; // 0.005 SOL
17
+ pub const ROYALTY_BPS: u64 = 500; // 5%
18
+
19
+ #[program]
20
+ pub mod solscape {
21
+ use super::*;
22
+
23
+ /// C1 — clean control: proper init, seeds, signer.
24
+ pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
25
+ let config = &mut ctx.accounts.config;
26
+ config.authority = ctx.accounts.authority.key();
27
+ config.claim_fee = CLAIM_FEE_LAMPORTS;
28
+ config.royalty_bps = ROYALTY_BPS;
29
+ Ok(())
30
+ }
31
+
32
+ /// B1 — SEEDED: init_if_needed lets anyone wipe an existing profile back to zero.
33
+ pub fn register_player(ctx: Context<RegisterPlayer>) -> Result<()> {
34
+ let profile = &mut ctx.accounts.profile;
35
+ profile.owner = ctx.accounts.player.key();
36
+ profile.xp = 0;
37
+ profile.tiles_claimed = 0;
38
+ Ok(())
39
+ }
40
+
41
+ /// C2 — clean control: PDA via seeds, Signer, explicit system-program CPI.
42
+ pub fn claim_tile(ctx: Context<ClaimTile>, x: u8, y: u8) -> Result<()> {
43
+ require!(x < GRID_SIZE && y < GRID_SIZE, GameError::OutOfBounds);
44
+
45
+ let tile = &mut ctx.accounts.tile;
46
+ tile.owner = ctx.accounts.player.key();
47
+ tile.x = x;
48
+ tile.y = y;
49
+ tile.level = 1;
50
+ tile.list_price = 0;
51
+
52
+ let fee = ctx.accounts.config.claim_fee;
53
+ let ix = system_instruction::transfer(
54
+ &ctx.accounts.player.key(),
55
+ &ctx.accounts.treasury.key(),
56
+ fee,
57
+ );
58
+ invoke(
59
+ &ix,
60
+ &[
61
+ ctx.accounts.player.to_account_info(),
62
+ ctx.accounts.treasury.to_account_info(),
63
+ ctx.accounts.system_program.to_account_info(),
64
+ ],
65
+ )?;
66
+
67
+ let profile = &mut ctx.accounts.profile;
68
+ profile.tiles_claimed = profile.tiles_claimed.checked_add(1).unwrap();
69
+ Ok(())
70
+ }
71
+
72
+ /// B2 — SEEDED: price curve overflows; at level ≥ ~42 the fee wraps to near zero.
73
+ pub fn upgrade_tile(ctx: Context<UpgradeTile>) -> Result<()> {
74
+ let tile = &mut ctx.accounts.tile;
75
+ let profile = &ctx.accounts.profile;
76
+
77
+ // fee = base * 2^level * tiles_owned — unchecked mul chain
78
+ let fee = UPGRADE_BASE_FEE
79
+ * 2u64.pow(tile.level as u32)
80
+ * profile.tiles_claimed as u64;
81
+
82
+ let ix = system_instruction::transfer(
83
+ &ctx.accounts.player.key(),
84
+ &ctx.accounts.treasury.key(),
85
+ fee,
86
+ );
87
+ invoke(
88
+ &ix,
89
+ &[
90
+ ctx.accounts.player.to_account_info(),
91
+ ctx.accounts.treasury.to_account_info(),
92
+ ],
93
+ )?;
94
+
95
+ tile.level = tile.level + 1;
96
+ Ok(())
97
+ }
98
+
99
+ /// C3 — clean control: has_one, Signer, no CPI.
100
+ pub fn list_tile(ctx: Context<ListTile>, price: u64) -> Result<()> {
101
+ let tile = &mut ctx.accounts.tile;
102
+ tile.list_price = price;
103
+ Ok(())
104
+ }
105
+
106
+ /// B3 — SEEDED: tile is a raw AccountInfo; owner/price are trusted from
107
+ /// attacker-controllable bytes. Pass any crafted account as "tile".
108
+ pub fn buy_tile(ctx: Context<BuyTile>) -> Result<()> {
109
+ let tile_info = &ctx.accounts.tile;
110
+ let data = tile_info.try_borrow_data()?;
111
+ // layout: [disc(8) | owner(32) | x(1) | y(1) | level(1) | list_price(8)]
112
+ let list_price = u64::from_le_bytes(data[43..51].try_into().unwrap());
113
+ let seller_key = Pubkey::try_from(&data[8..40]).unwrap();
114
+ require!(list_price > 0, GameError::NotForSale);
115
+
116
+ let royalty = list_price * ROYALTY_BPS / 10_000;
117
+ let seller_proceeds = list_price - royalty;
118
+
119
+ // pay seller — seller account is passed separately and unchecked
120
+ **ctx.accounts.buyer.try_borrow_mut_lamports()? -= list_price;
121
+ **ctx.accounts.seller.try_borrow_mut_lamports()? += seller_proceeds;
122
+ **ctx.accounts.treasury.try_borrow_mut_lamports()? += royalty;
123
+
124
+ // hand the tile to the buyer (in-memory only; never written back safely)
125
+ drop(data);
126
+ let mut data = tile_info.try_borrow_mut_data()?;
127
+ data[8..40].copy_from_slice(&ctx.accounts.buyer.key().to_bytes());
128
+ let _ = seller_key;
129
+ Ok(())
130
+ }
131
+
132
+ /// B4 + B5 — SEEDED: token program is an unchecked AccountInfo (arbitrary CPI
133
+ /// target), and the payment token account's mint is never bound to the official
134
+ /// fee mint. Pay with any token — or any program.
135
+ pub fn buy_tile_spl(ctx: Context<BuyTileSpl>, amount: u64) -> Result<()> {
136
+ let ix = spl_token_transfer(
137
+ &ctx.accounts.buyer_token.key(),
138
+ &ctx.accounts.seller_token.key(),
139
+ &ctx.accounts.buyer.key(),
140
+ amount,
141
+ );
142
+ invoke(
143
+ &ix,
144
+ &[
145
+ ctx.accounts.buyer_token.to_account_info(),
146
+ ctx.accounts.seller_token.to_account_info(),
147
+ ctx.accounts.buyer.to_account_info(),
148
+ ctx.accounts.token_program.to_account_info(),
149
+ ],
150
+ )?;
151
+
152
+ let tile = &mut ctx.accounts.tile;
153
+ tile.owner = ctx.accounts.buyer.key();
154
+ tile.list_price = 0;
155
+ Ok(())
156
+ }
157
+
158
+ /// B6 — SEEDED: manual close — lamports drained but discriminator zeroed only,
159
+ /// account can be revived in the same transaction with stale data.
160
+ pub fn close_tile(ctx: Context<CloseTile>) -> Result<()> {
161
+ let tile_info = &ctx.accounts.tile;
162
+ let dest = &ctx.accounts.owner;
163
+
164
+ let lamports = tile_info.lamports();
165
+ **tile_info.try_borrow_mut_lamports()? = 0;
166
+ **dest.try_borrow_mut_lamports()? += lamports;
167
+
168
+ let mut data = tile_info.try_borrow_mut_data()?;
169
+ for b in data.iter_mut() {
170
+ *b = 0;
171
+ }
172
+ Ok(())
173
+ }
174
+
175
+ /// B7 + B8 — SEEDED: authority is not a Signer (anyone can pass its pubkey), and
176
+ /// the treasury account is never checked against the derived PDA.
177
+ pub fn withdraw_treasury(ctx: Context<WithdrawTreasury>, amount: u64) -> Result<()> {
178
+ let ix = system_instruction::transfer(
179
+ &ctx.accounts.treasury.key(),
180
+ &ctx.accounts.recipient.key(),
181
+ amount,
182
+ );
183
+ invoke(
184
+ &ix,
185
+ &[
186
+ ctx.accounts.treasury.to_account_info(),
187
+ ctx.accounts.recipient.to_account_info(),
188
+ ],
189
+ )?;
190
+ Ok(())
191
+ }
192
+
193
+ /// B9 — SEEDED: profile is a raw AccountInfo with no owner check; a fake profile
194
+ /// account owned by an attacker program passes straight through.
195
+ pub fn grant_achievement(ctx: Context<GrantAchievement>, xp: u64) -> Result<()> {
196
+ let profile_info = &ctx.accounts.profile;
197
+ let mut data = profile_info.try_borrow_mut_data()?;
198
+ // layout: [disc(8) | owner(32) | xp(8) | tiles_claimed(4)]
199
+ let current = u64::from_le_bytes(data[40..48].try_into().unwrap());
200
+ data[40..48].copy_from_slice(&(current + xp).to_le_bytes());
201
+ Ok(())
202
+ }
203
+
204
+ /// C4 — clean control: checked math, has_one constraints.
205
+ pub fn harvest(ctx: Context<Harvest>) -> Result<()> {
206
+ let tile = &ctx.accounts.tile;
207
+ let profile = &mut ctx.accounts.profile;
208
+ let reward = (tile.level as u64)
209
+ .checked_mul(1_000)
210
+ .ok_or(GameError::Overflow)?;
211
+ profile.xp = profile.xp.checked_add(reward).ok_or(GameError::Overflow)?;
212
+ Ok(())
213
+ }
214
+ }
215
+
216
+ fn spl_token_transfer(from: &Pubkey, to: &Pubkey, authority: &Pubkey, amount: u64) -> anchor_lang::solana_program::instruction::Instruction {
217
+ // hand-rolled SPL Token transfer instruction (layout-compatible)
218
+ let mut data = vec![3u8];
219
+ data.extend_from_slice(&amount.to_le_bytes());
220
+ anchor_lang::solana_program::instruction::Instruction {
221
+ program_id: *from, // NOTE: caller passes the real program at invoke time; fixture only
222
+ accounts: vec![
223
+ AccountMeta::new(*from, false),
224
+ AccountMeta::new(*to, false),
225
+ AccountMeta::new_readonly(*authority, true),
226
+ ],
227
+ data,
228
+ }
229
+ }
230
+
231
+ // ------------------------- Accounts -------------------------
232
+
233
+ #[derive(Accounts)]
234
+ pub struct Initialize<'info> {
235
+ #[account(init, payer = authority, space = 8 + 32 + 8 + 8, seeds = [b"config"], bump)]
236
+ pub config: Account<'info, Config>,
237
+ /// CHECK: treasury PDA, system-owned vault
238
+ #[account(mut, seeds = [b"treasury"], bump)]
239
+ pub treasury: UncheckedAccount<'info>,
240
+ #[account(mut)]
241
+ pub authority: Signer<'info>,
242
+ pub system_program: Program<'info, System>,
243
+ }
244
+
245
+ #[derive(Accounts)]
246
+ pub struct RegisterPlayer<'info> {
247
+ #[account(init_if_needed, payer = player, space = 8 + 32 + 8 + 4, seeds = [b"player", player.key().as_ref()], bump)]
248
+ pub profile: Account<'info, PlayerProfile>,
249
+ #[account(mut)]
250
+ pub player: Signer<'info>,
251
+ pub system_program: Program<'info, System>,
252
+ }
253
+
254
+ #[derive(Accounts)]
255
+ #[instruction(x: u8, y: u8)]
256
+ pub struct ClaimTile<'info> {
257
+ #[account(seeds = [b"config"], bump)]
258
+ pub config: Account<'info, Config>,
259
+ #[account(init, payer = player, space = 8 + 32 + 1 + 1 + 1 + 8, seeds = [b"tile", &[x], &[y]], bump)]
260
+ pub tile: Account<'info, Tile>,
261
+ #[account(mut, seeds = [b"player", player.key().as_ref()], bump, has_one = owner @ GameError::BadProfile)]
262
+ pub profile: Account<'info, PlayerProfile>,
263
+ /// CHECK: treasury PDA
264
+ #[account(mut, seeds = [b"treasury"], bump)]
265
+ pub treasury: UncheckedAccount<'info>,
266
+ #[account(mut)]
267
+ pub player: Signer<'info>,
268
+ /// CHECK: constraint-checked against profile.owner
269
+ pub owner: UncheckedAccount<'info>,
270
+ pub system_program: Program<'info, System>,
271
+ }
272
+
273
+ #[derive(Accounts)]
274
+ pub struct UpgradeTile<'info> {
275
+ #[account(mut, has_one = owner)]
276
+ pub tile: Account<'info, Tile>,
277
+ #[account(seeds = [b"player", owner.key().as_ref()], bump)]
278
+ pub profile: Account<'info, PlayerProfile>,
279
+ /// CHECK: treasury PDA
280
+ #[account(mut)]
281
+ pub treasury: UncheckedAccount<'info>,
282
+ #[account(mut)]
283
+ pub player: Signer<'info>,
284
+ /// CHECK: matches tile.owner via has_one
285
+ pub owner: UncheckedAccount<'info>,
286
+ pub system_program: Program<'info, System>,
287
+ }
288
+
289
+ #[derive(Accounts)]
290
+ pub struct ListTile<'info> {
291
+ #[account(mut, has_one = owner)]
292
+ pub tile: Account<'info, Tile>,
293
+ pub owner: Signer<'info>,
294
+ }
295
+
296
+ #[derive(Accounts)]
297
+ pub struct BuyTile<'info> {
298
+ /// CHECK: SEEDED — raw account, no owner/discriminator validation (B3)
299
+ #[account(mut)]
300
+ pub tile: UncheckedAccount<'info>,
301
+ /// CHECK: seller paid from tile bytes, never cross-checked
302
+ #[account(mut)]
303
+ pub seller: UncheckedAccount<'info>,
304
+ /// CHECK: treasury
305
+ #[account(mut)]
306
+ pub treasury: UncheckedAccount<'info>,
307
+ #[account(mut)]
308
+ pub buyer: Signer<'info>,
309
+ }
310
+
311
+ #[derive(Accounts)]
312
+ pub struct BuyTileSpl<'info> {
313
+ #[account(mut, has_one = owner)]
314
+ pub tile: Account<'info, Tile>,
315
+ /// CHECK: SEEDED — buyer's token account; mint never bound (B5)
316
+ #[account(mut)]
317
+ pub buyer_token: UncheckedAccount<'info>,
318
+ /// CHECK: seller's token account
319
+ #[account(mut)]
320
+ pub seller_token: UncheckedAccount<'info>,
321
+ #[account(mut)]
322
+ pub buyer: Signer<'info>,
323
+ /// CHECK: matches tile.owner via has_one
324
+ pub owner: UncheckedAccount<'info>,
325
+ /// CHECK: SEEDED — token program passed unchecked (B4)
326
+ pub token_program: UncheckedAccount<'info>,
327
+ }
328
+
329
+ #[derive(Accounts)]
330
+ pub struct CloseTile<'info> {
331
+ /// CHECK: SEEDED — manual close path (B6)
332
+ #[account(mut)]
333
+ pub tile: UncheckedAccount<'info>,
334
+ #[account(mut)]
335
+ pub owner: Signer<'info>,
336
+ }
337
+
338
+ #[derive(Accounts)]
339
+ pub struct WithdrawTreasury<'info> {
340
+ /// CHECK: SEEDED — no signer enforcement, no PDA verification (B7, B8)
341
+ #[account(mut)]
342
+ pub treasury: UncheckedAccount<'info>,
343
+ /// CHECK: recipient
344
+ #[account(mut)]
345
+ pub recipient: UncheckedAccount<'info>,
346
+ /// CHECK: authority is never required to sign
347
+ pub authority: UncheckedAccount<'info>,
348
+ pub system_program: Program<'info, System>,
349
+ }
350
+
351
+ #[derive(Accounts)]
352
+ pub struct GrantAchievement<'info> {
353
+ /// CHECK: SEEDED — raw profile account, no owner check (B9)
354
+ #[account(mut)]
355
+ pub profile: UncheckedAccount<'info>,
356
+ pub oracle: Signer<'info>,
357
+ }
358
+
359
+ #[derive(Accounts)]
360
+ pub struct Harvest<'info> {
361
+ #[account(has_one = owner)]
362
+ pub tile: Account<'info, Tile>,
363
+ #[account(mut, seeds = [b"player", owner.key().as_ref()], bump)]
364
+ pub profile: Account<'info, PlayerProfile>,
365
+ pub owner: Signer<'info>,
366
+ }
367
+
368
+ // ------------------------- State -------------------------
369
+
370
+ #[account]
371
+ pub struct Config {
372
+ pub authority: Pubkey,
373
+ pub claim_fee: u64,
374
+ pub royalty_bps: u64,
375
+ }
376
+
377
+ #[account]
378
+ pub struct Tile {
379
+ pub owner: Pubkey,
380
+ pub x: u8,
381
+ pub y: u8,
382
+ pub level: u8,
383
+ pub list_price: u64,
384
+ }
385
+
386
+ #[account]
387
+ pub struct PlayerProfile {
388
+ pub owner: Pubkey,
389
+ pub xp: u64,
390
+ pub tiles_claimed: u32,
391
+ }
392
+
393
+ #[error_code]
394
+ pub enum GameError {
395
+ #[msg("Coordinates out of bounds")]
396
+ OutOfBounds,
397
+ #[msg("Tile is not listed for sale")]
398
+ NotForSale,
399
+ #[msg("Arithmetic overflow")]
400
+ Overflow,
401
+ #[msg("Profile mismatch")]
402
+ BadProfile,
403
+ }