@typesol/cli 0.15.0 → 0.16.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 (41) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +10 -6
  3. package/dist/cli.js +147 -20
  4. package/dist/compiler/analyze.d.ts +4 -0
  5. package/dist/compiler/analyze.js +51 -9
  6. package/dist/compiler/emit/base.js +1 -1
  7. package/dist/compiler/emit/calls.js +7 -0
  8. package/dist/compiler/emit/cpi.js +188 -9
  9. package/dist/compiler/emit/expressions.d.ts +11 -0
  10. package/dist/compiler/emit/expressions.js +107 -2
  11. package/dist/compiler/emit/function.js +11 -1
  12. package/dist/compiler/emit/shared.js +3 -2
  13. package/dist/compiler/emit/statements.d.ts +13 -0
  14. package/dist/compiler/emit/statements.js +131 -5
  15. package/dist/compiler/emit/storage.d.ts +2 -1
  16. package/dist/compiler/emit/storage.js +24 -7
  17. package/dist/compiler/idl.js +28 -14
  18. package/dist/compiler/model.d.ts +16 -3
  19. package/dist/compiler/toolchain.d.ts +2 -0
  20. package/dist/compiler/toolchain.js +27 -1
  21. package/dist/onchain.d.ts +30 -0
  22. package/dist/onchain.js +53 -0
  23. package/dist/portgen.d.ts +87 -0
  24. package/dist/portgen.js +311 -0
  25. package/dist/templates.js +49 -154
  26. package/dist/testing.d.ts +66 -0
  27. package/dist/testing.js +337 -0
  28. package/dist/workspace.js +11 -1
  29. package/docs/architecture.md +3 -0
  30. package/docs/cli.md +16 -2
  31. package/docs/language.md +11 -10
  32. package/docs/mainnet.md +5 -4
  33. package/docs/migrate-from-anchor.md +104 -0
  34. package/docs/test-your-program.md +52 -0
  35. package/docs/testing.md +1 -1
  36. package/docs/troubleshooting.md +7 -0
  37. package/lib/typesol.d.ts +68 -12
  38. package/package.json +19 -1
  39. package/runtime/typesol_rt.h +25 -5
  40. package/ts-plugin/index.js +112 -0
  41. package/ts-plugin/package.json +4 -0
package/CHANGELOG.md CHANGED
@@ -5,6 +5,67 @@ until 1.0, a minor version may change the language, the IDL layout or error code
5
5
 
6
6
  ## Unreleased
7
7
 
