@typesol/cli 0.27.0 → 0.28.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.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,27 @@ codes, and says so here.
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## 0.28.0 — 2026-10-29
10
+
11
+ A port of coral-xyz/multisig, checked against the program deployed on mainnet, and the features it needed. The
12
+ benchmark programs compile to the same numbers as in 0.27.0.
13
+
14
+ - New: `invokeInstruction(programId, metas, data, infos, signerSeeds?)`, a CPI decided at run time (Rust's
15
+ `invoke_signed` of an `Instruction` built from data): metas are structs with `pubkey`, `isSigner`, `isWritable`, the
16
+ data a list of u8, and every account in `infos` (the remaining accounts or a slice) goes to the runtime.
17
+ - New: `Signed<A>`, Anchor's `signer` constraint on an account that is not a `Signer` (2002); the IDL marks it a signer.
18
+ - New: `Zeroed<T>` of a `@layout borsh` struct (Anchor's `#[account(zero)]` on a Borsh account): it starts empty and is
19
+ written as Borsh when the instruction returns.
20
+ - New: a Seeds bump stored in another struct account, `Seeds<A, ["@multisig"], "@multisig.nonce">` (Anchor's
21
+ `bump = multisig.nonce`).
22
+ - New: `list = vecArgument` copies a `Vec` argument (or another list of the same elements) into a `FixedVec` (5104 when
23
+ it does not fit).
24
+ - Changed: a `Zeroed<T>` account whose discriminator is already set fails with 2013 (ConstraintZero), checked before its
25
+ owner, as under Anchor; it was 3000 after the owner check.
26
+ - Changed: `key`, `lamports`, `dataLen`, `isSigner`, `isWritable` and `pdaBump` are refused as field names only in
27
+ structs held in an account (where they would shadow `acct.key` and the others), no longer in every struct.
28
+ - Docs: `docs/case-study-multisig.md`; the mainnet binary is in `test/programs/coral_multisig.so`.
29
+
9
30
  ## 0.27.0 — 2026-10-29
10
31
 
11
32
  Anchor 1.0 parity: instruction introspection, `Dup<…>` and account migration. Existing programs compile to the same
package/README.md CHANGED
@@ -175,7 +175,8 @@ program's address.
175
175
 
176
176
  Coming from Anchor: [docs/migrate-from-anchor.md](docs/migrate-from-anchor.md) maps every construct, `typesol port`
177
177
  starts from your program's IDL, and [docs/case-study-escrow.md](docs/case-study-escrow.md) follows one complete Anchor
178
- program through the port. Tests use `typesol/testing` (accounts by name, plain values; [docs/test-your-program.md](docs/test-your-program.md)). To go to
178
+ program through the port; [docs/case-study-multisig.md](docs/case-study-multisig.md) ports coral-xyz/multisig and
179
+ checks it against the binary deployed on mainnet (same outcomes and account bytes; 73–84% fewer compute units). Tests use `typesol/testing` (accounts by name, plain values; [docs/test-your-program.md](docs/test-your-program.md)). To go to
179
180
  mainnet (wallet, cost, deploy, IDL, verification), follow [docs/mainnet.md](docs/mainnet.md).
180
181
 
181
182
  ## How it works
@@ -323,6 +324,7 @@ Details are in [docs/testing.md](docs/testing.md).
323
324
  | [`relay.ts`](examples/relay.ts), [`caller.ts`](examples/caller.ts) | generic `invoke`, PDA-signed transfers, return data |
324
325
  | [`profiles.ts`](examples/profiles.ts) | nested structs, FixedString, events |
325
326
  | [`orderbook/`](examples/orderbook) | a three-file, 28 KiB zero-copy limit order book |
327
+ | [`multisig.ts`](examples/multisig.ts) | coral-xyz/multisig: stored instructions run with `invokeInstruction`, a PDA signing, Anchor's Borsh accounts |
326
328
 
327
329
  ## Status
328
330
 
@@ -2,6 +2,8 @@ import ts from "typescript";
2
2
  import { Reporter } from "./diagnostics.js";
3
3
  import { type DataEnumDef, type EnumDef, type GenericHelper, type Helper, type ProgramModel, type ScalarType, type StructDef, type ValueType, isScalar } from "./model.js";
4
4
  export declare const LIB_PATH: string;
5
+ /** A struct used as account data: none of its fields may shadow an account member. */
6
+ export declare function checkAccountMembers(s: StructDef, at: ts.Node, reporter: Reporter): void;
5
7
  /** Why a name cannot be used as a C member, or undefined when it can. */
6
8
  export declare function cNameProblem(name: string): string | undefined;
7
9
  /** Solana's account size limit, and what a program can allocate itself through a CPI (Init<T>). */
@@ -8,7 +8,15 @@ import { borshIdentical } from "./emit/borsh.js";
8
8
  import { optionKey, INT_INFO, INT_NAMES, scalarKey, fixedVecName, isScalar, scalarSize, typeName, } from "./model.js";
9
9
  export const LIB_PATH = fileURLToPath(new URL("../../lib/typesol.d.ts", import.meta.url));
10
10
  /** Names a struct field may not use because account parameters already expose them. */
11
- const RESERVED_FIELDS = new Set(["key", "lamports", "dataLen", "create", "equals", "get", "set", "length", "isSigner", "isWritable", "pdaBump"]);
11
+ const RESERVED_FIELDS = new Set(["create", "equals", "get", "set", "length"]);
12
+ /** Members every account parameter has (`acct.key`, `acct.isSigner`, …): a struct held in an account cannot use them. */
13
+ const ACCOUNT_MEMBERS = new Set(["key", "lamports", "dataLen", "isSigner", "isWritable", "pdaBump"]);
14
+ /** A struct used as account data: none of its fields may shadow an account member. */
15
+ export function checkAccountMembers(s, at, reporter) {
16
+ const clash = s.fields.find((f) => ACCOUNT_MEMBERS.has(f.name));
17
+ if (clash)
18
+ reporter.fail(at, `${s.name}.${clash.name}: "${clash.name}" is an account member (acct.${clash.name}); rename the field to hold ${s.name} in an account`);
19
+ }
12
20
  /**
13
21
  * Struct fields and enum variants become C member names as written. C keywords and the object-like
14
22
  * macros of the Solana C SDK / TypeSOL runtime would break or silently change the generated C.
@@ -86,6 +94,7 @@ const TYPE_EXPORTS = new Set([
86
94
  "Owner",
87
95
  "Executable",
88
96
  "Dup",
97
+ "Signed",
89
98
  "Migrate",
90
99
  "Optional",
91
100
  "EventAuthority",
@@ -1341,7 +1350,8 @@ function analyzeHelper(fn, name, display, reporter, scope, structs, skip = new S
1341
1350
  wrapped === "Seeds" ||
1342
1351
  wrapped === "CloseTo" ||
1343
1352
  wrapped === "Address" ||
1344
- wrapped === "Ata")
1353
+ wrapped === "Ata" ||
1354
+ wrapped === "Signed")
1345
1355
  reporter.fail(p.type, "constraints belong on instruction parameters; helpers take the plain account type");
1346
1356
  const account = resolveAccountKind(p.type, reporter, scope.imports, structs);
1347
1357
  if (account.kind === "init" || account.kind === "initIfNeeded" || account.kind === "zeroed" || account.kind === "migrate")
@@ -1529,9 +1539,20 @@ function analyzeInstruction(fn, reporter, scope, structs) {
1529
1539
  if (!accounts.some((o) => o.name === f))
1530
1540
  reporter.fail(a.node, `HasOne<…, "${f}">: add an account parameter named "${f}"`);
1531
1541
  }
1532
- if (c.seeds?.bump) {
1542
+ if (c.seeds?.bump?.startsWith("@")) {
1543
+ // "@acct.field": the bump stored in another struct account (Anchor `bump = multisig.nonce`).
1544
+ const [owner, field, ...rest] = c.seeds.bump.slice(1).split(".");
1545
+ const o = accounts.find((x) => x.name === owner);
1546
+ const os = o?.kind.kind === "account" ? o.kind.struct : undefined;
1547
+ if (!os || !field || rest.length)
1548
+ reporter.fail(a.node, `Seeds bump: "${c.seeds.bump}" must name a u8 field of a struct account parameter ("@config.bump")`);
1549
+ const bf = os.fields.find((x) => x.name === field);
1550
+ if (!bf || bf.type.kind !== "int" || bf.type.name !== "u8")
1551
+ reporter.fail(a.node, `Seeds bump: ${os.name} needs a u8 field "${field}"`);
1552
+ }
1553
+ else if (c.seeds?.bump) {
1533
1554
  if (!struct)
1534
- reporter.fail(a.node, "Seeds with a bump field needs a struct account");
1555
+ reporter.fail(a.node, `Seeds with a bump field needs a struct account; for a bump stored elsewhere name it "@acct.field"`);
1535
1556
  const bf = struct.fields.find((x) => x.name === c.seeds.bump);
1536
1557
  if (!bf || bf.type.kind !== "int" || bf.type.name !== "u8")
1537
1558
  reporter.fail(a.node, `Seeds bump: ${struct.name} needs a u8 field "${c.seeds.bump}"`);
@@ -1562,6 +1583,7 @@ function analyzeInstruction(fn, reporter, scope, structs) {
1562
1583
  ...c.hasOne.map((e) => e.split("=").at(-1)),
1563
1584
  ...(c.seeds?.names ?? []).filter((n) => n.startsWith("@")).map((n) => n.slice(1).split(".")[0]),
1564
1585
  ...(c.seeds?.program?.startsWith("@") ? [c.seeds.program.slice(1)] : []),
1586
+ ...(c.seeds?.bump?.startsWith("@") ? [c.seeds.bump.slice(1).split(".")[0]] : []),
1565
1587
  ...(c.closeTo ? [c.closeTo] : []),
1566
1588
  ...(c.ata
1567
1589
  ? [c.ata.owner, c.ata.mint, ...(a.kind.kind === "token" ? [] : accounts.filter((o) => o.kind.kind === "tokenProgram").map((o) => o.name))]
@@ -1631,7 +1653,7 @@ function analyzeInstruction(fn, reporter, scope, structs) {
1631
1653
  /** Peel HasOne / Seeds / CloseTo wrappers off a parameter type, recording what they ask for. */
1632
1654
  function unwrapConstraints(node, reporter, imports, out) {
1633
1655
  const name = typesolName(node, imports);
1634
- if (!["HasOne", "TokenOf", "MintOf", "Seeds", "CloseTo", "Address", "Ata", "Owner", "Executable", "Dup"].includes(name ?? ""))
1656
+ if (!["HasOne", "TokenOf", "MintOf", "Seeds", "CloseTo", "Address", "Ata", "Owner", "Executable", "Dup", "Signed"].includes(name ?? ""))
1635
1657
  return node;
1636
1658
  const args = node.typeArguments ?? [];
1637
1659
  const lit = (t) => {
@@ -1675,6 +1697,8 @@ function unwrapConstraints(node, reporter, imports, out) {
1675
1697
  out.executable = true;
1676
1698
  if (name === "Dup")
1677
1699
  out.dup = true;
1700
+ if (name === "Signed")
1701
+ out.signed = true;
1678
1702
  if (name === "Address") {
1679
1703
  const text = lit(args[1])[0];
1680
1704
  const bytes = text === undefined ? undefined : decodeAddress(text);
@@ -1719,6 +1743,8 @@ function resolveAccountKind(node, reporter, imports, structs) {
1719
1743
  const s = ts.isTypeReferenceNode(t) && ts.isIdentifier(t.typeName) ? structs.get(t.typeName.text) : undefined;
1720
1744
  if (s && s.size === undefined)
1721
1745
  reporter.fail(t, `${s.name} has a variable-length string field and cannot be account data; use FixedString<N>`);
1746
+ if (s)
1747
+ checkAccountMembers(s, t, reporter);
1722
1748
  return s;
1723
1749
  };
1724
1750
  switch (outer) {
@@ -1831,9 +1857,7 @@ function resolveAccountKind(node, reporter, imports, structs) {
1831
1857
  if (!s)
1832
1858
  reporter.fail(node, "Zeroed<T> needs a struct declared in this program");
1833
1859
  if (s.vec)
1834
- reporter.fail(node, `${s.name} has a growable Vec; Zeroed<T> is for fixed-size (large) accounts`);
1835
- if (s.layout === "borsh")
1836
- reporter.fail(node, `${s.name} is @layout borsh; Zeroed<T> reads large accounts in place, so it takes fixed layouts only`);
1860
+ reporter.fail(node, `${s.name} has a growable Vec; Zeroed<T> is for fixed-size accounts (or @layout borsh with FixedVec)`);
1837
1861
  return { kind: "zeroed", struct: s };
1838
1862
  }
1839
1863
  case "Mut": {
@@ -131,6 +131,7 @@ export declare abstract class EmitterBase {
131
131
  protected abstract coreOp(node: ts.CallExpression, core: AccountParam, method: string): undefined;
132
132
  protected abstract bubblegumOp(node: ts.CallExpression, bg: AccountParam, method: string): undefined;
133
133
  protected abstract invoke(node: ts.CallExpression): undefined;
134
+ protected abstract invokeInstruction(node: ts.CallExpression): undefined;
134
135
  protected abstract emitEvent(node: ts.CallExpression, cpi?: {
135
136
  authority: string;
136
137
  program: string;
@@ -288,6 +288,8 @@ export class CallEmitter extends ExpressionEmitter {
288
288
  }
289
289
  case "invoke":
290
290
  return this.invoke(node);
291
+ case "invokeInstruction":
292
+ return this.invokeInstruction(node);
291
293
  case "sha256":
292
294
  case "keccak256":
293
295
  return this.hashCall(node, builtin);
@@ -157,6 +157,15 @@ export declare abstract class CpiEmitter extends CallEmitter {
157
157
  to: string;
158
158
  } | undefined;
159
159
  protected invoke(node: ts.CallExpression): undefined;
160
+ /**
161
+ * `invokeInstruction(programId, metas, data, infos, signerSeeds?)`: a CPI decided at run time (Rust's
162
+ * `invoke_signed(&Instruction { program_id, accounts, data }, infos, seeds)`), e.g. one stored in account data. Each
163
+ * meta is a struct with `pubkey`, `isSigner` and `isWritable`; `data` a list of u8; every account in `infos` (the
164
+ * remaining accounts, or a slice of them) goes to the runtime, which finds each meta's account there by key (an
165
+ * account missing there fails the CPI, as under Rust) and checks the privileges: a meta may only be a signer or
166
+ * writable as the transaction passed it, or a PDA of this program that `signerSeeds` sign for.
167
+ */
168
+ protected invokeInstruction(node: ts.CallExpression): undefined;
160
169
  /**
161
170
  * A byte buffer written front to back. Its C declaration is patched in afterwards so it is
162
171
  * exactly as large as the fixed content, and only falls back to `max` when strings make it dynamic.
@@ -2746,6 +2746,69 @@ export class CpiEmitter extends CallEmitter {
2746
2746
  this.line(`TS_TRY(ts_invoke(params, &${ixName}, NULL, 0));`);
2747
2747
  return undefined;
2748
2748
  }
2749
+ /**
2750
+ * `invokeInstruction(programId, metas, data, infos, signerSeeds?)`: a CPI decided at run time (Rust's
2751
+ * `invoke_signed(&Instruction { program_id, accounts, data }, infos, seeds)`), e.g. one stored in account data. Each
2752
+ * meta is a struct with `pubkey`, `isSigner` and `isWritable`; `data` a list of u8; every account in `infos` (the
2753
+ * remaining accounts, or a slice of them) goes to the runtime, which finds each meta's account there by key (an
2754
+ * account missing there fails the CPI, as under Rust) and checks the privileges: a meta may only be a signer or
2755
+ * writable as the transaction passed it, or a PDA of this program that `signerSeeds` sign for.
2756
+ */
2757
+ invokeInstruction(node) {
2758
+ const a = node.arguments;
2759
+ if (!this.ix)
2760
+ this.fail(node, "invokeInstruction is only available in instructions (it passes remaining accounts)");
2761
+ if (a.length !== 4 && a.length !== 5)
2762
+ this.fail(node, "invokeInstruction(programId, accounts, data, infos, signerSeeds?) takes 4 or 5 arguments");
2763
+ const program = this.expect(this.expr(a[0], PUBKEY), PUBKEY, a[0]);
2764
+ const programKey = this.tmp("dprog");
2765
+ this.line(`SolPubkey ${programKey} = *(${program.c});`);
2766
+ const list = (e, what) => {
2767
+ const l = ts.isIdentifier(e) ? this.lookup(e.text) : undefined;
2768
+ if (l?.type.kind === "vecArg")
2769
+ return { items: `${l.c}.ptr`, count: `((uint64_t)${l.c}.len)`, elem: l.type.elem };
2770
+ const r = this.ref(e);
2771
+ if (r?.vec && r.type.kind === "vec")
2772
+ return { items: `${r.vec.items}`, count: vecCount(r.vec), elem: r.type.elem };
2773
+ this.fail(e, `${what}: a Vec or FixedVec (account data, an argument or a local list)`);
2774
+ };
2775
+ const metas = list(a[1], "the accounts");
2776
+ const elem = metas.elem;
2777
+ const st = elem.kind === "struct" ? this.model.structs.get(elem.name) : undefined;
2778
+ const field = (name, kind) => st?.fields.find((f) => f.name === name && f.type.kind === kind);
2779
+ if (!st || !field("pubkey", "pubkey") || !field("isSigner", "bool") || !field("isWritable", "bool"))
2780
+ this.fail(a[1], "each account is a struct with pubkey: Pubkey, isSigner: boolean and isWritable: boolean");
2781
+ const data = list(a[2], "the data");
2782
+ if (data.elem.kind !== "int" || data.elem.name !== "u8")
2783
+ this.fail(a[2], "the data is a list of u8");
2784
+ const range = this.remainingRange(a[3]) ?? this.fail(a[3], "pass the accounts the CPI may use: remainingAccounts() or rest.slice(a, b)");
2785
+ const seeds = a.length === 5 ? this.signingSeeds(a[4]) : undefined;
2786
+ const n = this.tmp("dn");
2787
+ const m = this.tmp("dmetas");
2788
+ const i = this.tmp("di");
2789
+ const k = this.tmp("dk");
2790
+ this.line(`uint64_t ${n} = ${metas.count};`);
2791
+ this.line(`SolAccountMeta *${m} = NULL;`);
2792
+ this.line(`TS_TRY(ts_alloc(${n} * sizeof(SolAccountMeta) + 1, (uint8_t **)&${m}));`);
2793
+ this.line(`for (uint64_t ${i} = 0; ${i} < ${n}; ${i}++) {`);
2794
+ this.line(` const TSS_${st.name} *e = &((const TSS_${st.name} *)${metas.items})[${i}];`);
2795
+ this.line(` ${m}[${i}] = (SolAccountMeta){(SolPubkey *)&e->pubkey, e->isWritable != 0, e->isSigner != 0};`);
2796
+ this.line(`}`);
2797
+ // Every account offered goes along as an AccountInfo (ts_invoke's scratch holds TS_MAX_ACCOUNTS of them).
2798
+ const base = this.ix.accounts.length;
2799
+ this.line(`uint64_t ${k} = 0;`);
2800
+ this.line(`for (uint64_t ${i}b = ${base} + ${range.from}; ${i}b < ${base} + ${range.to}; ${i}b++) ts_info(params->ka[${i}b], &params->infos[${k}++]);`);
2801
+ const ixName = this.tmp("cpi");
2802
+ this.line(`SolInstruction ${ixName} = {&${programKey}, ${m}, ${n}, (uint8_t *)${data.items}, ${data.count}};`);
2803
+ if (seeds) {
2804
+ const signer = this.tmp("signer");
2805
+ this.line(`SolSignerSeeds ${signer} = {${seeds.name}, ${seeds.count}};`);
2806
+ this.line(`TS_TRY(sol_invoke_signed(&${ixName}, params->infos, ${k}, &${signer}, ${typeof seeds.count === "number" ? (seeds.count > 0 ? 1 : 0) : `${seeds.count} > 0 ? 1 : 0`}));`);
2807
+ }
2808
+ else
2809
+ this.line(`TS_TRY(sol_invoke_signed(&${ixName}, params->infos, ${k}, NULL, 0));`);
2810
+ return undefined;
2811
+ }
2749
2812
  /**
2750
2813
  * A byte buffer written front to back. Its C declaration is patched in afterwards so it is
2751
2814
  * exactly as large as the fixed content, and only falls back to `max` when strings make it dynamic.
@@ -40,8 +40,14 @@ export class FunctionEmitter extends StatementEmitter {
40
40
  head.push(` TS_TRY(ts_check_signer(${v}));`);
41
41
  // Mut<…>: Anchor's `mut` (2000). Anchor checks it with the constraints, after seeds and associated token; here it
42
42
  // sits with the type checks, which keeps binaries smaller (only a transaction breaking both sees the difference).
43
+ // Zeroed<T> (Anchor's `zero`): its discriminator, owner and size come before `mut`, as under Anchor.
44
+ if (k.kind === "zeroed")
45
+ head.push(` TS_TRY(ts_check_zeroed(${v}, params->program_id, ${k.struct.layout === "borsh" ? borshMinSize(k.struct, this.model) : `sizeof(TSS_${k.struct.name})`}));`);
43
46
  if (isWritable(k))
44
47
  head.push(` TS_TRY(ts_check_mut(${v}));`);
48
+ // Signed<…>: Anchor's `signer` constraint (2002), right after `mut`.
49
+ if (a.constraints.signed)
50
+ head.push(` if (!${v}->is_signer) TS_FAIL(TS_ERR_CONSTRAINT_SIGNER, "TypeSOL: ${a.name} must sign");`);
45
51
  // A @layout borsh account only has to hold its smallest Borsh form (unpacking checks the rest).
46
52
  if (k.kind === "account")
47
53
  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)}));`);
@@ -94,10 +100,10 @@ export class FunctionEmitter extends StatementEmitter {
94
100
  if (k.kind === "migrate")
95
101
  head.push(` TS_TRY(ts_check_program_account(${v}, params->program_id, TS_DISC_${k.from.name}, sizeof(TSS_${k.from.name})));`, ` const TSS_${k.from.name} *df_${a.name} = (const TSS_${k.from.name} *)(${v}->data + 8);`, ` uint64_t entry_${a.name} = ${v}->data_len;`);
96
102
  if (k.kind === "zeroed")
97
- head.push(` TS_TRY(ts_init_zeroed(${v}, params->program_id, TS_DISC_${k.struct.name}, sizeof(TSS_${k.struct.name})));`);
103
+ head.push(` TS_TRY(ts_init_zeroed(${v}, TS_DISC_${k.struct.name}));`);
98
104
  if (k.kind === "initIfNeeded" && k.struct.vec)
99
105
  this.reporter.fail(a.node, "InitIfNeeded accounts cannot end in a Vec yet");
100
- if ((k.kind === "account" || k.kind === "init" || k.kind === "initIfNeeded") && k.struct.layout === "borsh") {
106
+ if ((k.kind === "account" || k.kind === "init" || k.kind === "initIfNeeded" || k.kind === "zeroed") && k.struct.layout === "borsh") {
101
107
  // Worked on as its fixed-layout struct on the heap, packed back when the instruction returns. The heap is zero at
102
108
  // entry and these are its first allocations (before the body uses it), so a string's padding and a list's
103
109
  // unused slots start as zero: ts_unpack_* only writes what the Borsh bytes hold.
@@ -379,7 +385,7 @@ export class FunctionEmitter extends StatementEmitter {
379
385
  return [];
380
386
  // @layout borsh accounts go back to their Borsh bytes (unless closed, or never created).
381
387
  const packs = this.ix.accounts
382
- .filter((a) => (a.kind.kind === "account" ? a.kind.mut : a.kind.kind === "init" || a.kind.kind === "initIfNeeded"))
388
+ .filter((a) => (a.kind.kind === "account" ? a.kind.mut : a.kind.kind === "init" || a.kind.kind === "initIfNeeded" || a.kind.kind === "zeroed"))
383
389
  .filter((a) => a.kind.struct.layout === "borsh")
384
390
  .map((a) => {
385
391
  const v = `a_${a.name}`;
@@ -588,9 +594,11 @@ export class FunctionEmitter extends StatementEmitter {
588
594
  const seeds = `cs_${a.name}`;
589
595
  out.push(` SolSignerSeed ${seeds}[${parts.length + 1}] = {${parts.join(", ")}};`);
590
596
  if (c.seeds.bump) {
591
- out.push(` ${seeds}[${parts.length}] = (SolSignerSeed){&d_${a.name}->${c.seeds.bump}, 1};`);
597
+ // The account's own bump field, or "@acct.field": one stored in another struct account.
598
+ const bump = c.seeds.bump.startsWith("@") ? `d_${c.seeds.bump.slice(1).replace(".", "->")}` : `d_${a.name}->${c.seeds.bump}`;
599
+ out.push(` ${seeds}[${parts.length}] = (SolSignerSeed){&${bump}, 1};`);
592
600
  out.push(` TS_TRY(ts_require_pda(a_${a.name}, ${seeds}, ${parts.length + 1}, ${program}));`);
593
- this.bumps.set(a.name, `d_${a.name}->${c.seeds.bump}`);
601
+ this.bumps.set(a.name, bump);
594
602
  }
595
603
  else {
596
604
  bumpDecls.push(` uint8_t sb_${a.name}; (void)sb_${a.name};`);
@@ -685,9 +685,30 @@ export class StorageEmitter extends EmitterBase {
685
685
  this.line(`}`);
686
686
  return;
687
687
  }
688
+ // `list = owners`: a Vec argument, or a Vec / FixedVec of the same elements, copied whole into a FixedVec (Anchor's
689
+ // `multisig.owners = owners`); more elements than it holds fail with 5104, as push does.
690
+ if (target.type.kind === "vec" && target.type.cap !== undefined && target.type.elem.kind !== "string" && target.type.elem.kind !== "fstring") {
691
+ const t = target.type;
692
+ const l = ts.isIdentifier(valueNode) ? this.lookup(valueNode.text) : undefined;
693
+ const r = l?.type.kind === "vecArg" ? undefined : this.reporter.guard(() => this.ref(valueNode));
694
+ const from = l?.type.kind === "vecArg" && sameType(l.type.elem, t.elem)
695
+ ? { items: `${l.c}.ptr`, count: `((uint64_t)${l.c}.len)` }
696
+ : r?.vec && r.type.kind === "vec" && sameType(r.type.elem, t.elem) && !sameType(r.type, t)
697
+ ? { items: r.vec.items, count: vecCount(r.vec) }
698
+ : undefined;
699
+ if (from) {
700
+ const n = this.tmp("vn");
701
+ this.line(`uint64_t ${n} = ${from.count};`);
702
+ this.line(`if (${n} > ${t.cap}) TS_FAIL(TS_ERR_INDEX, "TypeSOL: more elements than the ${typeName(t)} holds");`);
703
+ // Slots past the new length keep what they held, as after pop() / clear().
704
+ this.line(`ts_memcpy(${target.lv}.items, (const uint8_t *)${from.items}, ${n} * ${this.fieldBytes(t.elem)});`);
705
+ this.line(`${target.lv}.len = (uint32_t)${n};`);
706
+ return;
707
+ }
708
+ }
688
709
  const src = this.ref(valueNode) ?? this.wholeAccount(valueNode);
689
710
  if (!src || !sameType(src.type, target.type))
690
- this.fail(valueNode, `expected a ${typeName(target.type)}: an object literal or another ${typeName(target.type)}`);
711
+ this.fail(valueNode, `expected a ${typeName(target.type)}: an object literal, another ${typeName(target.type)}${target.type.kind === "vec" && target.type.cap !== undefined ? " or a Vec of the same elements" : ""}`);
691
712
  // Same-typed places are either distinct or identical (`a.p = a.p`); ts_copy skips the latter.
692
713
  this.line(`ts_copy(&${target.lv}, &${src.lv}, sizeof(${target.lv}));`);
693
714
  }
@@ -93,7 +93,7 @@ function accountEntry(ix, name, k, programAddress) {
93
93
  entry.writable = true;
94
94
  if (param?.optional)
95
95
  entry.optional = true;
96
- if (k.kind === "signer" || param?.signsInit)
96
+ if (k.kind === "signer" || param?.signsInit || param?.constraints.signed)
97
97
  entry.signer = true;
98
98
  if (k.kind === "system")
99
99
  entry.address = SYSTEM_PROGRAM;
@@ -275,6 +275,8 @@ export interface Constraints {
275
275
  executable?: boolean;
276
276
  /** Dup<A> (Anchor's `dup`): may be the same account as another writable parameter (see function.ts). */
277
277
  dup?: boolean;
278
+ /** Signed<A> (Anchor's `signer` constraint on an account of another type): it must sign (2002). */
279
+ signed?: boolean;
278
280
  /** Close this account into that parameter when the instruction succeeds. */
279
281
  closeTo?: string;
280
282
  /** Address<A, "base58">: the account must be exactly this address. */
@@ -102,7 +102,8 @@ These invariants are easy to break. Each one has tests.
102
102
  pointer per account, and lengths, lamports and owners a CPI changes are seen without copying anything back. CPIs
103
103
  go through `ts_invoke`, which builds `SolAccountInfo` records only for the accounts the call lists. It finds each
104
104
  record from its meta (the key sits 8 bytes into it), so every `SolAccountMeta` must point at `&acct->key` of an
105
- instruction account, never at a derived or local `SolPubkey`.
105
+ instruction account, never at a derived or local `SolPubkey`. `invokeInstruction` is the one exception: its metas
106
+ point into data, so it calls `sol_invoke_signed` directly with records for every account it was handed.
106
107
  - **Runtime messages carry the source location.** `line()` appends `(file.ts:line)` to every `"TypeSOL: …"`
107
108
  message.
108
109
  - **No SDK byte loops.** Generated code and the runtime compare, copy and zero memory with `ts_key_eq`, `ts_mem_eq`,
package/docs/audit.md CHANGED
@@ -40,6 +40,7 @@ accepts. Areas where a bug would be most damaging, and where earlier internal re
40
40
  the Vecs that follow, `@layout borsh` accounts.
41
41
  - **Arithmetic**: every operator and cast at every width, compound assignment, `mulDiv`, rent.
42
42
  - **CPIs and signing**: signer-seed arrays (including their reuse across statements), account flags in `invoke`,
43
+ `invokeInstruction` (metas from data, the infos it hands over, `ts_alloc` of the meta array),
43
44
  account creation on pre-funded addresses, closing.
44
45
  - **The runtime's parsing**: instruction data, the serialized accounts, Token-2022 extension data, oracle accounts and
45
46
  the instructions sysvar.
@@ -0,0 +1,167 @@
1
+ # Case study: porting coral-xyz/multisig
2
+
3
+ This page ports a program that holds real authority on mainnet:
4
+ [coral-xyz/multisig](https://github.com/coral-xyz/multisig) (`programs/multisig/src/lib.rs`, Anchor 0.25, Apache-2.0),
5
+ deployed at `msigUdDBsR4zSUYqYEDrc1LcgtmuSDDM7KxpRUXNC6U`. The port is
6
+ [`examples/multisig.ts`](../examples/multisig.ts). Instead of a twin built for the occasion, it is checked against the
7
+ binary that runs on mainnet: [`test/examples/multisig.test.ts`](../test/examples/multisig.test.ts) runs both under the
8
+ same program id and compares every outcome and every account byte. See
9
+ [case-study-escrow.md](case-study-escrow.md) for a token program and [Migrate from Anchor](migrate-from-anchor.md) for
10
+ the general reference.
11
+
12
+ ## The program
13
+
14
+ A multisig executes arbitrary instructions once `threshold` of its owners approve them. Its PDA (`[multisig]`, bump
15
+ `multisig.nonce`) signs the instruction, so it can hold anything a key can: a program's upgrade authority, a mint
16
+ authority, a treasury.
17
+
18
+ - `create_multisig(owners, threshold, nonce)` initializes an account the client allocated in the same transaction.
19
+ - `create_transaction(pid, accs, data)` stores a proposed instruction; the proposer, an owner, approves it.
20
+ - `approve()` adds an owner's approval.
21
+ - `execute_transaction()` runs the stored instruction through a CPI signed by the PDA once enough owners approved.
22
+ - `set_owners(owners)`, `change_threshold(threshold)` and `set_owners_and_change_threshold(...)` change the multisig;
23
+ only its PDA can sign them, so they run as stored transactions the multisig executes on itself.
24
+
25
+ Upstream it is 337 lines of Rust; the port is 184 lines of TypeScript, comments included.
26
+
27
+ ## What it needed
28
+
29
+ The port needed five features TypeSOL did not have. They are general features now, each with tests of its own
30
+ (`test/language/dynamic-cpi.test.ts`):
31
+
32
+ | Anchor | TypeSOL | |
33
+ | --- | --- | --- |
34
+ | `invoke_signed(&ix, ctx.remaining_accounts, …)` with `ix` built from account data | `invokeInstruction(programId, metas, data, remainingAccounts(), seeds)` | A CPI decided at run time. Each meta is a struct with `pubkey`, `isSigner`, `isWritable`. The runtime finds each account among the infos by key and checks privileges, as it does for Rust. |
35
+ | `#[account(zero, signer)] multisig: Box<Account<'info, Multisig>>` | `Signed<Zeroed<Multisig>>` of a `/** @layout borsh */` struct | `Zeroed` now takes Borsh structs (they start empty and are written as Borsh on return). `Signed<…>` is Anchor's `signer` constraint (2002). `Zeroed` now reports an already-set discriminator first, with Anchor's 2013. |
36
+ | `seeds = [multisig.key().as_ref()], bump = multisig.nonce` | `Seeds<Signer, ["@multisig"], "@multisig.nonce">` | A bump stored in another account. |
37
+ | `multisig.owners = owners` | the same, into a `FixedVec<Pubkey, 34>` | A `Vec` argument copied whole into a list in account data. |
38
+ | `TransactionAccount { pubkey, is_signer, is_writable }` | `interface TransactionAccount { pubkey; isSigner; isWritable }` | `isSigner` / `isWritable` were reserved field names everywhere; now only in structs held in an account, where they would shadow `acct.isSigner`. |
39
+
40
+ ## Side by side
41
+
42
+ The heart of the program, executing an approved transaction:
43
+
44
+ ```rust
45
+ pub fn execute_transaction(ctx: Context<ExecuteTransaction>) -> Result<()> {
46
+ if ctx.accounts.transaction.did_execute {
47
+ return Err(ErrorCode::AlreadyExecuted.into());
48
+ }
49
+ let sig_count = ctx.accounts.transaction.signers.iter().filter(|&did_sign| *did_sign).count() as u64;
50
+ if sig_count < ctx.accounts.multisig.threshold {
51
+ return Err(ErrorCode::NotEnoughSigners.into());
52
+ }
53
+ let mut ix: Instruction = (*ctx.accounts.transaction).deref().into();
54
+ ix.accounts = ix.accounts.iter().map(|acc| {
55
+ let mut acc = acc.clone();
56
+ if &acc.pubkey == ctx.accounts.multisig_signer.key {
57
+ acc.is_signer = true;
58
+ }
59
+ acc
60
+ }).collect();
61
+ let multisig_key = ctx.accounts.multisig.key();
62
+ let seeds = &[multisig_key.as_ref(), &[ctx.accounts.multisig.nonce]];
63
+ let signer = &[&seeds[..]];
64
+ let accounts = ctx.remaining_accounts;
65
+ solana_program::program::invoke_signed(&ix, accounts, signer)?;
66
+ ctx.accounts.transaction.did_execute = true;
67
+ Ok(())
68
+ }
69
+ ```
70
+
71
+ ```ts
72
+ export function executeTransaction(
73
+ multisig: Account<Multisig>,
74
+ multisigSigner: Seeds<AccountInfo, ["@multisig"], "@multisig.nonce">,
75
+ transaction: HasOne<Mut<Transaction>, "multisig">,
76
+ ): void {
77
+ require(multisig.ownerSetSeqno === transaction.ownerSetSeqno, AnchorError.ConstraintRaw);
78
+ require(!transaction.didExecute, ErrorCode.AlreadyExecuted);
79
+ let sigCount = 0n;
80
+ for (const didSign of transaction.signers) if (didSign) sigCount++;
81
+ require(sigCount >= multisig.threshold, ErrorCode.NotEnoughSigners);
82
+ const metas: FixedVec<TransactionAccount, 32> = transaction.accounts.map((acc) => ({
83
+ pubkey: acc.pubkey,
84
+ isSigner: acc.isSigner || acc.pubkey === multisigSigner.key,
85
+ isWritable: acc.isWritable,
86
+ }));
87
+ invokeInstruction(transaction.programId, metas, transaction.data, remainingAccounts(), [multisig.key, multisig.nonce]);
88
+ transaction.didExecute = true;
89
+ }
90
+ ```
91
+
92
+ The `#[derive(Accounts)]` structs become parameter types. Anchor's `constraint = multisig.owner_set_seqno ==
93
+ transaction.owner_set_seqno` becomes the first `require`, raising the same 2003. The error enum keeps its order and
94
+ messages, so the codes stay 6000–6008. Account and instruction names and the Borsh layout are the original's, so
95
+ existing clients and existing accounts work with the port unchanged.
96
+
97
+ ## How it was checked
98
+
99
+ `test/programs/coral_multisig.so` is the program dumped from mainnet on 2026-10-29 (sha256 in
100
+ [`test/programs/SOURCES.md`](../test/programs/SOURCES.md)). The test loads it into one LiteSVM and the port into
101
+ another, both at `msigUdDB…`, then sends each transaction to both. After each one it requires the same outcome (success,
102
+ or the same error down to the code) and the same lamports, owner and data bytes for every account involved.
103
+
104
+ There are 43 transactions:
105
+
106
+ - **The upstream test suite** (`tests/multisig.js`): create a 2-of-3 multisig, propose `set_owners`, approve, and
107
+ execute through the PDA. Then `Assert Unique Owners` (6008).
108
+ - **Every `create_multisig` refusal**: threshold 0 or above the owner count (6007), no owners, the account not signing
109
+ (2002), not rent-exempt (2005), owned by another program (3007), too small for the empty value (3003) or for the
110
+ owners (3004), already initialized (2013).
111
+ - **Proposals, approvals and executions**:
112
+ - a proposer or approver outside the owner set (6000);
113
+ - another multisig's transaction (2001);
114
+ - too few approvals (6002);
115
+ - a wrong PDA (2006);
116
+ - executing twice (6006);
117
+ - a changed owner set invalidating a proposal (2003);
118
+ - `set_owners` sent directly, which the PDA cannot sign (3010);
119
+ - a Transaction passed as a Multisig (3002);
120
+ - the target program missing from the remaining accounts (the runtime's error).
121
+ - **Self-calls through `execute_transaction`**:
122
+ - `change_threshold`;
123
+ - `set_owners` shrinking the owner set below the threshold, which drops the threshold;
124
+ - `set_owners_and_change_threshold`;
125
+ - a threshold too high, which fails inside the CPI and fails the whole execution with 6007.
126
+ - **A real payout**: the PDA sends 1.5 SOL through a System transfer. A stored meta claiming the recipient signed is
127
+ refused by the runtime under both programs.
128
+
129
+ ## Cost
130
+
131
+ These are compute units per transaction, original versus port, from that test. Each `execute` figure includes the
132
+ instruction it runs.
133
+
134
+ | Instruction | Anchor 0.25 (mainnet) | TypeSOL | |
135
+ | --- | ---: | ---: | ---: |
136
+ | `create_multisig` | 3,591 | 564 | −84% |
137
+ | `create_transaction` | 6,591 | 1,077 | −84% |
138
+ | `approve` | 5,515 | 1,003 | −82% |
139
+ | `execute_transaction` → `set_owners` | 15,227 | 4,133 | −73% |
140
+ | `execute_transaction` → `change_threshold` | 14,723 | 3,781 | −74% |
141
+ | `execute_transaction` → `set_owners_and_change_threshold` | 15,129 | 4,095 | −73% |
142
+ | `execute_transaction` → System transfer | 9,961 | 3,182 | −68% |
143
+
144
+ Refusals cost less too. For example, an already-initialized account costs 3,378 against 160, and a missing signature
145
+ 4,229 against 177. The binary is 526,688 bytes on mainnet and 29,984 bytes for the port, 18 times smaller. Deploying it
146
+ needs about 3.5 SOL less rent.
147
+
148
+ The original was built with Anchor 0.25; a rebuild with a current Anchor would cost less than the mainnet figures. The
149
+ escrow case study and the benchmarks compare against Anchor 1.x.
150
+
151
+ ## Where they differ
152
+
153
+ - **List capacities.** Anchor's `Vec` fields are bounded only by the account's size. The port's lists are `FixedVec`s:
154
+ - 34 owners;
155
+ - 32 accounts and 1,100 data bytes per stored instruction;
156
+ - 34 approvals.
157
+
158
+ A transaction (1,232 bytes) cannot carry more than these, so a client never meets them. A program calling the
159
+ multisig through a CPI can send more (CPI data may reach 10 KiB). The original would accept that if the account were
160
+ large enough; the port refuses with 5104. Reading an account the original wrote with more elements fails with 3003.
161
+ - **Check order on one double fault.** In `approve` and `execute_transaction` the original checks the owner-set
162
+ sequence number (2003) with the multisig account, before the transaction's `has_one` (2001). The port checks
163
+ `has_one` in the prologue and the sequence number first in the body. So a transaction that belongs to another
164
+ multisig *and* has a stale sequence number gets 2001 here and 2003 there. A transaction breaking only one of the two
165
+ gets the same code under both.
166
+ - **Logs.** Error messages are logged in TypeSOL's words and the original's error names are not logged; codes are the
167
+ same.
package/docs/errors.md CHANGED
@@ -7,19 +7,20 @@ Framework errors use Anchor's numbers where Anchor has the same check, so client
7
7
  | 100 / 101 / 102 | instruction data missing / unknown instruction / malformed arguments (InstructionMissing / FallbackNotFound / DidNotDeserialize) |
8
8
  | 2000 | a `Mut<…>` / `Init<…>` account passed read-only (ConstraintMut) |
9
9
  | 2001 | HasOne constraint failed (ConstraintHasOne) |
10
- | 2002 | event authority did not sign (ConstraintSigner) |
10
+ | 2002 | a `Signed<…>` account (or the event authority) did not sign (ConstraintSigner) |
11
11
  | 2004 | Owner constraint failed (ConstraintOwner) |
12
12
  | 2005 | Zeroed account is not rent-exempt (ConstraintRentExempt) |
13
13
  | 2006 | PDA seeds mismatch (ConstraintSeeds) |
14
14
  | 2007 | Executable constraint failed (ConstraintExecutable) |
15
15
  | 2009 | not the associated token account (ConstraintAssociated) |
16
16
  | 2012 | not the expected address (ConstraintAddress) |
17
+ | 2013 | a `Zeroed<…>` account whose discriminator is already set (ConstraintZero; checked before its owner, as under Anchor) |
17
18
  | 2014 / 2015 | token account mint / owner mismatch (ConstraintTokenMint / ConstraintTokenOwner) |
18
19
  | 2016 / 2017 / 2018 | mint authority / freeze authority / decimals mismatch (2018: an existing mint `createIfNeeded` found) |
19
20
  | 2021 / 2022 | token account / mint not owned by the token program `TokenOf` / `MintOf` names (ConstraintTokenTokenProgram / ConstraintMintTokenProgram) |
20
21
  | 2024–2039 | an existing mint `createIfNeeded` found lacks a Token-2022 extension it was given, or holds other keys in it: group pointer 2024–2026, group member pointer 2027–2029, metadata pointer 2030–2032, close authority 2033–2034, permanent delegate 2035–2036, transfer hook 2037–2039 (the first code of each: the extension is missing; the next: the authority / address differs) (ConstraintMint…Extension…) |
21
22
  | 2040 | the same account passed in two writable slots (ConstraintDuplicateMutableAccount) |
22
- | 3000 | account already exists / initialized (AccountDiscriminatorAlreadySet) |
23
+ | 3000 | account already exists (AccountDiscriminatorAlreadySet; an `Init` account) |
23
24
  | 3002 | discriminator mismatch (AccountDiscriminatorMismatch) |
24
25
  | 3003 | data too small, not an initialized token account / mint, a read past the end, or a `@layout borsh` account whose bytes are not a valid Borsh value (AccountDidNotDeserialize) |
25
26
  | 3004 | a `@layout borsh` account too small for the value it would now hold (AccountDidNotSerialize) |
package/docs/language.md CHANGED
@@ -25,8 +25,8 @@ The full reference. For a tour, start with the [README](../README.md).
25
25
  | Enums | `export enum Status { Pending, Approved }` — unit variants stored as the Borsh u8 index; `Status.Approved`, `===` |
26
26
  | Options | `best: Option<u64>` (or `u64 \| null`) — `null`, `=== null`, TypeScript narrowing; `===` between options or with a value follows JS (null equals null, empty never equals a value); empty use aborts (5108); `acct?.field` on an optional account and `x.home?.field` on an `Option<struct>` give an Option (empty when the account was left out / the option is empty), `a ?? b` takes b only when a is empty (b runs only then; the result is an Option when b can be null), `x!` is the value or aborts with 5108; `${opt}` prints the value or `null`. Borsh `option` in arguments/events, fixed `{ is_some, value }` in account data |
27
27
  | Vecs | `entries: Vec<Entry>` in account data, any number of them, declared anywhere: they are stored after the fixed-size fields in the order declared (so every other field keeps its offset; the IDL lists that order), and a push, pop or clear moves the Vecs after it (a reference into one of those cannot be used across it). In a `@layout borsh` struct a Vec is the last field (Anchor's order) — `get`, `set`, `push(v, payer)`, `pop()`, `clear()`, `.length`; the account is resized (realloc) and rent topped up by `payer`; `Vec<u64>` arguments are read-only views; `Vec<string>` arguments (Anchor's `Vec<String>`) are views of the instruction data, each checked as UTF-8 on entry (102), and go back out as Borsh through `invoke`; `pop()` of a Vec of structs works as a statement |
28
- | Anchor's account bytes | `/** @layout borsh */` on an account struct stores it as Anchor does (Borsh: a `FixedString<N>` is a String, a `FixedVec<T, N>` a Vec, an `Option` one byte when empty, and the fields after them move), so a TypeSOL program can take over accounts an Anchor program wrote and the IDL lists Anchor's own types. The instruction unpacks it into the usual fixed layout when it starts (bad bytes fail with 3003) and packs it back when it returns (3004 when the account is too small for the new value), so everything else works on it unchanged. Programs invoked from the body (CPIs) see the account's bytes as they were when the instruction started. Costs more compute than the fixed layout (the whole account is read and written each time; see the benchmarks); nested structs and list elements must be fixed-size (FixedString elements allowed). The same struct is Borsh as a whole instruction argument (checked like Borsh deserialization, UTF-8 included), as return data and as an event; as those (not as account data) it may also hold `string` fields and data enums carrying strings, Anchor's `String` and enums in argument structs (account data takes data enums without strings); not inside list or enum arguments, `Zeroed<T>`, `loadAccount` or a growable `Vec<T>` |
29
- | Fixed-capacity lists | `owners: FixedVec<Pubkey, 8>` — any field, any number of them, in nested structs too: a u32 length and room for N elements (always 4 + N × size bytes), the same `get`, `set`, `push(v)`, `pop()`, `clear()`, `.length` and `for … of`; `push` on a full one fails (5104); `acct.owners = fixedVec([a, b])` writes a whole one (unused slots zeroed). In account data, returned structs and events; as an argument (`ids: FixedVec<u64, 8>`) it is a Borsh Vec of at most N elements, read like a `Vec` argument (more fail with 102) |
28
+ | Anchor's account bytes | `/** @layout borsh */` on an account struct stores it as Anchor does (Borsh: a `FixedString<N>` is a String, a `FixedVec<T, N>` a Vec, an `Option` one byte when empty, and the fields after them move), so a TypeSOL program can take over accounts an Anchor program wrote and the IDL lists Anchor's own types. The instruction unpacks it into the usual fixed layout when it starts (bad bytes fail with 3003) and packs it back when it returns (3004 when the account is too small for the new value), so everything else works on it unchanged. Programs invoked from the body (CPIs) see the account's bytes as they were when the instruction started. Costs more compute than the fixed layout (the whole account is read and written each time; see the benchmarks); nested structs and list elements must be fixed-size (FixedString elements allowed). The same struct is Borsh as a whole instruction argument (checked like Borsh deserialization, UTF-8 included), as return data and as an event; as those (not as account data) it may also hold `string` fields and data enums carrying strings, Anchor's `String` and enums in argument structs (account data takes data enums without strings); not inside list or enum arguments, `loadAccount` or a growable `Vec<T>` |
29
+ | Fixed-capacity lists | `owners: FixedVec<Pubkey, 8>` — any field, any number of them, in nested structs too: a u32 length and room for N elements (always 4 + N × size bytes), the same `get`, `set`, `push(v)`, `pop()`, `clear()`, `.length` and `for … of`; `push` on a full one fails (5104); `acct.owners = fixedVec([a, b])` writes a whole one (unused slots zeroed), and `acct.owners = owners` copies a `Vec` argument (or another list of the same elements) into it, Anchor's `multisig.owners = owners` (more than N fail with 5104). In account data, returned structs and events; as an argument (`ids: FixedVec<u64, 8>`) it is a Borsh Vec of at most N elements, read like a `Vec` argument (more fail with 102) |
30
30
  | Struct arguments | `order: Order`, `ids: FixedArray<u8, 4>`, `orders: Vec<Order>` — fixed layouts, Borsh-identical, validated on entry (bools, enums, option tags, string lengths, enum tags; 102) and read-only. A struct holding a FixedVec that is only ever an argument is read as Anchor sends it (its FixedVec a Borsh Vec, as `@layout borsh`) |
31
31
  | Remaining accounts | `remainingAccounts()` — `.length`, `.get(i)` → `key`, `owner`, `lamports` (writes check writability), `isSigner`, `isWritable`; `loadAccount<T>` / `loadAccountMut<T>` give checked struct views; pass them to `invoke`, one at a time or spread: `...remainingAccounts()` (each with the flags it was passed with), `...readonly(rest)` / `...writable(rest)` (all with that role), `rest.slice(a, b)` for part of them; a CPI lists at most 32 accounts (5113) |
32
32
  | Struct literals | `book.entries.push({ who: who.key, amount, status: Status.Pending, note }, who)` — every field required |
@@ -66,7 +66,7 @@ The full reference. For a tour, start with the [README](../README.md).
66
66
  | `Account<T>` / `Mut<T>` | owned by this program, 8-byte discriminator matches, data large enough (and writable) |
67
67
  | `Init<T>` | writable; `create(payer, seeds)` derives the PDA, verifies it, funds rent, writes the discriminator (up to 10 KiB); `create(payer, null)` creates a new keypair account that signs the transaction instead (3010 if it did not; a signer in the IDL); a third argument is Anchor's `space = …` (discriminator included, at least 8 + the struct's size) |
68
68
  | `InitIfNeeded<T>` | `const created = acct.createIfNeeded(payer, seeds)` — creates once; afterwards re-derives the PDA and checks owner, discriminator and size (Anchor `init_if_needed`); `acct.pdaBump` after create. An existing account is used as it is: check the fields you rely on |
69
- | `HasOne<A, "owner">`, `Seeds<A, ["vault", "@owner"], "bump">`, `CloseTo<A, "owner">` | declarative constraints like Anchor's `has_one`, `seeds`/`bump` and `close`: checked before the body (2001 / 2006), close runs when the instruction succeeds; `"@name"` = account key or argument bytes, `"@acct.field"` = a Pubkey, integer or unit enum field of a struct account parameter (also the account's own: `["offer", "@maker", "@offer.id"]`), other strings are literals; recorded as `relations` / `pda` in the IDL. `acct.pdaBump` is the bump (Anchor `ctx.bumps.acct`): the stored field when one is named, else the canonical bump the check found — use it to sign as the PDA |
69
+ | `HasOne<A, "owner">`, `Seeds<A, ["vault", "@owner"], "bump">`, `CloseTo<A, "owner">` | declarative constraints like Anchor's `has_one`, `seeds`/`bump` and `close`: checked before the body (2001 / 2006), close runs when the instruction succeeds; `"@name"` = account key or argument bytes, `"@acct.field"` = a Pubkey, integer or unit enum field of a struct account parameter (also the account's own: `["offer", "@maker", "@offer.id"]`), other strings are literals; recorded as `relations` / `pda` in the IDL. a bump stored in another struct account is named `"@acct.field"` (Anchor `bump = multisig.nonce`); `acct.pdaBump` is the bump (Anchor `ctx.bumps.acct`): the stored field when one is named, else the canonical bump the check found — use it to sign as the PDA |
70
70
  | `HasOne<TokenAccount, "mint" \| "owner">`, `HasOne<Mint, "mintAuthority" \| "freezeAuthority">` | token constraints (2014 / 2015 / 2016 / 2017) against the parameter with that name; `TokenOf<TokenAccount, "mintParam", "authorityParam", "tokenProgramParam">` / `MintOf<Mint, "authorityParam", "freezeParam", "tokenProgramParam">` name the parameters, as Anchor's `token::mint = …` / `mint::authority = …` / `…::token_program = …` do (`never` leaves one out; the token program one: owned by that token program, 2021 / 2022) |
71
71
  | `Ata<TokenAccount, "owner", "mint">` | the account is the associated token account of those parameters (2009; ATA `pda` in the IDL) |
72
72
  | `Ata<Init<TokenAccount>, "owner", "mint">`, `Ata<InitIfNeeded<TokenAccount>, "owner", "mint">` | Anchor `init` / `init_if_needed` with `associated_token::…`: `acct.create(payer)` creates the ATA (fails if it exists); `const created = acct.createIfNeeded(payer)` creates it only when missing, otherwise checks it is that ATA (2009). Then it is a `TokenAccount` of that mint and owner (2014 / 2015). Needs `AssociatedTokenProgram`, `SystemProgram` and a token program parameter; call it at the top level before using the account |
@@ -77,7 +77,8 @@ The full reference. For a tour, start with the [README](../README.md).
77
77
  | `ProgramData` | this program's ProgramData (address derived from the program id, upgradeable-loader owner and state checked); `upgradeAuthority`, `deploySlot` — gate admin setup on `programData.upgradeAuthority === admin.key` |
78
78
  | `EventAuthority`, `SelfProgram` | Anchor's `["__event_authority"]` PDA, and this program's own address, for `emitCpi` |
79
79
  | `Migrate<From, To>` | Anchor 1.0's `Migration`: an existing `From` account (owner, discriminator, size) rewritten as `To` by `acct.migrate(payer, value)`, a top-level statement the instruction must reach. `acct.from` (read-only) before it, `acct.to` after; the value is built in full before the rewrite, so it may read `acct.from`. Resized to `To` (the payer tops up rent when it grows, at most 10 KiB; shrinking keeps the lamports; growing needs `SystemProgram`). Fixed-layout structs without growable Vecs; no constraints yet; the account cannot be passed to a helper (use `acct.to` fields in the instruction). An account already migrated fails with 3002. Keep the old struct under a new name with its discriminator: `/** @discriminator account:Counter */ interface CounterV1 { … }` |
80
- | `Zeroed<T>` | large accounts (up to 10 MiB) the client allocated with System `CreateAccount`: owned by this program, big enough, rent-exempt, discriminator still zero — then initialized; like Anchor's zero-copy `zero` |
80
+ | `Zeroed<T>` | accounts (up to 10 MiB) the client allocated with System `CreateAccount`, Anchor's `zero`: discriminator still zero (2013, checked first as under Anchor), owned by this program, big enough, rent-exempt — then initialized. A `@layout borsh` struct starts empty and is written as Borsh when the instruction returns |
81
+ | `Signed<A>` | the account must sign the transaction (Anchor's `signer` constraint, 2002), e.g. `Signed<Zeroed<Multisig>>` for an account the client creates with a fresh keypair; the IDL marks it a signer |
81
82
  | `Init<Mint>`, `InitIfNeeded<Mint>` | Anchor `init` / `init_if_needed` with `mint::decimals` / `mint::authority` / `mint::freeze_authority`: `const bump = mint.create(payer, ["mint", payer.key], 6n, authority, freezeAuthority \| null)` creates the mint at that PDA of this program, or with `null` seeds as a new keypair account that signs (3010 if it did not), under the instruction's token program (its only token program parameter, or one passed as the last argument of `create` / `createIfNeeded`; plus `SystemProgram`). Token-2022 extensions (Anchor's `extensions::…`) go after the freeze authority: `mint.create(payer, null, 6n, authority, null, { metadataPointer: { authority, metadataAddress: mint.key }, transferHook: { authority, programId }, closeAuthority, permanentDelegate, nonTransferable: true, defaultAccountState: true, interestBearing: { rateAuthority, rate }, transferFee: { configAuthority, withdrawAuthority, basisPoints, maximumFee }, groupPointer, groupMemberPointer })` sizes the mint for them and initializes each before InitializeMint2. With `createIfNeeded` an existing mint must carry groupPointer / groupMemberPointer / metadataPointer / closeAuthority / permanentDelegate / transferHook with those keys (Anchor's 2024–2039); extensions not listed are not looked at, so an existing mint may carry others (a permanent delegate, say). `createIfNeeded(…)` returns whether it created it; an existing one must be that PDA and have those decimals and authorities (2018 / 2016 / 2017) |
82
83
  | `Init<TokenAccount>`, `InitIfNeeded<TokenAccount>` (no `Ata<…>`) | Anchor `init` / `init_if_needed` with `token::mint` / `token::authority`: `vault.create(payer, ["vault", mint.key], mint, authority)` (the authority may be `vault.key` itself) creates a token account at that PDA (or a keypair account with `null`), sized for the mint's extensions under Token-2022. `createIfNeeded` checks an existing one has that mint and owner (2014 / 2015), not its delegate or close authority (as Anchor) |
83
84
  | `TokenAccount` / `Mut<TokenAccount>` | owned by SPL Token or Token-2022 (extensions allowed, multisig accounts refused), initialized; `mint`, `owner`, `amount`, `delegate`, `delegatedAmount`, `isFrozen`, `isNative`, `closeAuthority`, `tokenProgram` |
@@ -151,6 +152,20 @@ export function payOut(admin: Signer, treasury: Mut<SystemAccount>, recipient: M
151
152
  - `signerSeeds` (with the bump) signs for this program's PDA.
152
153
  - After the call, `returnData<u64>(program)` reads what the callee returned, checking the size and that it came from `program`.
153
154
 
155
+ A call decided at run time, such as one a multisig stored in account data, is `invokeInstruction` (Rust's
156
+ `invoke_signed(&Instruction { program_id, accounts, data }, infos, seeds)`):
157
+
158
+ ```ts
159
+ invokeInstruction(transaction.programId, transaction.accounts, transaction.data, remainingAccounts(), [multisig.key, multisig.nonce]);
160
+ ```
161
+
162
+ - the accounts are a list of structs with `pubkey`, `isSigner` and `isWritable` (a Vec or FixedVec in account data, a
163
+ Vec argument, or a stored `map` result); the data a list of u8.
164
+ - every account in the fourth argument (`remainingAccounts()` or a slice) is handed to the runtime, which finds each
165
+ meta's account there by key: one that is missing fails the call, as under Rust.
166
+ - the runtime checks the privileges: a meta may be a signer or writable only as the transaction passed that account,
167
+ or as a PDA of this program the seeds (fifth argument) sign for. See `examples/multisig.ts`.
168
+
154
169
  Typed calls from an IDL: `typesol cpi other.json --out other_cpi.ts` writes one helper per instruction
155
170
  (`vaultDeposit(program, owner, vault, systemProgram, amount, seeds)`), with the account flags, discriminator and
156
171
  argument encoding taken from the IDL (Anchor 0.30+ / 1.x, or TypeSOL's own). Call it with `signer([...seeds, bump])`
@@ -49,6 +49,9 @@ and error codes. Its notes (on stderr) list what an existing Anchor client would
49
49
  | `Migration<'info, From, To>` | `Migrate<From, To>`, then `acct.migrate(payer, value)`; the old struct keeps its on-chain discriminator with `/** @discriminator account:<OldName> */` |
50
50
  | `#[account(discriminator = …)]` | `/** @discriminator <preimage or 0x8 bytes> */` on the struct |
51
51
  | `AccountLoader<T>` with `#[account(zero)]` | `Zeroed<T>` (TypeSOL reads every account in place: no copy to avoid) |
52
+ | `Account<T>` with `#[account(zero)]` (Borsh) | `Zeroed<T>` of a `/** @layout borsh */` struct |
53
+ | `#[account(signer)]` on a non-`Signer` account | `Signed<A>`, e.g. `Signed<Zeroed<Multisig>>` |
54
+ | `seeds = [multisig.key().as_ref()], bump = multisig.nonce` | `Seeds<A, ["@multisig"], "@multisig.nonce">` |
52
55
  | `Program<'info, System>` | `SystemProgram` |
53
56
  | `Program<'info, Token>` / `Token2022` / `Interface<'info, TokenInterface>` | `TokenProgram` / `Token2022Program` / `TokenInterface` |
54
57
  | `Account<'info, TokenAccount>` / `Account<'info, Mint>` (`InterfaceAccount`) | `TokenAccount` / `Mint` (`Mut<…>` when written) |
@@ -108,6 +111,8 @@ Anchor instruction that takes or returns a struct with `String` / `Vec` / `Optio
108
111
  | `system_program::transfer(CpiContext::new(…), lamports)` | `systemProgram.transfer(from, to, lamports)` |
109
112
  | `token::transfer_checked(CpiContext::new_with_signer(…, seeds), amount, decimals)` | `tokenProgram.transferChecked(from, mint, to, authority, amount, ["seed", key, bump])` |
110
113
  | `invoke_signed(&ix, &accounts, &[seeds])` | `invoke(program, [writable(a), readonly(b)], [data…], seeds)` |
114
+ | `invoke_signed(&Instruction { program_id, accounts, data }, ctx.remaining_accounts, …)` built at run time | `invokeInstruction(programId, metas, data, remainingAccounts(), seeds)` |
115
+ | `multisig.owners = owners` (a `Vec` argument into account data) | the same, into a `FixedVec<Pubkey, N>` |
111
116
  | `declare_program!(other)` / CPI to another Anchor program | `typesol cpi other.json` generates typed helpers |
112
117
  | `for x in v.iter()` | `for (const x of v)` |
113
118
  | `v.iter().any(\|x\| …)`, `.all`, `.position`, `.find`, `.fold(init, …)`, `.contains(&x)` | `v.some((x) => …)`, `every`, `findIndex`, `find`, `reduce((acc, x) => …, init)`, `includes(x)` |
@@ -65,6 +65,10 @@ Each holds for every instruction of every program the compiler accepts. A break
65
65
  12. **CPIs.** A program signs only for its own PDAs (the runtime enforces it; the seeds are those written in the
66
66
  source). `invoke` marks an account writable or signer only when the parameter is `Mut` / `Signer` or the program
67
67
  signs for it; remaining accounts keep the flags they were passed with, or are checked for the role requested.
68
+ `invokeInstruction` is the exception by design: its program, account flags and data come from values (stored in
69
+ account data or passed in), so the compiler cannot check them; the runtime does, refusing any account the
70
+ transaction did not pass that way and any signer the program's seeds do not cover. Only programs that store
71
+ instructions to run (a multisig, a DAO) need it; review who can write those values.
68
72
  Return data is read only from the program named, at the expected size (5106).
69
73
  13. **Events.** `emitCpi` is accepted only from this program's event-authority PDA, signed (2002).
70
74
 
package/lib/typesol.d.ts CHANGED
@@ -123,7 +123,8 @@ export interface FixedVec<T, N extends number> extends ListMethods<T> {
123
123
  readonly length: u64;
124
124
  get(index: bigint): T;
125
125
  set(index: bigint, value: T): void;
126
- push(value: T): void;
126
+ // `...rest: never[]` takes nothing, but lets a Vec (whose push takes a payer) be assigned to a FixedVec: `acct.owners = owners`.
127
+ push(value: T, ...rest: never[]): void;
127
128
  pop(): T;
128
129
  clear(): void;
129
130
  [Symbol.iterator](): Iterator<T>;
@@ -636,6 +637,11 @@ export type MintOf<A, AuthorityParam extends string, FreezeAuthorityParam extend
636
637
  * what you need before writing. Not for accounts that are created, closed, a close destination or `@layout borsh`.
637
638
  */
638
639
  export type Dup<A> = A & { readonly __dup?: true };
640
+ /**
641
+ * The account must sign the transaction (Anchor's `signer` constraint on an account that is not a `Signer`; 2002), e.g.
642
+ * `Signed<Zeroed<Multisig>>` for an account the client creates with a fresh keypair in the same transaction.
643
+ */
644
+ export type Signed<A> = A & { readonly __signed?: true };
639
645
  /**
640
646
  * Anchor 1.0's `Migration<From, To>`: an existing `From` account (owner, discriminator and size checked) that this
641
647
  * instruction rewrites as `To` with `acct.migrate(payer, value)`, a top-level statement every path must reach. Read
@@ -1379,9 +1385,10 @@ export type Init<T> = T extends TokenAccount
1379
1385
  };
1380
1386
 
1381
1387
  /**
1382
- * A large account (over 10 KiB, up to 10 MiB) the client already allocated with System CreateAccount,
1383
- * owned by this program and still zeroed. Owner, size, rent exemption and emptiness are checked, then
1384
- * the discriminator is written: from here on it is a `Mut<T>`. Same idea as Anchor's zero-copy `zero`.
1388
+ * An account the client already allocated with System CreateAccount (any size up to 10 MiB; over 10 KiB a program
1389
+ * cannot create it itself), owned by this program and still zeroed: Anchor's `zero`. Emptiness (2013), owner, size and
1390
+ * rent exemption are checked, then the discriminator is written: from here on it is a `Mut<T>`. A `@layout borsh`
1391
+ * struct starts empty (lists and strings empty, Options None) and is written as Borsh when the instruction returns.
1385
1392
  */
1386
1393
  export type Zeroed<T> = T & AccountMeta;
1387
1394
 
@@ -1610,6 +1617,32 @@ export declare function invoke(
1610
1617
  signerSeeds?: Seed[] | SignerSeeds,
1611
1618
  ): void;
1612
1619
 
1620
+ /** One account of an instruction built at run time (Rust's `AccountMeta`): any struct with these three fields. */
1621
+ export interface InstructionAccountMeta {
1622
+ readonly pubkey: Pubkey;
1623
+ readonly isSigner: boolean;
1624
+ readonly isWritable: boolean;
1625
+ }
1626
+ /** A list read by index: a Vec or FixedVec in account data, a Vec argument, or a stored local list. */
1627
+ export interface IndexedList<T> {
1628
+ readonly length: u64;
1629
+ get(index: bigint): T;
1630
+ }
1631
+ /**
1632
+ * A CPI decided at run time, e.g. one stored in account data (Rust's `invoke_signed(&Instruction { program_id,
1633
+ * accounts, data }, infos, seeds)`). Every account in `infos` (`remainingAccounts()` or a slice of it) is handed to the
1634
+ * runtime, which finds each meta's account there by key: one that is missing fails the CPI, as under Rust. A meta may
1635
+ * be a signer or writable only as the transaction passed that account, or as a PDA of this program `signerSeeds` sign
1636
+ * for (`signer([multisig.key, multisig.nonce])`).
1637
+ */
1638
+ export declare function invokeInstruction(
1639
+ programId: Pubkey,
1640
+ accounts: IndexedList<InstructionAccountMeta>,
1641
+ data: IndexedList<u8>,
1642
+ infos: RemainingAccounts | RemainingSlice,
1643
+ signerSeeds?: Seed[] | SignerSeeds,
1644
+ ): void;
1645
+
1613
1646
  /**
1614
1647
  * Anchor's own error codes (anchor-lang `ErrorCode`): `require(condition, AnchorError.AccountDidNotDeserialize)` fails
1615
1648
  * with that code, as `require!(condition, ErrorCode::AccountDidNotDeserialize)` does. Not listed in the IDL's errors.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@typesol/cli",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "description": "Compile a TypeScript-syntax language directly to Solana SBF programs.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,6 +27,7 @@
27
27
  #define TS_ERR_CONSTRAINT_EXECUTABLE 2007 /* ConstraintExecutable */
28
28
  #define TS_ERR_ASSOCIATED 2009 /* ConstraintAssociated */
29
29
  #define TS_ERR_ADDRESS 2012 /* ConstraintAddress */
30
+ #define TS_ERR_CONSTRAINT_ZERO 2013 /* ConstraintZero */
30
31
  #define TS_ERR_TOKEN_MINT 2014 /* ConstraintTokenMint */
31
32
  #define TS_ERR_TOKEN_OWNER 2015 /* ConstraintTokenOwner */
32
33
  #define TS_ERR_MINT_AUTHORITY 2016 /* ConstraintMintMintAuthority */
@@ -654,10 +655,15 @@ static inline uint64_t ts_now(int64_t *out) {
654
655
  * to this program. It must be writable, rent-exempt, big enough and still all-zero in its discriminator;
655
656
  * the discriminator is written here so the account can never be initialized twice.
656
657
  */
657
- static inline uint64_t ts_init_zeroed(TsAccount *a, const SolPubkey *program_id, const uint8_t disc[8], uint64_t size) {
658
+ /* Zeroed<T> (Anchor's `zero`), in Anchor's order: a discriminator already set (2013), then owner and size as the
659
+ * account type checks them; the rent check and the discriminator write come after the mut / signer checks. */
660
+ static inline uint64_t ts_check_zeroed(const TsAccount *a, const SolPubkey *program_id, uint64_t size) {
661
+ if (a->data_len >= 8 && ts_ld64(a->data) != 0) TS_FAIL(TS_ERR_CONSTRAINT_ZERO, "TypeSOL: account already initialized");
658
662
  if (!ts_key_eq(&a->owner, program_id)) TS_FAIL(TS_ERR_WRONG_OWNER, "TypeSOL: account not owned by this program");
659
663
  if (a->data_len < 8 + size) TS_FAIL(TS_ERR_ACCOUNT_TOO_SMALL, "TypeSOL: account data too small");
660
- if (ts_ld64(a->data) != 0) TS_FAIL(TS_ERR_ALREADY_INITIALIZED, "TypeSOL: account already initialized");
664
+ return SUCCESS;
665
+ }
666
+ static inline uint64_t ts_init_zeroed(TsAccount *a, const uint8_t disc[8]) {
661
667
  uint64_t need;
662
668
  TS_TRY(ts_minimum_balance(a->data_len, &need));
663
669
  if (a->lamports < need) TS_FAIL(TS_ERR_NOT_RENT_EXEMPT, "TypeSOL: account is not rent-exempt");