@typesol/cli 0.26.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,45 @@ 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
+
30
+ ## 0.27.0 — 2026-10-29
31
+
32
+ Anchor 1.0 parity: instruction introspection, `Dup<…>` and account migration. Existing programs compile to the same
33
+ binaries as in 0.26.0.
34
+
35
+ - New: instruction introspection. `InstructionsSysvar` has `length`, `currentIndex()` and `get(i)`, a
36
+ `TransactionInstruction` with `programId`, its accounts (key, signer, writable), its data
37
+ (`readU8/U16/U32/U64/readPubkey`) and `is(program, "name")` (an instruction by its Anchor discriminator); every read
38
+ is bounds-checked (5104). `stackHeight()` is 1 at the top level, more under a CPI.
39
+ - New: `Dup<…>`, Anchor's `dup`: a writable parameter that may be the same account as another, for accounts read and
40
+ written in place (not created, closed, a close destination or `@layout borsh`, which are refused at compile time).
41
+ - New: `Migrate<From, To>`, Anchor 1.0's `Migration`: an existing account rewritten from one struct to another with
42
+ `acct.migrate(payer, value)`, the old data readable through `acct.from` until then (the new value is built before
43
+ the bytes are rewritten), the new through `acct.to` after; resized with the payer topping up rent. For now:
44
+ fixed-layout structs without growable Vecs, no constraints on the account, and it cannot be passed to a helper.
45
+ - New: `/** @discriminator … */` on an account struct (a sha256 preimage such as `account:Counter`, or 0x and 8
46
+ bytes), Anchor's `#[account(discriminator = …)]`: keeps an old type's on-chain discriminator under a new name.
47
+
9
48
  ## 0.26.0 — 2026-10-29
10
49
 
