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.
- package/README.md +34 -12
- package/dist/cli.js +485 -41
- package/examples/solscape/ANSWER_KEY.md +35 -0
- package/examples/solscape/DESIGN.md +54 -0
- package/examples/solscape/README.md +20 -0
- package/examples/solscape/SCORECARD.md +102 -0
- package/examples/solscape/fixture_v1/lib_v1.rs +403 -0
- package/examples/solscape/programs/solscape/src/lib.rs +410 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|