@typesol/cli 0.25.0 → 0.27.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 (48) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +32 -14
  3. package/SECURITY.md +2 -0
  4. package/dist/cli.js +52 -37
  5. package/dist/compiler/analyze.js +62 -5
  6. package/dist/compiler/emit/base.d.ts +2 -0
  7. package/dist/compiler/emit/base.js +4 -1
  8. package/dist/compiler/emit/calls.d.ts +16 -1
  9. package/dist/compiler/emit/calls.js +129 -1
  10. package/dist/compiler/emit/expressions.js +18 -0
  11. package/dist/compiler/emit/function.js +28 -0
  12. package/dist/compiler/emit/shared.js +2 -0
  13. package/dist/compiler/emit/storage.js +18 -0
  14. package/dist/compiler/model.d.ts +12 -0
  15. package/dist/compiler/model.js +3 -0
  16. package/dist/templates.js +1 -1
  17. package/dist/testing.d.ts +1 -1
  18. package/dist/testing.js +1 -1
  19. package/dist/{clientgen.js → tools/clientgen.js} +1 -1
  20. package/dist/tools/doctor.d.ts +45 -0
  21. package/dist/tools/doctor.js +229 -0
  22. package/dist/{portgen.js → tools/portgen.js} +1 -1
  23. package/dist/workspace.js +1 -1
  24. package/docs/architecture.md +17 -17
  25. package/docs/audit.md +90 -0
  26. package/docs/case-study-escrow.md +4 -4
  27. package/docs/cli.md +3 -2
  28. package/docs/errors.md +1 -1
  29. package/docs/language.md +7 -2
  30. package/docs/migrate-from-anchor.md +4 -0
  31. package/docs/runtime.md +1 -1
  32. package/docs/security-model.md +105 -0
  33. package/docs/testing.md +5 -4
  34. package/lib/typesol.d.ts +55 -1
  35. package/package.json +13 -13
  36. package/runtime/typesol_rt.h +82 -21
  37. /package/dist/{clientgen.d.ts → tools/clientgen.d.ts} +0 -0
  38. /package/dist/{cpigen.d.ts → tools/cpigen.d.ts} +0 -0
  39. /package/dist/{cpigen.js → tools/cpigen.js} +0 -0
  40. /package/dist/{idlpublish.d.ts → tools/idlpublish.d.ts} +0 -0
  41. /package/dist/{idlpublish.js → tools/idlpublish.js} +0 -0
  42. /package/dist/{onchain.d.ts → tools/onchain.d.ts} +0 -0
  43. /package/dist/{onchain.js → tools/onchain.js} +0 -0
  44. /package/dist/{portgen.d.ts → tools/portgen.d.ts} +0 -0
  45. /package/dist/{profile.d.ts → tools/profile.d.ts} +0 -0
  46. /package/dist/{profile.js → tools/profile.js} +0 -0
  47. /package/dist/{svmrpc.d.ts → tools/svmrpc.d.ts} +0 -0
  48. /package/dist/{svmrpc.js → tools/svmrpc.js} +0 -0
