@typesol/cli 0.14.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 (44) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/README.md +35 -5
  3. package/dist/cli.js +161 -24
  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/idlpublish.js +43 -9
  22. package/dist/onchain.d.ts +30 -0
  23. package/dist/onchain.js +53 -0
  24. package/dist/portgen.d.ts +87 -0
  25. package/dist/portgen.js +311 -0
  26. package/dist/templates.d.ts +9 -0
  27. package/dist/templates.js +324 -0
  28. package/dist/testing.d.ts +66 -0
  29. package/dist/testing.js +337 -0
  30. package/dist/workspace.d.ts +3 -2
  31. package/dist/workspace.js +18 -106
  32. package/docs/architecture.md +3 -0
  33. package/docs/cli.md +17 -3
  34. package/docs/language.md +11 -10
  35. package/docs/mainnet.md +100 -0
  36. package/docs/migrate-from-anchor.md +104 -0
  37. package/docs/test-your-program.md +52 -0
  38. package/docs/testing.md +1 -1
  39. package/docs/troubleshooting.md +10 -2
  40. package/lib/typesol.d.ts +68 -12
  41. package/package.json +19 -1
  42. package/runtime/typesol_rt.h +25 -5
  43. package/ts-plugin/index.js +112 -0
  44. package/ts-plugin/package.json +4 -0
package/CHANGELOG.md CHANGED
@@ -5,6 +5,87 @@ 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
+
69
+ ## 0.15.0 — 2026-10-27
70
+
71
+ Project templates for `typesol init`, a mainnet deployment guide, and clearer `typesol idl publish` failures. No
72
+ language, IDL or error-code changes. Not externally audited (see SECURITY.md).
73
+
74
+ ### Added
75
+ - `typesol init --template counter|vault|escrow`: start from a PDA counter (the default, as before), a SOL vault or an
76
+ SPL token escrow. Each template is the matching `examples/` program with a LiteSVM test of its main flow and of a
77
+ refused call; the escrow project also gets `@solana-program/token`.
78
+
79
+ ### Fixed
80
+ - `typesol idl publish` names the reason a write failed (the transaction's error and its last log lines) instead of
81
+ "The provided transaction plan failed to execute".
82
+
83
+ ### Docs
84
+ - docs/mainnet.md: from `typesol init` to a verified program on mainnet-beta (wallet, cost, deploy, IDL, `verify`,
85
+ freezing and closing).
86
+ - README: the official escrow on mainnet-beta in all four versions, with each transaction's compute units linked to
87
+ Solscan; the TypeSOL program's IDL is published on chain.
88
+
8
89
  ## 0.14.0 — 2026-10-27
9
90
 
10
91
  PDAs are derived without the PDA syscalls, so instructions that create, check or search for PDAs use up to a few
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">
@@ -152,6 +152,9 @@ npx typesol build # target/counter/{counter.so, coun
152
152
  npx typesol deploy --url http://127.0.0.1:8899 --keypair ~/.config/solana/id.json
153
153
  ```
154
154
 
155
+ `--template vault` or `--template escrow` starts from a SOL vault or an SPL token escrow instead of the counter, each
156
+ with a passing LiteSVM test.
157
+
155
158
  The npm package is [`@typesol/cli`](https://www.npmjs.com/package/@typesol/cli). Projects install it under the name
156
159
  `typesol`, which is what programs import (`import { … } from "typesol"`); `typesol init` sets that up. To add it to an
157
160
  existing project: `npm install -D typesol@npm:@typesol/cli`.
@@ -159,6 +162,10 @@ existing project: `npm install -D typesol@npm:@typesol/cli`.
159
162
  Deploying again upgrades the program in place. Keep `target/deploy/<name>-keypair.json`: that keypair is your
160
163
  program's address.
161
164
 
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).
168
+
162
169
  ## How it works
163
170
 
164
171
  ```
@@ -185,7 +192,7 @@ Runtime failures point back to your source: `Error 6000 Unauthorized (counter.ts
185
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 |
186
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 |
187
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 |
188
- | **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 |
189
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`) |
190
197
  | **Events** | `emit` (log-based) and `emitCpi` (Anchor `emit_cpi!`, through a self-CPI) |
