@typesol/cli 0.24.1 → 0.26.0

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.
Files changed (47) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +35 -16
  3. package/SECURITY.md +2 -0
  4. package/dist/cli.js +52 -37
  5. package/dist/compiler/analyze.js +11 -1
  6. package/dist/compiler/emit/calls.js +10 -10
  7. package/dist/compiler/emit/cpi.js +4 -0
  8. package/dist/compiler/emit/function.d.ts +9 -0
  9. package/dist/compiler/emit/function.js +169 -139
  10. package/dist/compiler/emit/program.js +3 -0
  11. package/dist/compiler/emit/shared.d.ts +1 -0
  12. package/dist/compiler/emit/shared.js +1 -0
  13. package/dist/compiler/emit/statements.d.ts +6 -0
  14. package/dist/compiler/emit/statements.js +35 -5
  15. package/dist/templates.js +1 -1
  16. package/dist/testing.d.ts +1 -1
  17. package/dist/testing.js +1 -1
  18. package/dist/{clientgen.js → tools/clientgen.js} +4 -1
  19. package/dist/tools/doctor.d.ts +45 -0
  20. package/dist/tools/doctor.js +229 -0
  21. package/dist/{portgen.js → tools/portgen.js} +1 -1
  22. package/dist/workspace.js +1 -1
  23. package/docs/architecture.md +17 -17
  24. package/docs/audit.md +90 -0
  25. package/docs/case-study-escrow.md +224 -0
  26. package/docs/cli.md +3 -2
  27. package/docs/errors.md +26 -1
  28. package/docs/language.md +12 -8
  29. package/docs/migrate-from-anchor.md +3 -1
  30. package/docs/runtime.md +1 -1
  31. package/docs/security-model.md +97 -0
  32. package/docs/testing.md +5 -4
  33. package/lib/typesol.d.ts +35 -13
  34. package/package.json +13 -13
  35. package/runtime/typesol_rt.h +33 -4
  36. /package/dist/{clientgen.d.ts → tools/clientgen.d.ts} +0 -0
  37. /package/dist/{cpigen.d.ts → tools/cpigen.d.ts} +0 -0
  38. /package/dist/{cpigen.js → tools/cpigen.js} +0 -0
  39. /package/dist/{idlpublish.d.ts → tools/idlpublish.d.ts} +0 -0
  40. /package/dist/{idlpublish.js → tools/idlpublish.js} +0 -0
  41. /package/dist/{onchain.d.ts → tools/onchain.d.ts} +0 -0
  42. /package/dist/{onchain.js → tools/onchain.js} +0 -0
  43. /package/dist/{portgen.d.ts → tools/portgen.d.ts} +0 -0
  44. /package/dist/{profile.d.ts → tools/profile.d.ts} +0 -0
  45. /package/dist/{profile.js → tools/profile.js} +0 -0
  46. /package/dist/{svmrpc.d.ts → tools/svmrpc.d.ts} +0 -0
  47. /package/dist/{svmrpc.js → tools/svmrpc.js} +0 -0