package/CHANGELOG.md CHANGED
@@ -6,6 +6,45 @@ codes, and says so here.
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## 0.27.0 — 2026-10-29
10
+
11
+ Anchor 1.0 parity: instruction introspection, `Dup<…>` and account migration. Existing programs compile to the same
12
+ binaries as in 0.26.0.
13
+
14
+ - New: instruction introspection. `InstructionsSysvar` has `length`, `currentIndex()` and `get(i)`, a
15
+ `TransactionInstruction` with `programId`, its accounts (key, signer, writable), its data
16
+ (`readU8/U16/U32/U64/readPubkey`) and `is(program, "name")` (an instruction by its Anchor discriminator); every read
17
+ is bounds-checked (5104). `stackHeight()` is 1 at the top level, more under a CPI.
18
+ - New: `Dup<…>`, Anchor's `dup`: a writable parameter that may be the same account as another, for accounts read and
19
+ written in place (not created, closed, a close destination or `@layout borsh`, which are refused at compile time).
20
+ - New: `Migrate<From, To>`, Anchor 1.0's `Migration`: an existing account rewritten from one struct to another with
21
+ `acct.migrate(payer, value)`, the old data readable through `acct.from` until then (the new value is built before
22
+ the bytes are rewritten), the new through `acct.to` after; resized with the payer topping up rent. For now:
23
+ fixed-layout structs without growable Vecs, no constraints on the account, and it cannot be passed to a helper.
24
+ - New: `/** @discriminator … */` on an account struct (a sha256 preimage such as `account:Counter`, or 0x and 8
25
+ bytes), Anchor's `#[account(discriminator = …)]`: keeps an old type's on-chain discriminator under a new name.
26
+
27
+ ## 0.26.0 — 2026-10-29
28
+
29
+ A first-run experience pass, the security model and audit package, and more fuzzing. The compiler's output is the
30
+ same as 0.25.0 for every program.
31
+
32
+ - New: `typesol doctor` checks Node.js, the platform-tools, the Solana CLI, its keypair, the RPC and the keypair's
33
+ balance there, and the project, and prints the command that fixes each problem (exit 1 when building is impossible).
34
+ - `typesol init` prints the files it creates relative to where it runs, warns with the install commands when the
35
+ platform-tools are missing, and lists the next steps.
36
+ - `typesol deploy` checks, before sending anything, that the RPC answers (else: start `solana-test-validator`, or
37
+ `--url devnet`) and that the payer can pay the rent (else: how much, and the `solana airdrop` command on devnet or a
38
+ local validator). RPC URLs in these messages show only the host.
39
+ - Docs: [security model](docs/security-model.md) (the guarantees a compiled program can rely on, trust boundaries,
40
+ what is left to the author) and [audit package](docs/audit.md) (scope, priorities, earlier reviews, evidence);
41
+ README: deploying on devnet and on a local validator.
42
+ - Tests: `test/verification/fuzz-layout.test.ts`, model-based fuzzing of an account with two growable Vecs and of
43
+ `loadAccountMut` aliasing through remaining accounts (also in `pnpm fuzz`).
44
+ - Repository: tests grouped by subject under `test/`, the Anchor twins in `test/anchor-twins` (`pnpm build:twins`),
45
+ the generators and chain tooling in `src/tools/` (the package's import paths are unchanged); Dependabot for npm and
46
+ GitHub Actions.
47
+
9
48
  ## 0.25.0 — 2026-10-29
10
49
 
11
50
  Security review (October 2026) fixes, and error codes aligned with Anchor. Some refused transactions now report a
package/README.md CHANGED
@@ -149,9 +149,20 @@ npx @typesol/cli init my-app --name counter # typesol.json, programs/counter.t
149
149
  cd my-app && npm install
150
150
  npm test # builds the program and runs it in LiteSVM, an in-process Solana runtime
151
151
  npx typesol build # target/counter/{counter.so, counter.json (IDL), counter.client.ts, counter.build.json}
152
- npx typesol deploy --url http://127.0.0.1:8899 --keypair ~/.config/solana/id.json
153
152
  ```
154
153
 
154
+ `npx typesol doctor` checks all of this (Node.js, platform-tools, Solana CLI, keypair, RPC) and prints the command
155
+ that fixes anything missing. To deploy, on devnet:
156
+
157
+ ```sh
158
+ solana-keygen new # once, if you have no keypair (~/.config/solana/id.json)
159
+ solana airdrop 2 --url devnet # test SOL for the rent (rate-limited: https://faucet.solana.com also works)
160
+ npx typesol deploy --url devnet
161
+ ```
162
+
163
+ or on a local validator: `solana-test-validator` in another terminal, `solana airdrop 5 --url localhost`, then
164
+ `npx typesol deploy --url localhost`. `deploy` checks the RPC and the payer's balance before sending anything.
165
+
155
166
  `--template vault` or `--template escrow` starts from a SOL vault or an SPL token escrow instead of the counter, each
156
167
  with a passing LiteSVM test.
157
168
 
@@ -262,23 +273,28 @@ units are not a safety claim. The differential tests also run the escrow against
262
273
  ### On mainnet
263
274
 
264
275
  The official escrow ([tokens/escrow](https://github.com/solana-developers/program-examples/tree/de090195e4de698e150572c4f886c333323a64a5/tokens/escrow)),
265
- deployed to Solana mainnet-beta in all four versions on 2026-10-27 and called with the same mints, amounts and accounts,
266
- 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
267
- programs match these builds byte for byte (`typesol verify` for TypeSOL), and the TypeSOL program's IDL is published
268
- on chain with `typesol idl publish`.
276
+ deployed to Solana mainnet-beta in all four versions and called with the same mints, amounts and accounts, one
277
+ instruction per transaction. The TypeSOL program was upgraded in place to 0.25.0 on 2026-10-29; the others are as first
278
+ deployed on 2026-10-27. Each figure is the compute units of that transaction, linked to Solscan; the lowest per row is
279
+ in bold. The deployed programs match these builds byte for byte (`typesol verify` for TypeSOL), and the TypeSOL
280
+ program's IDL is published on chain with `typesol idl publish`.
269
281
 
270
282
  | Transaction | TypeSOL | Pinocchio | Native Rust | Anchor |
271
283
  | --- | ---: | ---: | ---: | ---: |
272
- | 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) |
273
- | 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) |
274
- | 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) |
275
- | 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) |
284
+ | make_offer (offer 1) | [29,205](https://solscan.io/tx/4AaenpnDGYmT2JXAm8HuN5D9GkEWLYeqyMNxJq6D9aYk6ssunSwNTigFmZoCJtY7MZ3FFBXLsig8jo1YmjUFVTpX) | [**21,145**](https://solscan.io/tx/3tWjcMQxTja6QjEmAvi1MRF9qCsnPBsAmQNPnd5RcNTwnkkJYmk9JFFnZoJbTAc6MEr2QYQd7KtgPGtaNr3inrCG) | [45,698](https://solscan.io/tx/4AgQzePrXL4f4qYrFQBzE65d8cszpmqSiQVt9yVsAotbrYTnartXuM2g2PFg7VhUGaZpbhfH9jzXgwXdRLXwB7tk) | [38,648](https://solscan.io/tx/4Hm62foYd5hoNqmEfLk51HhVVb5VHa5oTr81rjAKNNXyKFYwSrpXZhzFVwbk3WxRzEsiqGxHbn7B4eBop6RC1Sgx) |
285
+ | take_offer (offer 1) | [**9,951**](https://solscan.io/tx/52ej6M3zFt5kgMGKUef9VGZdmZWqJdSoHCvCEB9P3btg7DSbvHkM24uk95W7HXjSX12XHmEmXATC7GLcQQ1U22kK) | [26,706](https://solscan.io/tx/4KLfVqTM2KmwgqF4xECWmNuZWYufsWcocdT5saamWdZQfRYhY7aTsyEyk5pVTxug6LJnngJhWDU7NMiPnZxu9sH2) | [31,628](https://solscan.io/tx/5f2VxEcPUfZbCVHpbYbUntVwfUN4k6ieetxSjdpJVwfck2tDuv2QTGbFFtNcy34Nn8d6bKSp5efzKDSbg7xj5TbJ) | [32,835](https://solscan.io/tx/3sJVTUvGRRtvYisFyqthvYQUZpeLmNN2UtXJq9TsQHW2TLP8YwtiuFGoPvu51w5YoZyJjKc3dM3zF6JXTFRFjc6H) |
286
+ | make_offer (offer 2) | [20,205](https://solscan.io/tx/36mSJoMESxSLoxL5apVkoPzptAgFmUM9z2ZVuKpCadQajxbTF3T6BT93bw2ugHfLX7qSXvcBQ96kd8m22y3DsbTH) | [**19,645**](https://solscan.io/tx/PXPGNGCN3RN15L812K9Ho99Xz5YmUgMDaDy8aXuSg1kFgtdX42Z6foLLJkn8X9XB68xgeQsTBQhoDdUfRPKks2f) | [29,198](https://solscan.io/tx/2n38Urd1FsijNB2kbQy1b2cNHrvU7sXwYe5zH9SeyZUJRhUaD8TXSJGkvjEGYCTy3bdmAvEqqE27Ufgcxg3S3uFe) | [38,648](https://solscan.io/tx/185ZpYg3H1Ku4upHPBoBavrTczwzwxQiz7wjqqYWXd8MmxbimbLz7VvjuQdnqAGaAMy11G8M1QqPzXbrPof8gQb) |
287
+ | refund_offer (offer 2) | [**5,478**](https://solscan.io/tx/55Q7pTfsew4F4N9DPB21BEqeHPANhkgQmUGAYVerFP73D7qMTp4eeo5CPMpYQqU7SLS4e49fuKtLkZ9XowbyoKET) | [13,387](https://solscan.io/tx/2cNkvA4xuFJBQzFcFTnLrxRzDU1q5617T8TCF5sBS7JeNzCPUxCPfzS37uv1RUg7xs9Mqyya6KDV9GSZxGiToevT) | [17,731](https://solscan.io/tx/Nj8RGLqEgbj8SEhd9hLcB2JQJY7YwkP1CnMdeJFBdsFhftntFqm1j1wAD1Mc715wrkPv2pFCWu93AF29oHQUDYr) | [20,576](https://solscan.io/tx/5on9Fxe6mADcbWwVn43yQ5wYtdWyx2u3Ry2hSniLJjRqoj2J55Hbgv4AbZoVZ5CDp2VrrghEkKRBvmXxjv1TynmY) |
288
+ | escrow program's own part, 4 transactions | **28,118** | 36,956 | 93,273 | 102,926 |
276
289
  | 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) |
277
290
 
278
- Most of make_offer is the Associated Token Account program creating the vault (about 13,500 CU in every version); it
279
- 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
280
- offer 1 (its own part was 6,719 CU both times). The Pinocchio version takes the offer PDA's bump from the client instead
281
- of searching for it (its own part: 6,051 CU). Mainnet figures differ from the LiteSVM means above.
291
+ Most of make_offer is the Associated Token Account program creating the vault. It searches for the vault address's
292
+ bump itself at 1,500 CU a try, and every offer has its own vault, so that part varies from offer to offer in every
293
+ version (13,585 to 22,585 CU of the make_offers above); programs that check the vault's address re-derive it, so
294
+ take_offer and refund_offer vary with it too. The escrow program's own part is steadier: TypeSOL's make_offer spent
295
+ 6,620 CU of its own on both offers. The Pinocchio version takes the offer PDA's bump from the client instead of
296
+ searching for it. Mainnet figures differ from the LiteSVM means above. Every run, including the one before the
297
+ upgrade, is in [typesol/benchmarks](https://github.com/typesol/benchmarks) (`results/`).
282
298
 
283
299
  ## How it is tested
284
300
 
@@ -316,7 +332,9 @@ change.
316
332
  - The compiler, runtime and tooling have been through internal security reviews and are covered by the tests above.
317
333
  - They have **not been audited by an independent firm**. Do not put significant value behind programs built with
318
334
  TypeSOL until such an audit is published.
319
- - Audit your own programs as you would any Solana program.
335
+ - Audit your own programs as you would any Solana program. What a compiled program can rely on, and what is left to
336
+ you, is in [docs/security-model.md](docs/security-model.md); [docs/audit.md](docs/audit.md) is the package for an
337
+ external audit.
320
338
  - Report vulnerabilities as described in [SECURITY.md](SECURITY.md).
321
339
 
322
340
  Not supported yet: see the end of [docs/language.md](docs/language.md#not-supported-yet).
package/SECURITY.md CHANGED
@@ -9,6 +9,8 @@ TypeSOL generates programs that hold funds. Treat it accordingly.
9
9
  firm.** Do not put significant value behind programs built with it until an external audit of the compiler and
10
10
  runtime is published, and audit your own programs as you would any Solana program.
11
11
  - Only the latest 0.x release receives security fixes.
12
+ - What a compiled program can rely on is specified in [docs/security-model.md](docs/security-model.md); the package
13
+ for an external audit is [docs/audit.md](docs/audit.md).
12
14
 
13
15
  ## Reporting a vulnerability
14
16
 
package/dist/cli.js CHANGED
@@ -7,14 +7,15 @@ import { fileURLToPath } from "node:url";
7
7
  import { parseArgs } from "node:util";
8
8
  import ts from "typescript";
9
9
  import { build, CompileError, COMPILER_VERSION, generate, SBPF_VERSIONS, verifyBinary } from "./compiler/index.js";
10
- import { generateCpi } from "./cpigen.js";
11
- import { generatePort } from "./portgen.js";
12
- import { generateClient, writeClient } from "./clientgen.js";
10
+ import { generateCpi } from "./tools/cpigen.js";
11
+ import { generatePort } from "./tools/portgen.js";
12
+ import { generateClient, writeClient } from "./tools/clientgen.js";
13
13
  import { buildIdl } from "./compiler/idl.js";
14
14
  import { decodeAddress, loadOrCreateKeypair, readKeypair } from "./keys.js";
15
- import { formatProfile, profileLogs } from "./profile.js";
15
+ import { formatProfile, profileLogs } from "./tools/profile.js";
16
16
  import { TEMPLATE_NAMES } from "./templates.js";
17
17
  import { buildWorkspace, CONFIG, deployArgs, findWorkspace, programAddress, programForEntry, programPaths, scaffold } from "./workspace.js";
18
+ import { clusterUrl, deployPreflight, doctorChecks, formatChecks, INSTALL_AGAVE, solanaConfig as readSolanaConfig, toolchainCheck } from "./tools/doctor.js";
18
19
  const ASSETS = fileURLToPath(new URL("../assets/", import.meta.url));
19
20
  /** A usage problem: printed with the command's help, exit code 2. */
20
21
  class UsageError extends Error {
@@ -65,9 +66,18 @@ const COMMANDS = {
65
66
  .replace(/^_+|_+$/g, "")
66
67
  .toLowerCase();
67
68
  const name = v.name ?? (/^[a-z]/.test(derived) ? derived : `p_${derived || "program"}`);
69
+ const rel = path.relative(process.cwd(), root) || ".";
68
70
  for (const f of scaffold(root, name, template))
69
- log(` created ${path.join(root, f)}`);
70
- log(`\n next: cd ${path.relative(process.cwd(), root) || "."} && npm install && npm test`);
71
+ log(` created ${path.join(rel, f)}`);
72
+ const tools = toolchainCheck();
73
+ if (!tools.ok)
74
+ log(`\n ! The Solana platform-tools are not installed yet; npm test compiles with them. Install once:\n` +
75
+ (tools.fix ?? []).map((f) => ` ${f}`).join("\n"));
76
+ log(`\n next:\n` +
77
+ (rel === "." ? "" : ` cd ${rel}\n`) +
78
+ ` npm install\n` +
79
+ ` npm test # builds ${name} and runs its test in LiteSVM, a Solana runtime in-process\n` +
80
+ ` npx typesol doctor # checks the toolchain, keypair and RPC before a deploy`);
71
81
  return 0;
