@typesol/cli 0.24.1 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +35 -16
  3. package/SECURITY.md +2 -0
  4. package/dist/cli.js +52 -37
  5. package/dist/compiler/analyze.js +11 -1
  6. package/dist/compiler/emit/calls.js +10 -10
  7. package/dist/compiler/emit/cpi.js +4 -0
  8. package/dist/compiler/emit/function.d.ts +9 -0
  9. package/dist/compiler/emit/function.js +169 -139
  10. package/dist/compiler/emit/program.js +3 -0
  11. package/dist/compiler/emit/shared.d.ts +1 -0
  12. package/dist/compiler/emit/shared.js +1 -0
  13. package/dist/compiler/emit/statements.d.ts +6 -0
  14. package/dist/compiler/emit/statements.js +35 -5
  15. package/dist/templates.js +1 -1
  16. package/dist/testing.d.ts +1 -1
  17. package/dist/testing.js +1 -1
  18. package/dist/{clientgen.js → tools/clientgen.js} +4 -1
  19. package/dist/tools/doctor.d.ts +45 -0
  20. package/dist/tools/doctor.js +229 -0
  21. package/dist/{portgen.js → tools/portgen.js} +1 -1
  22. package/dist/workspace.js +1 -1
  23. package/docs/architecture.md +17 -17
  24. package/docs/audit.md +90 -0
  25. package/docs/case-study-escrow.md +224 -0
  26. package/docs/cli.md +3 -2
  27. package/docs/errors.md +26 -1
  28. package/docs/language.md +12 -8
  29. package/docs/migrate-from-anchor.md +3 -1
  30. package/docs/runtime.md +1 -1
  31. package/docs/security-model.md +97 -0
  32. package/docs/testing.md +5 -4
  33. package/lib/typesol.d.ts +35 -13
  34. package/package.json +13 -13
  35. package/runtime/typesol_rt.h +33 -4
  36. /package/dist/{clientgen.d.ts → tools/clientgen.d.ts} +0 -0
  37. /package/dist/{cpigen.d.ts → tools/cpigen.d.ts} +0 -0
  38. /package/dist/{cpigen.js → tools/cpigen.js} +0 -0
  39. /package/dist/{idlpublish.d.ts → tools/idlpublish.d.ts} +0 -0
  40. /package/dist/{idlpublish.js → tools/idlpublish.js} +0 -0
  41. /package/dist/{onchain.d.ts → tools/onchain.d.ts} +0 -0
  42. /package/dist/{onchain.js → tools/onchain.js} +0 -0
  43. /package/dist/{portgen.d.ts → tools/portgen.d.ts} +0 -0
  44. /package/dist/{profile.d.ts → tools/profile.d.ts} +0 -0
  45. /package/dist/{profile.js → tools/profile.js} +0 -0
  46. /package/dist/{svmrpc.d.ts → tools/svmrpc.d.ts} +0 -0
  47. /package/dist/{svmrpc.js → tools/svmrpc.js} +0 -0
@@ -38,8 +38,10 @@ export class FunctionEmitter extends StatementEmitter {
38
38
  const k = a.kind;
39
39
  if (k.kind === "signer")
40
40
  head.push(` TS_TRY(ts_check_signer(${v}));`);
41
+ // Mut<…>: Anchor's `mut` (2000). Anchor checks it with the constraints, after seeds and associated token; here it
42
+ // sits with the type checks, which keeps binaries smaller (only a transaction breaking both sees the difference).
41
43
  if (isWritable(k))
42
- head.push(` TS_TRY(ts_check_writable(${v}));`);
44
+ head.push(` TS_TRY(ts_check_mut(${v}));`);
43
45
  // A @layout borsh account only has to hold its smallest Borsh form (unpacking checks the rest).
44
46
  if (k.kind === "account")
45
47
  head.push(` TS_TRY(ts_check_program_account(${v}, params->program_id, TS_DISC_${k.struct.name}, ${k.struct.layout === "borsh" ? borshMinSize(k.struct, this.model) : emptySizeC(k.struct)}));`);
@@ -160,7 +162,9 @@ export class FunctionEmitter extends StatementEmitter {
160
162
  pairs.push(`if (${present}a_${writable[i].name} == a_${writable[j].name}) TS_FAIL(TS_ERR_DUPLICATE_MUT, "TypeSOL: ${writable[i].name} and ${writable[j].name} must be different accounts");`);
161
163
  }
162
164
  head.push(...pairs.map((p) => ` ${p}`));
163
- // Arguments: Borsh, read sequentially (strings make later offsets dynamic).
165
+ // Arguments: Borsh, read sequentially (strings make later offsets dynamic). Anchor decodes them before the accounts;
166
+ // here they come after the account types (decoding first keeps them live across the checks: larger binaries), so
167
+ // only a transaction wrong in both ways sees a different error.
164
168
  if (ix.args.length)
165
169
  head.push(` uint64_t off = ${ix.discriminator.length};`);
166
170
  const need = (bytes) => head.push(` if (params->data_len < off + ${bytes}) TS_FAIL(TS_ERR_INSTRUCTION_DATA, "TypeSOL: instruction data too short");`);
@@ -365,6 +369,11 @@ export class FunctionEmitter extends StatementEmitter {
365
369
  }),
366
370
  ];
367
371
  }