@@ -0,0 +1,229 @@
1
+ // `typesol doctor` and the checks `init` and `deploy` run before they need something: Node, the platform-tools the
2
+ // compiler builds with, the Solana CLI, its keypair and RPC, the project, and (with an RPC) whether it answers and the
3
+ // payer can afford a deploy. Each problem comes with the command that fixes it.
4
+ import { execFileSync } from "node:child_process";
5
+ import { existsSync, readFileSync, statSync } from "node:fs";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+ import { findToolchain } from "../compiler/toolchain.js";
9
+ import { base58, readKeypair } from "../keys.js";
10
+ import { findWorkspace } from "../workspace.js";
11
+ export const INSTALL_AGAVE = 'sh -c "$(curl -sSfL https://release.anza.xyz/stable/install)"';
12
+ const MIN_NODE = [22, 12];
13
+ /** The platform-tools the compiler needs: found, or what to run. */
14
+ export function toolchainCheck() {
15
+ try {
16
+ const tc = findToolchain();
17
+ let version = "";
18
+ try {
19
+ version = execFileSync(tc.clang, ["--version"], { encoding: "utf8" })
20
+ .split("\n")[0]
21
+ .replace(/\s*\(.*$/, "");
22
+ }
23
+ catch {
24
+ /* the path is enough */
25
+ }
26
+ return { ok: true, what: "platform-tools", detail: version || tc.sdk };
27
+ }
28
+ catch (e) {
29
+ return {
30
+ ok: false,
31
+ what: "platform-tools",
32
+ detail: e.message.split(". ")[0],
33
+ fix: [INSTALL_AGAVE, "cargo-build-sbf --version # once: downloads the platform-tools TypeSOL compiles with"],
34
+ };
35
+ }
36
+ }
37
+ /** json_rpc_url / keypair_path from a Solana CLI config (the given file, or the CLI's default one). */
38
+ export function solanaConfig(file) {
39
+ const configPath = file ?? path.join(os.homedir(), ".config/solana/cli/config.yml");
40
+ if (!existsSync(configPath))
41
+ return { file: configPath, exists: false };
42
+ const text = readFileSync(configPath, "utf8");
43
+ const get = (k) => new RegExp(`^${k}:\\s*["']?([^"'\\n]+?)["']?\\s*$`, "m").exec(text)?.[1];
44
+ const expand = (p) => (p?.startsWith("~/") ? path.join(os.homedir(), p.slice(2)) : p);
45
+ return { url: get("json_rpc_url"), keypair: expand(get("keypair_path")), file: configPath, exists: true };
46
+ }
47
+ /** The Solana CLI's cluster monikers, as `solana --url` accepts them; anything else is used as given. */
48
+ export function clusterUrl(url) {
49
+ const monikers = {
50
+ "mainnet-beta": "https://api.mainnet-beta.solana.com",
51
+ m: "https://api.mainnet-beta.solana.com",
52
+ devnet: "https://api.devnet.solana.com",
53
+ d: "https://api.devnet.solana.com",
54
+ testnet: "https://api.testnet.solana.com",
55
+ t: "https://api.testnet.solana.com",
56
+ localhost: "http://127.0.0.1:8899",
57
+ l: "http://127.0.0.1:8899",
58
+ };
59
+ return monikers[url] ?? url;
60
+ }
61
+ /** Only the host: an RPC URL can carry an API key in its path or query, and these messages may be shared. */
62
+ export function rpcHost(url) {
63
+ try {
64
+ return new URL(url).host;
65
+ }
66
+ catch {
67
+ return "the RPC";
68
+ }
69
+ }
70
+ /** The cluster an RPC belongs to, for the airdrop hint (mainnet has none). */
71
+ function clusterOf(url) {
72
+ const host = rpcHost(url);
73
+ if (/devnet/.test(host))
74
+ return "devnet";
75
+ if (/testnet/.test(host))
76
+ return "testnet";
77
+ if (/^(127\.0\.0\.1|localhost|0\.0\.0\.0)(:|$)/.test(host))
78
+ return "localhost";
79
+ return undefined;
80
+ }
81
+ export async function rpc(url, method, params = []) {
82
+ const res = await fetch(url, {
83
+ method: "POST",
84
+ headers: { "Content-Type": "application/json" },
85
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
86
+ signal: AbortSignal.timeout(10_000),
87
+ });
88
+ const body = (await res.json());
89
+ if (body.error)
90
+ throw new Error(body.error.message);
91
+ return body.result;
92
+ }
93
+ /** fetch's "fetch failed" says little: the cause's code (ECONNREFUSED, ENOTFOUND, a timeout) says more. */
94
+ export const errText = (e) => {
95
+ const err = e;
96
+ return err.cause?.code ?? err.cause?.message ?? (err.name === "TimeoutError" ? "no answer in 10 s" : err.message);
97
+ };
98
+ const sol = (lamports) => lamports === 0 ? "0 SOL" : lamports < 100_000 ? `${lamports} lamports` : `${(lamports / 1e9).toFixed(lamports < 1e7 ? 4 : 2)} SOL`;
99
+ /** A cost, rounded up so the advice is enough. */
100
+ const solUp = (lamports) => `${(Math.ceil(lamports / 1e7) / 100).toFixed(2)} SOL`;
101
+ /**
102
+ * Before `solana program deploy`: the RPC answers, and the payer holds enough for the program's rent (a first deploy
103
+ * holds about twice the binary's rent while the buffer exists; an upgrade, the buffer's). Throws with what to do.
104
+ */
105
+ export async function deployPreflight(opts) {
106
+ const host = rpcHost(opts.url);
107
+ try {
108
+ await rpc(opts.url, "getVersion");
109
+ }
110
+ catch (e) {
111
+ const local = clusterOf(opts.url) === "localhost";
112
+ throw new Error(`cannot reach ${host} (${errText(e)}). ` +
113
+ (local
114
+ ? "Start a local validator in another terminal: `solana-test-validator`, or deploy to devnet with --url devnet."
115
+ : "Check --url (or json_rpc_url in the Solana CLI config); for a test deploy use --url devnet or a local `solana-test-validator`."));
116
+ }
117
+ if (!opts.payer || !existsSync(opts.payer))
118
+ return; // the Solana CLI reports a missing keypair itself
119
+ const payer = base58(readKeypair(opts.payer).subarray(32));
120
+ let need = 0;
121
+ for (const p of opts.programs) {
122
+ const size = statSync(p.soPath).size;
123
+ const exists = (await rpc(opts.url, "getAccountInfo", [p.address, { encoding: "base64", dataSlice: { offset: 0, length: 0 } }])).value;
124
+ const rent = await rpc(opts.url, "getMinimumBalanceForRentExemption", [size + 45]);
125
+ need += exists ? rent : 2 * rent;
126
+ }
127
+ need += 5_000_000; // fees for the buffer writes
128
+ const balance = (await rpc(opts.url, "getBalance", [payer])).value;
129
+ if (balance >= need)
130
+ return;
131
+ const cluster = clusterOf(opts.url);
132
+ const airdrop = cluster ? ` Get test SOL: \`solana airdrop ${Math.max(2, Math.ceil((need - balance) / 1e9))} ${payer} --url ${cluster}\`.` : "";
133
+ throw new Error(`the payer ${payer} has ${sol(balance)} on ${host}; deploying needs about ${solUp(need)} (rent, mostly returned when a buffer closes).${airdrop}`);
134
+ }
135
+ /** Everything `typesol doctor` checks, in order. */
136
+ export async function doctorChecks(opts) {
137
+ const checks = [];
138
+ const [major, minor] = process.versions.node.split(".").map(Number);
139
+ const nodeOk = major > MIN_NODE[0] || (major === MIN_NODE[0] && minor >= MIN_NODE[1]);
140
+ checks.push({
141
+ ok: nodeOk,
142
+ what: "Node.js",
143
+ detail: process.versions.node,
144
+ fix: nodeOk ? undefined : [`install Node.js ${MIN_NODE.join(".")} or later (https://nodejs.org)`],
145
+ });
146
+ checks.push(toolchainCheck());
147
+ let solanaVersion;
148
+ try {
149
+ solanaVersion = execFileSync("solana", ["--version"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] })
150
+ .split(" (")[0]
151
+ .trim();
152
+ }
153
+ catch {
154
+ /* not installed */
155
+ }
156
+ checks.push(solanaVersion
157
+ ? { ok: true, what: "Solana CLI", detail: solanaVersion }
158
+ : { ok: false, optional: true, what: "Solana CLI", detail: "not on PATH (needed to deploy, not to build or test)", fix: [INSTALL_AGAVE] });
159
+ const cfg = solanaConfig(opts.config);
160
+ const keypair = cfg.keypair ?? path.join(os.homedir(), ".config/solana/id.json");
161
+ if (existsSync(keypair)) {
162
+ let address = "";
163
+ try {
164
+ address = base58(readKeypair(keypair).subarray(32));
165
+ }
166
+ catch (e) {
167
+ checks.push({ ok: false, optional: true, what: "keypair", detail: e.message });
168
+ }
169
+ if (address)
170
+ checks.push({ ok: true, what: "keypair", detail: `${address} (${keypair.replace(os.homedir(), "~")})` });
171
+ }
172
+ else
173
+ checks.push({
174
+ ok: false,
175
+ optional: true,
176
+ what: "keypair",
177
+ detail: `none at ${keypair.replace(os.homedir(), "~")} (the deploy payer)`,
178
+ fix: ["solana-keygen new"],
179
+ });
180
+ const url = opts.url ? clusterUrl(opts.url) : cfg.url;
181
+ if (!url)
182
+ checks.push({ ok: false, optional: true, what: "RPC", detail: "none set (deploy takes --url)", fix: ["solana config set --url devnet"] });
183
+ else {
184
+ const cluster = clusterOf(url);
185
+ try {
186
+ const v = await rpc(url, "getVersion");
187
+ checks.push({ ok: true, what: "RPC", detail: `${rpcHost(url)} (solana-core ${v["solana-core"]})` });
188
+ if (existsSync(keypair)) {
189
+ const balance = (await rpc(url, "getBalance", [base58(readKeypair(keypair).subarray(32))])).value;
190
+ checks.push(balance > 0
191
+ ? { ok: true, what: "balance", detail: sol(balance) }
192
+ : {
193
+ ok: false,
194
+ optional: true,
195
+ what: "balance",
196
+ detail: `0 SOL on ${rpcHost(url)}`,
197
+ fix: [
198
+ cluster && cluster !== "localhost"
199
+ ? `solana airdrop 2 --url ${cluster}`
200
+ : cluster
201
+ ? "solana airdrop 2 --url localhost"
202
+ : "fund the keypair above",
203
+ ],
204
+ });
205
+ }
206
+ }
207
+ catch (e) {
208
+ checks.push({
209
+ ok: false,
210
+ optional: true,
211
+ what: "RPC",
212
+ detail: `${rpcHost(url)} does not answer (${errText(e)})`,
213
+ fix: cluster === "localhost" ? ["solana-test-validator # in another terminal"] : ["pass --url devnet, or fix json_rpc_url in the Solana CLI config"],
214
+ });
215
+ }
216
+ }
217
+ const ws = findWorkspace(opts.cwd);
218
+ checks.push(ws
219
+ ? { ok: true, what: "project", detail: `${path.relative(opts.cwd, ws.file) || ws.file}: ${Object.keys(ws.programs).join(", ")}` }
220
+ : { ok: true, optional: true, what: "project", detail: "none here (typesol init <dir> creates one)" });
221
+ return checks;
222
+ }
223
+ export function formatChecks(checks) {
224
+ const mark = (c) => (c.ok ? "ok " : c.optional ? " ! " : "ERR");
225
+ const width = Math.max(...checks.map((c) => c.what.length));
226
+ return checks
227
+ .map((c) => ` ${mark(c)} ${c.what.padEnd(width)} ${c.detail}${(c.fix ?? []).map((f) => `\n${" ".repeat(width + 9)}${f}`).join("")}`)
228
+ .join("\n");
229
+ }
@@ -9,7 +9,7 @@
9
9
  * data enum, a COption) it says so in a note and a comment.