11
50
  A first-run experience pass, the security model and audit package, and more fuzzing. The compiler's output is the
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.
@@ -85,6 +93,9 @@ const TYPE_EXPORTS = new Set([
85
93
  "Ata",
86
94
  "Owner",
87
95
  "Executable",
96
+ "Dup",
97
+ "Signed",
98
+ "Migrate",
88
99
  "Optional",
89
100
  "EventAuthority",
90
101
  "MplCoreProgram",
@@ -114,6 +125,22 @@ export function snakeCase(name) {
114
125
  .replace(/([A-Z])([A-Z][a-z])/g, "$1_$2")
115
126
  .toLowerCase();
116
127
  }
128
+ /**
129
+ * An account struct's discriminator: Anchor's sha256("account:<Name>")[..8], or the one a `/** @discriminator … *\/` tag
130
+ * gives (a sha256 preimage such as `account:Counter`, or 0x and 8 bytes), e.g. for an old version of a struct kept under
131
+ * a new name for Migrate<From, To>.
132
+ */
133
+ function accountDiscriminator(decl, name, reporter) {
134
+ const tag = ts.getJSDocTags(decl).find((t) => t.tagName.text === "discriminator");
135
+ if (!tag)
136
+ return discriminator(`account:${name}`);
137
+ const text = (typeof tag.comment === "string" ? tag.comment : (ts.getTextOfJSDocComment(tag.comment) ?? "")).trim();
138
+ if (/^0x([0-9a-fA-F]{2}){8}$/.test(text))
139
+ return [...Buffer.from(text.slice(2), "hex")];
140
+ if (text && !text.startsWith("0x"))
141
+ return discriminator(text);
142
+ reporter.fail(decl, "@discriminator on an account struct needs a sha256 preimage (account:<Name>) or 0x and 8 bytes");
143
+ }
117
144
  export function discriminator(preimage) {
118
145
  return [...createHash("sha256").update(preimage).digest().subarray(0, 8)];
119
146
  }
@@ -601,12 +628,17 @@ export function analyze(entry) {
601
628
  // A returned struct goes out in its fixed layout, like an argument comes in: it cannot also be a Borsh event.
602
629
  if (ix.returns?.kind === "struct")
603
630
  collectStructs(ix.returns, structs, dataEnums, argStructs);
604
- for (const a of ix.accounts)
631
+ for (const a of ix.accounts) {
632
+ if (a.kind.kind === "migrate") {
633
+ accountStructs.add(a.kind.from.name);
634
+ accountStructs.add(a.kind.to.name);
635
+ }
605
636
  if (a.kind.kind === "account" || a.kind.kind === "init" || a.kind.kind === "zeroed" || a.kind.kind === "initIfNeeded") {
606
637
  accountStructs.add(a.kind.struct.name);
607
638
  if (a.kind.struct.viewsOnly)
608
639
  reporter.error(a.node, `${a.kind.struct.name} holds a string (or an enum carrying one), so it is an argument, return value or event; account data uses FixedString<N>`);
609
640
  }
641
+ }
610
642
  }
611
643
  // Arguments are Borsh, where a FixedVec is a Vec (only what it holds). A struct passed as a whole argument and used
612
644
  // nowhere else (not account data, not nested, not returned) is read as Anchor sends it: @layout borsh, implied.
@@ -966,7 +998,7 @@ function analyzeStructs(items, aliases, dataEnums, reporter, scopeFor, fstrings,
966
998
  name,
967
999
  fields: stored,
968
1000
  size: undefined,
969
- accountDiscriminator: discriminator(`account:${name}`),
1001
+ accountDiscriminator: accountDiscriminator(decl, name, reporter),
970
1002
  eventDiscriminator: discriminator(`event:${name}`),
971
1003
  node: decl,
972
1004
  layout: layoutText === "borsh" ? "borsh" : "fixed",
@@ -1318,10 +1350,11 @@ function analyzeHelper(fn, name, display, reporter, scope, structs, skip = new S
1318
1350
  wrapped === "Seeds" ||
1319
1351
  wrapped === "CloseTo" ||
1320
1352
  wrapped === "Address" ||
1321
- wrapped === "Ata")
1353
+ wrapped === "Ata" ||
1354
+ wrapped === "Signed")
1322
1355
  reporter.fail(p.type, "constraints belong on instruction parameters; helpers take the plain account type");
1323
1356
  const account = resolveAccountKind(p.type, reporter, scope.imports, structs);
1324
- if (account.kind === "init" || account.kind === "initIfNeeded" || account.kind === "zeroed")
1357
+ if (account.kind === "init" || account.kind === "initIfNeeded" || account.kind === "zeroed" || account.kind === "migrate")
1325
1358
  reporter.fail(p.type, "helpers take accounts that exist: create Init/InitIfNeeded/Zeroed accounts in the instruction, then pass them as Mut<T>");
1326
1359
  return { name: p.name.text, type: { kind: "account" }, account, node: p };
1327
1360
  });