372
+ /**
373
+ * Each account's constraints, account by account in parameter order and, within one account, in Anchor's order
374
+ * (anchor-syn's `linearize`): seeds, associated token, has_one, owner, executable, address, token::, mint::.
375
+ * A transaction that breaks several checks then fails with the same error code as under Anchor.
376
+ */
368
377
  constraintChecks(ix) {
369
378
  const out = [];
370
379
  // Bumps found by Seeds<…> checks, declared ahead of the (possibly optional-guarded) checks for `acct.pdaBump`.
@@ -375,21 +384,40 @@ export class FunctionEmitter extends StatementEmitter {
375
384
  const k = a.kind.kind;
376
385
  if (c.hasOne.length && k !== "account" && k !== "token" && k !== "mint")
377
386
  this.reporter.fail(a.node, "HasOne applies to existing accounts (Account<T> / Mut<T>), TokenAccount and Mint");
378
- for (const entry of c.hasOne) {
387
+ if (c.closeTo) {
388
+ const dest = ix.accounts.find((o) => o.name === c.closeTo);
389
+ if (!isWritable(dest.kind))
390
+ this.reporter.fail(a.node, `CloseTo<…, "${c.closeTo}">: ${c.closeTo} must be writable (Mut<…>)`);
391
+ }
392
+ if (c.seeds)
393
+ out.push(...this.seedsCheck(ix, a, bumpDecls));
394
+ if (c.ata)
395
+ out.push(...this.ataCheck(ix, a));
396
+ // has_one on a program account; on a token account or mint the same entries are Anchor's token:: / mint::
397
+ // constraints, which come last.
398
+ const hasOne = [];
399
+ // Anchor's order inside token:: (authority, mint, token program) and mint:: (authorities, token program).
400
+ const rank = (e) => ["owner=", "mint=", "mintAuthority=", "freezeAuthority=", "tokenProgram="].findIndex((x) => e.startsWith(x));
401
+ const entries = k === "token" || k === "mint" ? [...c.hasOne].sort((x, y) => rank(x) - rank(y)) : c.hasOne;
402
+ for (const entry of entries) {
379
403
  // "field=param" (TokenOf / MintOf) or a field checked against the parameter of the same name (HasOne).
380
404
  const [f, p = f] = entry.split("=");
381
- if (k === "token")
382
- out.push(f === "mint"
405
+ if (f === "tokenProgram")
406
+ hasOne.push(` if (!ts_key_eq(&a_${a.name}->owner, &a_${p}->key)) TS_FAIL(${k === "mint" ? "TS_ERR_MINT_TOKEN_PROGRAM" : "TS_ERR_TOKEN_TOKEN_PROGRAM"}, "TypeSOL: ${a.name} is not owned by ${p}");`);
407
+ else if (k === "token")
408
+ hasOne.push(f === "mint"
383
409
  ? ` if (!ts_key_eq(ts_token_mint(a_${a.name}), &a_${p}->key)) TS_FAIL(TS_ERR_TOKEN_MINT, "TypeSOL: ${a.name} is not a token account of ${p}");`
384
410
  : ` if (!ts_key_eq(ts_token_owner(a_${a.name}), &a_${p}->key)) TS_FAIL(TS_ERR_TOKEN_OWNER, "TypeSOL: ${a.name} is not owned by ${p}");`);
385
411
  else if (k === "mint") {
386
412
  const at = f === "mintAuthority" ? 0 : 46;
387
413
  const code = f === "mintAuthority" ? "TS_ERR_MINT_AUTHORITY" : "TS_ERR_FREEZE_AUTHORITY";
388
- out.push(` if (ts_read_le(a_${a.name}->data + ${at}, 4) != 1 || !ts_key_eq((const SolPubkey *)(a_${a.name}->data + ${at + 4}), &a_${p}->key)) TS_FAIL(${code}, "TypeSOL: ${p} is not the ${f} of ${a.name}");`);
414
+ hasOne.push(` if (ts_read_le(a_${a.name}->data + ${at}, 4) != 1 || !ts_key_eq((const SolPubkey *)(a_${a.name}->data + ${at + 4}), &a_${p}->key)) TS_FAIL(${code}, "TypeSOL: ${p} is not the ${f} of ${a.name}");`);
389
415
  }
390
416
  else
391
- out.push(` if (!ts_key_eq(&d_${a.name}->${f}, &a_${f}->key)) TS_FAIL(TS_ERR_CONSTRAINT, "TypeSOL: ${a.name}.${f} must be the ${f} account");`);
417
+ hasOne.push(` if (!ts_key_eq(&d_${a.name}->${f}, &a_${f}->key)) TS_FAIL(TS_ERR_CONSTRAINT, "TypeSOL: ${a.name}.${f} must be the ${f} account");`);
392
418
  }
419
+ if (k === "account")
420
+ out.push(...hasOne);
393
421
  if (c.owner) {
394
422
  out.push(` static const SolPubkey co_${a.name} = {${cBytes(c.owner.bytes)}};`);
395
423
  out.push(` if (!ts_key_eq(&a_${a.name}->owner, &co_${a.name})) TS_FAIL(TS_ERR_CONSTRAINT_OWNER, "TypeSOL: ${a.name} is not owned by the expected program");`);
@@ -400,138 +428,8 @@ export class FunctionEmitter extends StatementEmitter {
400
428
  out.push(` static const SolPubkey ca_${a.name} = {${cBytes(c.address.bytes)}};`);
401
429
  out.push(` if (!ts_key_eq(&a_${a.name}->key, &ca_${a.name})) TS_FAIL(TS_ERR_ADDRESS, "TypeSOL: ${a.name} is not the expected address");`);
402
430
  }
403
- if (c.ata && a.kind.kind === "token" && a.kind.init) {
404
- // Created in the body: the ATA program checks the address when it creates it, and createIfNeeded checks an
405
- // existing one there. Only the IDL entry is recorded here.
406
- const tp = ix.accounts.find((o) => o.kind.kind === "tokenProgram");
407
- if (tp && !ix.pdas.has(a.name)) {
408
- ix.pdas.set(a.name, [
409
- { kind: "account", path: snakeCase(c.ata.owner) },
410
- { kind: "account", path: snakeCase(tp.name) },
411
- { kind: "account", path: snakeCase(c.ata.mint) },
412
- ]);
413
- ix.pdaPrograms.set(a.name, { kind: "const", value: ATA_PROGRAM_BYTES });
414
- }
415
- }
416
- else if (c.ata) {
417
- // The token program is the account's owner; an AccountInfo not created yet takes the instruction's token program.
418
- let program = `&a_${a.name}->owner`;
419
- if (k !== "token") {
420
- const tp = ix.accounts.find((o) => o.kind.kind === "tokenProgram");
421
- if (!tp)
422
- this.reporter.fail(a.node, "Ata<AccountInfo, …> needs a token program parameter to derive the address");
423
- program = `&a_${tp.name}->key`;
424
- }
425
- out.push(` { SolPubkey ata_${a.name};`);
426
- out.push(` TS_TRY(ts_ata_address(&a_${c.ata.owner}->key, &a_${c.ata.mint}->key, ${program}, &ata_${a.name}));`);
427
- out.push(` if (!ts_key_eq(&ata_${a.name}, &a_${a.name}->key)) TS_FAIL(TS_ERR_ASSOCIATED, "TypeSOL: ${a.name} is not the associated token account of ${c.ata.owner} and ${c.ata.mint}"); }`);
428
- // The address alone is not enough: legacy SPL Token lets an ATA's owner be reassigned.
429
- if (k === "token") {
430
- out.push(` if (!ts_key_eq(ts_token_mint(a_${a.name}), &a_${c.ata.mint}->key)) TS_FAIL(TS_ERR_TOKEN_MINT, "TypeSOL: ${a.name} is not a token account of ${c.ata.mint}");`);
431
- out.push(` if (!ts_key_eq(ts_token_owner(a_${a.name}), &a_${c.ata.owner}->key)) TS_FAIL(TS_ERR_TOKEN_OWNER, "TypeSOL: ${a.name} is not owned by ${c.ata.owner}");`);
432
- }
433
- const tp = ix.accounts.find((o) => o.kind.kind === "tokenProgram");
434
- if (tp && !ix.pdas.has(a.name)) {
435
- ix.pdas.set(a.name, [
436
- { kind: "account", path: snakeCase(c.ata.owner) },
437
- { kind: "account", path: snakeCase(tp.name) },
438
- { kind: "account", path: snakeCase(c.ata.mint) },
439
- ]);
440
- ix.pdaPrograms.set(a.name, { kind: "const", value: ATA_PROGRAM_BYTES });
441
- }
442
- }
443
- if (c.closeTo) {
444
- const dest = ix.accounts.find((o) => o.name === c.closeTo);
445
- if (!isWritable(dest.kind))
446
- this.reporter.fail(a.node, `CloseTo<…, "${c.closeTo}">: ${c.closeTo} must be writable (Mut<…>)`);
447
- }
448
- if (c.seeds) {
449
- const parts = [];
450
- const idl = [];
451
- for (const raw of c.seeds.names) {
452
- // "@name" refers to an account parameter (its key) or an argument (its bytes); anything else is a literal.
453
- const ref = raw.startsWith("@") ? raw.slice(1) : undefined;
454
- const n = ref ?? raw;
455
- // "@acct.field": a field of a struct account parameter (its data was checked in the head), Anchor's
456
- // `seeds = [pool.amm.as_ref()]`. The account may be this one (`seeds = [offer.id.to_le_bytes()]`).
457
- if (ref?.includes(".")) {
458
- const [owner, field, ...rest] = ref.split(".");
459
- const o = ix.accounts.find((x) => x.name === owner);
460
- if (!o || o.kind.kind !== "account" || rest.length)
461
- this.reporter.fail(a.node, `Seeds: "@${ref}" must name a field of a struct account parameter ("@vault.owner")`);
462
- const st = o.kind.struct;
463
- const f = st.fields.find((x) => x.name === field);
464
- if (!f)
465
- this.reporter.fail(a.node, `Seeds: ${st.name} has no field "${field}"`);
466
- const t = f.type;
467
- if (t.kind === "pubkey")
468
- parts.push(`{(const uint8_t *)&d_${owner}->${field}, 32}`);
469
- else if (t.kind === "int" || t.kind === "enum")
470
- parts.push(`{(const uint8_t *)&d_${owner}->${field}, ${scalarSize(t)}}`);
471
- else
472
- this.reporter.fail(a.node, `Seeds: field "${ref}" of type ${typeName(t)} cannot be a seed (use a Pubkey, an integer or a unit enum)`);
473
- idl.push({ kind: "account", path: `${snakeCase(owner)}.${snakeCase(field)}`, account: st.name });
474
- continue;
475
- }
476
- const acct = ref ? ix.accounts.find((o) => o.name === ref) : undefined;
477
- const arg = ref ? ix.args.find((o) => o.name === ref) : undefined;
478
- if (ref && !acct && !arg)
479
- this.reporter.fail(a.node, `Seeds: "@${ref}" is neither an account parameter nor an argument`);
480
- if (acct) {
481
- parts.push(`{(const uint8_t *)&a_${n}->key, 32}`);
482
- idl.push({ kind: "account", path: snakeCase(n) });
483
- }
484
- else if (arg) {
485
- const t = arg.type;
486
- if (t.kind === "string")
487
- parts.push(`{arg_${n}.ptr, arg_${n}.len}`);
488
- else if (t.kind === "pubkey")
489
- parts.push(`{(const uint8_t *)&arg_${n}, 32}`);
490
- else if (t.kind === "int" || t.kind === "enum")
491
- parts.push(`{(const uint8_t *)&arg_${n}, ${scalarSize(t)}}`);
492
- else
493
- this.reporter.fail(a.node, `Seeds: argument "${n}" of type ${typeName(t)} cannot be a seed`);
494
- idl.push({ kind: "arg", path: snakeCase(n) });
495
- }
496
- else {
497
- if (Buffer.byteLength(n, "utf8") > 32)
498
- this.reporter.fail(a.node, "a seed can be at most 32 bytes");
499
- parts.push(`{(const uint8_t *)${cString(n)}, ${Buffer.byteLength(n, "utf8")}}`);
500
- idl.push({ kind: "const", value: [...Buffer.from(n, "utf8")] });
501
- }
502
- }
503
- if (!ix.pdas.has(a.name))
504
- ix.pdas.set(a.name, idl);
505
- // The deriving program: this one, another account parameter ("@name"), or a fixed address.
506
- let program = "params->program_id";
507
- const p = c.seeds.program;
508
- if (p?.startsWith("@")) {
509
- const pa = ix.accounts.find((o) => o.name === p.slice(1));
510
- if (!pa)
511
- this.reporter.fail(a.node, `Seeds program: "${p}" is not an account parameter`);
512
- program = `&a_${pa.name}->key`;
513
- ix.pdaPrograms.set(a.name, { kind: "account", path: snakeCase(pa.name) });
514
- }
515
- else if (p) {
516
- const bytes = [...decodeAddress(p)];
517
- out.push(` static const SolPubkey sp_${a.name} = {${cBytes(bytes)}};`);
518
- program = `&sp_${a.name}`;
519
- ix.pdaPrograms.set(a.name, { kind: "const", value: bytes });
520
- }
521
- const seeds = `cs_${a.name}`;
522
- out.push(` SolSignerSeed ${seeds}[${parts.length + 1}] = {${parts.join(", ")}};`);
523
- if (c.seeds.bump) {
524
- out.push(` ${seeds}[${parts.length}] = (SolSignerSeed){&d_${a.name}->${c.seeds.bump}, 1};`);
525
- out.push(` TS_TRY(ts_require_pda(a_${a.name}, ${seeds}, ${parts.length + 1}, ${program}));`);
526
- this.bumps.set(a.name, `d_${a.name}->${c.seeds.bump}`);
527
- }
528
- else {
529
- bumpDecls.push(` uint8_t sb_${a.name}; (void)sb_${a.name};`);
530
- out.push(` { SolPubkey pda_${a.name};`);
531
- out.push(` if (ts_find_pda(${seeds}, ${parts.length}, ${program}, &pda_${a.name}, &sb_${a.name}) != SUCCESS || !ts_key_eq(&pda_${a.name}, &a_${a.name}->key)) TS_FAIL(TS_ERR_SEEDS, "TypeSOL: ${a.name} is not the expected PDA"); }`);
532
- this.bumps.set(a.name, `sb_${a.name}`);
533
- }
534
- }
431
+ if (k !== "account")
432
+ out.push(...hasOne);
535
433
  // Constraints run only when every optional account they mention was passed.
536
434
  const lines = out.splice(start);
537
435
  const optional = this.optionalIn(lines.join("\n"));
@@ -542,6 +440,138 @@ export class FunctionEmitter extends StatementEmitter {
542
440
  }
543
441
  return [...bumpDecls, ...out];
544
442
  }
443
+ /** Ata<…>: the account's owner (2015) and address (2009) as Anchor checks them, then its mint. */
444
+ ataCheck(ix, a) {
445
+ const out = [];
446
+ const c = a.constraints;
447
+ const k = a.kind.kind;
448
+ const ata = c.ata;
449
+ // With two token programs the address depends on which the mint uses: no IDL derivation, and an Ata<AccountInfo>
450
+ // (derived here) is refused.
451
+ const tps = ix.accounts.filter((o) => o.kind.kind === "tokenProgram");
452
+ const tp = tps.length === 1 ? tps[0] : undefined;
453
+ if (tp && !ix.pdas.has(a.name)) {
454
+ ix.pdas.set(a.name, [
455
+ { kind: "account", path: snakeCase(ata.owner) },
456
+ { kind: "account", path: snakeCase(tp.name) },
457
+ { kind: "account", path: snakeCase(ata.mint) },
458
+ ]);
459
+ ix.pdaPrograms.set(a.name, { kind: "const", value: ATA_PROGRAM_BYTES });
460
+ }
461
+ // Created in the body: the ATA program checks the address when it creates it, and createIfNeeded checks an
462
+ // existing one there. Only the IDL entry is recorded.
463
+ if (k === "token" && a.kind.init)
464
+ return out;
465
+ // The address alone is not enough: legacy SPL Token lets an ATA's owner be reassigned.
466
+ if (k === "token")
467
+ out.push(` if (!ts_key_eq(ts_token_owner(a_${a.name}), &a_${ata.owner}->key)) TS_FAIL(TS_ERR_TOKEN_OWNER, "TypeSOL: ${a.name} is not owned by ${ata.owner}");`);
468
+ // The token program is the account's owner; an AccountInfo not created yet takes the instruction's token program.
469
+ let program = `&a_${a.name}->owner`;
470
+ if (k !== "token") {
471
+ if (!tp)
472
+ this.reporter.fail(a.node, tps.length
473
+ ? "Ata<AccountInfo, …> needs exactly one token program parameter to derive the address; use TokenInterface"
474
+ : "Ata<AccountInfo, …> needs a token program parameter to derive the address");
475
+ program = `&a_${tp.name}->key`;
476
+ }
477
+ out.push(` { SolPubkey ata_${a.name};`);
478
+ out.push(` TS_TRY(ts_ata_address(&a_${ata.owner}->key, &a_${ata.mint}->key, ${program}, &ata_${a.name}));`);
479
+ out.push(` if (!ts_key_eq(&ata_${a.name}, &a_${a.name}->key)) TS_FAIL(TS_ERR_ASSOCIATED, "TypeSOL: ${a.name} is not the associated token account of ${ata.owner} and ${ata.mint}"); }`);
480
+ if (k === "token")
481
+ out.push(` if (!ts_key_eq(ts_token_mint(a_${a.name}), &a_${ata.mint}->key)) TS_FAIL(TS_ERR_TOKEN_MINT, "TypeSOL: ${a.name} is not a token account of ${ata.mint}");`);
482
+ return out;
483
+ }
484
+ /** Seeds<…>: the account is the PDA of its seeds (with the stored bump, or the canonical one found here). */
485
+ seedsCheck(ix, a, bumpDecls) {
486
+ const out = [];
487
+ const c = a.constraints;
488
+ const parts = [];
489
+ const idl = [];
490
+ for (const raw of c.seeds.names) {
491
+ // "@name" refers to an account parameter (its key) or an argument (its bytes); anything else is a literal.
492
+ const ref = raw.startsWith("@") ? raw.slice(1) : undefined;
493
+ const n = ref ?? raw;
494
+ // "@acct.field": a field of a struct account parameter (its data was checked in the head), Anchor's
495
+ // `seeds = [pool.amm.as_ref()]`. The account may be this one (`seeds = [offer.id.to_le_bytes()]`).
496
+ if (ref?.includes(".")) {
497
+ const [owner, field, ...rest] = ref.split(".");
498
+ const o = ix.accounts.find((x) => x.name === owner);
499
+ if (!o || o.kind.kind !== "account" || rest.length)
500
+ this.reporter.fail(a.node, `Seeds: "@${ref}" must name a field of a struct account parameter ("@vault.owner")`);
501
+ const st = o.kind.struct;
502
+ const f = st.fields.find((x) => x.name === field);
503
+ if (!f)
504
+ this.reporter.fail(a.node, `Seeds: ${st.name} has no field "${field}"`);
505
+ const t = f.type;
506
+ if (t.kind === "pubkey")
507
+ parts.push(`{(const uint8_t *)&d_${owner}->${field}, 32}`);
508
+ else if (t.kind === "int" || t.kind === "enum")
509
+ parts.push(`{(const uint8_t *)&d_${owner}->${field}, ${scalarSize(t)}}`);
510
+ else
511
+ this.reporter.fail(a.node, `Seeds: field "${ref}" of type ${typeName(t)} cannot be a seed (use a Pubkey, an integer or a unit enum)`);
512
+ idl.push({ kind: "account", path: `${snakeCase(owner)}.${snakeCase(field)}`, account: st.name });
513
+ continue;
514
+ }
515
+ const acct = ref ? ix.accounts.find((o) => o.name === ref) : undefined;
516
+ const arg = ref ? ix.args.find((o) => o.name === ref) : undefined;
517
+ if (ref && !acct && !arg)
518
+ this.reporter.fail(a.node, `Seeds: "@${ref}" is neither an account parameter nor an argument`);
519
+ if (acct) {
520
+ parts.push(`{(const uint8_t *)&a_${n}->key, 32}`);
521
+ idl.push({ kind: "account", path: snakeCase(n) });
522
+ }
523
+ else if (arg) {
524
+ const t = arg.type;
525
+ if (t.kind === "string")
526
+ parts.push(`{arg_${n}.ptr, arg_${n}.len}`);
527
+ else if (t.kind === "pubkey")
528
+ parts.push(`{(const uint8_t *)&arg_${n}, 32}`);
529
+ else if (t.kind === "int" || t.kind === "enum")
530
+ parts.push(`{(const uint8_t *)&arg_${n}, ${scalarSize(t)}}`);
531
+ else
532
+ this.reporter.fail(a.node, `Seeds: argument "${n}" of type ${typeName(t)} cannot be a seed`);
533
+ idl.push({ kind: "arg", path: snakeCase(n) });
534
+ }
535
+ else {
536
+ if (Buffer.byteLength(n, "utf8") > 32)
537
+ this.reporter.fail(a.node, "a seed can be at most 32 bytes");
538
+ parts.push(`{(const uint8_t *)${cString(n)}, ${Buffer.byteLength(n, "utf8")}}`);
539
+ idl.push({ kind: "const", value: [...Buffer.from(n, "utf8")] });
540
+ }
541
+ }
542
+ if (!ix.pdas.has(a.name))
543
+ ix.pdas.set(a.name, idl);
544
+ // The deriving program: this one, another account parameter ("@name"), or a fixed address.
545
+ let program = "params->program_id";
546
+ const p = c.seeds.program;
547
+ if (p?.startsWith("@")) {
548
+ const pa = ix.accounts.find((o) => o.name === p.slice(1));
549
+ if (!pa)
550
+ this.reporter.fail(a.node, `Seeds program: "${p}" is not an account parameter`);
551
+ program = `&a_${pa.name}->key`;
552
+ ix.pdaPrograms.set(a.name, { kind: "account", path: snakeCase(pa.name) });
553
+ }
554
+ else if (p) {
555
+ const bytes = [...decodeAddress(p)];
556
+ out.push(` static const SolPubkey sp_${a.name} = {${cBytes(bytes)}};`);
557
+ program = `&sp_${a.name}`;
558
+ ix.pdaPrograms.set(a.name, { kind: "const", value: bytes });
559
+ }
560
+ const seeds = `cs_${a.name}`;
561
+ out.push(` SolSignerSeed ${seeds}[${parts.length + 1}] = {${parts.join(", ")}};`);
562
+ if (c.seeds.bump) {
563
+ out.push(` ${seeds}[${parts.length}] = (SolSignerSeed){&d_${a.name}->${c.seeds.bump}, 1};`);
564
+ out.push(` TS_TRY(ts_require_pda(a_${a.name}, ${seeds}, ${parts.length + 1}, ${program}));`);
565
+ this.bumps.set(a.name, `d_${a.name}->${c.seeds.bump}`);
566
+ }
567
+ else {
568
+ bumpDecls.push(` uint8_t sb_${a.name}; (void)sb_${a.name};`);
569
+ out.push(` { SolPubkey pda_${a.name};`);
570
+ out.push(` if (ts_find_pda(${seeds}, ${parts.length}, ${program}, &pda_${a.name}, &sb_${a.name}) != SUCCESS || !ts_key_eq(&pda_${a.name}, &a_${a.name}->key)) TS_FAIL(TS_ERR_SEEDS, "TypeSOL: ${a.name} is not the expected PDA"); }`);
571
+ this.bumps.set(a.name, `sb_${a.name}`);
572
+ }
573
+ return out;
574
+ }
545
575
  /** Optional account parameters a piece of generated C refers to (a_name / d_name). */
546
576
  optionalIn(code) {
547
577
  return (this.ix?.accounts ?? []).filter((a) => a.optional && new RegExp(`\\b[ad]_${a.name}\\b`).test(code)).map((a) => a.name);
@@ -137,6 +137,9 @@ export function emitProgram(analysis, opts = {}) {
137
137
  // refuse more than the array holds instead (ts_deserialize relies on this bound).
138
138
  out.push(` if (*(const uint64_t *)input > TS_MAX_ACCOUNTS) TS_FAIL(TS_ERR_TOO_MANY_ACCOUNTS, "TypeSOL: at most ${MAX_ACCOUNTS} accounts per instruction");`);
139
139
  out.push(` ts_deserialize(input, &params);`);
140
+ // loadAccountMut's record of the accounts it loaded: set up only in programs that use it (one store otherwise wasted).
141
+ if ([...helperBodies, ...ixBodies].some((b) => b.includes("ts_claim_mut(")))
142
+ out.push(` params.loaded_mut = 0;`);
140
143
  const minDisc = Math.min(8, ...model.instructions.map((ix) => ix.discriminator.length));
141
144
  out.push(` if (params.data_len < ${minDisc}) TS_FAIL(TS_ERR_INSTRUCTION_MISSING, "TypeSOL: instruction data too short");`);
142
145
  // Self-CPI events (emitCpi): accept the event instruction when its authority PDA signed it, like Anchor.
@@ -11,6 +11,7 @@ export declare const ATA_PROGRAM_BYTES: number[];
11
11
  export declare const U64: ScalarType;
12
12
  export declare const U8: ScalarType;
13
13
  export declare const I64: ScalarType;
14
+ export declare const I32: ScalarType;
14
15
  export declare const BOOL: ScalarType;
15
16
  export declare const PUBKEY: ScalarType;
16
17
  export declare const STRING: ValueType;
@@ -13,6 +13,7 @@ export const ATA_PROGRAM_BYTES = [
13
13
  export const U64 = { kind: "int", name: "u64" };
14
14
  export const U8 = { kind: "int", name: "u8" };
15
15
  export const I64 = { kind: "int", name: "i64" };
16
+ export const I32 = { kind: "int", name: "i32" };
16
17
  export const BOOL = { kind: "bool" };
17
18
  export const PUBKEY = { kind: "pubkey" };
18
19
  export const STRING = { kind: "string" };
@@ -93,4 +93,10 @@ export declare abstract class StatementEmitter extends CpiEmitter {
93
93
  /** Whether a place goes through a Vec element (`v.get(i).x`, `v.get(i)`). */
94
94
  protected writesThroughVec(node: ts.Expression): boolean;
95
95
  protected helperTakesMutAccounts(node: ts.CallExpression): boolean;
96
+ /**
97
+ * Whether evaluating `node` may resize a growable Vec (and so move the Vecs after it): a push / pop / clear, a
98
+ * realloc or close, a helper taking a writable account or a function argument, or a closure (its body is not
99
+ * looked into: it may capture the account). Conservative: it only decides whether a Vec element may be the target.
100
+ */
101
+ protected mayResizeVecs(node: ts.Node): boolean;
96
102
  }
@@ -1085,6 +1085,10 @@ export class StatementEmitter extends CpiEmitter {
1085
1085
  this.fail(e, "this expression has no effect; only assignments and calls can be statements");
1086
1086
  }
1087
1087
  assign(target, valueNode, op, topLevel = false) {
1088
+ // The place is resolved before the right side runs (JS order). A right side that can resize a Vec of the account
1089
+ // would move the element out from under it: the write would land in the bytes that follow.
1090
+ if (this.writesThroughVec(target) && this.mayResizeVecs(valueNode))
1091
+ this.fail(target, "the right side may change this Vec (a helper, closure or push/pop/clear); store its result in a local first");
1088
1092
  if (op !== undefined) {
1089
1093
  const place = this.place(target);
1090
1094
  let current = { c: place.read, type: place.type };
@@ -1143,8 +1147,6 @@ export class StatementEmitter extends CpiEmitter {
1143
1147
  const structCall = isCall && ts.isIdentifier(valueNode.expression)
1144
1148
  ? this.helperOf(valueNode)?.returns?.kind === "struct"
1145
1149
  : false;
1146
- if (structCall && this.writesThroughVec(target) && this.helperTakesMutAccounts(valueNode))
1147
- this.fail(target, "the right side calls a helper that may change this Vec; store its result in a local first");
1148
1150
  // Whole arrays / structs: copy between identical layouts.
1149
1151
  const targetRef = isCall && !structCall ? undefined : this.ref(target);
1150
1152
  if (targetRef &&
@@ -1161,9 +1163,6 @@ export class StatementEmitter extends CpiEmitter {
1161
1163
  }
1162
1164
  const place = this.place(target);
1163
1165
  if (isCall && !createsFirst) {
1164
- // A helper that may replace a Vec element would leave `place` pointing at the new one (JS writes the old object).
1165
- if (this.writesThroughVec(target) && this.helperTakesMutAccounts(valueNode))
1166
- this.fail(target, "the right side calls a helper that may change this Vec; store its result in a local first");
1167
1166
  raw = this.call(valueNode, topLevel);
1168
1167
  if (!raw)
1169
1168
  this.fail(valueNode, "this call does not produce a value");
@@ -1194,4 +1193,35 @@ export class StatementEmitter extends CpiEmitter {
1194
1193
  const h = this.helperOf(node);
1195
1194
  return !!h?.params.some((p) => p.account && isWritable(p.account));
1196
1195
  }
1196
+ /**
1197
+ * Whether evaluating `node` may resize a growable Vec (and so move the Vecs after it): a push / pop / clear, a
1198
+ * realloc or close, a helper taking a writable account or a function argument, or a closure (its body is not
1199
+ * looked into: it may capture the account). Conservative: it only decides whether a Vec element may be the target.
1200
+ */
1201
+ mayResizeVecs(node) {
1202
+ let found = false;
1203
+ const isClosure = (e) => ts.isArrowFunction(e) || ts.isFunctionExpression(e) || (ts.isIdentifier(e) && !!this.lookup(e.text)?.closure);
1204
+ const visit = (n) => {
1205
+ if (found)
1206
+ return;
1207
+ if (ts.isCallExpression(n)) {
1208
+ const callee = n.expression;
1209
+ if (ts.isPropertyAccessExpression(callee) && ["push", "pop", "clear"].includes(callee.name.text))
1210
+ found = true;
1211
+ else if (ts.isCallExpression(callee))
1212
+ found = true; // a function a function returns
1213
+ else if (ts.isIdentifier(callee)) {
1214
+ if (this.lookup(callee.text)?.closure)
1215
+ found = true;
1216
+ else if (["realloc", "close"].includes(this.exportName(callee) ?? ""))
1217
+ found = true;
1218
+ else if (this.helperOf(n) && (this.helperTakesMutAccounts(n) || n.arguments.some(isClosure)))
1219
+ found = true;
1220
+ }
1221
+ }
1222
+ ts.forEachChild(n, visit);
1223
+ };
1224
+ visit(node);
1225
+ return found;
1226
+ }
1197
1227
  }
package/dist/templates.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // Programs and tests that `typesol init --template <name>` writes. Each test runs its program in LiteSVM and covers its
2
2
  // main flow and at least one refused call, so a new project starts from something that passes and shows how to test.
3
- // The programs are copies of examples/ (test/workspace.test.ts checks they still match).
3
+ // The programs are copies of examples/ (test/cli/workspace.test.ts checks they still match).
4
4
  export const TEMPLATE_NAMES = ["counter", "vault", "escrow"];
5
5
  /** Every test starts the same way: build the project's program into a LiteSVM with `typesol/testing`. */
6
6
  const testFile = (name, o) => `// Runs the program in LiteSVM (an in-process Solana runtime): \`npm test\`.
package/dist/testing.d.ts CHANGED
@@ -19,7 +19,7 @@
19
19
  import anchor, { type Idl } from "@anchor-lang/core";
20
20
  import { type Address, type Instruction, type KeyPairSigner } from "@solana/kit";
21
21
  import { FailedTransactionMetadata, LiteSVM, TransactionMetadata } from "litesvm";
22
- import { type SvmConnection, svmConnection } from "./svmrpc.js";
22
+ import { type SvmConnection, svmConnection } from "./tools/svmrpc.js";
23
23
  export { type SvmConnection, svmConnection };
24
24
  declare const BorshCoder: typeof anchor.BorshCoder;
25
25
  type BorshCoder = InstanceType<typeof BorshCoder>;
package/dist/testing.js CHANGED
@@ -22,7 +22,7 @@ import { FailedTransactionMetadata, LiteSVM } from "litesvm";
22
22
  import { createHash } from "node:crypto";
23
23
  import path from "node:path";
24
24
  import { fileURLToPath } from "node:url";
25
- import { svmConnection } from "./svmrpc.js";
25
+ import { svmConnection } from "./tools/svmrpc.js";
26
26
  import { buildWorkspace, findWorkspace } from "./workspace.js";
27
27
  export { svmConnection };
28
28
  // @anchor-lang/core is CommonJS: Node 22 cannot import its classes by name from an installed ES module.
@@ -18,7 +18,7 @@
18
18
  * sanitized comments.
19
19
  */
20
20
  import { mkdirSync, writeFileSync } from "node:fs";
21
- import { ANCHOR_ERRORS } from "./compiler/anchor-errors.js";
21
+ import { ANCHOR_ERRORS } from "../compiler/anchor-errors.js";
22
22
  import path from "node:path";
23
23
  /** An IDL the generator refuses: malformed, unsafe to embed, or older than the 0.30 IDL format. */
24
24
  export class ClientIdlError extends Error {
@@ -32,6 +32,7 @@ export const TYPESOL_ERRORS = {
32
32
  100: "InstructionMissing",
33
33
  101: "InstructionFallbackNotFound",
34
34
  102: "InstructionDidNotDeserialize",
35
+ 2000: "ConstraintMut",
35
36
  2001: "ConstraintHasOne",
36
37
  2002: "ConstraintSigner",
37
38
  2004: "ConstraintOwner",
@@ -45,6 +46,8 @@ export const TYPESOL_ERRORS = {
45
46
  2016: "ConstraintMintMintAuthority",
46
47
  2017: "ConstraintMintFreezeAuthority",
47
48
  2018: "ConstraintMintDecimals",
49
+ 2021: "ConstraintTokenTokenProgram",
50
+ 2022: "ConstraintMintTokenProgram",
48
51
  2040: "ConstraintDuplicateMutableAccount",
49
52
  3000: "AccountDiscriminatorAlreadySet",
50
53
  3002: "AccountDiscriminatorMismatch",
@@ -0,0 +1,45 @@
1
+ export declare const INSTALL_AGAVE = "sh -c \"$(curl -sSfL https://release.anza.xyz/stable/install)\"";
2
+ export interface Check {
3
+ ok: boolean;
4
+ /** A missing optional piece (the Solana CLI is only needed to deploy). */
5
+ optional?: boolean;
6
+ what: string;
7
+ detail: string;
8
+ fix?: string[];
9
+ }
10
+ /** The platform-tools the compiler needs: found, or what to run. */
11
+ export declare function toolchainCheck(): Check;
12
+ /** json_rpc_url / keypair_path from a Solana CLI config (the given file, or the CLI's default one). */
13
+ export declare function solanaConfig(file?: string): {
14
+ url?: string;
15
+ keypair?: string;
16
+ file: string;
17
+ exists: boolean;
18
+ };
19
+ /** The Solana CLI's cluster monikers, as `solana --url` accepts them; anything else is used as given. */
20
+ export declare function clusterUrl(url: string): string;
21
+ /** Only the host: an RPC URL can carry an API key in its path or query, and these messages may be shared. */
22
+ export declare function rpcHost(url: string): string;
23
+ export declare function rpc<T>(url: string, method: string, params?: unknown[]): Promise<T>;
24
+ /** fetch's "fetch failed" says little: the cause's code (ECONNREFUSED, ENOTFOUND, a timeout) says more. */
25
+ export declare const errText: (e: unknown) => string;
26
+ /**
27
+ * Before `solana program deploy`: the RPC answers, and the payer holds enough for the program's rent (a first deploy
28
+ * holds about twice the binary's rent while the buffer exists; an upgrade, the buffer's). Throws with what to do.
29
+ */
30
+ export declare function deployPreflight(opts: {
31
+ url: string;
32
+ payer?: string;
33
+ programs: {
34
+ soPath: string;
35
+ address: string;
36
+ name: string;
37
+ }[];
38
+ }): Promise<void>;
39
+ /** Everything `typesol doctor` checks, in order. */
40
+ export declare function doctorChecks(opts: {
41
+ cwd: string;
42
+ url?: string;
43
+ config?: string;
44
+ }): Promise<Check[]>;
45
+ export declare function formatChecks(checks: Check[]): string;