10
10
  */
11
11
  import { createHash } from "node:crypto";
12
- import { cNameProblem } from "./compiler/analyze.js";
12
+ import { cNameProblem } from "../compiler/analyze.js";
13
13
  const RESERVED = new Set("break case catch class const continue debugger default delete do else enum export extends false finally for function if import in instanceof new null return super switch this throw true try typeof var void while with yield let static implements interface package private protected public await".split(" "));
14
14
  const camel = (s) => s.replace(/[_-]+([a-zA-Z0-9])/g, (_, c) => c.toUpperCase()).replace(/^[A-Z]/, (c) => c.toLowerCase());
15
15
  const pascal = (s) => camel(s).replace(/^[a-z]/, (c) => c.toUpperCase());
package/dist/workspace.js CHANGED
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
15
15
  import path from "node:path";
16
- import { writeClient } from "./clientgen.js";
16
+ import { writeClient } from "./tools/clientgen.js";
17
17
  import { build, COMPILER_VERSION, SBPF_VERSIONS } from "./compiler/index.js";
18
18
  import { loadOrCreateKeypair } from "./keys.js";
19
19
  import { TEMPLATES } from "./templates.js";
@@ -26,13 +26,13 @@ program.so + program.build.json (manifest: versions, sha256 of sources and binar
26
26
 
27
27
  | Path | Contents |
28
28
  | --- | --- |
29
- | `src/` | The compiler (`src/compiler/`) and the tools around it: CLI, workspace, client, CPI and port generators, testing, IDL publishing (source map below) |
29
+ | `src/` | The compiler (`src/compiler/`); the CLI, projects, init templates and `typesol/testing` at the top; generators and chain tooling in `src/tools/` (client, CPI and port generators, IDL publishing, on-chain reads, profiling, the LiteSVM RPC). Source map below |
30
30
  | `runtime/` | `typesol_rt.h`, the C runtime every program includes ([runtime.md](runtime.md)) |
31
31
  | `lib/` | `typesol.d.ts`, the language surface programs typecheck against |
32
32
  | `ts-plugin/` | The editor plugin (`typesol/ts-plugin`) |
33
33
  | `examples/` | Example programs; the `typesol init` templates are copies of three of them |
34
- | `test/` | The test suite: `fixtures/` (programs the tests compile), `ports/` (TypeSOL rewrites of solana-developers/program-examples), `golden/` (pinned generated clients), `programs/` (third-party program binaries, see its SOURCES.md) |
35
- | `bench/anchor/` | Anchor twins of the counter, vault and escrow examples, for the differential tests (`pnpm bench:anchor`) and the zero-copy client test; the benchmarks themselves are in [typesol/benchmarks](https://github.com/typesol/benchmarks) |
34
+ | `test/` | The test suite, by subject: `language/`, `security/`, `examples/`, `integrations/` (token, Metaplex, oracle and other CPIs), `client/`, `cli/`, `porting/`, `verification/` (differential and fuzz tests). Shared: `harness.ts` and the `*-helpers.ts`, `fixtures/` (programs the tests compile), `ports/` (TypeSOL rewrites of solana-developers/program-examples), `golden/` (pinned generated C and clients), `programs/` (third-party program binaries, see its SOURCES.md), `anchor-twins/` (below) |
35
+ | `test/anchor-twins/` | Anchor twins of the counter, vault and escrow examples, for the differential tests (`pnpm build:twins`) and the zero-copy client test; the benchmarks themselves are in [typesol/benchmarks](https://github.com/typesol/benchmarks) |
36
36
  | `scripts/` | `smoke.ts` and `localnet.sh` (a local validator run), `fetch-programs.sh` (third-party binaries), `readme-bench.ts` (the README numbers) |
37
37
  | `docs/` | User and contributor documentation (also published on typesol.com) |
38
38
  | `assets/` | The CLI's logo and the README images (`readme/`, generated) |
@@ -53,17 +53,17 @@ program.so + program.build.json (manifest: versions, sha256 of sources and binar
53
53
  | `src/compiler/index.ts` | The compiler's API (`generate`, `build`, `verifyBinary`, the build manifest). |
54
54
  | `runtime/typesol_rt.h` | The C runtime: error codes, word-wise memory helpers, the entry point's input parser (`ts_deserialize`), account checks, checked math, Borsh helpers, CPIs (System, Token, Token-2022, ATA, Metaplex), oracles and the heap allocator. Header-only and `static inline`, so unused code is dropped. |
55
55
  | `src/workspace.ts` | `typesol.json` projects, program keypairs, `typesol init` templates and deploy arguments. |
56
- | `src/cpigen.ts` | `typesol cpi`: reads an Anchor IDL and writes typed TypeSOL CPI helpers. |
57
- | `src/clientgen.ts` | The TypeScript client (`<name>.client.ts`, [client.md](client.md)): reads an IDL, validates it (it may come from anywhere), and writes one `@solana/kit` file. Not part of the compiler core: `workspace.ts` and the CLI write it after `build()`, and `typesol client` writes it for any IDL. |
58
- | `src/idlpublish.ts` | `typesol idl publish` through the Program Metadata program, and reading a program's IDL back from the chain (Program Metadata or Anchor's IDL account) for `typesol client --address`. |
59
- | `src/portgen.ts` | `typesol port`: a TypeSOL program skeleton from an Anchor IDL (structs, enums, errors, events, one function per instruction). |
56
+ | `src/tools/cpigen.ts` | `typesol cpi`: reads an Anchor IDL and writes typed TypeSOL CPI helpers. |
57
+ | `src/tools/clientgen.ts` | The TypeScript client (`<name>.client.ts`, [client.md](client.md)): reads an IDL, validates it (it may come from anywhere), and writes one `@solana/kit` file. Not part of the compiler core: `workspace.ts` and the CLI write it after `build()`, and `typesol client` writes it for any IDL. |
58
+ | `src/tools/idlpublish.ts` | `typesol idl publish` through the Program Metadata program, and reading a program's IDL back from the chain (Program Metadata or Anchor's IDL account) for `typesol client --address`. |
59
+ | `src/tools/portgen.ts` | `typesol port`: a TypeSOL program skeleton from an Anchor IDL (structs, enums, errors, events, one function per instruction). |
60
60
  | `src/templates.ts` | The programs and tests `typesol init --template` writes (copies of `examples/`; a test checks they match). |
61
- | `src/svmrpc.ts` | `svmConnection`: a kit RPC and subscriptions pair answered by LiteSVM, so generated clients run in tests (exported by `typesol/testing`). |
61
+ | `src/tools/svmrpc.ts` | `svmConnection`: a kit RPC and subscriptions pair answered by LiteSVM, so generated clients run in tests (exported by `typesol/testing`). |
62
62
  | `src/keys.ts` | Base58 and program keypair helpers. |
63
63
  | `ts-plugin/index.js` | `typesol/ts-plugin` (CommonJS, loaded by tsserver): runs `typesol check --json --stdin` on program entries and adds the diagnostics. |
64
64
  | `src/testing.ts` | `typesol/testing`: LiteSVM test helpers for projects (accounts by name from the IDL, plain values). |
65
- | `src/onchain.ts` | `typesol verify --address`: a deployed program's ELF, authority and deploy slot under loader v2, v3 or v4. |
66
- | `src/profile.ts` | Turns `--profile` logs into compute units per source line. |
65
+ | `src/tools/onchain.ts` | `typesol verify --address`: a deployed program's ELF, authority and deploy slot under loader v2, v3 or v4. |
66
+ | `src/tools/profile.ts` | Turns `--profile` logs into compute units per source line. |
67
67
  | `src/cli.ts` | Command parsing. Every command has strict options, `--help`, and exit codes 0 (success), 1 (failure), 2 (usage error) and 3 (verify mismatch). |
68
68
 
69
69
  ## The emitter
@@ -123,7 +123,7 @@ These invariants are easy to break. Each one has tests.
123
123
  3. Put any C helper in `runtime/typesol_rt.h` as `static inline`, returning `uint64_t` (`SUCCESS` or an error code)
124
124
  so that callers can wrap it in `TS_TRY`.
125
125
  4. Add an execution test that compiles a fixture under `test/fixtures/` and runs it in LiteSVM. Add a diagnostics
126
- case in `test/diagnostics.test.ts` for each misuse you reject.
126
+ case in `test/language/diagnostics.test.ts` for each misuse you reject.
127
127
  5. Document it in `docs/language.md` and add a line to `CHANGELOG.md`.
128
128
 
129
129
  ### Add an account constraint
@@ -145,7 +145,7 @@ These invariants are easy to break. Each one has tests.
145
145
  ### Change generated C
146
146
 
147
147
  `test/golden/counter.c` pins the output for the counter example. Run
148
- `UPDATE_GOLDEN=1 pnpm test test/diagnostics.test.ts`, review the diff, and commit it with the change.
148
+ `UPDATE_GOLDEN=1 pnpm test test/language/diagnostics.test.ts`, review the diff, and commit it with the change.
149
149
 
150
150
  For refactors that should not change behaviour, compare binaries: build every example and fixture before and after
151
151
  the change. The sha256 values in the `*.build.json` manifests must not change.
@@ -154,12 +154,12 @@ the change. The sha256 values in the `*.build.json` manifests must not change.
154
154
 
155
155
  | Files | What they cover |
156
156
  | --- | --- |
157
- | `test/<feature>.test.ts` | Execution tests. Each compiles fixtures and runs them in LiteSVM through `test/harness.ts`. |
158
- | `test/diagnostics.test.ts` | Every rejected construct, plus the golden C. |
159
- | `test/hardening.test.ts`, `test/regressions.test.ts` | Attacks and edge cases that once failed. Keep each as a negative test. |
160
- | `test/fuzz-*.test.ts`, `test/differential.test.ts` | Randomized checks against BigInt, a TypeScript state model, and the Anchor twins in `bench/anchor`. See [testing.md](testing.md). |
157
+ | `test/<subject>/<feature>.test.ts` | Execution tests. Each compiles fixtures and runs them in LiteSVM through `test/harness.ts`. |
158
+ | `test/language/diagnostics.test.ts` | Every rejected construct, plus the golden C. |
159
+ | `test/security/hardening.test.ts`, `test/security/regressions.test.ts` | Attacks and edge cases that once failed. Keep each as a negative test. |
160
+ | `test/verification/fuzz-*.test.ts`, `test/verification/differential.test.ts` | Randomized checks against BigInt, a TypeScript state model, and the Anchor twins in `test/anchor-twins`. See [testing.md](testing.md). |
161
161
  | `scripts/smoke.ts` | End to end against a real `solana-test-validator` (`scripts/localnet.sh`). |
162
162
 
163
163
  Some tests need optional inputs and are skipped without them:
164
164
  - `pnpm fetch:programs` downloads the Metaplex programs, which cannot be redistributed.
165
- - `pnpm bench:anchor` builds the Anchor twins and needs Rust and Anchor.
165
+ - `pnpm build:twins` builds the Anchor twins and needs Rust and Anchor.
package/docs/audit.md ADDED
@@ -0,0 +1,90 @@
1
+ # Audit package
2
+
3
+ For a security firm reviewing TypeSOL. TypeSOL compiles a subset of TypeScript to C and then to Solana SBF; every
4
+ program it builds includes one C runtime header. The question for an audit is whether a program compiled by TypeSOL
5
+ can be made to do something its source and [the security model](security-model.md) say it cannot. Programs written
6
+ with TypeSOL are out of scope; their authors audit them.
7
+
8
+ ## Scope
9
+
10
+ In order of priority. Line counts at 0.25.0.
11
+
12
+ | Priority | Component | Files | Lines | Why |
13
+ | --- | --- | --- | ---: | --- |
14
+ | 1 | C runtime | `runtime/typesol_rt.h` | 1,740 | In every program: input parsing, account checks, checked arithmetic, PDAs, system and token CPIs, account creation, resizing and closing, oracles, signature checks, the heap |
15
+ | 1 | Instruction prologue and constraints | `src/compiler/emit/function.ts` | 584 | Which checks each account parameter gets, and their order |
16
+ | 1 | Account data, Vecs and assignment | `src/compiler/emit/storage.ts`, `statements.ts`, `base.ts` | 2,296 | Reads and writes of account data, growable Vec layout and resizing, references and aliasing, closure and helper expansion |
17
+ | 1 | Calls and CPIs | `src/compiler/emit/calls.ts`, `cpi.ts` | 4,276 | Built-ins, remaining accounts, `invoke` and its account flags, signer seeds, token and Metaplex CPIs, account creation |
18
+ | 1 | Expressions | `src/compiler/emit/expressions.ts`, `shared.ts`, `borsh.ts` | 1,512 | Checked arithmetic and casts, Borsh encoding of arguments and `@layout borsh` accounts |
19
+ | 2 | Front end | `src/compiler/analyze.ts`, `model.ts` | 2,202 | What the language accepts; constraints and account types as parsed from the source |
20
+ | 2 | Interface | `src/compiler/idl.ts`, `src/tools/clientgen.ts` | 2,211 | The IDL and the generated client must describe the program exactly (a mismatch can make clients sign the wrong thing) |
21
+ | 3 | Build and tooling | `src/compiler/toolchain.ts`, `src/cli.ts`, `src/workspace.ts`, `src/tools/` | | Build flags, deploy and verification commands |
22
+
23
+ The language surface is `lib/typesol.d.ts` (1,711 lines): every type and built-in a program can use, with its
24
+ documented behavior. [docs/language.md](language.md) is the reference; [docs/architecture.md](architecture.md) maps
25
+ the code, [docs/runtime.md](runtime.md) the runtime's conventions.
26
+
27
+ Out of scope: the Solana runtime, platform-tools (clang, lld), the TypeScript compiler, and programs written with
28
+ TypeSOL.
29
+
30
+ ## What to check
31
+
32
+ The guarantees in [security-model.md](security-model.md), each of which should hold for every program the compiler
33
+ accepts. Areas where a bug would be most damaging, and where earlier internal reviews found the issues listed below:
34
+
35
+ - **Account checks**: every parameter type and constraint, also when an account only reaches a helper, a closure or a
36
+ CPI; optional accounts; the duplicate-writable check.
37
+ - **Aliasing**: two views of one account (parameters, remaining accounts, references held across a call), writes
38
+ through a place the right side moved.
39
+ - **Account data layout**: growable Vecs after the fixed fields, their lengths against the account size, resizing and
40
+ the Vecs that follow, `@layout borsh` accounts.
41
+ - **Arithmetic**: every operator and cast at every width, compound assignment, `mulDiv`, rent.
42
+ - **CPIs and signing**: signer-seed arrays (including their reuse across statements), account flags in `invoke`,
43
+ account creation on pre-funded addresses, closing.
44
+ - **The runtime's parsing**: instruction data, the serialized accounts, Token-2022 extension data, oracle accounts and
45
+ the instructions sysvar.
46
+
47
+ ## Earlier reviews
48
+
49
+ Internal reviews, by the maintainers, in October 2026. Their findings and fixes are in the CHANGELOG:
50
+
51
+ - **0.25.0** (runtime and emitted checks): two Medium issues fixed, a remaining account loaded mutably twice through
52
+ the same slot, and a Vec element written after its right side resized the Vec; plus a token program ambiguity,
53
+ `mint::token_program`, oracle options and documentation of `requirePda`, Merkle leaves and `createIfNeeded`.
54
+ - **0.24.0** (compiler review): helper and closure expansion scoped to their own module, stale struct references
55
+ across Vec moves refused, account-shadowing parameters refused; client SDK hardening.
56
+ - Earlier releases: see `test/security/regressions.test.ts` and `test/security/hardening.test.ts`, which keep every
57
+ attack that once worked as a failing transaction.
58
+
59
+ ## Evidence
60
+
61
+ - **Tests**: 570, by subject under `test/` (`docs/architecture.md`). `test/security/` holds the attacks.
62
+ - **Differential tests**: the same transactions to TypeSOL and to Anchor twins (`test/anchor-twins`), comparing
63
+ outcomes, account contents and balances after every step.
64
+ - **Model-based fuzzing** (`test/verification/`): arithmetic against BigInt at every width; honest and hostile action
65
+ sequences against state models with value conservation; account layout with two growable Vecs and aliasing through
66
+ remaining accounts. Each run prints its seed (`FUZZ_SEED`), `pnpm fuzz` runs them wide.
67
+ - **Ported programs**: all 46 programs of solana-developers/program-examples, run through their original tests' flows
68
+ and failure paths ([test/ports/README.md](../test/ports/README.md)).
69
+ - **Four-way comparison**: [typesol/benchmarks](https://github.com/typesol/benchmarks) runs 17 of those programs in
70
+ TypeSOL, Anchor, native Rust and Pinocchio, requires the same outcome, and records how each refuses the hostile calls
71
+ and with which error.
72
+
73
+ ## Running it
74
+
75
+ ```sh
76
+ git clone https://github.com/typesol/typesol && cd typesol
77
+ pnpm install && pnpm build
78
+ pnpm fetch:programs # Metaplex binaries for the token-metadata, Core and Bubblegum tests
79
+ pnpm build:twins # the Anchor twins (Rust + Anchor 1.0.2)
80
+ pnpm test # everything; CI runs it on Ubuntu (Node 22, 24) per change, macOS daily
81
+ pnpm fuzz # the randomized checks, wide
82
+ ```
83
+
84
+ `node dist/cli.js build <program.ts> --out <dir>` writes the generated C next to the binary, which is the most direct
85
+ way to review what a construct compiles to; `test/golden/counter.c` is a pinned example.
86
+
87
+ ## Contact
88
+
89
+ Coordinate through a private advisory on https://github.com/typesol/typesol/security, or report findings the same way
90
+ (SECURITY.md).
@@ -0,0 +1,224 @@
1
+ # Case study: porting Anchor's escrow
2
+
3
+ This page follows one complete Anchor program into TypeSOL: the token escrow from
4
+ [solana-developers/program-examples](https://github.com/solana-developers/program-examples/tree/de090195e4de698e150572c4f886c333323a64a5/tokens/escrow/anchor)
5
+ (`tokens/escrow`, commit `de09019`). It shows the original and the port side by side, what changed, how the two were
6
+ checked against each other, where they still differ, and what the port costs to run. The port is
7
+ [`test/ports/tokens/escrow.ts`](../test/ports/tokens/escrow.ts); [Migrate from Anchor](migrate-from-anchor.md) is the
8
+ general reference.
9
+
10
+ ## The program
11
+
12
+ A maker offers an amount of token A for an amount of token B.
13
+
14
+ - `make_offer(id, token_a_offered_amount, token_b_wanted_amount)` creates the offer account, a PDA at
15
+ `["offer", maker, id]`, creates the vault (the offer's associated token account for mint A) and moves the offered
16
+ tokens into it.
17
+ - `take_offer()` sends token B from the taker to the maker, releases the vault to the taker, closes the vault and the
18
+ offer, and returns their rent to the maker. It creates the taker's mint A account and the maker's mint B account if
19
+ they do not exist.
20
+ - `refund_offer()` returns the vault to the maker and closes both accounts.
21
+
22
+ Upstream, this is 398 lines of Rust in ten files. The port is one 95-line TypeScript file.
23
+
24
+ ## Side by side
25
+
26
+ ### State
27
+
28
+ ```rust
29
+ #[account]
30
+ #[derive(InitSpace)]
31
+ pub struct Offer {
32
+ pub id: u64,
33
+ pub maker: Pubkey,
34
+ pub token_mint_a: Pubkey,
35
+ pub token_mint_b: Pubkey,
36
+ pub token_b_wanted_amount: u64,
37
+ pub bump: u8,
38
+ }
39
+ ```
40
+
41
+ ```ts
42
+ export interface Offer {
43
+ id: u64;
44
+ maker: Pubkey;
45
+ tokenMintA: Pubkey;
46
+ tokenMintB: Pubkey;
47
+ tokenBWantedAmount: u64;
48
+ bump: u8;
49
+ }
50
+ ```
51
+
52
+ The account has the same discriminator and the same Borsh bytes: an Anchor client decodes a TypeSOL offer unchanged.
53
+ Field names are camelCase in TypeScript and snake_case in the IDL, as Anchor writes them.
54
+
55
+ ### make_offer
56
+
57
+ Anchor declares the accounts in a struct with constraint attributes, then splits the work into helper functions:
58
+
59
+ ```rust
60
+ #[derive(Accounts)]
61
+ #[instruction(id: u64)]
62
+ pub struct MakeOffer<'info> {
63
+ #[account(mut)]
64
+ pub maker: Signer<'info>,
65
+ #[account(mint::token_program = token_program)]
66
+ pub token_mint_a: InterfaceAccount<'info, Mint>,
67
+ #[account(mint::token_program = token_program)]
68
+ pub token_mint_b: InterfaceAccount<'info, Mint>,
69
+ #[account(mut, associated_token::mint = token_mint_a, associated_token::authority = maker,
70
+ associated_token::token_program = token_program)]
71
+ pub maker_token_account_a: InterfaceAccount<'info, TokenAccount>,
72
+ #[account(init, payer = maker, space = ANCHOR_DISCRIMINATOR + Offer::INIT_SPACE,
73
+ seeds = [b"offer", maker.key().as_ref(), id.to_le_bytes().as_ref()], bump)]
74
+ pub offer: Account<'info, Offer>,
75
+ #[account(init, payer = maker, associated_token::mint = token_mint_a, associated_token::authority = offer,
76
+ associated_token::token_program = token_program)]
77
+ pub vault: InterfaceAccount<'info, TokenAccount>,
78
+ pub associated_token_program: Program<'info, AssociatedToken>,
79
+ pub token_program: Interface<'info, TokenInterface>,
80
+ pub system_program: Program<'info, System>,
81
+ }
82
+ // + send_offered_tokens_to_vault (a TransferChecked CPI through a shared transfer_tokens helper)
83
+ // + save_offer (offer.set_inner(Offer { … bump: context.bumps.offer }))
84
+ ```
85
+
86
+ In TypeSOL the accounts are the function's parameters, in the same order, and each constraint is part of the
87
+ parameter's type:
88
+
89
+ ```ts
90
+ export function makeOffer(
91
+ maker: Mut<Signer>,
92
+ tokenMintA: MintOf<Mint, never, never, "tokenProgram">,
93
+ tokenMintB: MintOf<Mint, never, never, "tokenProgram">,
94
+ makerTokenAccountA: Ata<Mut<TokenAccount>, "maker", "tokenMintA">,
95
+ offer: Init<Offer>,
96
+ vault: Ata<Init<TokenAccount>, "offer", "tokenMintA">,
97
+ associatedTokenProgram: AssociatedTokenProgram,
98
+ tokenProgram: TokenInterface,
99
+ systemProgram: SystemProgram,
100
+ id: u64,
101
+ tokenAOfferedAmount: u64,
102
+ tokenBWantedAmount: u64,
103
+ ): void {
104
+ offer.bump = offer.create(maker, ["offer", maker.key, id]);
105
+ vault.create(maker);
106
+ tokenProgram.transferChecked(makerTokenAccountA, tokenMintA, vault, maker, tokenAOfferedAmount);
107
+ offer.id = id;
108
+ offer.maker = maker.key;
109
+ offer.tokenMintA = tokenMintA.key;
110
+ offer.tokenMintB = tokenMintB.key;
111
+ offer.tokenBWantedAmount = tokenBWantedAmount;
112
+ }
113
+ ```
114
+
115
+ `MintOf<Mint, never, never, "tokenProgram">` is `mint::token_program = token_program`: the mint must be owned by
116
+ the token program passed (2022 otherwise). `offer.create` finds the canonical bump, creates the PDA with the computed space and returns the bump, which Anchor
117
+ exposes as `context.bumps.offer`. `transferChecked` reads the mint's decimals itself.
118
+
119
+ ### take_offer and refund_offer
120
+
121
+ The offer is checked declaratively, as in Anchor: the seeds come from the maker account and the offer's own `id`
122
+ field, and the stored bump is used.
123
+
124
+ ```rust
125
+ #[account(mut, close = maker, has_one = maker, has_one = token_mint_a, has_one = token_mint_b,
126
+ seeds = [b"offer", maker.key().as_ref(), offer.id.to_le_bytes().as_ref()], bump = offer.bump)]
127
+ offer: Account<'info, Offer>,
128
+ ```
129
+
130
+ ```ts
131
+ offer: Seeds<CloseTo<HasOne<Mut<Offer>, "maker" | "tokenMintA" | "tokenMintB">, "maker">, ["offer", "@maker", "@offer.id"], "bump">,
132
+ ```
133
+
134
+ The body signs for the vault with the offer's seeds, where Anchor builds a `signer_seeds` array and a
135
+ `CpiContext::new_with_signer` for each call:
136
+
137
+ ```ts
138
+ takerTokenAccountA.createIfNeeded(taker);
139
+ makerTokenAccountB.createIfNeeded(taker);
140
+ tokenProgram.transferChecked(takerTokenAccountB, tokenMintB, makerTokenAccountB, taker, offer.tokenBWantedAmount);
141
+ tokenProgram.transferChecked(vault, tokenMintA, takerTokenAccountA, offer, vault.amount, ["offer", maker.key, offer.id, offer.bump]);
142
+ tokenProgram.closeAccount(vault, maker, offer, ["offer", maker.key, offer.id, offer.bump]);
143
+ ```
144
+
145
+ `CloseTo<…, "maker">` closes the offer after the body returns, as Anchor's `close = maker` does.
146
+
147
+ ## What changed
148
+
149
+ | Anchor | Port |
150
+ | --- | --- |
151
+ | `Signer`, `SystemAccount` (`#[account(mut)]`) | `Mut<Signer>`, `Mut<SystemAccount>` |
152
+ | `InterfaceAccount<Mint>`, `InterfaceAccount<TokenAccount>` | `Mint`, `TokenAccount` |
153
+ | `mint::token_program = token_program` | `MintOf<Mint, never, never, "tokenProgram">` |
154
+ | `associated_token::mint = m, associated_token::authority = a` | `Ata<TokenAccount, "a", "m">` |
155
+ | `init` / `init_if_needed` on an associated token account | `Ata<Init<…>>` + `vault.create(payer)` / `Ata<InitIfNeeded<…>>` + `createIfNeeded(payer)` |
156
+ | `init, seeds, bump` + `context.bumps.offer` | `Init<Offer>` + `offer.bump = offer.create(maker, seeds)` |
157
+ | `seeds = […], bump = offer.bump` | `Seeds<…, ["offer", "@maker", "@offer.id"], "bump">` |
158
+ | `has_one`, `close` | `HasOne<…>`, `CloseTo<…>` |
159
+ | `transfer_checked(CpiContext::new_with_signer(…), amount, decimals)` | `tokenProgram.transferChecked(from, mint, to, authority, amount, seeds)` |
160
+ | `ErrorCode::CustomError` (unused upstream) | `enum ErrorCode`, the same code 6000 |
161
+
162
+ Nothing in the program needed a construct TypeSOL lacks, and every account constraint has its TypeSOL form; the port
163
+ is marked PORTED in [the ports table](../test/ports/README.md).
164
+
165
+ ## Same inputs, same results
166
+
167
+ Two independent checks run the original and the port against each other.
168
+
169
+ - **The port's own test** ([`test/porting/ports-tokens.test.ts`](../test/porting/ports-tokens.test.ts)) runs the upstream flow with SPL
170
+ Token mints and again with Token-2022 mints: make, take (creating the taker's and the maker's missing token accounts),
171
+ make again, a refund attempt by someone other than the maker, and the refund. It checks balances, the decoded offer
172
+ and that offer and vault are closed.
173
+ - **The four-way comparison** in [typesol/benchmarks](https://github.com/typesol/benchmarks) builds the upstream Anchor,
174
+ native and Pinocchio versions unmodified and sends all four the same 21 offers (ten taken, ten refunded, one left
175
+ open). The decoded open offer, every token balance, the vault's authority and which accounts were closed must be
176
+ identical before any number is reported. They are.
177
+
178
+ Both implementations refuse the hostile calls the comparison makes:
179
+
180
+ | Hostile call | Anchor | TypeSOL |
181
+ | --- | --- | --- |
182
+ | refund_offer signed by someone other than the maker | 2006 ConstraintSeeds | 2006 ConstraintSeeds |
183
+ | take_offer naming the taker as the maker | 2015 ConstraintTokenOwner | 2040 ConstraintDuplicateMutableAccount |
184
+
185
+ TypeSOL checks each account's constraints in Anchor's order, so a refused transaction usually gets the same error
186
+ code (see [which error a transaction gets](errors.md#which-error-a-transaction-gets)). The second call differs on
187
+ purpose: taker and maker are then the same writable account, which TypeSOL refuses for any two writable parameters,
188
+ where Anchor only compares program and token accounts and fails later, on the maker's token account. A client that
189
+ matches on this code needs to accept either.
190
+
191
+ ## Cost
192
+
193
+ Mean compute units per instruction in LiteSVM (21 make_offer, 10 take_offer and 10 refund_offer calls), and the
194
+ program size, from typesol/benchmarks (TypeSOL at this version; Anchor 1.0.2). Lower is better.
195
+
196
+ | | TypeSOL | Anchor | Native Rust | Pinocchio |
197
+ | --- | ---: | ---: | ---: | ---: |
198
+ | make_offer | **19,697** | 35,726 | 33,206 | 20,581 |
199
+ | take_offer | **7,053** | 29,235 | 24,995 | 21,606 |
200
+ | refund_offer | **4,271** | 15,326 | 12,477 | 8,137 |
201
+ | Binary | 32.0 KB | 244.5 KB | 120.4 KB | **25.1 KB** |
202
+
203
+ Most of `make_offer` in every version is the Associated Token Account program creating the vault. The native and
204
+ Pinocchio versions are teaching code with fewer checks (no owner or type check on the offer, `Transfer` instead of
205
+ `TransferChecked`; Pinocchio takes the offer's bump from the client).
206
+
207
+ The same program, built with an earlier TypeSOL, runs on mainnet next to the other three; the transactions are listed
208
+ with their Solscan links in the [performance report](https://typesol.com/report/) and the
209
+ [README](../README.md#on-mainnet).
210
+
211
+ ## Porting your own program
212
+
213
+ The steps this port followed, in order:
214
+
215
+ 1. Generate the skeleton from the Anchor IDL: `npx typesol port target/idl/escrow.json --out programs/escrow.ts`. It
216
+ keeps instruction names, discriminators, account order and error codes.
217
+ 2. Turn each `#[account(...)]` into the parameter's type, using the table above and
218
+ [Accounts and constraints](migrate-from-anchor.md#accounts-and-constraints). Keep the account order: it is the
219
+ wire format.
220
+ 3. Port the bodies. Helpers like `transfer_tokens` usually disappear into one call; `signer_seeds` arrays become the
221
+ last argument of the CPI.
222
+ 4. Run the upstream test flow against the port with `typesol/testing`, including the failure paths, and compare the
223
+ decoded accounts.
224
+ 5. Compare the IDLs, and check the error codes your clients match on (the table above shows how they can differ).