@@ -1445,7 +1478,7 @@ function analyzeInstruction(fn, reporter, scope, structs) {
1445
1478
  const inner = unwrapConstraints(typeNode, reporter, imports, constraints);
1446
1479
  const kind = resolveAccountKind(inner, reporter, imports, structs);
1447
1480
  if (optional &&
1448
- (["init", "initIfNeeded", "zeroed", "pyth", "eventAuthority", "selfProgram", "programData"].includes(kind.kind) ||
1481
+ (["init", "initIfNeeded", "zeroed", "migrate", "pyth", "eventAuthority", "selfProgram", "programData"].includes(kind.kind) ||
1449
1482
  ((kind.kind === "token" || kind.kind === "mint") && kind.init)))
1450
1483
  reporter.fail(p, "Init, InitIfNeeded, Zeroed, PythPriceUpdate, EventAuthority, SelfProgram and ProgramData accounts cannot be optional");
1451
1484
  accounts.push({ name: p.name.text, index: accounts.length, kind, node: p, constraints, ...(optional ? { optional } : {}) });
@@ -1506,9 +1539,20 @@ function analyzeInstruction(fn, reporter, scope, structs) {
1506
1539
  if (!accounts.some((o) => o.name === f))
1507
1540
  reporter.fail(a.node, `HasOne<…, "${f}">: add an account parameter named "${f}"`);
1508
1541
  }
1509
- 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) {
1510
1554
  if (!struct)
1511
- 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"`);
1512
1556
  const bf = struct.fields.find((x) => x.name === c.seeds.bump);
1513
1557
  if (!bf || bf.type.kind !== "int" || bf.type.name !== "u8")
1514
1558
  reporter.fail(a.node, `Seeds bump: ${struct.name} needs a u8 field "${c.seeds.bump}"`);
@@ -1539,6 +1583,7 @@ function analyzeInstruction(fn, reporter, scope, structs) {
1539
1583
  ...c.hasOne.map((e) => e.split("=").at(-1)),
1540
1584
  ...(c.seeds?.names ?? []).filter((n) => n.startsWith("@")).map((n) => n.slice(1).split(".")[0]),
1541
1585
  ...(c.seeds?.program?.startsWith("@") ? [c.seeds.program.slice(1)] : []),
1586
+ ...(c.seeds?.bump?.startsWith("@") ? [c.seeds.bump.slice(1).split(".")[0]] : []),
1542
1587
  ...(c.closeTo ? [c.closeTo] : []),
1543
1588
  ...(c.ata
1544
1589
  ? [c.ata.owner, c.ata.mint, ...(a.kind.kind === "token" ? [] : accounts.filter((o) => o.kind.kind === "tokenProgram").map((o) => o.name))]
@@ -1566,6 +1611,18 @@ function analyzeInstruction(fn, reporter, scope, structs) {
1566
1611
  reporter.error(fn.name, "instructions that grow a Vec (push) need a SystemProgram parameter to top up rent");
1567
1612
  if (needs("init") && !needs("system"))
1568
1613
  reporter.error(fn.name, "instructions with Init<...> accounts need a SystemProgram parameter");
1614
+ if (accounts.some((a) => a.kind.kind === "migrate" && a.kind.to.size > a.kind.from.size) && !needs("system"))
1615
+ reporter.error(fn.name, "a Migrate<From, To> that grows the account needs a SystemProgram parameter (the payer tops up its rent)");
1616
+ for (const a of accounts)
1617
+ if (a.kind.kind === "migrate" &&
1618
+ (a.constraints.hasOne.length ||
1619
+ a.constraints.seeds ||
1620
+ a.constraints.closeTo ||
1621
+ a.constraints.ata ||
1622
+ a.constraints.address ||
1623
+ a.constraints.owner ||
1624
+ a.constraints.dup))
1625
+ reporter.error(a.node, "Migrate<From, To> takes no constraints yet: check the old fields with require(…, …) on acct.from");
1569
1626
  const name = snakeCase(fn.name.text);
1570
1627
  // `/** @discriminator spl-transfer-hook-interface:execute */` (sha256 preimage) or `@discriminator 0x...` (raw bytes)
1571
1628
  // lets an instruction implement another program's interface.
@@ -1596,7 +1653,7 @@ function analyzeInstruction(fn, reporter, scope, structs) {
1596
1653
  /** Peel HasOne / Seeds / CloseTo wrappers off a parameter type, recording what they ask for. */
1597
1654
  function unwrapConstraints(node, reporter, imports, out) {
1598
1655
  const name = typesolName(node, imports);
1599
- if (!["HasOne", "TokenOf", "MintOf", "Seeds", "CloseTo", "Address", "Ata", "Owner", "Executable"].includes(name ?? ""))
1656
+ if (!["HasOne", "TokenOf", "MintOf", "Seeds", "CloseTo", "Address", "Ata", "Owner", "Executable", "Dup", "Signed"].includes(name ?? ""))
1600
1657
  return node;
1601
1658
  const args = node.typeArguments ?? [];
1602
1659
  const lit = (t) => {
@@ -1638,6 +1695,10 @@ function unwrapConstraints(node, reporter, imports, out) {
1638
1695
  }
1639
1696
  if (name === "Executable")
1640
1697
  out.executable = true;
1698
+ if (name === "Dup")
1699
+ out.dup = true;
1700
+ if (name === "Signed")
1701
+ out.signed = true;
1641
1702
  if (name === "Address") {
1642
1703
  const text = lit(args[1])[0];
1643
1704
  const bytes = text === undefined ? undefined : decodeAddress(text);
@@ -1668,6 +1729,8 @@ function unwrapConstraints(node, reporter, imports, out) {
1668
1729
  reporter.fail(node, `${name}<…> wraps an account type`);
1669
1730
  return unwrapConstraints(args[0], reporter, imports, out);
1670
1731
  }
1732
+ /** Solana: an account grows at most this many bytes per instruction. */
1733
+ const MAX_PERMITTED_DATA_INCREASE = 10_240;
1671
1734
  function resolveAccountKind(node, reporter, imports, structs) {
1672
1735
  const outer = typesolName(node, imports);
1673
1736
  const ref = node;
@@ -1680,6 +1743,8 @@ function resolveAccountKind(node, reporter, imports, structs) {
1680
1743
  const s = ts.isTypeReferenceNode(t) && ts.isIdentifier(t.typeName) ? structs.get(t.typeName.text) : undefined;
1681
1744
  if (s && s.size === undefined)
1682
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);
1683
1748
  return s;
1684
1749
  };
1685
1750
  switch (outer) {
@@ -1769,14 +1834,30 @@ function resolveAccountKind(node, reporter, imports, structs) {
1769
1834
  reporter.fail(node, `${s.name} is too large to create in a program; use Zeroed<${s.name}>`);
1770
1835
  return { kind: "initIfNeeded", struct: s };
1771
1836
  }
1837
+ case "Migrate": {
1838
+ const [fromNode, toNode] = ref.typeArguments ?? [];
1839
+ const from = fromNode && structOf(fromNode);
1840
+ const to = toNode && structOf(toNode);
1841
+ if (!from || !to || ref.typeArguments.length !== 2)
1842
+ reporter.fail(node, "Migrate<From, To> takes two account structs declared in this program");
1843
+ for (const s of [from, to]) {
1844
+ if (s.vec)
1845
+ reporter.fail(node, `${s.name} has a growable Vec; Migrate<From, To> takes fixed-size structs`);
1846
+ if (s.layout === "borsh")
1847
+ reporter.fail(node, `${s.name} is @layout borsh; Migrate<From, To> takes fixed layouts`);
1848
+ }
1849
+ if (from.accountDiscriminator.join() === to.accountDiscriminator.join())
1850
+ reporter.fail(node, `${from.name} and ${to.name} have the same discriminator: give the new struct another name (or the old one /** @discriminator account:<OldName> */)`);
1851
+ if (to.size - from.size > MAX_PERMITTED_DATA_INCREASE)
1852
+ reporter.fail(node, `${to.name} is more than 10 KiB larger than ${from.name}; an account grows at most 10 KiB per instruction`);
1853
+ return { kind: "migrate", from, to };
1854
+ }
1772
1855
  case "Zeroed": {
1773
1856
  const s = structOf(inner());
1774
1857
  if (!s)
1775
1858
  reporter.fail(node, "Zeroed<T> needs a struct declared in this program");
1776
1859
  if (s.vec)
1777
- reporter.fail(node, `${s.name} has a growable Vec; Zeroed<T> is for fixed-size (large) accounts`);
1778
- if (s.layout === "borsh")
1779
- 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)`);
1780
1861
  return { kind: "zeroed", struct: s };
1781
1862
  }
1782
1863
  case "Mut": {
@@ -112,6 +112,8 @@ export declare abstract class EmitterBase {
112
112
  protected abstract call(node: ts.CallExpression, topLevel: boolean): Value | undefined;
113
113
  protected abstract isRemaining(node: ts.Expression): boolean;
114
114
  protected abstract handle(node: ts.Expression): string | undefined;
115
+ protected abstract instructionsParam(node: ts.Expression): AccountParam | undefined;
116
+ protected abstract isTxIx(node: ts.Expression): boolean;
115
117
  protected abstract seeds(node: ts.Expression, extra: number): {
116
118
  name: string;
117
119
  count: number;
@@ -129,6 +131,7 @@ export declare abstract class EmitterBase {
129
131
  protected abstract coreOp(node: ts.CallExpression, core: AccountParam, method: string): undefined;
130
132
  protected abstract bubblegumOp(node: ts.CallExpression, bg: AccountParam, method: string): undefined;
131
133
  protected abstract invoke(node: ts.CallExpression): undefined;
134
+ protected abstract invokeInstruction(node: ts.CallExpression): undefined;
132
135
  protected abstract emitEvent(node: ts.CallExpression, cpi?: {
133
136
  authority: string;
134
137
  program: string;
@@ -65,7 +65,10 @@ export class EmitterBase {
65
65
  this.ix = target;
66
66
  for (const a of target.accounts) {
67
67
  this.accounts.set(a.name, a);
68
- if (a.kind.kind === "init" || a.kind.kind === "initIfNeeded" || ((a.kind.kind === "token" || a.kind.kind === "mint") && a.kind.init))
68
+ if (a.kind.kind === "init" ||
69
+ a.kind.kind === "initIfNeeded" ||
70
+ a.kind.kind === "migrate" ||
71
+ ((a.kind.kind === "token" || a.kind.kind === "mint") && a.kind.init))
69
72
  this.pendingInit.add(a.name);
70
73
  }
71
74
  for (const a of target.args)
@@ -1,5 +1,5 @@
1
1
  import ts from "typescript";
2
- import { type ErrorDef, type FieldType, type Helper } from "../model.js";
2
+ import { type AccountParam, type ErrorDef, type FieldType, type Helper } from "../model.js";
3
3
  import { type Value, type Ref, type Closure } from "./shared.js";
4
4
  import { ExpressionEmitter } from "./expressions.js";
5
5
  export declare abstract class CallEmitter extends ExpressionEmitter {
@@ -20,6 +20,15 @@ export declare abstract class CallEmitter extends ExpressionEmitter {
20
20
  /** `remainingAccounts()` or a const bound to it. */
21
21
  protected isRemaining(node: ts.Expression): boolean;
22
22
  /** C pointer (`TsAccount *`) for an expression naming a remaining account, or undefined. */
23
+ /** Whether `node` is an InstructionsSysvar parameter. */
24
+ protected instructionsParam(node: ts.Expression): AccountParam | undefined;
25
+ /** Whether `node` is a TransactionInstruction: a local holding one, or `ixs.get(i)`. */
26
+ protected isTxIx(node: ts.Expression): boolean;
27
+ /**
28
+ * InstructionsSysvar.currentIndex() / .get(i) and the methods of a TransactionInstruction: bounds-checked reads of the
29
+ * sysvar (ts_ix_* in the runtime). Undefined when the call is something else.
30
+ */
31
+ protected instructionsCall(node: ts.CallExpression, callee: ts.PropertyAccessExpression, method: string): Value | undefined;
23
32
  protected handle(node: ts.Expression): string | undefined;
24
33
  /** A declared account parameter or a remaining account, as a `TsAccount *` C expression. */
25
34
  protected anyAccount(node: ts.Expression, what: string): string;
@@ -29,6 +38,12 @@ export declare abstract class CallEmitter extends ExpressionEmitter {
29
38
  protected loadForeign(node: ts.CallExpression): Value;
30
39
  /** `loadAccount<T>(acct)` / `loadAccountMut<T>(acct)`: checked view of a program account. */
31
40
  protected loadAccount(node: ts.CallExpression, mut: boolean): Value;
41
+ /**
42
+ * `acct.migrate(payer, value)` on a Migrate<From, To> account: the new value is built in full first (it may read
43
+ * acct.from, whose bytes the rewrite overwrites), then the account is resized (the payer tops up the rent when it
44
+ * grows; shrinking keeps the lamports) and rewritten with To's discriminator and the value.
45
+ */
46
+ protected migrate(node: ts.CallExpression, a: AccountParam, topLevel: boolean): Value | undefined;
32
47
  /** Whether a helper (or one it calls) grows a Vec, which needs the instruction's SystemProgram for rent. */
33
48
  private helperGrows;
34
49
  /**
@@ -1,5 +1,5 @@
1
1
  import ts from "typescript";
2
- import { calleeName, resolveScalarType, resolveValueType } from "../analyze.js";
2
+ import { calleeName, discriminator, resolveScalarType, resolveValueType, snakeCase } from "../analyze.js";
3
3
  import { ANCHOR_ERRORS } from "../anchor-errors.js";
4
4
  import { decodeAddress } from "../../keys.js";
5
5
  import { INT_INFO, INT_NAMES, isScalar, isWritable, sameType, scalarSize, typeName, } from "../model.js";
@@ -21,6 +21,11 @@ export class CallEmitter extends ExpressionEmitter {
21
21
  return this.cast(args[0], { kind: "int", name: builtin });
22
22
  }
23
23
  switch (builtin) {
24
+ case "stackHeight": {
25
+ if (args.length)
26
+ this.fail(node, "stackHeight() takes no arguments");
27
+ return { c: "sol_get_stack_height()", type: U64 };
28
+ }
24
29
  case "require": {
25
30
  if (args.length !== 2)
26
31
  this.fail(node, "require(condition, error) takes two arguments");
@@ -283,6 +288,8 @@ export class CallEmitter extends ExpressionEmitter {
283
288
  }
284
289
  case "invoke":
285
290
  return this.invoke(node);
291
+ case "invokeInstruction":
292
+ return this.invokeInstruction(node);
286
293
  case "sha256":
287
294
  case "keccak256":
288
295
  return this.hashCall(node, builtin);
@@ -484,6 +491,9 @@ export class CallEmitter extends ExpressionEmitter {
484
491
  return this.callHelper(node, helper);
485
492
  if (ts.isPropertyAccessExpression(callee)) {
486
493
  const method = callee.name.text;
494
+ const introspected = this.instructionsCall(node, callee, method);
495
+ if (introspected)
496
+ return introspected;
487
497
  // xs.some(…) and friends on a Vec, FixedArray, FixedVec or Vec argument: a loop with the callback expanded in it.
488
498
  if (LIST_METHODS.has(method)) {
489
499
  const l = ts.isIdentifier(callee.expression) ? this.lookup(callee.expression.text) : undefined;
@@ -570,6 +580,8 @@ export class CallEmitter extends ExpressionEmitter {
570
580
  return target.kind.kind === "token" && target.constraints.ata ? this.createAta(node, target, topLevel) : this.createToken(node, target, topLevel);
571
581
  if (target?.kind.kind === "init" && method === "create")
572
582
  return this.create(node, target, topLevel);
583
+ if (target?.kind.kind === "migrate" && method === "migrate")
584
+ return this.migrate(node, target, topLevel);
573
585
  if (target?.kind.kind === "initIfNeeded" && method === "createIfNeeded")
574
586
  return this.create(node, target, topLevel, true);
575
587
  if (target?.kind.kind === "system" && method === "transfer")
@@ -878,6 +890,92 @@ export class CallEmitter extends ExpressionEmitter {
878
890
  return ts.isCallExpression(node) && this.exportName(node.expression) === "remainingAccounts";
879
891
  }
880
892
  /** C pointer (`TsAccount *`) for an expression naming a remaining account, or undefined. */
893
+ /** Whether `node` is an InstructionsSysvar parameter. */
894
+ instructionsParam(node) {
895
+ const a = ts.isIdentifier(node) ? this.accounts.get(node.text) : undefined;
896
+ return a?.kind.kind === "instructionsSysvar" ? a : undefined;
897
+ }
898
+ /** Whether `node` is a TransactionInstruction: a local holding one, or `ixs.get(i)`. */
899
+ isTxIx(node) {
900
+ if (ts.isParenthesizedExpression(node))
901
+ return this.isTxIx(node.expression);
902
+ if (ts.isIdentifier(node))
903
+ return this.lookup(node.text)?.type.kind === "txIx";
904
+ return (ts.isCallExpression(node) &&
905
+ ts.isPropertyAccessExpression(node.expression) &&
906
+ node.expression.name.text === "get" &&
907
+ !!this.instructionsParam(node.expression.expression));
908
+ }
909
+ /**
910
+ * InstructionsSysvar.currentIndex() / .get(i) and the methods of a TransactionInstruction: bounds-checked reads of the
911
+ * sysvar (ts_ix_* in the runtime). Undefined when the call is something else.
912
+ */
913
+ instructionsCall(node, callee, method) {
914
+ const args = node.arguments;
915
+ const sysvar = this.instructionsParam(callee.expression);
916
+ if (sysvar) {
917
+ this.assertUsable(sysvar, callee.expression);
918
+ if (method === "currentIndex") {
919
+ if (args.length)
920
+ this.fail(node, "currentIndex() takes no arguments");
921
+ const t = this.tmp("ixi");
922
+ this.line(`uint64_t ${t};`);
923
+ this.line(`TS_TRY(ts_ix_current(a_${sysvar.name}, &${t}));`);
924
+ return { c: t, type: U64 };
925
+ }
926
+ if (method === "get") {
927
+ if (args.length !== 1)
928
+ this.fail(node, "get(index) takes the instruction's index");
929
+ const i = this.expect(this.expr(args[0], U64), U64, args[0]);
930
+ const t = this.tmp("ix");
931
+ this.line(`TsIx ${t};`);
932
+ this.line(`TS_TRY(ts_ix_load(a_${sysvar.name}, ${i.c}, &${t}));`);
933
+ return { c: t, type: { kind: "txIx" } };
934
+ }
935
+ this.fail(node, "the Instructions sysvar has .length, currentIndex() and get(index)");
936
+ }
937
+ if (!this.isTxIx(callee.expression))
938
+ return undefined;
939
+ const ix = this.expr(callee.expression);
940
+ const one = (what) => {
941
+ if (args.length !== 1)
942
+ this.fail(node, `${method}(${what}) takes one argument`);
943
+ return this.expect(this.expr(args[0], U64), U64, args[0]).c;
944
+ };
945
+ if (method === "accountKey" || method === "accountIsSigner" || method === "accountIsWritable") {
946
+ const i = one("i");
947
+ const t = this.tmp("ixa");
948
+ this.line(`const uint8_t *${t};`);
949
+ this.line(`TS_TRY(ts_ix_account(&${ix.c}, ${i}, &${t}));`);
950
+ if (method === "accountKey")
951
+ return { c: `((const SolPubkey *)(${t} + 1))`, type: PUBKEY };
952
+ return { c: `((${t}[0] & ${method === "accountIsSigner" ? 1 : 2}) != 0)`, type: BOOL };
953
+ }
954
+ const reads = {
955
+ readU8: ["uint8_t", U8],
956
+ readU16: ["uint16_t", { kind: "int", name: "u16" }],
957
+ readU32: ["uint32_t", { kind: "int", name: "u32" }],
958
+ readU64: ["uint64_t", U64],
959
+ readPubkey: ["SolPubkey", PUBKEY],
960
+ };
961
+ if (reads[method]) {
962
+ const [c, type] = reads[method];
963
+ const off = one("offset");
964
+ const t = this.tmp("ixr");
965
+ this.line(`${c} ${t};`);
966
+ this.line(`TS_TRY(ts_ix_read(&${ix.c}, ${off}, sizeof(${c}), &${t}));`);
967
+ return type.kind === "pubkey" ? { c: `(&${t})`, type } : { c: t, type };
968
+ }
969
+ if (method === "is") {
970
+ if (args.length !== 2 || !ts.isStringLiteralLike(args[1]))
971
+ this.fail(node, 'is(program, "instruction_name") takes the program and the instruction name as a string literal');
972
+ const program = this.expect(this.expr(args[0], PUBKEY), PUBKEY, args[0]);
973
+ const disc = this.tmp("disc");
974
+ this.line(`static const uint8_t ${disc}[8] = ${cBytes(discriminator(`global:${snakeCase(args[1].text)}`))};`);
975
+ return { c: `ts_ix_is(&${ix.c}, ${program.c}, ${disc})`, type: BOOL };
976
+ }
977
+ this.fail(node, "a TransactionInstruction has programId, accountsLength, dataLength, accountKey/accountIsSigner/accountIsWritable(i), readU8/U16/U32/U64/readPubkey(offset) and is(program, name)");
978
+ }
881
979
  handle(node) {
882
980
  if (ts.isParenthesizedExpression(node))
883
981
  return this.handle(node.expression);
@@ -984,6 +1082,38 @@ export class CallEmitter extends ExpressionEmitter {
984
1082
  }
985
1083
  return { c: `((TSS_${s.name} *)(${h}->data + 8))`, type: { kind: "structRef", struct: s.name, writable: mut } };
986
1084
  }
1085
+ /**
1086
+ * `acct.migrate(payer, value)` on a Migrate<From, To> account: the new value is built in full first (it may read
1087
+ * acct.from, whose bytes the rewrite overwrites), then the account is resized (the payer tops up the rent when it
1088
+ * grows; shrinking keeps the lamports) and rewritten with To's discriminator and the value.
1089
+ */
1090
+ migrate(node, a, topLevel) {
1091
+ const k = a.kind;
1092
+ const call = `${a.name}.migrate`;
1093
+ if (!topLevel)
1094
+ this.fail(node, `${call}(…) must be a top-level statement of the instruction`);
1095
+ if (this.created.has(a.name))
1096
+ this.fail(node, `${a.name} is already migrated`);
1097
+ if (node.arguments.length !== 2)
1098
+ this.fail(node, `${call}(payer, value) takes the payer and the new ${k.to.name}`);
1099
+ const payer = this.accountParam(node.arguments[0], "the payer");
1100
+ if (payer.kind.kind !== "signer" || !payer.kind.mut)
1101
+ this.fail(node.arguments[0], "the payer must be a Mut<Signer>");
1102
+ this.assertNotAliased([a.name], node, "migrate");
1103
+ const To = k.to.name;
1104
+ const tmp = this.tmp("next");
1105
+ this.line(`TSS_${To} *${tmp} = NULL;`);
1106
+ this.line(`TS_TRY(ts_alloc(sizeof(TSS_${To}), (uint8_t **)&${tmp}));`);
1107
+ this.writeAggregate({ lv: `(*${tmp})`, type: { kind: "struct", name: To }, writable: true }, node.arguments[1]);
1108
+ this.line(`TS_TRY(ts_resize(params, a_${payer.name}, a_${a.name}, 8 + sizeof(TSS_${To}), entry_${a.name}));`);
1109
+ this.line(`ts_memcpy(a_${a.name}->data, TS_DISC_${To}, 8);`);
1110
+ this.line(`ts_memcpy(a_${a.name}->data + 8, ${tmp}, sizeof(TSS_${To}));`);
1111
+ this.line(`TSS_${To} *dt_${a.name} = (TSS_${To} *)(a_${a.name}->data + 8);`);
1112
+ this.line(`(void)dt_${a.name};`);
1113
+ this.created.add(a.name);
1114
+ this.pendingInit.delete(a.name);
1115
+ return undefined;
1116
+ }
987
1117
  /** Whether a helper (or one it calls) grows a Vec, which needs the instruction's SystemProgram for rent. */
988
1118
  helperGrows(body, seen = new Set()) {
989
1119
  if (seen.has(body))
@@ -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.