72
82
  },
73
83
  },
@@ -166,6 +176,20 @@ const COMMANDS = {
166
176
  return 0;
167
177
  },
168
178
  },
179
+ doctor: {
180
+ usage: "typesol doctor [--url <rpc>] [--config <solana cli config>]",
181
+ help: [
182
+ "Check what building, testing and deploying need: Node.js, the platform-tools, the Solana CLI, its keypair, the RPC",
183
+ "(and the keypair's balance there) and the project. Each problem comes with the command that fixes it. Exit code 1",
184
+ "when building is impossible (Node.js or the platform-tools); a missing deploy piece is a warning.",
185
+ ].join("\n "),
186
+ options: { url: { type: "string" }, config: { type: "string" } },
187
+ async run(v) {
188
+ const checks = await doctorChecks({ cwd: process.cwd(), url: v.url, config: v.config });
189
+ log(formatChecks(checks));
190
+ return checks.some((c) => !c.ok && !c.optional) ? 1 : 0;
191
+ },
192
+ },
169
193
  deploy: {
170
194
  usage: "typesol deploy [<program>…] [--url <rpc>] [--keypair <payer.json>] [--config <solana cli config>] [--sbpf v0|v1|v2] [--dry-run]",
171
195
  help: [
@@ -174,9 +198,20 @@ const COMMANDS = {
174
198
  "Needs the Agave CLI (`solana`) on PATH.",
175
199
  ].join("\n "),
176
200
  options: { url: { type: "string" }, keypair: { type: "string" }, config: { type: "string" }, "dry-run": { type: "boolean" }, sbpf: { type: "string" } },
177
- run(v, args) {
201
+ async run(v, args) {
178
202
  const ws = needWorkspace("deploy");
179
203
  const built = buildWorkspace(ws, names(ws, args), { sbpf: sbpfOf(v) });
204
+ if (!v["dry-run"]) {
205
+ // Before any transaction: the RPC answers and the payer can afford it, with what to do if not.
206
+ const cfg = solanaConfig(v.config);
207
+ const url = v.url ?? cfg.url;
208
+ if (url)
209
+ await deployPreflight({
210
+ url: clusterUrl(url),
211
+ payer: v.keypair ?? cfg.keypair ?? path.join(os.homedir(), ".config/solana/id.json"),
212
+ programs: built.map((b) => ({ soPath: b.result.soPath, address: b.address, name: b.paths.name })),
213
+ });
214
+ }
180
215
  for (const b of built) {
181
216
  const cliArgs = deployArgs(b.result.soPath, b.paths.keypair, { url: v.url, payer: v.keypair, config: v.config });
182
217
  if (v["dry-run"]) {
@@ -189,7 +224,7 @@ const COMMANDS = {
189
224
  }
190
225
  catch (e) {
191
226
  if (e.code === "ENOENT")
192
- throw new Error("`solana` was not found on PATH. Install the Agave CLI: https://docs.anza.xyz/cli/install");
227
+ throw new Error(`\`solana\` was not found on PATH. Install the Agave CLI: ${INSTALL_AGAVE}`);
193
228
  throw new Error(`solana program deploy failed for ${b.paths.name} (see its output above)`);
194
229
  }
195
230
  }
@@ -217,7 +252,7 @@ const COMMANDS = {
217
252
  throw new Error(`no signer keypair${keyFile ? ` at ${keyFile}` : ""}: pass --keypair, or set keypair_path in the Solana CLI config`);
218
253
  // Loaded only here: the other commands do not need an RPC client.
219
254
  const kit = await import("@solana/kit");
220
- const { publishIdl, rpcChain } = await import("./idlpublish.js");
255
+ const { publishIdl, rpcChain } = await import("./tools/idlpublish.js");
221
256
  const signer = await kit.createKeyPairSignerFromBytes(readKeypair(keyFile));
222
257
  const chain = rpcChain(kit.createSolanaRpc(clusterUrl(rpcUrl)));
223
258
  for (const name of names(ws, args)) {
@@ -297,7 +332,7 @@ const COMMANDS = {
297
332
  throw new Error("no RPC: pass --url, or set json_rpc_url in the Solana CLI config");
298
333
  // Loaded only here: the other commands do not need an RPC client.
299
334
  const kit = await import("@solana/kit");
300
- const { fetchProgramIdl } = await import("./idlpublish.js");
335
+ const { fetchProgramIdl } = await import("./tools/idlpublish.js");
301
336
  const found = await fetchProgramIdl(kit.createSolanaRpc(clusterUrl(rpcUrl)), kit.address(v.address));
302
337
  console.error(` IDL from ${found.source === "anchor" ? "Anchor's IDL account" : "the Program Metadata program"}`);
303
338
  idl = found.idl;
@@ -360,7 +395,7 @@ const COMMANDS = {
360
395
  throw new Error("no RPC: pass --url, or set json_rpc_url in the Solana CLI config");
361
396
  // Loaded only here: the other commands do not need an RPC client.
362
397
  const kit = await import("@solana/kit");
363
- const { fetchDeployed } = await import("./onchain.js");
398
+ const { fetchDeployed } = await import("./tools/onchain.js");
364
399
  const rpc = kit.createSolanaRpc(clusterUrl(rpcUrl));
365
400
  const info = await fetchDeployed(rpc, address);
366
401
  onChain = { address, info, rpc };
@@ -387,8 +422,8 @@ const COMMANDS = {
387
422
  return same ? 0 : 3;
388
423
  const { info } = onChain;
389
424
  log(` authority ${info.authority ?? "none: the program is immutable"}${info.slot !== undefined ? `; last deployed in slot ${info.slot}` : ""}`);
390
- const { fetchIdl, metadataAddress } = await import("./idlpublish.js");
391
- const { canonicalJson } = await import("./onchain.js");
425
+ const { fetchIdl, metadataAddress } = await import("./tools/idlpublish.js");
426
+ const { canonicalJson } = await import("./tools/onchain.js");
392
427
  // No canonical metadata account means nothing was published; one that cannot be read is an error, not "none".
393
428
  const at = await metadataAddress(onChain.address, null);
394
429
  const exists = (await onChain.rpc.getAccountInfo(at, { encoding: "base64" }).send()).value !== null;
@@ -492,32 +527,12 @@ function overrideFile(file, text) {
492
527
  sys.fileExists = fileExists;
493
528
  };
494
529
  }
495
- /** The Solana CLI's cluster monikers, as `solana --url` accepts them; anything else is used as given. */
496
- function clusterUrl(url) {
497
- const monikers = {
498
- "mainnet-beta": "https://api.mainnet-beta.solana.com",
499
- m: "https://api.mainnet-beta.solana.com",
500
- devnet: "https://api.devnet.solana.com",
501
- d: "https://api.devnet.solana.com",
502
- testnet: "https://api.testnet.solana.com",
503
- t: "https://api.testnet.solana.com",
504
- localhost: "http://127.0.0.1:8899",
505
- l: "http://127.0.0.1:8899",
506
- };
507
- return monikers[url] ?? url;
508
- }
509
530
  /** json_rpc_url / keypair_path from a Solana CLI config (the given file, or the CLI's default one). */
510
531
  function solanaConfig(file) {
511
- const configPath = file ?? path.join(os.homedir(), ".config/solana/cli/config.yml");
512
- if (!existsSync(configPath)) {
513
- if (file)
514
- throw new UsageError(`${file} does not exist`);
515
- return {};
516
- }
517
- const text = readFileSync(configPath, "utf8");
518
- const get = (k) => new RegExp(`^${k}:\\s*["']?([^"'\\n]+?)["']?\\s*$`, "m").exec(text)?.[1];
519
- const expand = (p) => (p?.startsWith("~/") ? path.join(os.homedir(), p.slice(2)) : p);
520
- return { url: get("json_rpc_url"), keypair: expand(get("keypair_path")) };
532
+ const cfg = readSolanaConfig(file);
533
+ if (file && !cfg.exists)
534
+ throw new UsageError(`${file} does not exist`);
535
+ return { url: cfg.url, keypair: cfg.keypair };
521
536
  }
522
537
  export async function main(argv) {
523
538
  const [cmd, ...rest] = argv;
@@ -85,6 +85,8 @@ const TYPE_EXPORTS = new Set([
85
85
  "Ata",
86
86
  "Owner",
87
87
  "Executable",
88
+ "Dup",
89
+ "Migrate",
88
90
  "Optional",
89
91
  "EventAuthority",
90
92
  "MplCoreProgram",
@@ -114,6 +116,22 @@ export function snakeCase(name) {
114
116
  .replace(/([A-Z])([A-Z][a-z])/g, "$1_$2")
115
117
  .toLowerCase();
116
118
  }
119
+ /**
120
+ * An account struct's discriminator: Anchor's sha256("account:<Name>")[..8], or the one a `/** @discriminator … *\/` tag
121
+ * gives (a sha256 preimage such as `account:Counter`, or 0x and 8 bytes), e.g. for an old version of a struct kept under
122
+ * a new name for Migrate<From, To>.
123
+ */
124
+ function accountDiscriminator(decl, name, reporter) {
125
+ const tag = ts.getJSDocTags(decl).find((t) => t.tagName.text === "discriminator");
126
+ if (!tag)
127
+ return discriminator(`account:${name}`);
128
+ const text = (typeof tag.comment === "string" ? tag.comment : (ts.getTextOfJSDocComment(tag.comment) ?? "")).trim();
129
+ if (/^0x([0-9a-fA-F]{2}){8}$/.test(text))
130
+ return [...Buffer.from(text.slice(2), "hex")];
131
+ if (text && !text.startsWith("0x"))
132
+ return discriminator(text);
133
+ reporter.fail(decl, "@discriminator on an account struct needs a sha256 preimage (account:<Name>) or 0x and 8 bytes");
134
+ }
117
135
  export function discriminator(preimage) {
118
136
  return [...createHash("sha256").update(preimage).digest().subarray(0, 8)];
119
137
  }
@@ -601,12 +619,17 @@ export function analyze(entry) {
601
619
  // A returned struct goes out in its fixed layout, like an argument comes in: it cannot also be a Borsh event.
602
620
  if (ix.returns?.kind === "struct")
603
621
  collectStructs(ix.returns, structs, dataEnums, argStructs);
604
- for (const a of ix.accounts)
622
+ for (const a of ix.accounts) {
623
+ if (a.kind.kind === "migrate") {
624
+ accountStructs.add(a.kind.from.name);
625
+ accountStructs.add(a.kind.to.name);
626
+ }
605
627
  if (a.kind.kind === "account" || a.kind.kind === "init" || a.kind.kind === "zeroed" || a.kind.kind === "initIfNeeded") {
606
628
  accountStructs.add(a.kind.struct.name);
607
629
  if (a.kind.struct.viewsOnly)
608
630
  reporter.error(a.node, `${a.kind.struct.name} holds a string (or an enum carrying one), so it is an argument, return value or event; account data uses FixedString<N>`);
609
631
  }
632
+ }
610
633
  }
611
634
  // Arguments are Borsh, where a FixedVec is a Vec (only what it holds). A struct passed as a whole argument and used
612
635
  // nowhere else (not account data, not nested, not returned) is read as Anchor sends it: @layout borsh, implied.
@@ -966,7 +989,7 @@ function analyzeStructs(items, aliases, dataEnums, reporter, scopeFor, fstrings,
966
989
  name,
967
990
  fields: stored,
968
991
  size: undefined,
969
- accountDiscriminator: discriminator(`account:${name}`),
992
+ accountDiscriminator: accountDiscriminator(decl, name, reporter),
970
993
  eventDiscriminator: discriminator(`event:${name}`),
971
994
  node: decl,
972
995
  layout: layoutText === "borsh" ? "borsh" : "fixed",
@@ -1321,7 +1344,7 @@ function analyzeHelper(fn, name, display, reporter, scope, structs, skip = new S
1321
1344
  wrapped === "Ata")
1322
1345
  reporter.fail(p.type, "constraints belong on instruction parameters; helpers take the plain account type");
1323
1346
  const account = resolveAccountKind(p.type, reporter, scope.imports, structs);
1324
- if (account.kind === "init" || account.kind === "initIfNeeded" || account.kind === "zeroed")
1347
+ if (account.kind === "init" || account.kind === "initIfNeeded" || account.kind === "zeroed" || account.kind === "migrate")
1325
1348
  reporter.fail(p.type, "helpers take accounts that exist: create Init/InitIfNeeded/Zeroed accounts in the instruction, then pass them as Mut<T>");
1326
1349
  return { name: p.name.text, type: { kind: "account" }, account, node: p };
1327
1350
  });
@@ -1445,7 +1468,7 @@ function analyzeInstruction(fn, reporter, scope, structs) {
1445
1468
  const inner = unwrapConstraints(typeNode, reporter, imports, constraints);
1446
1469
  const kind = resolveAccountKind(inner, reporter, imports, structs);
1447
1470
  if (optional &&
1448
- (["init", "initIfNeeded", "zeroed", "pyth", "eventAuthority", "selfProgram", "programData"].includes(kind.kind) ||
1471
+ (["init", "initIfNeeded", "zeroed", "migrate", "pyth", "eventAuthority", "selfProgram", "programData"].includes(kind.kind) ||
1449
1472
  ((kind.kind === "token" || kind.kind === "mint") && kind.init)))
1450
1473
  reporter.fail(p, "Init, InitIfNeeded, Zeroed, PythPriceUpdate, EventAuthority, SelfProgram and ProgramData accounts cannot be optional");
1451
1474
  accounts.push({ name: p.name.text, index: accounts.length, kind, node: p, constraints, ...(optional ? { optional } : {}) });
@@ -1566,6 +1589,18 @@ function analyzeInstruction(fn, reporter, scope, structs) {
1566
1589
  reporter.error(fn.name, "instructions that grow a Vec (push) need a SystemProgram parameter to top up rent");
1567
1590
  if (needs("init") && !needs("system"))
1568
1591
  reporter.error(fn.name, "instructions with Init<...> accounts need a SystemProgram parameter");
1592
+ if (accounts.some((a) => a.kind.kind === "migrate" && a.kind.to.size > a.kind.from.size) && !needs("system"))
1593
+ reporter.error(fn.name, "a Migrate<From, To> that grows the account needs a SystemProgram parameter (the payer tops up its rent)");
1594
+ for (const a of accounts)
1595
+ if (a.kind.kind === "migrate" &&
1596
+ (a.constraints.hasOne.length ||
1597
+ a.constraints.seeds ||
1598
+ a.constraints.closeTo ||
1599
+ a.constraints.ata ||
1600
+ a.constraints.address ||
1601
+ a.constraints.owner ||
1602
+ a.constraints.dup))
1603
+ reporter.error(a.node, "Migrate<From, To> takes no constraints yet: check the old fields with require(…, …) on acct.from");
1569
1604
  const name = snakeCase(fn.name.text);
1570
1605
  // `/** @discriminator spl-transfer-hook-interface:execute */` (sha256 preimage) or `@discriminator 0x...` (raw bytes)
1571
1606
  // lets an instruction implement another program's interface.
@@ -1596,7 +1631,7 @@ function analyzeInstruction(fn, reporter, scope, structs) {
1596
1631
  /** Peel HasOne / Seeds / CloseTo wrappers off a parameter type, recording what they ask for. */
1597
1632
  function unwrapConstraints(node, reporter, imports, out) {
1598
1633
  const name = typesolName(node, imports);
1599
- if (!["HasOne", "TokenOf", "MintOf", "Seeds", "CloseTo", "Address", "Ata", "Owner", "Executable"].includes(name ?? ""))
1634
+ if (!["HasOne", "TokenOf", "MintOf", "Seeds", "CloseTo", "Address", "Ata", "Owner", "Executable", "Dup"].includes(name ?? ""))
1600
1635
  return node;
1601
1636
  const args = node.typeArguments ?? [];
1602
1637
  const lit = (t) => {
@@ -1638,6 +1673,8 @@ function unwrapConstraints(node, reporter, imports, out) {
1638
1673
  }
1639
1674
  if (name === "Executable")
1640
1675
  out.executable = true;
1676
+ if (name === "Dup")
1677
+ out.dup = true;
1641
1678
  if (name === "Address") {
1642
1679
  const text = lit(args[1])[0];
1643
1680
  const bytes = text === undefined ? undefined : decodeAddress(text);
@@ -1668,6 +1705,8 @@ function unwrapConstraints(node, reporter, imports, out) {
1668
1705
  reporter.fail(node, `${name}<…> wraps an account type`);
1669
1706
  return unwrapConstraints(args[0], reporter, imports, out);
1670
1707
  }
1708
+ /** Solana: an account grows at most this many bytes per instruction. */
1709
+ const MAX_PERMITTED_DATA_INCREASE = 10_240;
1671
1710
  function resolveAccountKind(node, reporter, imports, structs) {
1672
1711
  const outer = typesolName(node, imports);
1673
1712
  const ref = node;
@@ -1769,6 +1808,24 @@ function resolveAccountKind(node, reporter, imports, structs) {
1769
1808
  reporter.fail(node, `${s.name} is too large to create in a program; use Zeroed<${s.name}>`);
1770
1809
  return { kind: "initIfNeeded", struct: s };
1771
1810
  }
1811
+ case "Migrate": {
1812
+ const [fromNode, toNode] = ref.typeArguments ?? [];
1813
+ const from = fromNode && structOf(fromNode);
1814
+ const to = toNode && structOf(toNode);
1815
+ if (!from || !to || ref.typeArguments.length !== 2)
1816
+ reporter.fail(node, "Migrate<From, To> takes two account structs declared in this program");
1817
+ for (const s of [from, to]) {
1818
+ if (s.vec)
1819
+ reporter.fail(node, `${s.name} has a growable Vec; Migrate<From, To> takes fixed-size structs`);
1820
+ if (s.layout === "borsh")
1821
+ reporter.fail(node, `${s.name} is @layout borsh; Migrate<From, To> takes fixed layouts`);
1822
+ }
1823
+ if (from.accountDiscriminator.join() === to.accountDiscriminator.join())
1824
+ reporter.fail(node, `${from.name} and ${to.name} have the same discriminator: give the new struct another name (or the old one /** @discriminator account:<OldName> */)`);
1825
+ if (to.size - from.size > MAX_PERMITTED_DATA_INCREASE)
1826
+ reporter.fail(node, `${to.name} is more than 10 KiB larger than ${from.name}; an account grows at most 10 KiB per instruction`);
1827
+ return { kind: "migrate", from, to };
1828
+ }
1772
1829
  case "Zeroed": {
1773
1830
  const s = structOf(inner());
1774
1831
  if (!s)
@@ -112,6 +112,8 @@ export declare abstract class EmitterBase {
112
112
  protected abstract call(node: ts.CallExpression, topLevel: boolean): Value | undefined;
113
113
  protected abstract isRemaining(node: ts.Expression): boolean;
114
114
  protected abstract handle(node: ts.Expression): string | undefined;
115
+ protected abstract instructionsParam(node: ts.Expression): AccountParam | undefined;
116
+ protected abstract isTxIx(node: ts.Expression): boolean;
115
117
  protected abstract seeds(node: ts.Expression, extra: number): {
116
118
  name: string;
117
119
  count: number;
@@ -65,7 +65,10 @@ export class EmitterBase {
65
65
  this.ix = target;
66
66
  for (const a of target.accounts) {
67
67
  this.accounts.set(a.name, a);
68
- if (a.kind.kind === "init" || a.kind.kind === "initIfNeeded" || ((a.kind.kind === "token" || a.kind.kind === "mint") && a.kind.init))
68
+ if (a.kind.kind === "init" ||
69
+ a.kind.kind === "initIfNeeded" ||
70
+ a.kind.kind === "migrate" ||
71
+ ((a.kind.kind === "token" || a.kind.kind === "mint") && a.kind.init))
69
72
  this.pendingInit.add(a.name);
70
73
  }
71
74
  for (const a of target.args)
@@ -1,5 +1,5 @@
1
1
  import ts from "typescript";
2
- import { type ErrorDef, type FieldType, type Helper } from "../model.js";
2
+ import { type AccountParam, type ErrorDef, type FieldType, type Helper } from "../model.js";
3
3
  import { type Value, type Ref, type Closure } from "./shared.js";
4
4
  import { ExpressionEmitter } from "./expressions.js";
5
5
  export declare abstract class CallEmitter extends ExpressionEmitter {
@@ -20,6 +20,15 @@ export declare abstract class CallEmitter extends ExpressionEmitter {
20
20
  /** `remainingAccounts()` or a const bound to it. */
21
21
  protected isRemaining(node: ts.Expression): boolean;
22
22
  /** C pointer (`TsAccount *`) for an expression naming a remaining account, or undefined. */
23
+ /** Whether `node` is an InstructionsSysvar parameter. */
24
+ protected instructionsParam(node: ts.Expression): AccountParam | undefined;
25
+ /** Whether `node` is a TransactionInstruction: a local holding one, or `ixs.get(i)`. */
26
+ protected isTxIx(node: ts.Expression): boolean;
27
+ /**
28
+ * InstructionsSysvar.currentIndex() / .get(i) and the methods of a TransactionInstruction: bounds-checked reads of the
29
+ * sysvar (ts_ix_* in the runtime). Undefined when the call is something else.
30
+ */
31
+ protected instructionsCall(node: ts.CallExpression, callee: ts.PropertyAccessExpression, method: string): Value | undefined;
23
32
  protected handle(node: ts.Expression): string | undefined;
24
33
  /** A declared account parameter or a remaining account, as a `TsAccount *` C expression. */
25
34
  protected anyAccount(node: ts.Expression, what: string): string;
@@ -29,6 +38,12 @@ export declare abstract class CallEmitter extends ExpressionEmitter {
29
38
  protected loadForeign(node: ts.CallExpression): Value;
30
39
  /** `loadAccount<T>(acct)` / `loadAccountMut<T>(acct)`: checked view of a program account. */
31
40
  protected loadAccount(node: ts.CallExpression, mut: boolean): Value;
41
+ /**
42
+ * `acct.migrate(payer, value)` on a Migrate<From, To> account: the new value is built in full first (it may read
43
+ * acct.from, whose bytes the rewrite overwrites), then the account is resized (the payer tops up the rent when it
44
+ * grows; shrinking keeps the lamports) and rewritten with To's discriminator and the value.
45
+ */
46
+ protected migrate(node: ts.CallExpression, a: AccountParam, topLevel: boolean): Value | undefined;
32
47
  /** Whether a helper (or one it calls) grows a Vec, which needs the instruction's SystemProgram for rent. */
33
48
  private helperGrows;
34
49
  /**