191
198
  | **Crypto & oracles** | `sha256`, `keccak256`, Merkle proofs, Ed25519 and secp256k1 signature checks, Pyth pull oracle, Switchboard |
@@ -204,7 +211,9 @@ The full reference, with every type, check and built-in, is in [docs/language.md
204
211
  | `typesol idl publish` | upload the IDL to the Program Metadata program (canonical when the upgrade authority signs) |
205
212
  | `typesol cpi <idl.json>` | generate typed CPI helpers for another program |
206
213
  | `typesol client <idl.json>` | generate a typed TypeScript client from an Anchor IDL (`typesol build` writes one per program) |
207
- | `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`) |
208
217
  | `typesol profile` | compute units per TypeScript line, from a `--profile` build's logs |
209
218
  | `typesol emit-c` | print the generated C |
210
219
 
@@ -246,6 +255,27 @@ CPI account records only for the accounts a call lists. It uses fewer compute un
246
255
  instruction here, while also refusing duplicate writable accounts as Anchor does. Fewer compute
247
256
  units are not a safety claim. The differential tests also run the escrow against its Anchor twin ([docs/testing.md](docs/testing.md)).
248
257
 
258
+ ### On mainnet
259
+
260
+ The official escrow ([tokens/escrow](https://github.com/solana-developers/program-examples/tree/de090195e4de698e150572c4f886c333323a64a5/tokens/escrow)),
261
+ deployed to Solana mainnet-beta in all four versions on 2026-10-27 and called with the same mints, amounts and accounts,
262
+ one instruction per transaction. Each figure is the compute units of that transaction, linked to Solscan; the lowest per row is in bold. The deployed
263
+ programs match these builds byte for byte (`typesol verify` for TypeSOL), and the TypeSOL program's IDL is published
264
+ on chain with `typesol idl publish`.
265
+
266
+ | Transaction | TypeSOL | Pinocchio | Native Rust | Anchor |
267
+ | --- | ---: | ---: | ---: | ---: |
268
+ | make_offer (offer 1) | [20,304](https://solscan.io/tx/32PVva77dT1rSWrMWX2sHTMPZiwztdEZYtpANaxrsuJxPcd3hnV6pDgJJaV4LkL3bX6ALDoHMNF8fjngtodYHBS1) | [**19,645**](https://solscan.io/tx/528BdMTrnLWauSXVh2vaFfy4XwKFpbyt95hQ6w3fwjtpkT85RPkRhGydunAizNDRqTTLPxcEVMrdcDvRer4H9LRE) | [30,698](https://solscan.io/tx/4MQgt75MvojaAkfxnvrE2yJWabGpE8bsZhNq2CeGJxHZg6uD6A64Kv71JmrsgiGnaEovvv8K34DYL49owLjhs4QJ) | [38,648](https://solscan.io/tx/9T1ZY9JaSMb1GMqv1i22AuZuJQN6B5XcgqeDv5tGqEm5Uox3MBTdXsECt59GwcXkFG9QKgXJyqndTEzpSXhbAVM) |
269
+ | take_offer (offer 1) | [**7,998**](https://solscan.io/tx/49EQwgnKShFdsAs37AEzYmv6tLY8DipcCjmRgqwj4fVTFEgVVWdEXw5AvuEufL5ymJYs4T4hVFdkENW9XK9een5m) | [25,206](https://solscan.io/tx/21MGkXpKRe4c7EeA1hC6JHevsk78xUdbcWmyqG47EbLeCNKSMTzTjkJQqtydnQmV8uHPmr6bJHnvQmmQTSZB7Am2) | [28,612](https://solscan.io/tx/B1hu1EL4Y5vWhBVWdyZEpzRNJb8kd6G9CgN7KtyNNGpQ84o9dfn1sdy9Z1UCHECNtp24Mw8bEH2iYz2YADdEUjx) | [32,835](https://solscan.io/tx/3ZLuQQz7Bx5YdgmFXpVQWHn5kyc8v39jr2BnfofH6sqenzfJpAdVm4h38uYcHwYygnfso92kJe7HRHeASsABoKPQ) |
270
+ | make_offer (offer 2) | [21,804](https://solscan.io/tx/xuu9KXbZvzGDpTEEcDE9LYvBqmPen81y6ty92e6crRxT8m4Mc4XyxhjWHbjVNsr9RE3QY4M1j1Sw9cH7PRMKKuc) | [**19,645**](https://solscan.io/tx/LLzS13AJrqACGoT1VHZN278ij39pNkttb1Z4Wh2AYXknWxwjaiaiMeD7WZomjP6TBv18RHfQHUfogQzApvXr2mC) | [35,198](https://solscan.io/tx/4xiKfzyccVatef1T52tZ3VAJwcx5R7iWgHWezBzZ3p2WpvoLzMp1PSUgr7x3sspE53mDE5JaghaNPKuUohVgPuwc) | [47,648](https://solscan.io/tx/4mxW8D5zpY8t4fL8JRbvhp6ejU6CUvB3iWmFjACipzexW6tZfFeatfnEnDTX3Y97BxsvAftoX9eQauiVu84xrLxh) |
271
+ | refund_offer (offer 2) | [**5,961**](https://solscan.io/tx/4kY7s3y95Lrd2WGCWGPWW3zFBVtEKH6EskdscuYmRCRRgPt2rJTZ1CmZMGUUkHs7aai9AygEiTSUG23EMUrFr7rg) | [13,387](https://solscan.io/tx/QUXWwdbKRcT6WhZvSe4U8nxr4GAMaPNV6pZ1fwrC28kRxdJm7HKXVr2DFRuKD9bEzoS6hFdiA2rLr1mQWyrN3oz) | [17,731](https://solscan.io/tx/5zj7E4c61j1JzUsUPC1fNs9ECFLVmCug9rsdYsVLn7YD1RSxdbAJUQDam1BX3epTzW6ed2uFGwTiNhwaYo7vqdwm) | [23,576](https://solscan.io/tx/5Kjwnq5tN8mzj5s67VGxSry4kRzZk6HLdgXW65FyTvpUeC1D7aQoDGTwzrA7kqyYEvV9y9znnZmLaqU98zFrJJo4) |
272
+ | program | [65ybkcg6…](https://solscan.io/account/65ybkcg6mQa9t5EuHtirRdvf2qAu12qRiK6wYBbGk16P) | [FrQwq3iU…](https://solscan.io/account/FrQwq3iUa8ZkWEne1y27webXTAyLgH4Hj6t986t6VS2G) | [5pnmi1Md…](https://solscan.io/account/5pnmi1MdxzXKmgX7ooRSE212rzZK8rb7nNpRTSMqP8V1) | [EG7M3sLL…](https://solscan.io/account/EG7M3sLLwB7MhmtzwYsaxbhZaFDbshL9hmVK79bQVyVq) |
273
+
274
+ Most of make_offer is the Associated Token Account program creating the vault (about 13,500 CU in every version); it
275
+ searches for the vault address's bump itself at 1,500 CU a try, which is why TypeSOL's offer 2 cost 1,500 more than
276
+ offer 1 (its own part was 6,719 CU both times). The Pinocchio version takes the offer PDA's bump from the client instead
277
+ of searching for it (its own part: 6,051 CU). Mainnet figures differ from the LiteSVM means above.
278
+
249
279
  ## How it is tested
250
280
 
251
281
  - **Execution tests.** Every feature is compiled and run in LiteSVM.
@@ -276,7 +306,7 @@ Details are in [docs/testing.md](docs/testing.md).
276
306
 
277
307
  ## Status
278
308
 
279
- **TypeSOL is pre-1.0 (0.14).** Releases follow the organization's
309
+ **TypeSOL is pre-1.0 (0.16).** Releases follow the organization's
280
310
  [versioning policy](https://github.com/typesol/.github/blob/main/VERSIONING.md); [CHANGELOG.md](CHANGELOG.md) lists every
281
311
  change.
282
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,12 +5,15 @@ 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";
13
15
  import { formatProfile, profileLogs } from "./profile.js";
16
+ import { TEMPLATE_NAMES } from "./templates.js";
14
17
  import { buildWorkspace, CONFIG, deployArgs, findWorkspace, programAddress, programForEntry, programPaths, scaffold } from "./workspace.js";
15
18
  const ASSETS = fileURLToPath(new URL("../assets/", import.meta.url));
16
19
  /** A usage problem: printed with the command's help, exit code 2. */
@@ -38,14 +41,23 @@ function banner() {
38
41
  const SBPF = `--sbpf v0|v1|v2 SBPF version (default v0, or "sbpf" in ${CONFIG}); v1/v2 need clusters that enabled them`;
39
42
  const COMMANDS = {
40
43
  init: {
41
- usage: "typesol init [<dir>] [--name <program>]",
42
- help: "Create a project: typesol.json, programs/<name>.ts, tests/<name>.test.ts (LiteSVM), package.json, tsconfig.json, .gitignore.\n Refuses to overwrite files. <name> must be snake_case (default: derived from <dir>).",
43
- options: { name: { type: "string" } },
44
+ usage: "typesol init [<dir>] [--name <program>] [--template counter|vault|escrow]",
45
+ help: [
46
+ "Create a project: typesol.json, programs/<name>.ts, tests/<name>.test.ts (LiteSVM), package.json, tsconfig.json, .gitignore.",
47
+ "Refuses to overwrite files. <name> must be snake_case (default: derived from <dir>).",
48
+ "--template counter a PDA counter per user (default)",
49
+ "--template vault a SOL vault at a PDA: deposit, withdraw above rent, close",
50
+ "--template escrow an SPL token escrow: make, take, refund, with the offer PDA signing for its vault",
51
+ ].join("\n "),
52
+ options: { name: { type: "string" }, template: { type: "string" } },
44
53
  run(v, args) {
45
54
  if (args.length > 1)
46
55
  throw new UsageError("init takes at most one directory");
47
56
  if (v.name !== undefined && !/^[a-z][a-z0-9_]*$/.test(v.name))
48
57
  throw new UsageError(`--name "${v.name}" must be snake_case (a-z, 0-9, _; starting with a letter)`);
58
+ const template = v.template ?? "counter";
59
+ if (!TEMPLATE_NAMES.includes(template))
60
+ throw new UsageError(`--template "${template}" is not one of ${TEMPLATE_NAMES.join(", ")}`);
49
61
  const root = path.resolve(args[0] ?? ".");
50
62
  const derived = path
51
63
  .basename(root)
@@ -53,7 +65,7 @@ const COMMANDS = {
53
65
  .replace(/^_+|_+$/g, "")
54
66
  .toLowerCase();
55
67
  const name = v.name ?? (/^[a-z]/.test(derived) ? derived : `p_${derived || "program"}`);
56
- for (const f of scaffold(root, name))
68
+ for (const f of scaffold(root, name, template))
57
69
  log(` created ${path.join(root, f)}`);
58
70
  log(`\n next: cd ${path.relative(process.cwd(), root) || "."} && npm install && npm test`);
59
71
  return 0;
@@ -110,13 +122,35 @@ const COMMANDS = {
110
122
  },
111
123
  },
112
124
  check: {
113
- usage: "typesol check <program.ts>",
114
- help: "Typecheck and validate the TypeSOL subset without building.",
115
- options: {},
116
- run(_, args) {
117
- const { model } = generate(mustExist(one(args, "<program.ts>")));
118
- log(`ok ${model.name}: ${model.instructions.length} instruction(s), ${model.accountStructs.size} account type(s), ${model.events.size} event(s)`);
119
- 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
+ }
120
154
  },
121
155
  },
122
156
  "emit-c": {
@@ -185,7 +219,7 @@ const COMMANDS = {
185
219
  const kit = await import("@solana/kit");
186
220
  const { publishIdl, rpcChain } = await import("./idlpublish.js");
187
221
  const signer = await kit.createKeyPairSignerFromBytes(readKeypair(keyFile));
188
- const chain = rpcChain(kit.createSolanaRpc(rpcUrl));
222
+ const chain = rpcChain(kit.createSolanaRpc(clusterUrl(rpcUrl)));
189
223
  for (const name of names(ws, args)) {
190
224
  // The IDL only needs the compiled model and the program address: the deployed binary is left alone.
191
225
  const { address } = programAddress(ws, name);
@@ -218,6 +252,30 @@ const COMMANDS = {
218
252
  return 0;
219
253
  },
220
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
+ },
221
279
  client: {
222
280
  usage: "typesol client <idl.json> [--out <file.ts>] [--address <base58>]",
223
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.",
@@ -239,41 +297,95 @@ const COMMANDS = {
239
297
  },
240
298
  },
241
299
  verify: {
242
- 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]",
243
301
  help: [
244
- "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.",
245
307
  "The SBPF version is read from the deployed binary unless --sbpf is given. Reproducible builds need the same",
246
308
  "TypeSOL version and platform-tools as the original (see its <name>.build.json).",
247
309
  ].join("\n "),
248
- options: { sbpf: { type: "string" } },
249
- run(v, args) {
250
- if (args.length !== 2)
251
- 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");
252
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`);
253
319
  const ws = findWorkspace(process.cwd());
254
320
  const name = ws && (ws.programs[target] ? target : programForEntry(ws, target));
255
321
  const entry = ws && name ? programPaths(ws, name).entry : target;
256
322
  if (!existsSync(entry))
257
323
  throw new UsageError(`${entry} does not exist`);
258
- if (!existsSync(deployedPath))
259
- throw new UsageError(`${deployedPath} does not exist`);
260
- 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
+ }
261
346
  // ELF e_flags (offset 0x30) hold the SBPF version: 0 for v0, 1 for v1, 2 for v2.
262
347
  const flags = deployed.length > 0x34 && deployed.readUInt32BE(0) === 0x7f454c46 ? deployed.readUInt32LE(0x30) : 0;
263
348
  const sbpf = sbpfOf(v) ?? SBPF_VERSIONS[flags] ?? "v0";
264
349
  const dir = mkdtempSync(path.join(os.tmpdir(), "typesol-verify-"));
265
350
  let res, same;
266
351
  try {
267
- res = build(entry, dir, "11111111111111111111111111111111", { sbpf });
352
+ res = build(entry, dir, onChain?.address ?? "11111111111111111111111111111111", { sbpf });
268
353
  same = verifyBinary(deployed, readFileSync(res.soPath));
269
354
  }
270
355
  finally {
271
356
  rmSync(dir, { recursive: true, force: true });
272
357
  }
358
+ const what = deployedPath ??
359
+ `${onChain.address} (${onChain.info.dataAccount === onChain.address ? "" : `data ${onChain.info.dataAccount}, `}loader ${onChain.info.loader})`;
273
360
  log(same
274
361
  ? ` match ${res.manifest.binary} (sbpf ${sbpf})`
275
- : ` MISMATCH ${deployedPath} is not what ${entry} builds to (${res.manifest.binary}, sbpf ${sbpf})`);
276
- 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;
277
389
  },
278
390
  },
279
391
  profile: {
@@ -346,6 +458,31 @@ function readJson(file) {
346
458
  throw new Error(`${file}: not valid JSON (${e.message})`);
347
459
  }
348
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
+ }
349
486
  /** json_rpc_url / keypair_path from a Solana CLI config (the given file, or the CLI's default one). */
350
487
  function solanaConfig(file) {
351
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 };