8
+ ## 0.16.0 — 2026-10-28
9
+
10
+ Everyday TypeScript that Anchor developers reach for (`for … of`, `??`, destructuring, `**`, structs returned from
11
+ helpers and instructions, strings in enum variants), tools to move an Anchor program over (`typesol port`, a migration
12
+ guide) and to work on it (`typesol/testing`, an editor plugin, `verify` against the cluster), Token-2022 extension
13
+ setup and Metaplex creators. No IDL or error-code changes for existing programs. Not externally audited (see
14
+ SECURITY.md).
15
+
16
+ ### Added
17
+ - Metaplex `createV3(…, isMutable, { creators, collection }, signerSeeds?)`: 1–5 creators (shares checked to add up to 100
18
+ when literal) and an unverified collection mint, checked against the real Token Metadata program.
19
+ - Data-enum variants can hold a `string` (`type Cmd = { kind: "Say"; text: string } | …`), as Anchor enums with `String`
20
+ payloads: Borsh in instruction arguments and events, a value in code (a copy when built from account text), refused
21
+ in account data. The IDL describes them as Borsh enums only.
22
+ - `typesol port <idl.json>`: a TypeSOL program skeleton from an Anchor IDL (structs, enums, errors, one function per
23
+ instruction with its accounts and arguments). Discriminators, account names and error codes are kept, so existing
24
+ clients keep working where layouts match; notes list where Anchor's Borsh layout differs. Round-trips every example
25
+ (example → IDL → port → IDL) with the same instructions, accounts, layouts and error codes.
26
+ - Token-2022 extension initializers on `Token2022Program` / `TokenInterface` (refused on `TokenProgram`): transfer fee,
27
+ mint close authority, non-transferable, permanent delegate, default account state, metadata / group / group-member
28
+ pointers, transfer hook, interest-bearing, immutable owner. Byte layouts checked against the official
29
+ @solana-program/token-2022 decoder on real mints.
30
+ - `typesol/ts-plugin`: a TypeScript language-service plugin that shows the compiler's own errors in the editor, for
31
+ the unsaved text, once TypeScript has none. `typesol init` projects enable it in `programs/tsconfig.json` (the
32
+ editor must use the workspace's TypeScript). `typesol check` gains `--json` and `--stdin`.
33
+ - `typesol/testing`: `testProgram(name, import.meta.url)` builds a project program into LiteSVM, with `wallet()`, `pda(…)`,
34
+ `ix(name, args, accounts)` (accounts by name, flags, fixed addresses and PDAs from the IDL, plain argument values),
35
+ `send`, `ok`, `errorCode`, `account` (decoded to bigints and address strings), `returned` and `balance`. The
36
+ `typesol init` templates' tests use it (the counter test went from 70 lines to 20). See docs/test-your-program.md.
37
+ - `a ** b` on integers, every multiplication checked (5100 on overflow or a negative exponent, where JS throws). A
38
+ literal exponent unrolls at compile time; otherwise a square-and-multiply loop runs.
39
+ - Object destructuring: `const { owner, pos: p } = acct` (`let` for scalars) from a name or a field path, read like
40
+ `acct.owner` (scalars copied, nested structs referenced). Rest elements, defaults and nested patterns are refused.
41
+ - Instructions can return a fixed-size struct as return data (a literal, a stored struct or a helper's result), at
42
+ most 1024 bytes. It goes out in its fixed layout, which the IDL describes under `returns`, so Anchor clients and the
43
+ generated TypeScript client decode it.
44
+ - Helpers can return a fixed-size struct: `return { x, y }`, a stored struct or another helper's result. The caller
45
+ gets its own copy to bind (`const p: Point = at(1n, 2n)`), store (`acct.home = at(…)`, `arr.set(i, at(…))`) or read
46
+ (`at(…).x`). Structs with a Vec or variable-length strings cannot be returned.
47
+ - `a ?? b` on Options of integers, booleans and Pubkeys (b is evaluated only when a is empty; the result is an
48
+ Option when b can be null), and `x!` on an Option (its value; an empty one aborts with 5108).
49
+ - `for (const x of list)` over a `Vec` (account field or argument) or a `FixedArray`: each `x` is what `list.get(i)`
50
+ gives (a value, or a reference into account data for structs, with the same aliasing checks), the length is read
51
+ again before every step as in JS, and `break` / `continue` work. Remaining accounts are still iterated by index.
52
+ - `typesol verify <program>` without a `.so` reads the deployed program from the cluster (`--address`, or the address
53
+ in `typesol.json`; `--url`, cluster monikers or the Solana CLI config) under loader v2, v3 or v4, prints its upgrade
54
+ authority or that it is immutable and its last deploy slot, and compares the IDL published on chain with the built
55
+ one. A published IDL that differs is a mismatch (exit 3). No `solana program dump` step needed.
56
+ - `typesol idl publish --url` accepts the Solana CLI's cluster monikers (`mainnet-beta`, `devnet`, …).
57
+
58
+ ### Fixed
59
+ - `createV3` evaluates its arguments in source order (a creator share computed by a helper that changes account text
60
+ no longer changes the name or fee read before it).
61
+ - A function whose stack frame exceeds SBPF v0's 4 KiB used to build and then corrupt memory at runtime (the toolchain
62
+ only printed a note). The build now fails, naming the instruction or helper and its frame size (v1/v2 frames grow
63
+ dynamically and are not limited).
64
+
65
+ ### Docs
66
+ - docs/migrate-from-anchor.md: Anchor constructs, account data, code and tooling mapped to TypeSOL, with the `typesol port`
67
+ workflow and which account layouts differ.
68
+
8
69
  ## 0.15.0 — 2026-10-27
9
70
 
10
71
  Project templates for `typesol init`, a mainnet deployment guide, and clearer `typesol idl publish` failures. No
package/README.md CHANGED
@@ -34,8 +34,8 @@ clients read TypeSOL programs unchanged.
34
34
  - **Small and fast.** On the same programs, TypeSOL uses 5–46% of Anchor's compute units, with binaries about 10×
35
35
  smaller. It also uses fewer compute units than native Rust and than hand-tuned Pinocchio on every benchmarked
36
36
  instruction.
37
- - **Reproducible.** The same sources and toolchain produce the same bytes. `typesol verify` checks a deployed program
38
- against its source.
37
+ - **Reproducible.** The same sources and toolchain produce the same bytes. `typesol verify` reads a deployed program
38
+ from the cluster and checks it, and the IDL published on chain, against its source.
39
39
 
40
40
  <p align="center">
41
41
  <a href="#numbers">
@@ -162,7 +162,9 @@ existing project: `npm install -D typesol@npm:@typesol/cli`.
162
162
  Deploying again upgrades the program in place. Keep `target/deploy/<name>-keypair.json`: that keypair is your
163
163
  program's address.
164
164
 
165
- To go to mainnet (wallet, cost, deploy, IDL, verification), follow [docs/mainnet.md](docs/mainnet.md).
165
+ Coming from Anchor: [docs/migrate-from-anchor.md](docs/migrate-from-anchor.md) maps every construct, and `typesol port`
166
+ starts from your program's IDL. Tests use `typesol/testing` (accounts by name, plain values; [docs/test-your-program.md](docs/test-your-program.md)). To go to
167
+ mainnet (wallet, cost, deploy, IDL, verification), follow [docs/mainnet.md](docs/mainnet.md).
166
168
 
167
169
  ## How it works
168
170
 
@@ -190,7 +192,7 @@ Runtime failures point back to your source: `Error 6000 Unauthorized (counter.ts
190
192
  | **Accounts** | `Signer`, `Account<T>`, `Mut<T>`, `Init<T>`, `InitIfNeeded<T>` (PDAs and associated token accounts), `Zeroed<T>` (up to 10 MiB), optional accounts (`A \| null`), token accounts and mints, programs, sysvars |
191
193
  | **Constraints** | `HasOne`, `Seeds` (also from account fields and under another program; `pdaBump` like `ctx.bumps`), `CloseTo`, `Ata`, `Address`, `Owner`, `Executable`. They are checked before the body runs and recorded in the IDL |
192
194
  | **Data** | `u8`…`u128`, `i8`…`i128`, `boolean`, `Pubkey`, `Hash`, nested structs, `FixedArray`, `FixedString`, `Option`, unit enums, data enums (discriminated unions), `Vec` that resizes its account |
193
- | **Control flow** | `if`, `for`, `while`, `switch` (with narrowing on data enums), ternaries, helper functions across multiple files |
195
+ | **Control flow** | `if`, `for`, `for … of`, `while`, `switch` (with narrowing on data enums), ternaries, `??`, destructuring, helper functions (returning structs too) across multiple files |
194
196
  | **CPIs** | System, SPL Token, Token-2022 (including extension reads and metadata), ATA, Metaplex Token Metadata, Core, Bubblegum. Generic `invoke` with PDA signing, return data, and typed bindings generated from any Anchor IDL (`typesol cpi`) |
195
197
  | **Events** | `emit` (log-based) and `emitCpi` (Anchor `emit_cpi!`, through a self-CPI) |
196
198
  | **Crypto & oracles** | `sha256`, `keccak256`, Merkle proofs, Ed25519 and secp256k1 signature checks, Pyth pull oracle, Switchboard |
@@ -209,7 +211,9 @@ The full reference, with every type, check and built-in, is in [docs/language.md
209
211
  | `typesol idl publish` | upload the IDL to the Program Metadata program (canonical when the upgrade authority signs) |
210
212
  | `typesol cpi <idl.json>` | generate typed CPI helpers for another program |
211
213
  | `typesol client <idl.json>` | generate a typed TypeScript client from an Anchor IDL (`typesol build` writes one per program) |
212
- | `typesol verify` | rebuild from source and compare with a deployed binary |
214
+ | `typesol port` | a TypeSOL skeleton of an Anchor program from its IDL, keeping discriminators and error codes |
215
+ | `typesol/ts-plugin` | the compiler's errors in the editor as you type ([docs/cli.md](docs/cli.md#editor)) |
216
+ | `typesol verify` | rebuild from source and compare with the program and IDL on chain (or a dumped `.so`) |
213
217
  | `typesol profile` | compute units per TypeScript line, from a `--profile` build's logs |
214
218
  | `typesol emit-c` | print the generated C |
215
219
 
@@ -302,7 +306,7 @@ Details are in [docs/testing.md](docs/testing.md).
302
306
 
303
307
  ## Status
304
308
 
305
- **TypeSOL is pre-1.0 (0.15).** Releases follow the organization's
309
+ **TypeSOL is pre-1.0 (0.16).** Releases follow the organization's
306
310
  [versioning policy](https://github.com/typesol/.github/blob/main/VERSIONING.md); [CHANGELOG.md](CHANGELOG.md) lists every
307
311
  change.
308
312
  - The compiler, runtime and tooling have been through internal security reviews and are covered by the tests above.
package/dist/cli.js CHANGED
@@ -5,8 +5,10 @@ import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { parseArgs } from "node:util";
8
+ import ts from "typescript";
8
9
  import { build, CompileError, COMPILER_VERSION, generate, SBPF_VERSIONS, verifyBinary } from "./compiler/index.js";
9
10
  import { generateCpi } from "./cpigen.js";
11
+ import { generatePort } from "./portgen.js";
10
12
  import { generateClient, writeClient } from "./clientgen.js";
11
13
  import { buildIdl } from "./compiler/idl.js";
12
14
  import { decodeAddress, loadOrCreateKeypair, readKeypair } from "./keys.js";
@@ -120,13 +122,35 @@ const COMMANDS = {
120
122
  },
121
123
  },
122
124
  check: {
123
- usage: "typesol check <program.ts>",
124
- help: "Typecheck and validate the TypeSOL subset without building.",
125
- options: {},
126
- run(_, args) {
127
- const { model } = generate(mustExist(one(args, "<program.ts>")));
128
- log(`ok ${model.name}: ${model.instructions.length} instruction(s), ${model.accountStructs.size} account type(s), ${model.events.size} event(s)`);
129
- return 0;
125
+ usage: "typesol check <program.ts> [--json] [--stdin]",
126
+ help: [
127
+ "Typecheck and validate the TypeSOL subset without building.",
128
+ "--json print { ok, diagnostics: [{ file, line, column, message }] } (exit 1 when there are diagnostics)",
129
+ "--stdin read <program.ts>'s text from stdin instead of the file (an editor's unsaved buffer); its imports are read from disk",
130
+ ].join("\n "),
131
+ options: { json: { type: "boolean" }, stdin: { type: "boolean" } },
132
+ run(v, args) {
133
+ const file = path.resolve(one(args, "<program.ts>"));
134
+ const restore = v.stdin ? overrideFile(file, readFileSync(0, "utf8")) : undefined;
135
+ try {
136
+ if (!v.stdin)
137
+ mustExist(file);
138
+ const { model } = generate(file);
139
+ if (v.json)
140
+ log(JSON.stringify({ ok: true, diagnostics: [] }));
141
+ else
142
+ log(`ok ${model.name}: ${model.instructions.length} instruction(s), ${model.accountStructs.size} account type(s), ${model.events.size} event(s)`);
143
+ return 0;
144
+ }
145
+ catch (e) {
146
+ if (!v.json || !(e instanceof CompileError))
147
+ throw e;
148
+ log(JSON.stringify({ ok: false, diagnostics: e.diagnostics }));
149
+ return 1;
150
+ }
151
+ finally {
152
+ restore?.();
153
+ }
130
154
  },
131
155
  },
132
156
  "emit-c": {
@@ -195,7 +219,7 @@ const COMMANDS = {
195
219
  const kit = await import("@solana/kit");
196
220
  const { publishIdl, rpcChain } = await import("./idlpublish.js");
197
221
  const signer = await kit.createKeyPairSignerFromBytes(readKeypair(keyFile));
198
- const chain = rpcChain(kit.createSolanaRpc(rpcUrl));
222
+ const chain = rpcChain(kit.createSolanaRpc(clusterUrl(rpcUrl)));
199
223
  for (const name of names(ws, args)) {
200
224
  // The IDL only needs the compiled model and the program address: the deployed binary is left alone.
201
225
  const { address } = programAddress(ws, name);
@@ -228,6 +252,30 @@ const COMMANDS = {
228
252
  return 0;
229
253
  },
230
254
  },
255
+ port: {
256
+ usage: "typesol port <idl.json> [--out <file.ts>]",
257
+ help: [
258
+ "A TypeSOL program skeleton from an Anchor IDL (0.30+/1.x): account structs, enums, errors, events and one function per",
259
+ "instruction with its accounts and arguments (bodies to fill in). Discriminators, account names and error order are kept,",
260
+ "so existing clients keep working where the layouts match; the notes (stderr) list where they do not.",
261
+ ].join("\n "),
262
+ options: { out: { type: "string" } },
263
+ run(v, args) {
264
+ const file = one(args, "<idl.json>");
265
+ const res = generatePort(readJson(file));
266
+ if (v.out) {
267
+ if (existsSync(v.out))
268
+ throw new UsageError(`${v.out} exists; refusing to overwrite it`);
269
+ writeFileSync(v.out, res.source);
270
+ log(` wrote ${v.out}`);
271
+ }
272
+ else
273
+ process.stdout.write(res.source);
274
+ for (const n of res.notes)
275
+ console.error(` note: ${n}`);
276
+ return 0;
277
+ },
278
+ },
231
279
  client: {
232
280
  usage: "typesol client <idl.json> [--out <file.ts>] [--address <base58>]",
233
281
  help: "Generate a typed TypeScript client (on @solana/kit) from an Anchor IDL (0.30+/1.x or TypeSOL). The IDL is validated first;\n what the client leaves out is printed as notes. `typesol build` already writes <name>.client.ts for TypeSOL programs.",
@@ -249,41 +297,95 @@ const COMMANDS = {
249
297
  },
250
298
  },
251
299
  verify: {
252
- usage: "typesol verify <program.ts | program> <deployed.so> [--sbpf v0|v1|v2]",
300
+ usage: "typesol verify <program.ts | program> [<deployed.so>] [--address <program id>] [--url <rpc>] [--config <solana cli config>] [--sbpf v0|v1|v2]",
253
301
  help: [
254
- "Rebuild and compare with a deployed binary (e.g. from `solana program dump`). Exit 0 on a match, 3 on a mismatch.",
302
+ "Rebuild and compare with what is deployed. Exit 0 on a match, 3 on a mismatch.",
303
+ "With a .so (e.g. from `solana program dump`): compare with that file.",
304
+ "Without one: read the program from the cluster (--url, else the Solana CLI config) at --address, or at the address",
305
+ "typesol.json records for a project program. Also prints the upgrade authority and compares the IDL published",
306
+ "on chain (`typesol idl publish`) with the one the source builds; a published IDL that differs is a mismatch.",
255
307
  "The SBPF version is read from the deployed binary unless --sbpf is given. Reproducible builds need the same",
256
308
  "TypeSOL version and platform-tools as the original (see its <name>.build.json).",
257
309
  ].join("\n "),
258
- options: { sbpf: { type: "string" } },
259
- run(v, args) {
260
- if (args.length !== 2)
261
- throw new UsageError("verify takes the program and the deployed .so");
310
+ options: { sbpf: { type: "string" }, address: { type: "string" }, url: { type: "string" }, config: { type: "string" } },
311
+ async run(v, args) {
312
+ if (args.length < 1 || args.length > 2)
313
+ throw new UsageError("verify takes the program and, optionally, the deployed .so");
262
314
  const [target, deployedPath] = args;
315
+ if (deployedPath && (v.address || v.url))
316
+ throw new UsageError("give either a deployed .so or --address/--url, not both");
317
+ if (v.address && !decodeAddress(v.address))
318
+ throw new UsageError(`--address "${v.address}" is not a base58 Solana address`);
263
319
  const ws = findWorkspace(process.cwd());
264
320
  const name = ws && (ws.programs[target] ? target : programForEntry(ws, target));
265
321
  const entry = ws && name ? programPaths(ws, name).entry : target;
266
322
  if (!existsSync(entry))
267
323
  throw new UsageError(`${entry} does not exist`);
268
- if (!existsSync(deployedPath))
269
- throw new UsageError(`${deployedPath} does not exist`);
270
- const deployed = readFileSync(deployedPath);
324
+ let deployed;
325
+ let onChain;
326
+ if (deployedPath) {
327
+ if (!existsSync(deployedPath))
328
+ throw new UsageError(`${deployedPath} does not exist`);
329
+ deployed = readFileSync(deployedPath);
330
+ }
331
+ else {
332
+ const address = v.address ?? (ws && name ? ws.programs[name].address : undefined);
333
+ if (!address)
334
+ throw new UsageError("no program address: pass --address, a deployed .so, or name a project program that has been built");
335
+ const rpcUrl = v.url ?? solanaConfig(v.config).url;
336
+ if (!rpcUrl)
337
+ throw new Error("no RPC: pass --url, or set json_rpc_url in the Solana CLI config");
338
+ // Loaded only here: the other commands do not need an RPC client.
339
+ const kit = await import("@solana/kit");
340
+ const { fetchDeployed } = await import("./onchain.js");
341
+ const rpc = kit.createSolanaRpc(clusterUrl(rpcUrl));
342
+ const info = await fetchDeployed(rpc, address);
343
+ onChain = { address, info, rpc };
344
+ deployed = info.elf;
345
+ }
271
346
  // ELF e_flags (offset 0x30) hold the SBPF version: 0 for v0, 1 for v1, 2 for v2.
272
347
  const flags = deployed.length > 0x34 && deployed.readUInt32BE(0) === 0x7f454c46 ? deployed.readUInt32LE(0x30) : 0;
273
348
  const sbpf = sbpfOf(v) ?? SBPF_VERSIONS[flags] ?? "v0";
274
349
  const dir = mkdtempSync(path.join(os.tmpdir(), "typesol-verify-"));
275
350
  let res, same;
276
351
  try {
277
- res = build(entry, dir, "11111111111111111111111111111111", { sbpf });
352
+ res = build(entry, dir, onChain?.address ?? "11111111111111111111111111111111", { sbpf });
278
353
  same = verifyBinary(deployed, readFileSync(res.soPath));
279
354
  }
280
355
  finally {
281
356
  rmSync(dir, { recursive: true, force: true });
282
357
  }
358
+ const what = deployedPath ??
359
+ `${onChain.address} (${onChain.info.dataAccount === onChain.address ? "" : `data ${onChain.info.dataAccount}, `}loader ${onChain.info.loader})`;
283
360
  log(same
284
361
  ? ` match ${res.manifest.binary} (sbpf ${sbpf})`
285
- : ` MISMATCH ${deployedPath} is not what ${entry} builds to (${res.manifest.binary}, sbpf ${sbpf})`);
286
- return same ? 0 : 3;
362
+ : ` MISMATCH ${what} is not what ${entry} builds to (${res.manifest.binary}, sbpf ${sbpf})`);
363
+ if (!onChain)
364
+ return same ? 0 : 3;
365
+ const { info } = onChain;
366
+ log(` authority ${info.authority ?? "none: the program is immutable"}${info.slot !== undefined ? `; last deployed in slot ${info.slot}` : ""}`);
367
+ const { fetchIdl, metadataAddress } = await import("./idlpublish.js");
368
+ const { canonicalJson } = await import("./onchain.js");
369
+ // No canonical metadata account means nothing was published; one that cannot be read is an error, not "none".
370
+ const at = await metadataAddress(onChain.address, null);
371
+ const exists = (await onChain.rpc.getAccountInfo(at, { encoding: "base64" }).send()).value !== null;
372
+ if (!exists) {
373
+ log(" idl none published on chain (typesol idl publish)");
374
+ return same ? 0 : 3;
375
+ }
376
+ let published;
377
+ try {
378
+ published = await fetchIdl(onChain.rpc, onChain.address);
379
+ }
380
+ catch (e) {
381
+ log(` IDL ERROR the IDL at ${at} could not be read: ${e.message}`);
382
+ return 3;
383
+ }
384
+ const idlSame = canonicalJson(published) === canonicalJson(res.idl);
385
+ log(idlSame
386
+ ? " idl match: the published IDL is the one the source builds"
387
+ : " IDL MISMATCH the published IDL differs from the one the source builds");
388
+ return same && idlSame ? 0 : 3;
287
389
  },
288
390
  },
289
391
  profile: {
@@ -356,6 +458,31 @@ function readJson(file) {
356
458
  throw new Error(`${file}: not valid JSON (${e.message})`);
357
459
  }
358
460
  }
461
+ /** Make the compiler read `text` for `file` (instead of the disk) until the returned function is called. */
462
+ function overrideFile(file, text) {
463
+ const sys = ts.sys;
464
+ const { readFile, fileExists } = sys;
465
+ sys.readFile = (p, enc) => (path.resolve(p) === file ? text : readFile.call(sys, p, enc));
466
+ sys.fileExists = (p) => path.resolve(p) === file || fileExists.call(sys, p);
467
+ return () => {
468
+ sys.readFile = readFile;
469
+ sys.fileExists = fileExists;
470
+ };
471
+ }
472
+ /** The Solana CLI's cluster monikers, as `solana --url` accepts them; anything else is used as given. */
473
+ function clusterUrl(url) {
474
+ const monikers = {
475
+ "mainnet-beta": "https://api.mainnet-beta.solana.com",
476
+ m: "https://api.mainnet-beta.solana.com",
477
+ devnet: "https://api.devnet.solana.com",
478
+ d: "https://api.devnet.solana.com",
479
+ testnet: "https://api.testnet.solana.com",
480
+ t: "https://api.testnet.solana.com",
481
+ localhost: "http://127.0.0.1:8899",
482
+ l: "http://127.0.0.1:8899",
483
+ };
484
+ return monikers[url] ?? url;
485
+ }
359
486
  /** json_rpc_url / keypair_path from a Solana CLI config (the given file, or the CLI's default one). */
360
487
  function solanaConfig(file) {
361
488
  const configPath = file ?? path.join(os.homedir(), ".config/solana/cli/config.yml");
@@ -2,8 +2,12 @@ import ts from "typescript";
2
2
  import { Reporter } from "./diagnostics.js";
3
3
  import { type DataEnumDef, type EnumDef, type ProgramModel, type ScalarType, type StructDef, type ValueType, isScalar } from "./model.js";
4
4
  export declare const LIB_PATH: string;
5
+ /** Why a name cannot be used as a C member, or undefined when it can. */
6
+ export declare function cNameProblem(name: string): string | undefined;
5
7
  /** Solana's account size limit, and what a program can allocate itself through a CPI (Init<T>). */
6
8
  export declare const MAX_ACCOUNT_SIZE: number;
9
+ /** sol_set_return_data's limit. */
10
+ export declare const MAX_RETURN_DATA = 1024;
7
11
  export declare const MAX_CPI_ALLOCATION: number;
8
12
  export interface Analysis {
9
13
  model: ProgramModel;
@@ -17,7 +17,7 @@ const C_RESERVED = new Set([
17
17
  ..."NULL SUCCESS HEAP_LENGTH HEAP_START_ADDRESS MAX_PERMITTED_DATA_INCREASE MAX_RETURN_DATA MAX_SEED_LENGTH_EXCEEDED INVALID_SEEDS SIZE_PUBKEY memcpy memset strlen static_assert offsetof sol_log_64".split(" "),
18
18
  ]);
19
19
  /** Why a name cannot be used as a C member, or undefined when it can. */
20
- function cNameProblem(name) {
20
+ export function cNameProblem(name) {
21
21
  if (name.includes("__") || name.startsWith("_"))
22
22
  return `"${name}": names starting with "_" or containing "__" are reserved for generated code`;
23
23
  if (name.includes("$"))
@@ -94,6 +94,8 @@ const MAX_ARRAY_LEN = 1_000_000;
94
94
  const MAX_STRING_CAP = 10_240;
95
95
  /** Solana's account size limit, and what a program can allocate itself through a CPI (Init<T>). */
96
96
  export const MAX_ACCOUNT_SIZE = 10 * 1024 * 1024;
97
+ /** sol_set_return_data's limit. */
98
+ export const MAX_RETURN_DATA = 1024;
97
99
  export const MAX_CPI_ALLOCATION = 10 * 1024;
98
100
  export function snakeCase(name) {
99
101
  return name
@@ -388,6 +390,9 @@ export function analyze(entry) {
388
390
  instructions.push(ix);
389
391
  for (const a of ix.args)
390
392
  collectStructs(a.type, structs, dataEnums, argStructs);
393
+ // A returned struct goes out in its fixed layout, like an argument comes in: it cannot also be a Borsh event.
394
+ if (ix.returns?.kind === "struct")
395
+ collectStructs(ix.returns, structs, dataEnums, argStructs);
391
396
  for (const a of ix.accounts)
392
397
  if (a.kind.kind === "account" || a.kind.kind === "init" || a.kind.kind === "zeroed" || a.kind.kind === "initIfNeeded")
393
398
  accountStructs.add(a.kind.struct.name);
@@ -696,8 +701,8 @@ function analyzeStructs(items, aliases, dataEnums, reporter, scopeFor, fstrings)
696
701
  if (problem)
697
702
  reporter.fail(p.name, problem);
698
703
  const t = fieldType(p.type);
699
- if (t.kind === "string" || t.kind === "vec")
700
- reporter.fail(p.type, "variant fields must have a fixed size (use FixedString<N>)");
704
+ if (t.kind === "vec")
705
+ reporter.fail(p.type, "variant fields must have a fixed size (a Vec cannot be one; use FixedArray<T, N>)");
701
706
  fields.push({ name: p.name.text, type: t });
702
707
  }
703
708
  if (!vname || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(vname))
@@ -734,11 +739,31 @@ function analyzeStructs(items, aliases, dataEnums, reporter, scopeFor, fstrings)
734
739
  reporter.fail(at, `data enum ${e.name} contains itself`);
735
740
  if (state.get(`enum:${e.name}`) !== "done") {
736
741
  state.set(`enum:${e.name}`, "visiting");
742
+ // A string field is a TS_Str view (16 bytes) in the C payload and makes the enum variable-size.
743
+ const fieldSize = (f) => {
744
+ if (f.kind === "string") {
745
+ e.variable = true;
746
+ return 16;
747
+ }
748
+ if (f.kind === "dataEnum") {
749
+ const inner = dataEnums.get(f.name);
750
+ const size = sizeOf(f, e.node);
751
+ if (inner?.variable) {
752
+ e.variable = true;
753
+ return 1 + inner.payload;
754
+ }
755
+ return size;
756
+ }
757
+ return sizeOf(f, e.node);
758
+ };
737
759
  for (const v of e.variants)
738
- v.size = v.fields.reduce((sum, f) => sum + sizeOf(f.type, e.node), 0);
760
+ v.size = v.fields.reduce((sum, f) => sum + fieldSize(f.type), 0);
739
761
  e.payload = Math.max(0, ...e.variants.map((v) => v.size));
740
762
  state.set(`enum:${e.name}`, "done");
741
763
  }
764
+ // Variable enums have no fixed layout: a struct holding one is event-only, like one holding a string.
765
+ if (e.variable)
766
+ return undefined;
742
767
  return 1 + e.payload;
743
768
  }
744
769
  case "vec":
@@ -801,10 +826,16 @@ function analyzeHelper(fn, reporter, scope, structs) {
801
826
  if (fn.typeParameters?.length || fn.asteriskToken)
802
827
  reporter.fail(fn, "helper functions cannot be generic or generators");
803
828
  if (!fn.type)
804
- reporter.fail(fn.name, "helper functions need an explicit return type (void, an integer, boolean or Pubkey)");
805
- const returns = fn.type.kind === ts.SyntaxKind.VoidKeyword ? undefined : resolveScalarType(fn.type, scope);
806
- if (fn.type.kind !== ts.SyntaxKind.VoidKeyword && !returns)
807
- reporter.fail(fn.type, "helpers return void, an integer, boolean or Pubkey");
829
+ reporter.fail(fn.name, "helper functions need an explicit return type (void, an integer, boolean, Pubkey or a struct)");
830
+ let returns = fn.type.kind === ts.SyntaxKind.VoidKeyword ? undefined : resolveScalarType(fn.type, scope);
831
+ if (fn.type.kind !== ts.SyntaxKind.VoidKeyword && !returns) {
832
+ const s = ts.isTypeReferenceNode(fn.type) && ts.isIdentifier(fn.type.typeName) && !fn.type.typeArguments ? structs.get(fn.type.typeName.text) : undefined;
833
+ if (s && (s.size === undefined || s.vec))
834
+ reporter.fail(fn.type, `${s.name} has variable-size fields and cannot be returned; return a struct of fixed size`);
835
+ if (!s)
836
+ reporter.fail(fn.type, "helpers return void, an integer, boolean, Pubkey or a struct");
837
+ returns = { kind: "struct", name: s.name };
838
+ }
808
839
  const params = fn.parameters.map((p) => {
809
840
  if (!ts.isIdentifier(p.name) || !p.type || p.initializer || p.questionToken || p.dotDotDotToken)
810
841
  reporter.fail(p, "parameters need a plain name and an explicit type");
@@ -865,8 +896,19 @@ function analyzeInstruction(fn, reporter, scope, structs) {
865
896
  let returns;
866
897
  if (fn.type && fn.type.kind !== ts.SyntaxKind.VoidKeyword) {
867
898
  returns = resolveScalarType(fn.type, scope);
899
+ const s = !returns && ts.isTypeReferenceNode(fn.type) && ts.isIdentifier(fn.type.typeName) && !fn.type.typeArguments
900
+ ? structs.get(fn.type.typeName.text)
901
+ : undefined;
902
+ if (s) {
903
+ if (s.size === undefined || s.vec)
904
+ reporter.fail(fn.type, `${s.name} has variable-size fields and cannot be return data`);
905
+ // The runtime caps return data at 1024 bytes.
906
+ if (s.size > MAX_RETURN_DATA)
907
+ reporter.fail(fn.type, `${s.name} is ${s.size} bytes; return data holds at most ${MAX_RETURN_DATA}`);
908
+ returns = { kind: "struct", name: s.name };
909
+ }
868
910
  if (!returns)
869
- reporter.fail(fn.type, "instructions return void or one integer, boolean or Pubkey (sent as return data)");
911
+ reporter.fail(fn.type, "instructions return void, one integer, boolean or Pubkey, or a fixed-size struct (sent as return data)");
870
912
  }
871
913
  const accounts = [];
872
914
  const args = [];
@@ -136,7 +136,7 @@ export class EmitterBase {
136
136
  if ((ts.isPropertyAccessExpression(node) || ts.isCallExpression(node) || ts.isElementAccessExpression(node)) && node.questionDotToken)
137
137
  this.fail(node, "optional chaining (?.) is not supported; check `x !== null` first");
138
138
  if (ts.isNonNullExpression(node))
139
- this.fail(node, "the ! assertion is not supported; check `x !== null` so TypeScript narrows it");
139
+ this.fail(node, "the ! assertion works on Option values only; check `x !== null` so TypeScript narrows it");
140
140
  }
141
141
  lookup(name) {
142
142
  for (let i = this.scopes.length - 1; i >= 0; i--) {
@@ -905,6 +905,13 @@ export class CallEmitter extends ExpressionEmitter {
905
905
  return undefined;
906
906
  }
907
907
  const t = this.tmp();
908
+ if (h.returns.kind === "struct") {
909
+ // The result lives in a local the caller owns: a reference to it reads and copies like any struct. Declared at
910
+ // the top of the function, so a reference taken inside a block (a ternary's branch) stays valid after it.
911
+ this.hoisted.push(` TSS_${h.returns.name} ${t};`);
912
+ this.line(`TS_TRY(fn_${h.name}(${[...args, `&${t}`].join(", ")}));`);
913
+ return { c: `(&${t})`, type: { kind: "structRef", struct: h.returns.name, writable: true } };
914
+ }
908
915
  this.line(`${scalarC(h.returns)} ${t};`);
909
916
  this.line(`TS_TRY(fn_${h.name}(${[...args, `&${t}`].join(", ")}));`);
910
917
  return h.returns.kind === "pubkey" ? { c: `(&${t})`, type: h.returns } : { c: t, type: h.returns };