solaudit 0.3.1 → 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 +18 -7
- package/dist/cli.js +122 -49
- 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
package/README.md
CHANGED
|
@@ -55,7 +55,7 @@ $ solaudit ./examples
|
|
|
55
55
|
```
|
|
56
56
|
|
|
57
57
|
```
|
|
58
|
-
SolAudit v0.3.
|
|
58
|
+
SolAudit v0.3.2 — static analysis for Anchor/Solana programs
|
|
59
59
|
Scanned 1 file(s) at 2026-08-27T06:00:00.000Z
|
|
60
60
|
──────────────────────────────────────────────────────────────────────
|
|
61
61
|
|
|
@@ -96,15 +96,15 @@ bun run dev
|
|
|
96
96
|
|
|
97
97
|
| ID | Severity | Name | What it catches |
|
|
98
98
|
|---|---|---|---|
|
|
99
|
-
| `SOL-001` | CRITICAL | Missing Signer Check | `transfer`/`mint_to` calls with no signer evidence anywhere: no nearby `is_signer` check, no `Signer<'info>` field on the governing Accounts struct, and no PDA signing (`invoke_signed` / `new_with_signer`) |
|
|
99
|
+
| `SOL-001` | CRITICAL | Missing Signer Check | `transfer`/`mint_to` calls with no signer evidence anywhere: no nearby `is_signer` check, no `Signer<'info>` field on the governing Accounts struct, and no PDA signing (`invoke_signed` / `new_with_signer`). Also flags PDA-signed transfers whose *destination* is a caller-supplied unchecked account with no `Signer` governing the instruction (the classic vault drain) |
|
|
100
100
|
| `SOL-002` | HIGH | Unchecked Account Deserialization | `Account::try_from` / `AccountLoader::try_from` on an account with no validating `#[account(...)]` constraint (constraint-aware: fields carrying `owner`/`constraint`/`has_one` bindings are recognized as validated) |
|
|
101
|
-
| `SOL-003` | HIGH | Arbitrary CPI | `invoke`/`invoke_signed`
|
|
102
|
-
| `SOL-004` | HIGH | Missing Bump Seed Canonicalization | `create_program_address` called directly with a bump that didn't come from a nearby `find_program_address` (which is canonical by construction) |
|
|
103
|
-
| `SOL-005` | MEDIUM | Integer Overflow Risk | Raw `+`/`-`/`*` arithmetic *written into account state* (field/compound assignment) on balance-like values instead of `checked_*`/`saturating_
|
|
101
|
+
| `SOL-003` | HIGH | Arbitrary CPI | `invoke`/`invoke_signed` whose program ID comes from a raw (unchecked) program account (`program_id: ctx.accounts.*` or a raw `*_program` field); typed `Program<'info, T>` targets are recognized as safe |
|
|
102
|
+
| `SOL-004` | HIGH | Missing Bump Seed Canonicalization / Unverified PDA | `create_program_address` called directly with a bump that didn't come from a nearby `find_program_address` (which is canonical by construction); also raw, mutable vault/treasury/pool-style accounts used in value movement with no `seeds`/`address` binding |
|
|
103
|
+
| `SOL-005` | MEDIUM | Integer Overflow Risk | Raw `+`/`-`/`*` arithmetic *written into account state* (field/compound assignment) on balance-like values instead of `checked_*`/`saturating_*`; also variable-exponent `pow()` in value math (e.g. `2u64.pow(level)` in a fee curve) |
|
|
104
104
|
| `SOL-006` | HIGH | Type Cosplay / Missing Discriminator Check | Manual `try_from_slice` deserialization with no discriminator validation or `Account<'info, T>` wrapper |
|
|
105
105
|
| `SOL-007` | MEDIUM | Insecure Account Initialization | `#[account(init, ...)]` constraints missing `payer` and/or `space` |
|
|
106
|
-
| `SOL-008` | LOW | Missing Owner Check | Raw `AccountInfo`/`UncheckedAccount` fields with no constraint binding whose data is trusted (deserialized/read) or whose key is assigned into persistent state, with no `.owner
|
|
107
|
-
| `SOL-009` | HIGH | Insecure Manual Account Closure | An account's lamports manually drained to zero without discriminator zeroing or owner reassignment (unlike Anchor's `close = destination`), enabling closed-account revival |
|
|
106
|
+
| `SOL-008` | LOW | Missing Owner Check | Raw `AccountInfo`/`UncheckedAccount` fields with no constraint binding whose data is trusted (deserialized/read, including through `let x = &ctx.accounts.f` aliases) or whose key is assigned into persistent state, with no genuine `.owner ==` comparison; usages are scoped to the owning struct's handlers |
|
|
107
|
+
| `SOL-009` | HIGH | Insecure Manual Account Closure | An account's lamports manually drained to zero (`lamports.borrow_mut() = 0` or `try_borrow_mut_lamports() = 0`) without discriminator zeroing or owner reassignment (unlike Anchor's `close = destination`), enabling closed-account revival |
|
|
108
108
|
| `SOL-010` | MEDIUM | Reinitialization Risk via init_if_needed | `#[account(init_if_needed, ...)]` without canonical PDA seeds / `has_one` constraints, letting a pre-existing attacker-crafted account pass as freshly initialized |
|
|
109
109
|
| `SOL-011` | MEDIUM | Missing Rent-Exemption Check | A partial lamport withdrawal with no nearby `Rent::get()?.minimum_balance(...)` check, risking runtime garbage-collection of the account |
|
|
110
110
|
|
|
@@ -124,6 +124,17 @@ On the committed labeled suite in [`benchmark/`](./benchmark), the deterministic
|
|
|
124
124
|
|
|
125
125
|
Benchmark label integrity note: three samples' labels were revised in 0.3.1 to match SOL-008's sharpened definition (raw accounts whose *data* is trusted or whose key is *assigned into state*); each revision is documented inline in [`benchmark/labels.json`](./benchmark/labels.json).
|
|
126
126
|
|
|
127
|
+
### Ground-truth validation (0.3.2): SolScape
|
|
128
|
+
|
|
129
|
+
0.3.1 was measured against **purpose-built ground truth**: [SolScape](https://github.com/blueeyez425-art/solaudit/tree/main/examples/solscape), a complete (intentionally vulnerable) Anchor game program — a persistent on-chain territory map with a marketplace, treasury, and player profiles — seeded with 9 real exploit classes (missing-signer treasury drain, unchecked PDA substitution, overflow price curve, arbitrary CPI, unbound token mint, close-then-revive, raw-byte trust, reinitialization) plus 4 correctly-secured control handlers. The answer key was written **before** the scan.
|
|
130
|
+
|
|
131
|
+
- **0.3.1 caught 2 of 9 seeded bugs** and false-flagged 2 of 4 controls. We published that scorecard, then fixed the engine.
|
|
132
|
+
- **0.3.2 catches 8 of 9 by default, 9 of 9 with `--experimental`** (the unbound-mint class is SOL-013), with **all 4 controls clean**.
|
|
133
|
+
- Fixes: SOL-001 now detects PDA-signed transfers to caller-chosen unchecked recipients when no `Signer` governs the instruction (the classic vault drain); SOL-003 only flags CPIs whose *program* is a raw account (typed `Program<'info, T>` no longer flags); SOL-004 also flags unseeds-verified vault/treasury/pool accounts used in value movement; SOL-005 catches variable-exponent `pow()` in value math; SOL-008 follows `let x = &ctx.accounts.f` aliases, scopes usages to the owning struct's handlers, and no longer mistakes `field.owner = ...` assignments for checks; SOL-009 recognizes `try_borrow_mut_lamports() = 0` manual closes.
|
|
134
|
+
- marginfi re-scan sanity check: 24 findings (was 20) — the main program stays clean; the new signal lands in mock/integration programs.
|
|
135
|
+
|
|
136
|
+
Fixture revisions disclosed: SolScape's `withdraw_treasury` was rewritten to the genuinely exploitable `invoke_signed` shape (v1's plain `invoke` could not actually drain), `close_tile` now leaves data intact (v1 zeroed it, which is the *safe* pattern), and `buy_tile_spl` gained the realistic typed-token + mint shape SOL-013 is designed to scope to. v1 is archived in the fixture directory.
|
|
137
|
+
|
|
127
138
|
**Duplicate mutable accounts** and **token accounts unbound to their mint** (the Cashio class) are covered by the experimental `SOL-012` and `SOL-013` rules, available via `solaudit --experimental`. A “distinctness” heuristic false-positives on common legitimate patterns (for example a deposit instruction’s user/vault token accounts of the same type with no explicit `!=` constraint), so it stays opt-in to protect the default ruleset's precision. These numbers describe this suite, not general security effectiveness.
|
|
128
139
|
|
|
129
140
|
## Output formats (CLI)
|
package/dist/cli.js
CHANGED
|
@@ -264,10 +264,23 @@ var missingSignerCheck = {
|
|
|
264
264
|
const hasSignerEvidence = /is_signer/.test(window) || /Signer<\s*'info\s*>/.test(window) || /#\[account\([^)]*signer[^)]*\)\]/i.test(window);
|
|
265
265
|
if (hasSignerEvidence)
|
|
266
266
|
return;
|
|
267
|
+
const struct = structAt(model, offset);
|
|
267
268
|
const extWindow = lines.slice(windowStart, Math.min(lines.length, idx + 16)).join(`
|
|
268
269
|
`);
|
|
269
|
-
if (/\binvoke_signed\b/.test(extWindow))
|
|
270
|
+
if (/\binvoke_signed\b/.test(extWindow)) {
|
|
271
|
+
if (struct && !struct.fields.some((f) => f.isSignerTyped)) {
|
|
272
|
+
const callBlock = lines.slice(idx, Math.min(lines.length, idx + 8)).join(`
|
|
273
|
+
`);
|
|
274
|
+
if (/system_instruction::transfer|::transfer(?:_checked)?\s*\(/.test(callBlock)) {
|
|
275
|
+
const refs = [...callBlock.matchAll(/ctx\.accounts\.(\w+)/g)].map((m) => m[1]);
|
|
276
|
+
const toField = refs.length >= 2 ? findField(model, refs[1]) : null;
|
|
277
|
+
if (toField && toField.isRaw && !toField.validated) {
|
|
278
|
+
findings.push(makeFinding(missingSignerCheck, filePath, idx + 1, rawLine, `This instruction PDA-signs a transfer whose destination (\`${refs[1]}\`) is a caller-supplied unchecked account, and its Accounts struct has no Signer — anyone can call it and drain the vault. Require an authority Signer and bind the recipient to validated state.`));
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
270
282
|
return;
|
|
283
|
+
}
|
|
271
284
|
if (/\bnew_with_signer\b/.test(extWindow))
|
|
272
285
|
return;
|
|
273
286
|
if (/system_instruction::transfer/.test(line) && /\binvoke(?:_signed)?\s*\(/.test(extWindow)) {
|
|
@@ -281,7 +294,6 @@ var missingSignerCheck = {
|
|
|
281
294
|
if (prevLines.some((l) => /\binvoke(?:_signed)?\s*\([^)]*$/.test(l)))
|
|
282
295
|
return;
|
|
283
296
|
}
|
|
284
|
-
const struct = structAt(model, offset);
|
|
285
297
|
if (struct && struct.fields.some((f) => f.isSignerTyped))
|
|
286
298
|
return;
|
|
287
299
|
findings.push(makeFinding(missingSignerCheck, filePath, idx + 1, rawLine, "Verify the authority/owner account is a Signer or explicitly check `.is_signer` before transferring or minting tokens."));
|
|
@@ -329,17 +341,35 @@ var arbitraryCpi = {
|
|
|
329
341
|
check(fileContent, filePath) {
|
|
330
342
|
const findings = [];
|
|
331
343
|
const lines = splitLines(fileContent);
|
|
332
|
-
const cpiPattern = /\b(invoke|invoke_signed)\s*\(/;
|
|
344
|
+
const cpiPattern = /\b(?:invoke|invoke_signed)\s*\(/;
|
|
345
|
+
const model = getConstraintModel(fileContent);
|
|
333
346
|
lines.forEach((line, idx) => {
|
|
334
347
|
if (!cpiPattern.test(line))
|
|
335
348
|
return;
|
|
336
|
-
const
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
const
|
|
341
|
-
|
|
349
|
+
const offset = lineOffset(lines, idx);
|
|
350
|
+
if (inRanges(offset, model.testRanges))
|
|
351
|
+
return;
|
|
352
|
+
const windowStart = Math.max(0, idx - 12);
|
|
353
|
+
const windowEnd = Math.min(lines.length, idx + 12);
|
|
354
|
+
const window = stripLineComments(lines.slice(windowStart, windowEnd).join(`
|
|
355
|
+
`));
|
|
356
|
+
const suspectNames = new Set;
|
|
357
|
+
for (const m of window.matchAll(/program_id\s*:\s*[*&]?\s*(?:ctx\.accounts\.)?(\w+)/g)) {
|
|
358
|
+
suspectNames.add(m[1]);
|
|
359
|
+
}
|
|
360
|
+
for (const m of window.matchAll(/ctx\.accounts\.(\w*(?:^|_)program\w*)/g)) {
|
|
361
|
+
suspectNames.add(m[1]);
|
|
362
|
+
}
|
|
363
|
+
for (const name of suspectNames) {
|
|
364
|
+
const field = findField(model, name);
|
|
365
|
+
if (field && field.wrapperType === "Program")
|
|
366
|
+
continue;
|
|
367
|
+
if (field && !field.isRaw)
|
|
368
|
+
continue;
|
|
369
|
+
if (!field && /Program<\s*'info/.test(window))
|
|
370
|
+
continue;
|
|
342
371
|
findings.push(makeFinding(arbitraryCpi, filePath, idx + 1, line, "Validate the target program ID against a known constant (e.g. `token_program.key() == spl_token::ID`) or type the account as `Program<'info, T>` before invoking it."));
|
|
372
|
+
break;
|
|
343
373
|
}
|
|
344
374
|
});
|
|
345
375
|
return findings;
|
|
@@ -368,6 +398,44 @@ var missingBumpCanonicalization = {
|
|
|
368
398
|
return;
|
|
369
399
|
findings.push(makeFinding(missingBumpCanonicalization, filePath, idx + 1, line, "Use `Pubkey::find_program_address` to derive the canonical bump instead of calling `create_program_address` directly with a caller-supplied bump."));
|
|
370
400
|
});
|
|
401
|
+
if (model.structs.length > 0) {
|
|
402
|
+
const offsets = [];
|
|
403
|
+
let acc = 0;
|
|
404
|
+
for (const l of lines) {
|
|
405
|
+
offsets.push(acc);
|
|
406
|
+
acc += l.length + 1;
|
|
407
|
+
}
|
|
408
|
+
for (const s of model.structs) {
|
|
409
|
+
const regions = model.regions.filter((r) => r.contextType === s.name);
|
|
410
|
+
const regionText = regions.length > 0 ? regions.map((r) => fileContent.slice(r.start, r.end)).join(`
|
|
411
|
+
`) : fileContent;
|
|
412
|
+
const movesValue = /system_instruction::transfer|token::transfer|try_borrow_mut_lamports|lamports\.borrow_mut/.test(stripLineComments(regionText));
|
|
413
|
+
if (!movesValue)
|
|
414
|
+
continue;
|
|
415
|
+
const inScope = (lineIdx) => {
|
|
416
|
+
if (regions.length === 0)
|
|
417
|
+
return true;
|
|
418
|
+
const off = offsets[lineIdx];
|
|
419
|
+
return regions.some((r) => off >= r.start && off < r.end);
|
|
420
|
+
};
|
|
421
|
+
for (const f of s.fields) {
|
|
422
|
+
if (!f.isRaw || f.validated)
|
|
423
|
+
continue;
|
|
424
|
+
if (!/\bmut\b/.test(f.attrs))
|
|
425
|
+
continue;
|
|
426
|
+
if (!/vault|treasury|pool|escrow|fund|bank|reward/i.test(f.name))
|
|
427
|
+
continue;
|
|
428
|
+
for (let i = 0;i < lines.length; i++) {
|
|
429
|
+
if (!inScope(i))
|
|
430
|
+
continue;
|
|
431
|
+
if (!lines[i].includes(`ctx.accounts.${f.name}`))
|
|
432
|
+
continue;
|
|
433
|
+
findings.push(makeFinding(missingBumpCanonicalization, filePath, i + 1, lines[i], `\`${f.name}\` is a mutable unchecked account used in value movement with no \`seeds\` binding. Verify it against its derived PDA (\`seeds = [...], bump\`) or pin it with \`address =\` — otherwise the caller can substitute an attacker-controlled account and redirect the funds.`));
|
|
434
|
+
break;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
371
439
|
return findings;
|
|
372
440
|
}
|
|
373
441
|
};
|
|
@@ -399,6 +467,9 @@ var integerOverflowRisk = {
|
|
|
399
467
|
if (arithmeticPattern.test(code) && stateWritePattern.test(code)) {
|
|
400
468
|
findings.push(makeFinding(integerOverflowRisk, filePath, idx + 1, line, "Replace raw arithmetic written to state with `.checked_add()/.checked_sub()/.checked_mul()` (propagating errors via `ok_or(ErrorCode::MathOverflow)?`) or `saturating_*` equivalents."));
|
|
401
469
|
}
|
|
470
|
+
if (/\.pow\s*\([^)]*[a-zA-Z_]/.test(code) && !/checked_pow|saturating|wrapping/.test(code)) {
|
|
471
|
+
findings.push(makeFinding(integerOverflowRisk, filePath, idx + 1, line, "Exponentiation with a non-constant exponent overflows silently and quickly (2^64 wraps a u64 to 0). Use `checked_pow` with error propagation, or bound the exponent explicitly."));
|
|
472
|
+
}
|
|
402
473
|
});
|
|
403
474
|
return findings;
|
|
404
475
|
}
|
|
@@ -462,53 +533,55 @@ var missingOwnerCheck = {
|
|
|
462
533
|
const lines = splitLines(fileContent);
|
|
463
534
|
const model = getConstraintModel(fileContent);
|
|
464
535
|
if (model.structs.length > 0) {
|
|
465
|
-
const
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
536
|
+
const codeOnly = stripLineComments(fileContent);
|
|
537
|
+
const offsets = [];
|
|
538
|
+
{
|
|
539
|
+
let off = 0;
|
|
540
|
+
for (const l of lines) {
|
|
541
|
+
offsets.push(off);
|
|
542
|
+
off += l.length + 1;
|
|
470
543
|
}
|
|
471
544
|
}
|
|
472
|
-
const
|
|
545
|
+
const touchPattern = /try_borrow_data|try_borrow_mut_data|try_borrow_mut_lamports|\.load\s*\(|data\.borrow|try_from|\.lamports\s*\(\s*\)|accessor::/;
|
|
473
546
|
for (const s of model.structs) {
|
|
547
|
+
const regions = model.regions.filter((r) => r.contextType === s.name);
|
|
548
|
+
const inScope = (lineIdx) => {
|
|
549
|
+
if (regions.length === 0)
|
|
550
|
+
return true;
|
|
551
|
+
const off = offsets[lineIdx];
|
|
552
|
+
return regions.some((r) => off >= r.start && off < r.end);
|
|
553
|
+
};
|
|
474
554
|
for (const f of s.fields) {
|
|
475
|
-
if (!f.isRaw ||
|
|
555
|
+
if (!f.isRaw || f.validated)
|
|
476
556
|
continue;
|
|
477
|
-
|
|
557
|
+
const ownerCheck = new RegExp(`\\b${f.name}\\.owner\\s*==|require!\\([^)]*\\b${f.name}\\.owner\\b|require_keys_eq!\\([^)]*\\b${f.name}\\b`);
|
|
558
|
+
if (ownerCheck.test(codeOnly))
|
|
478
559
|
continue;
|
|
560
|
+
const aliases = new Set;
|
|
561
|
+
const aliasRe = new RegExp(`let\\s+(\\w+)\\s*=\\s*&?\\s*ctx\\.accounts\\.${f.name}\\b`, "g");
|
|
562
|
+
let am;
|
|
563
|
+
while ((am = aliasRe.exec(codeOnly)) !== null)
|
|
564
|
+
aliases.add(am[1]);
|
|
565
|
+
let reportLine = -1;
|
|
566
|
+
let snippet = "";
|
|
567
|
+
for (let i = 0;i < lines.length; i++) {
|
|
568
|
+
if (!inScope(i))
|
|
569
|
+
continue;
|
|
570
|
+
const l = lines[i];
|
|
571
|
+
const direct = l.includes(`ctx.accounts.${f.name}`);
|
|
572
|
+
const aliasTouch = [...aliases].some((a) => new RegExp(`\\b${a}\\s*\\.`).test(l) && touchPattern.test(l));
|
|
573
|
+
const directTouch = direct && touchPattern.test(l);
|
|
574
|
+
const keyAssign = direct && new RegExp(`=\\s*&?\\s*ctx\\.accounts\\.${f.name}\\.key\\s*\\(`).test(l) && !/==|require_keys_eq/.test(l);
|
|
575
|
+
if (aliasTouch || directTouch || keyAssign) {
|
|
576
|
+
reportLine = i + 1;
|
|
577
|
+
snippet = l;
|
|
578
|
+
break;
|
|
579
|
+
}
|
|
479
580
|
}
|
|
480
|
-
if (
|
|
481
|
-
suspects.set(f.name, f.line);
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
for (const [name, declLine] of suspects) {
|
|
485
|
-
const usagePattern = new RegExp(`ctx\\.accounts\\.${name}\\b`);
|
|
486
|
-
let reportLine = declLine;
|
|
487
|
-
let snippet = lines[declLine - 1] || name;
|
|
488
|
-
let touchesData = false;
|
|
489
|
-
for (let i = 0;i < lines.length; i++) {
|
|
490
|
-
if (!usagePattern.test(lines[i]) || i + 1 === declLine)
|
|
581
|
+
if (reportLine === -1)
|
|
491
582
|
continue;
|
|
492
|
-
|
|
493
|
-
reportLine = i + 1;
|
|
494
|
-
snippet = lines[i];
|
|
495
|
-
}
|
|
496
|
-
if (/try_borrow_data|\.load\s*\(|data\.borrow|try_from|\.lamports\s*\(|accessor::/.test(lines[i])) {
|
|
497
|
-
touchesData = true;
|
|
498
|
-
reportLine = i + 1;
|
|
499
|
-
snippet = lines[i];
|
|
500
|
-
}
|
|
501
|
-
if (new RegExp(`=\\s*(?:&)?ctx\\.accounts\\.${name}\\.key\\s*\\(`).test(lines[i]) && !/==|require_keys_eq/.test(lines[i])) {
|
|
502
|
-
touchesData = true;
|
|
503
|
-
reportLine = i + 1;
|
|
504
|
-
snippet = lines[i];
|
|
505
|
-
}
|
|
583
|
+
findings.push(makeFinding(missingOwnerCheck, filePath, reportLine, snippet, `\`${f.name}\` is a raw AccountInfo with no constraint binding. Add an explicit \`require!(ctx.accounts.${f.name}.owner == &expected_program_id, ErrorCode::InvalidOwner)\` check (or an \`#[account(owner = ...)]\` constraint) before trusting its data.`));
|
|
506
584
|
}
|
|
507
|
-
if (reportLine === declLine)
|
|
508
|
-
continue;
|
|
509
|
-
if (!touchesData)
|
|
510
|
-
continue;
|
|
511
|
-
findings.push(makeFinding(missingOwnerCheck, filePath, reportLine, snippet, `\`${name}\` is a raw AccountInfo with no constraint binding. Add an explicit \`require!(ctx.accounts.${name}.owner == &expected_program_id, ErrorCode::InvalidOwner)\` check (or an \`#[account(owner = ...)]\` constraint) before trusting its data.`));
|
|
512
585
|
}
|
|
513
586
|
return findings;
|
|
514
587
|
}
|
|
@@ -551,7 +624,7 @@ var insecureAccountClosure = {
|
|
|
551
624
|
check(fileContent, filePath) {
|
|
552
625
|
const findings = [];
|
|
553
626
|
const lines = splitLines(fileContent);
|
|
554
|
-
const zeroLamportsPattern =
|
|
627
|
+
const zeroLamportsPattern = /(?:\.lamports\.borrow_mut\(\)|\.try_borrow_mut_lamports\(\)\??)\s*=\s*0\s*;/;
|
|
555
628
|
lines.forEach((line, idx) => {
|
|
556
629
|
if (!zeroLamportsPattern.test(line))
|
|
557
630
|
return;
|
|
@@ -843,7 +916,7 @@ function badge(severity) {
|
|
|
843
916
|
var SEVERITY_ORDER = ["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"];
|
|
844
917
|
function printReport(result) {
|
|
845
918
|
const timestamp = new Date().toISOString();
|
|
846
|
-
console.log(`${BOLD}${CYAN}SolAudit${RESET} ${DIM}v0.3.
|
|
919
|
+
console.log(`${BOLD}${CYAN}SolAudit${RESET} ${DIM}v0.3.2 — static analysis for Anchor/Solana programs${RESET}`);
|
|
847
920
|
console.log(`${DIM}Scanned ${result.scannedFiles} file(s) at ${timestamp}${RESET}`);
|
|
848
921
|
console.log(`${GRAY}${"─".repeat(70)}${RESET}`);
|
|
849
922
|
if (result.findings.length === 0) {
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
//! SolScape — persistent on-chain territory game.
|
|
2
|
+
//!
|
|
3
|
+
//! ⚠️ INTENTIONALLY VULNERABLE TEST FIXTURE (v2) ⚠️
|
|
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
|
+
//! v2 realism fixes (disclosed in SCORECARD.md):
|
|
9
|
+
//! - withdraw_treasury now uses invoke_signed (the genuinely exploitable shape)
|
|
10
|
+
//! - close_tile leaves account data intact (genuine close-then-revive)
|
|
11
|
+
//! - buy_tile_spl takes typed token accounts + a mint, with the CPI program id
|
|
12
|
+
//! sourced from an unchecked account (textbook arbitrary-CPI shape)
|
|
13
|
+
|
|
14
|
+
use anchor_lang::prelude::*;
|
|
15
|
+
use anchor_lang::solana_program::instruction::{AccountMeta, Instruction};
|
|
16
|
+
use anchor_lang::solana_program::program::{invoke, invoke_signed};
|
|
17
|
+
use anchor_lang::solana_program::system_instruction;
|
|
18
|
+
use anchor_spl::token::{Mint, TokenAccount};
|
|
19
|
+
|
|
20
|
+
declare_id!("So1Scape11111111111111111111111111111111111");
|
|
21
|
+
|
|
22
|
+
pub const GRID_SIZE: u8 = 100;
|
|
23
|
+
pub const CLAIM_FEE_LAMPORTS: u64 = 10_000_000; // 0.01 SOL
|
|
24
|
+
pub const UPGRADE_BASE_FEE: u64 = 5_000_000; // 0.005 SOL
|
|
25
|
+
pub const ROYALTY_BPS: u64 = 500; // 5%
|
|
26
|
+
|
|
27
|
+
#[program]
|
|
28
|
+
pub mod solscape {
|
|
29
|
+
use super::*;
|
|
30
|
+
|
|
31
|
+
/// C1 — clean control: proper init, seeds, signer.
|
|
32
|
+
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
|
|
33
|
+
let config = &mut ctx.accounts.config;
|
|
34
|
+
config.authority = ctx.accounts.authority.key();
|
|
35
|
+
config.claim_fee = CLAIM_FEE_LAMPORTS;
|
|
36
|
+
config.royalty_bps = ROYALTY_BPS;
|
|
37
|
+
Ok(())
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/// B1 — SEEDED: init_if_needed lets anyone wipe an existing profile back to zero.
|
|
41
|
+
pub fn register_player(ctx: Context<RegisterPlayer>) -> Result<()> {
|
|
42
|
+
let profile = &mut ctx.accounts.profile;
|
|
43
|
+
profile.owner = ctx.accounts.player.key();
|
|
44
|
+
profile.xp = 0;
|
|
45
|
+
profile.tiles_claimed = 0;
|
|
46
|
+
Ok(())
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/// C2 — clean control: PDA via seeds, Signer, explicit system-program CPI.
|
|
50
|
+
pub fn claim_tile(ctx: Context<ClaimTile>, x: u8, y: u8) -> Result<()> {
|
|
51
|
+
require!(x < GRID_SIZE && y < GRID_SIZE, GameError::OutOfBounds);
|
|
52
|
+
|
|
53
|
+
let tile = &mut ctx.accounts.tile;
|
|
54
|
+
tile.owner = ctx.accounts.player.key();
|
|
55
|
+
tile.x = x;
|
|
56
|
+
tile.y = y;
|
|
57
|
+
tile.level = 1;
|
|
58
|
+
tile.list_price = 0;
|
|
59
|
+
|
|
60
|
+
let fee = ctx.accounts.config.claim_fee;
|
|
61
|
+
let ix = system_instruction::transfer(
|
|
62
|
+
&ctx.accounts.player.key(),
|
|
63
|
+
&ctx.accounts.treasury.key(),
|
|
64
|
+
fee,
|
|
65
|
+
);
|
|
66
|
+
invoke(
|
|
67
|
+
&ix,
|
|
68
|
+
&[
|
|
69
|
+
ctx.accounts.player.to_account_info(),
|
|
70
|
+
ctx.accounts.treasury.to_account_info(),
|
|
71
|
+
ctx.accounts.system_program.to_account_info(),
|
|
72
|
+
],
|
|
73
|
+
)?;
|
|
74
|
+
|
|
75
|
+
let profile = &mut ctx.accounts.profile;
|
|
76
|
+
profile.tiles_claimed = profile.tiles_claimed.checked_add(1).unwrap();
|
|
77
|
+
Ok(())
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/// B2 — SEEDED: price curve overflows; at high level the fee wraps to ~zero.
|
|
81
|
+
pub fn upgrade_tile(ctx: Context<UpgradeTile>) -> Result<()> {
|
|
82
|
+
let tile = &mut ctx.accounts.tile;
|
|
83
|
+
let profile = &ctx.accounts.profile;
|
|
84
|
+
|
|
85
|
+
// fee = base * 2^level * tiles_owned — unchecked pow/mul chain
|
|
86
|
+
let fee = UPGRADE_BASE_FEE
|
|
87
|
+
* 2u64.pow(tile.level as u32)
|
|
88
|
+
* profile.tiles_claimed as u64;
|
|
89
|
+
|
|
90
|
+
let ix = system_instruction::transfer(
|
|
91
|
+
&ctx.accounts.player.key(),
|
|
92
|
+
&ctx.accounts.treasury.key(),
|
|
93
|
+
fee,
|
|
94
|
+
);
|
|
95
|
+
invoke(
|
|
96
|
+
&ix,
|
|
97
|
+
&[
|
|
98
|
+
ctx.accounts.player.to_account_info(),
|
|
99
|
+
ctx.accounts.treasury.to_account_info(),
|
|
100
|
+
],
|
|
101
|
+
)?;
|
|
102
|
+
|
|
103
|
+
tile.level = tile.level + 1;
|
|
104
|
+
Ok(())
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/// C3 — clean control: has_one, Signer, no CPI.
|
|
108
|
+
pub fn list_tile(ctx: Context<ListTile>, price: u64) -> Result<()> {
|
|
109
|
+
let tile = &mut ctx.accounts.tile;
|
|
110
|
+
tile.list_price = price;
|
|
111
|
+
Ok(())
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/// B3 — SEEDED: tile is a raw account; price/seller are trusted from
|
|
115
|
+
/// attacker-controllable bytes. Pass any crafted account as "tile".
|
|
116
|
+
pub fn buy_tile(ctx: Context<BuyTile>) -> Result<()> {
|
|
117
|
+
let tile_info = &ctx.accounts.tile;
|
|
118
|
+
let data = tile_info.try_borrow_data()?;
|
|
119
|
+
// layout: [disc(8) | owner(32) | x(1) | y(1) | level(1) | list_price(8)]
|
|
120
|
+
let list_price = u64::from_le_bytes(data[43..51].try_into().unwrap());
|
|
121
|
+
require!(list_price > 0, GameError::NotForSale);
|
|
122
|
+
|
|
123
|
+
let royalty = list_price * ROYALTY_BPS / 10_000;
|
|
124
|
+
let seller_proceeds = list_price - royalty;
|
|
125
|
+
|
|
126
|
+
**ctx.accounts.buyer.try_borrow_mut_lamports()? -= list_price;
|
|
127
|
+
**ctx.accounts.seller.try_borrow_mut_lamports()? += seller_proceeds;
|
|
128
|
+
**ctx.accounts.treasury.try_borrow_mut_lamports()? += royalty;
|
|
129
|
+
|
|
130
|
+
drop(data);
|
|
131
|
+
let mut data = tile_info.try_borrow_mut_data()?;
|
|
132
|
+
data[8..40].copy_from_slice(&ctx.accounts.buyer.key().to_bytes());
|
|
133
|
+
Ok(())
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/// B4 + B5 — SEEDED: the CPI program id comes from an UNCHECKED account
|
|
137
|
+
/// (arbitrary CPI), and buyer_token is never bound to the expected mint
|
|
138
|
+
/// (pay with worthless tokens — the Cashio class).
|
|
139
|
+
pub fn buy_tile_spl(ctx: Context<BuyTileSpl>, amount: u64) -> Result<()> {
|
|
140
|
+
let ix = spl_token_transfer(
|
|
141
|
+
&ctx.accounts.token_program.key(),
|
|
142
|
+
&ctx.accounts.buyer_token.key(),
|
|
143
|
+
&ctx.accounts.seller_token.key(),
|
|
144
|
+
&ctx.accounts.buyer.key(),
|
|
145
|
+
amount,
|
|
146
|
+
);
|
|
147
|
+
invoke(
|
|
148
|
+
&ix,
|
|
149
|
+
&[
|
|
150
|
+
ctx.accounts.buyer_token.to_account_info(),
|
|
151
|
+
ctx.accounts.seller_token.to_account_info(),
|
|
152
|
+
ctx.accounts.buyer.to_account_info(),
|
|
153
|
+
ctx.accounts.token_program.to_account_info(),
|
|
154
|
+
],
|
|
155
|
+
)?;
|
|
156
|
+
|
|
157
|
+
let tile = &mut ctx.accounts.tile;
|
|
158
|
+
tile.owner = ctx.accounts.buyer.key();
|
|
159
|
+
tile.list_price = 0;
|
|
160
|
+
Ok(())
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/// B6 — SEEDED: lamports drained but data/discriminator left intact and owner
|
|
164
|
+
/// not reassigned — the account can be re-funded and revived with stale state.
|
|
165
|
+
pub fn close_tile(ctx: Context<CloseTile>) -> Result<()> {
|
|
166
|
+
let tile_info = &ctx.accounts.tile;
|
|
167
|
+
let dest = &ctx.accounts.owner;
|
|
168
|
+
|
|
169
|
+
let lamports = tile_info.lamports();
|
|
170
|
+
**tile_info.try_borrow_mut_lamports()? = 0;
|
|
171
|
+
**dest.try_borrow_mut_lamports()? += lamports;
|
|
172
|
+
Ok(())
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/// B7 + B8 — SEEDED: the program PDA-signs the transfer (so the runtime needs
|
|
176
|
+
/// no caller signature), `authority` is never required to sign, `recipient` is
|
|
177
|
+
/// caller-chosen, and `treasury` is never verified against the derived PDA.
|
|
178
|
+
/// Anyone can call this and drain the vault to themselves.
|
|
179
|
+
pub fn withdraw_treasury(ctx: Context<WithdrawTreasury>, amount: u64, bump: u8) -> Result<()> {
|
|
180
|
+
let ix = system_instruction::transfer(
|
|
181
|
+
&ctx.accounts.treasury.key(),
|
|
182
|
+
&ctx.accounts.recipient.key(),
|
|
183
|
+
amount,
|
|
184
|
+
);
|
|
185
|
+
invoke_signed(
|
|
186
|
+
&ix,
|
|
187
|
+
&[
|
|
188
|
+
ctx.accounts.treasury.to_account_info(),
|
|
189
|
+
ctx.accounts.recipient.to_account_info(),
|
|
190
|
+
],
|
|
191
|
+
&[&[b"treasury", &[bump]]],
|
|
192
|
+
)?;
|
|
193
|
+
Ok(())
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/// B9 — SEEDED: profile is a raw account with no owner check; a fake profile
|
|
197
|
+
/// account owned by an attacker program passes straight through.
|
|
198
|
+
pub fn grant_achievement(ctx: Context<GrantAchievement>, xp: u64) -> Result<()> {
|
|
199
|
+
let profile_info = &ctx.accounts.profile;
|
|
200
|
+
let mut data = profile_info.try_borrow_mut_data()?;
|
|
201
|
+
// layout: [disc(8) | owner(32) | xp(8) | tiles_claimed(4)]
|
|
202
|
+
let current = u64::from_le_bytes(data[40..48].try_into().unwrap());
|
|
203
|
+
data[40..48].copy_from_slice(&(current + xp).to_le_bytes());
|
|
204
|
+
Ok(())
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/// C4 — clean control: checked math, has_one constraints.
|
|
208
|
+
pub fn harvest(ctx: Context<Harvest>) -> Result<()> {
|
|
209
|
+
let tile = &ctx.accounts.tile;
|
|
210
|
+
let profile = &mut ctx.accounts.profile;
|
|
211
|
+
let reward = (tile.level as u64)
|
|
212
|
+
.checked_mul(1_000)
|
|
213
|
+
.ok_or(GameError::Overflow)?;
|
|
214
|
+
profile.xp = profile.xp.checked_add(reward).ok_or(GameError::Overflow)?;
|
|
215
|
+
Ok(())
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
fn spl_token_transfer(
|
|
220
|
+
program_id: &Pubkey,
|
|
221
|
+
from: &Pubkey,
|
|
222
|
+
to: &Pubkey,
|
|
223
|
+
authority: &Pubkey,
|
|
224
|
+
amount: u64,
|
|
225
|
+
) -> Instruction {
|
|
226
|
+
let mut data = vec![3u8];
|
|
227
|
+
data.extend_from_slice(&amount.to_le_bytes());
|
|
228
|
+
Instruction {
|
|
229
|
+
program_id: *program_id,
|
|
230
|
+
accounts: vec![
|
|
231
|
+
AccountMeta::new(*from, false),
|
|
232
|
+
AccountMeta::new(*to, false),
|
|
233
|
+
AccountMeta::new_readonly(*authority, true),
|
|
234
|
+
],
|
|
235
|
+
data,
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ------------------------- Accounts -------------------------
|
|
240
|
+
|
|
241
|
+
#[derive(Accounts)]
|
|
242
|
+
pub struct Initialize<'info> {
|
|
243
|
+
#[account(init, payer = authority, space = 8 + 32 + 8 + 8, seeds = [b"config"], bump)]
|
|
244
|
+
pub config: Account<'info, Config>,
|
|
245
|
+
/// CHECK: treasury PDA, system-owned vault
|
|
246
|
+
#[account(mut, seeds = [b"treasury"], bump)]
|
|
247
|
+
pub treasury: UncheckedAccount<'info>,
|
|
248
|
+
#[account(mut)]
|
|
249
|
+
pub authority: Signer<'info>,
|
|
250
|
+
pub system_program: Program<'info, System>,
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
#[derive(Accounts)]
|
|
254
|
+
pub struct RegisterPlayer<'info> {
|
|
255
|
+
#[account(init_if_needed, payer = player, space = 8 + 32 + 8 + 4, seeds = [b"player", player.key().as_ref()], bump)]
|
|
256
|
+
pub profile: Account<'info, PlayerProfile>,
|
|
257
|
+
#[account(mut)]
|
|
258
|
+
pub player: Signer<'info>,
|
|
259
|
+
pub system_program: Program<'info, System>,
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
#[derive(Accounts)]
|
|
263
|
+
#[instruction(x: u8, y: u8)]
|
|
264
|
+
pub struct ClaimTile<'info> {
|
|
265
|
+
#[account(seeds = [b"config"], bump)]
|
|
266
|
+
pub config: Account<'info, Config>,
|
|
267
|
+
#[account(init, payer = player, space = 8 + 32 + 1 + 1 + 1 + 8, seeds = [b"tile", &[x], &[y]], bump)]
|
|
268
|
+
pub tile: Account<'info, Tile>,
|
|
269
|
+
#[account(mut, seeds = [b"player", player.key().as_ref()], bump)]
|
|
270
|
+
pub profile: Account<'info, PlayerProfile>,
|
|
271
|
+
/// CHECK: treasury PDA
|
|
272
|
+
#[account(mut, seeds = [b"treasury"], bump)]
|
|
273
|
+
pub treasury: UncheckedAccount<'info>,
|
|
274
|
+
#[account(mut)]
|
|
275
|
+
pub player: Signer<'info>,
|
|
276
|
+
pub system_program: Program<'info, System>,
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
#[derive(Accounts)]
|
|
280
|
+
pub struct UpgradeTile<'info> {
|
|
281
|
+
#[account(mut, has_one = owner)]
|
|
282
|
+
pub tile: Account<'info, Tile>,
|
|
283
|
+
#[account(seeds = [b"player", owner.key().as_ref()], bump)]
|
|
284
|
+
pub profile: Account<'info, PlayerProfile>,
|
|
285
|
+
/// CHECK: treasury PDA — SEEDED B8-adjacent: no seeds binding here either
|
|
286
|
+
#[account(mut)]
|
|
287
|
+
pub treasury: UncheckedAccount<'info>,
|
|
288
|
+
#[account(mut)]
|
|
289
|
+
pub player: Signer<'info>,
|
|
290
|
+
/// CHECK: matches tile.owner via has_one
|
|
291
|
+
pub owner: UncheckedAccount<'info>,
|
|
292
|
+
pub system_program: Program<'info, System>,
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
#[derive(Accounts)]
|
|
296
|
+
pub struct ListTile<'info> {
|
|
297
|
+
#[account(mut, has_one = owner)]
|
|
298
|
+
pub tile: Account<'info, Tile>,
|
|
299
|
+
pub owner: Signer<'info>,
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
#[derive(Accounts)]
|
|
303
|
+
pub struct BuyTile<'info> {
|
|
304
|
+
/// CHECK: SEEDED B3 — raw account, no owner/discriminator validation
|
|
305
|
+
#[account(mut)]
|
|
306
|
+
pub tile: UncheckedAccount<'info>,
|
|
307
|
+
/// CHECK: SEEDED B3 — seller paid without cross-checking against tile state
|
|
308
|
+
#[account(mut)]
|
|
309
|
+
pub seller: UncheckedAccount<'info>,
|
|
310
|
+
/// CHECK: SEEDED — treasury never PDA-verified in this struct
|
|
311
|
+
#[account(mut)]
|
|
312
|
+
pub treasury: UncheckedAccount<'info>,
|
|
313
|
+
#[account(mut)]
|
|
314
|
+
pub buyer: Signer<'info>,
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
#[derive(Accounts)]
|
|
318
|
+
pub struct BuyTileSpl<'info> {
|
|
319
|
+
#[account(mut, has_one = owner)]
|
|
320
|
+
pub tile: Account<'info, Tile>,
|
|
321
|
+
/// The official fee-token mint.
|
|
322
|
+
pub mint: Account<'info, Mint>,
|
|
323
|
+
/// SEEDED B5 — never bound to `mint` (token::mint = mint missing)
|
|
324
|
+
#[account(mut)]
|
|
325
|
+
pub buyer_token: Account<'info, TokenAccount>,
|
|
326
|
+
#[account(mut)]
|
|
327
|
+
pub seller_token: Account<'info, TokenAccount>,
|
|
328
|
+
#[account(mut)]
|
|
329
|
+
pub buyer: Signer<'info>,
|
|
330
|
+
/// CHECK: matches tile.owner via has_one
|
|
331
|
+
pub owner: UncheckedAccount<'info>,
|
|
332
|
+
/// CHECK: SEEDED B4 — CPI program id sourced from this unchecked account
|
|
333
|
+
pub token_program: UncheckedAccount<'info>,
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
#[derive(Accounts)]
|
|
337
|
+
pub struct CloseTile<'info> {
|
|
338
|
+
/// CHECK: SEEDED B6 — manual close path
|
|
339
|
+
#[account(mut)]
|
|
340
|
+
pub tile: UncheckedAccount<'info>,
|
|
341
|
+
#[account(mut)]
|
|
342
|
+
pub owner: Signer<'info>,
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
#[derive(Accounts)]
|
|
346
|
+
pub struct WithdrawTreasury<'info> {
|
|
347
|
+
/// CHECK: SEEDED B8 — never verified against the derived treasury PDA
|
|
348
|
+
#[account(mut)]
|
|
349
|
+
pub treasury: UncheckedAccount<'info>,
|
|
350
|
+
/// CHECK: SEEDED B7 — caller-chosen destination
|
|
351
|
+
#[account(mut)]
|
|
352
|
+
pub recipient: UncheckedAccount<'info>,
|
|
353
|
+
/// CHECK: SEEDED B7 — authority is never required to sign
|
|
354
|
+
pub authority: UncheckedAccount<'info>,
|
|
355
|
+
pub system_program: Program<'info, System>,
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
#[derive(Accounts)]
|
|
359
|
+
pub struct GrantAchievement<'info> {
|
|
360
|
+
/// CHECK: SEEDED B9 — raw profile account, no owner check
|
|
361
|
+
#[account(mut)]
|
|
362
|
+
pub profile: UncheckedAccount<'info>,
|
|
363
|
+
pub oracle: Signer<'info>,
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
#[derive(Accounts)]
|
|
367
|
+
pub struct Harvest<'info> {
|
|
368
|
+
#[account(has_one = owner)]
|
|
369
|
+
pub tile: Account<'info, Tile>,
|
|
370
|
+
#[account(mut, seeds = [b"player", owner.key().as_ref()], bump)]
|
|
371
|
+
pub profile: Account<'info, PlayerProfile>,
|
|
372
|
+
pub owner: Signer<'info>,
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ------------------------- State -------------------------
|
|
376
|
+
|
|
377
|
+
#[account]
|
|
378
|
+
pub struct Config {
|
|
379
|
+
pub authority: Pubkey,
|
|
380
|
+
pub claim_fee: u64,
|
|
381
|
+
pub royalty_bps: u64,
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
#[account]
|
|
385
|
+
pub struct Tile {
|
|
386
|
+
pub owner: Pubkey,
|
|
387
|
+
pub x: u8,
|
|
388
|
+
pub y: u8,
|
|
389
|
+
pub level: u8,
|
|
390
|
+
pub list_price: u64,
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
#[account]
|
|
394
|
+
pub struct PlayerProfile {
|
|
395
|
+
pub owner: Pubkey,
|
|
396
|
+
pub xp: u64,
|
|
397
|
+
pub tiles_claimed: u32,
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
#[error_code]
|
|
401
|
+
pub enum GameError {
|
|
402
|
+
#[msg("Coordinates out of bounds")]
|
|
403
|
+
OutOfBounds,
|
|
404
|
+
#[msg("Tile is not listed for sale")]
|
|
405
|
+
NotForSale,
|
|
406
|
+
#[msg("Arithmetic overflow")]
|
|
407
|
+
Overflow,
|
|
408
|
+
#[msg("Profile mismatch")]
|
|
409
|
+
BadProfile,
|
|
410
|
+
}
|