@typesol/cli 0.26.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,24 @@ codes, and says so here.
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## 0.27.0 — 2026-10-29
10
+
11
+ Anchor 1.0 parity: instruction introspection, `Dup<…>` and account migration. Existing programs compile to the same
12
+ binaries as in 0.26.0.
13
+
14
+ - New: instruction introspection. `InstructionsSysvar` has `length`, `currentIndex()` and `get(i)`, a
15
+ `TransactionInstruction` with `programId`, its accounts (key, signer, writable), its data
16
+ (`readU8/U16/U32/U64/readPubkey`) and `is(program, "name")` (an instruction by its Anchor discriminator); every read
17
+ is bounds-checked (5104). `stackHeight()` is 1 at the top level, more under a CPI.
18
+ - New: `Dup<…>`, Anchor's `dup`: a writable parameter that may be the same account as another, for accounts read and
19
+ written in place (not created, closed, a close destination or `@layout borsh`, which are refused at compile time).
20
+ - New: `Migrate<From, To>`, Anchor 1.0's `Migration`: an existing account rewritten from one struct to another with
21
+ `acct.migrate(payer, value)`, the old data readable through `acct.from` until then (the new value is built before
22
+ the bytes are rewritten), the new through `acct.to` after; resized with the payer topping up rent. For now:
23
+ fixed-layout structs without growable Vecs, no constraints on the account, and it cannot be passed to a helper.
24
+ - New: `/** @discriminator … */` on an account struct (a sha256 preimage such as `account:Counter`, or 0x and 8
25
+ bytes), Anchor's `#[account(discriminator = …)]`: keeps an old type's on-chain discriminator under a new name.
26
+
9
27
  ## 0.26.0 — 2026-10-29
10
28
 
11
29
  A first-run experience pass, the security model and audit package, and more fuzzing. The compiler's output is the
@@ -85,6 +85,8 @@ const TYPE_EXPORTS = new Set([
85
85
  "Ata",
86
86
  "Owner",
87
87
  "Executable",
88
+ "Dup",
89
+ "Migrate",
88
90
  "Optional",
89
91
  "EventAuthority",
90
92
  "MplCoreProgram",
@@ -114,6 +116,22 @@ export function snakeCase(name) {
114
116
  .replace(/([A-Z])([A-Z][a-z])/g, "$1_$2")
115
117
  .toLowerCase();
116
118
  }
119
+ /**
120
+ * An account struct's discriminator: Anchor's sha256("account:<Name>")[..8], or the one a `/** @discriminator … *\/` tag
121
+ * gives (a sha256 preimage such as `account:Counter`, or 0x and 8 bytes), e.g. for an old version of a struct kept under
122
+ * a new name for Migrate<From, To>.
123
+ */
124
+ function accountDiscriminator(decl, name, reporter) {
125
+ const tag = ts.getJSDocTags(decl).find((t) => t.tagName.text === "discriminator");
126
+ if (!tag)
127
+ return discriminator(`account:${name}`);
128
+ const text = (typeof tag.comment === "string" ? tag.comment : (ts.getTextOfJSDocComment(tag.comment) ?? "")).trim();
129
+ if (/^0x([0-9a-fA-F]{2}){8}$/.test(text))
130
+ return [...Buffer.from(text.slice(2), "hex")];
131
+ if (text && !text.startsWith("0x"))
132
+ return discriminator(text);
133
+ reporter.fail(decl, "@discriminator on an account struct needs a sha256 preimage (account:<Name>) or 0x and 8 bytes");
134
+ }
117
135
  export function discriminator(preimage) {
118
136
  return [...createHash("sha256").update(preimage).digest().subarray(0, 8)];
119
137
  }
@@ -601,12 +619,17 @@ export function analyze(entry) {
601
619
  // A returned struct goes out in its fixed layout, like an argument comes in: it cannot also be a Borsh event.
602
620
  if (ix.returns?.kind === "struct")
603
621
  collectStructs(ix.returns, structs, dataEnums, argStructs);
604
- for (const a of ix.accounts)
622
+ for (const a of ix.accounts) {
623
+ if (a.kind.kind === "migrate") {
624
+ accountStructs.add(a.kind.from.name);
625
+ accountStructs.add(a.kind.to.name);
626
+ }
605
627
  if (a.kind.kind === "account" || a.kind.kind === "init" || a.kind.kind === "zeroed" || a.kind.kind === "initIfNeeded") {
606
628
  accountStructs.add(a.kind.struct.name);
607
629
  if (a.kind.struct.viewsOnly)
608
630
  reporter.error(a.node, `${a.kind.struct.name} holds a string (or an enum carrying one), so it is an argument, return value or event; account data uses FixedString<N>`);
609
631
  }
632
+ }
610
633
  }
611
634
  // Arguments are Borsh, where a FixedVec is a Vec (only what it holds). A struct passed as a whole argument and used
612
635
  // nowhere else (not account data, not nested, not returned) is read as Anchor sends it: @layout borsh, implied.
@@ -966,7 +989,7 @@ function analyzeStructs(items, aliases, dataEnums, reporter, scopeFor, fstrings,
966
989
  name,
967
990
  fields: stored,
968
991
  size: undefined,
969
- accountDiscriminator: discriminator(`account:${name}`),
992
+ accountDiscriminator: accountDiscriminator(decl, name, reporter),
970
993
  eventDiscriminator: discriminator(`event:${name}`),
971
994
  node: decl,
972
995
  layout: layoutText === "borsh" ? "borsh" : "fixed",
@@ -1321,7 +1344,7 @@ function analyzeHelper(fn, name, display, reporter, scope, structs, skip = new S
1321
1344
  wrapped === "Ata")
1322
1345
  reporter.fail(p.type, "constraints belong on instruction parameters; helpers take the plain account type");
1323
1346
  const account = resolveAccountKind(p.type, reporter, scope.imports, structs);
1324
- if (account.kind === "init" || account.kind === "initIfNeeded" || account.kind === "zeroed")
1347
+ if (account.kind === "init" || account.kind === "initIfNeeded" || account.kind === "zeroed" || account.kind === "migrate")
1325
1348
  reporter.fail(p.type, "helpers take accounts that exist: create Init/InitIfNeeded/Zeroed accounts in the instruction, then pass them as Mut<T>");
1326
1349
  return { name: p.name.text, type: { kind: "account" }, account, node: p };
1327
1350
  });
@@ -1445,7 +1468,7 @@ function analyzeInstruction(fn, reporter, scope, structs) {
1445
1468
  const inner = unwrapConstraints(typeNode, reporter, imports, constraints);
1446
1469
  const kind = resolveAccountKind(inner, reporter, imports, structs);
1447
1470
  if (optional &&
1448
- (["init", "initIfNeeded", "zeroed", "pyth", "eventAuthority", "selfProgram", "programData"].includes(kind.kind) ||
1471
+ (["init", "initIfNeeded", "zeroed", "migrate", "pyth", "eventAuthority", "selfProgram", "programData"].includes(kind.kind) ||
1449
1472
  ((kind.kind === "token" || kind.kind === "mint") && kind.init)))
1450
1473
  reporter.fail(p, "Init, InitIfNeeded, Zeroed, PythPriceUpdate, EventAuthority, SelfProgram and ProgramData accounts cannot be optional");
1451
1474
  accounts.push({ name: p.name.text, index: accounts.length, kind, node: p, constraints, ...(optional ? { optional } : {}) });
@@ -1566,6 +1589,18 @@ function analyzeInstruction(fn, reporter, scope, structs) {
1566
1589
  reporter.error(fn.name, "instructions that grow a Vec (push) need a SystemProgram parameter to top up rent");
1567
1590
  if (needs("init") && !needs("system"))
1568
1591
  reporter.error(fn.name, "instructions with Init<...> accounts need a SystemProgram parameter");
1592
+ if (accounts.some((a) => a.kind.kind === "migrate" && a.kind.to.size > a.kind.from.size) && !needs("system"))
1593
+ reporter.error(fn.name, "a Migrate<From, To> that grows the account needs a SystemProgram parameter (the payer tops up its rent)");
1594
+ for (const a of accounts)
1595
+ if (a.kind.kind === "migrate" &&
1596
+ (a.constraints.hasOne.length ||
1597
+ a.constraints.seeds ||
1598
+ a.constraints.closeTo ||
1599
+ a.constraints.ata ||
1600
+ a.constraints.address ||
1601
+ a.constraints.owner ||
1602
+ a.constraints.dup))
1603
+ reporter.error(a.node, "Migrate<From, To> takes no constraints yet: check the old fields with require(…, …) on acct.from");
1569
1604
  const name = snakeCase(fn.name.text);
1570
1605
  // `/** @discriminator spl-transfer-hook-interface:execute */` (sha256 preimage) or `@discriminator 0x...` (raw bytes)
1571
1606
  // lets an instruction implement another program's interface.
@@ -1596,7 +1631,7 @@ function analyzeInstruction(fn, reporter, scope, structs) {
1596
1631
  /** Peel HasOne / Seeds / CloseTo wrappers off a parameter type, recording what they ask for. */
1597
1632
  function unwrapConstraints(node, reporter, imports, out) {
1598
1633
  const name = typesolName(node, imports);
1599
- if (!["HasOne", "TokenOf", "MintOf", "Seeds", "CloseTo", "Address", "Ata", "Owner", "Executable"].includes(name ?? ""))
1634
+ if (!["HasOne", "TokenOf", "MintOf", "Seeds", "CloseTo", "Address", "Ata", "Owner", "Executable", "Dup"].includes(name ?? ""))
1600
1635
  return node;
1601
1636
  const args = node.typeArguments ?? [];
1602
1637
  const lit = (t) => {
@@ -1638,6 +1673,8 @@ function unwrapConstraints(node, reporter, imports, out) {
1638
1673
  }
1639
1674
  if (name === "Executable")
1640
1675
  out.executable = true;
1676
+ if (name === "Dup")
1677
+ out.dup = true;
1641
1678
  if (name === "Address") {
1642
1679
  const text = lit(args[1])[0];
1643
1680
  const bytes = text === undefined ? undefined : decodeAddress(text);
@@ -1668,6 +1705,8 @@ function unwrapConstraints(node, reporter, imports, out) {
1668
1705
  reporter.fail(node, `${name}<…> wraps an account type`);
1669
1706
  return unwrapConstraints(args[0], reporter, imports, out);
1670
1707
  }
1708
+ /** Solana: an account grows at most this many bytes per instruction. */
1709
+ const MAX_PERMITTED_DATA_INCREASE = 10_240;
1671
1710
  function resolveAccountKind(node, reporter, imports, structs) {
1672
1711
  const outer = typesolName(node, imports);
1673
1712
  const ref = node;
@@ -1769,6 +1808,24 @@ function resolveAccountKind(node, reporter, imports, structs) {
1769
1808
  reporter.fail(node, `${s.name} is too large to create in a program; use Zeroed<${s.name}>`);
1770
1809
  return { kind: "initIfNeeded", struct: s };
1771
1810
  }
1811
+ case "Migrate": {
1812
+ const [fromNode, toNode] = ref.typeArguments ?? [];
1813
+ const from = fromNode && structOf(fromNode);
1814
+ const to = toNode && structOf(toNode);
1815
+ if (!from || !to || ref.typeArguments.length !== 2)
1816
+ reporter.fail(node, "Migrate<From, To> takes two account structs declared in this program");
1817
+ for (const s of [from, to]) {
1818
+ if (s.vec)
1819
+ reporter.fail(node, `${s.name} has a growable Vec; Migrate<From, To> takes fixed-size structs`);
1820
+ if (s.layout === "borsh")
1821
+ reporter.fail(node, `${s.name} is @layout borsh; Migrate<From, To> takes fixed layouts`);
1822
+ }
1823
+ if (from.accountDiscriminator.join() === to.accountDiscriminator.join())
1824
+ reporter.fail(node, `${from.name} and ${to.name} have the same discriminator: give the new struct another name (or the old one /** @discriminator account:<OldName> */)`);
1825
+ if (to.size - from.size > MAX_PERMITTED_DATA_INCREASE)
1826
+ reporter.fail(node, `${to.name} is more than 10 KiB larger than ${from.name}; an account grows at most 10 KiB per instruction`);
1827
+ return { kind: "migrate", from, to };
1828
+ }
1772
1829
  case "Zeroed": {
1773
1830
  const s = structOf(inner());
1774
1831
  if (!s)
@@ -112,6 +112,8 @@ export declare abstract class EmitterBase {
112
112
  protected abstract call(node: ts.CallExpression, topLevel: boolean): Value | undefined;
113
113
  protected abstract isRemaining(node: ts.Expression): boolean;
114
114
  protected abstract handle(node: ts.Expression): string | undefined;
115
+ protected abstract instructionsParam(node: ts.Expression): AccountParam | undefined;
116
+ protected abstract isTxIx(node: ts.Expression): boolean;
115
117
  protected abstract seeds(node: ts.Expression, extra: number): {
116
118
  name: string;
117
119
  count: number;
@@ -65,7 +65,10 @@ export class EmitterBase {
65
65
  this.ix = target;
66
66
  for (const a of target.accounts) {
67
67
  this.accounts.set(a.name, a);
68
- if (a.kind.kind === "init" || a.kind.kind === "initIfNeeded" || ((a.kind.kind === "token" || a.kind.kind === "mint") && a.kind.init))
68
+ if (a.kind.kind === "init" ||
69
+ a.kind.kind === "initIfNeeded" ||
70
+ a.kind.kind === "migrate" ||
71
+ ((a.kind.kind === "token" || a.kind.kind === "mint") && a.kind.init))
69
72
  this.pendingInit.add(a.name);
70
73
  }
71
74
  for (const a of target.args)
@@ -1,5 +1,5 @@
1
1
  import ts from "typescript";
2
- import { type ErrorDef, type FieldType, type Helper } from "../model.js";
2
+ import { type AccountParam, type ErrorDef, type FieldType, type Helper } from "../model.js";
3
3
  import { type Value, type Ref, type Closure } from "./shared.js";
4
4
  import { ExpressionEmitter } from "./expressions.js";
5
5
  export declare abstract class CallEmitter extends ExpressionEmitter {
@@ -20,6 +20,15 @@ export declare abstract class CallEmitter extends ExpressionEmitter {
20
20
  /** `remainingAccounts()` or a const bound to it. */
21
21
  protected isRemaining(node: ts.Expression): boolean;
22
22
  /** C pointer (`TsAccount *`) for an expression naming a remaining account, or undefined. */
23
+ /** Whether `node` is an InstructionsSysvar parameter. */
24
+ protected instructionsParam(node: ts.Expression): AccountParam | undefined;
25
+ /** Whether `node` is a TransactionInstruction: a local holding one, or `ixs.get(i)`. */
26
+ protected isTxIx(node: ts.Expression): boolean;
27
+ /**
28
+ * InstructionsSysvar.currentIndex() / .get(i) and the methods of a TransactionInstruction: bounds-checked reads of the
29
+ * sysvar (ts_ix_* in the runtime). Undefined when the call is something else.
30
+ */
31
+ protected instructionsCall(node: ts.CallExpression, callee: ts.PropertyAccessExpression, method: string): Value | undefined;
23
32
  protected handle(node: ts.Expression): string | undefined;
24
33
  /** A declared account parameter or a remaining account, as a `TsAccount *` C expression. */
25
34
  protected anyAccount(node: ts.Expression, what: string): string;
@@ -29,6 +38,12 @@ export declare abstract class CallEmitter extends ExpressionEmitter {
29
38
  protected loadForeign(node: ts.CallExpression): Value;
30
39
  /** `loadAccount<T>(acct)` / `loadAccountMut<T>(acct)`: checked view of a program account. */
31
40
  protected loadAccount(node: ts.CallExpression, mut: boolean): Value;
41
+ /**
42
+ * `acct.migrate(payer, value)` on a Migrate<From, To> account: the new value is built in full first (it may read
43
+ * acct.from, whose bytes the rewrite overwrites), then the account is resized (the payer tops up the rent when it
44
+ * grows; shrinking keeps the lamports) and rewritten with To's discriminator and the value.
45
+ */
46
+ protected migrate(node: ts.CallExpression, a: AccountParam, topLevel: boolean): Value | undefined;
32
47
  /** Whether a helper (or one it calls) grows a Vec, which needs the instruction's SystemProgram for rent. */
33
48
  private helperGrows;
34
49
  /**
@@ -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");
@@ -484,6 +489,9 @@ export class CallEmitter extends ExpressionEmitter {
484
489
  return this.callHelper(node, helper);
485
490
  if (ts.isPropertyAccessExpression(callee)) {
486
491
  const method = callee.name.text;
492
+ const introspected = this.instructionsCall(node, callee, method);
493
+ if (introspected)
494
+ return introspected;
487
495
  // xs.some(…) and friends on a Vec, FixedArray, FixedVec or Vec argument: a loop with the callback expanded in it.
488
496
  if (LIST_METHODS.has(method)) {
489
497
  const l = ts.isIdentifier(callee.expression) ? this.lookup(callee.expression.text) : undefined;
@@ -570,6 +578,8 @@ export class CallEmitter extends ExpressionEmitter {
570
578
  return target.kind.kind === "token" && target.constraints.ata ? this.createAta(node, target, topLevel) : this.createToken(node, target, topLevel);
571
579
  if (target?.kind.kind === "init" && method === "create")
572
580
  return this.create(node, target, topLevel);
581
+ if (target?.kind.kind === "migrate" && method === "migrate")
582
+ return this.migrate(node, target, topLevel);
573
583
  if (target?.kind.kind === "initIfNeeded" && method === "createIfNeeded")
574
584
  return this.create(node, target, topLevel, true);
575
585
  if (target?.kind.kind === "system" && method === "transfer")
@@ -878,6 +888,92 @@ export class CallEmitter extends ExpressionEmitter {
878
888
  return ts.isCallExpression(node) && this.exportName(node.expression) === "remainingAccounts";
879
889
  }
880
890
  /** C pointer (`TsAccount *`) for an expression naming a remaining account, or undefined. */
891
+ /** Whether `node` is an InstructionsSysvar parameter. */
892
+ instructionsParam(node) {
893
+ const a = ts.isIdentifier(node) ? this.accounts.get(node.text) : undefined;
894
+ return a?.kind.kind === "instructionsSysvar" ? a : undefined;
895
+ }
896
+ /** Whether `node` is a TransactionInstruction: a local holding one, or `ixs.get(i)`. */
897
+ isTxIx(node) {
898
+ if (ts.isParenthesizedExpression(node))
899
+ return this.isTxIx(node.expression);
900
+ if (ts.isIdentifier(node))
901
+ return this.lookup(node.text)?.type.kind === "txIx";
902
+ return (ts.isCallExpression(node) &&
903
+ ts.isPropertyAccessExpression(node.expression) &&
904
+ node.expression.name.text === "get" &&
905
+ !!this.instructionsParam(node.expression.expression));
906
+ }
907
+ /**
908
+ * InstructionsSysvar.currentIndex() / .get(i) and the methods of a TransactionInstruction: bounds-checked reads of the
909
+ * sysvar (ts_ix_* in the runtime). Undefined when the call is something else.
910
+ */
911
+ instructionsCall(node, callee, method) {
912
+ const args = node.arguments;
913
+ const sysvar = this.instructionsParam(callee.expression);
914
+ if (sysvar) {
915
+ this.assertUsable(sysvar, callee.expression);
916
+ if (method === "currentIndex") {
917
+ if (args.length)
918
+ this.fail(node, "currentIndex() takes no arguments");
919
+ const t = this.tmp("ixi");
920
+ this.line(`uint64_t ${t};`);
921
+ this.line(`TS_TRY(ts_ix_current(a_${sysvar.name}, &${t}));`);
922
+ return { c: t, type: U64 };
923
+ }
924
+ if (method === "get") {
925
+ if (args.length !== 1)
926
+ this.fail(node, "get(index) takes the instruction's index");
927
+ const i = this.expect(this.expr(args[0], U64), U64, args[0]);
928
+ const t = this.tmp("ix");
929
+ this.line(`TsIx ${t};`);
930
+ this.line(`TS_TRY(ts_ix_load(a_${sysvar.name}, ${i.c}, &${t}));`);
931
+ return { c: t, type: { kind: "txIx" } };
932
+ }
933
+ this.fail(node, "the Instructions sysvar has .length, currentIndex() and get(index)");
934
+ }
935
+ if (!this.isTxIx(callee.expression))
936
+ return undefined;
937
+ const ix = this.expr(callee.expression);
938
+ const one = (what) => {
939
+ if (args.length !== 1)
940
+ this.fail(node, `${method}(${what}) takes one argument`);
941
+ return this.expect(this.expr(args[0], U64), U64, args[0]).c;
942
+ };
943
+ if (method === "accountKey" || method === "accountIsSigner" || method === "accountIsWritable") {
944
+ const i = one("i");
945
+ const t = this.tmp("ixa");
946
+ this.line(`const uint8_t *${t};`);
947
+ this.line(`TS_TRY(ts_ix_account(&${ix.c}, ${i}, &${t}));`);
948
+ if (method === "accountKey")
949
+ return { c: `((const SolPubkey *)(${t} + 1))`, type: PUBKEY };
950
+ return { c: `((${t}[0] & ${method === "accountIsSigner" ? 1 : 2}) != 0)`, type: BOOL };
951
+ }
952
+ const reads = {
953
+ readU8: ["uint8_t", U8],
954
+ readU16: ["uint16_t", { kind: "int", name: "u16" }],
955
+ readU32: ["uint32_t", { kind: "int", name: "u32" }],
956
+ readU64: ["uint64_t", U64],
957
+ readPubkey: ["SolPubkey", PUBKEY],
958
+ };
959
+ if (reads[method]) {
960
+ const [c, type] = reads[method];
961
+ const off = one("offset");
962
+ const t = this.tmp("ixr");
963
+ this.line(`${c} ${t};`);
964
+ this.line(`TS_TRY(ts_ix_read(&${ix.c}, ${off}, sizeof(${c}), &${t}));`);
965
+ return type.kind === "pubkey" ? { c: `(&${t})`, type } : { c: t, type };
966
+ }
967
+ if (method === "is") {
968
+ if (args.length !== 2 || !ts.isStringLiteralLike(args[1]))
969
+ this.fail(node, 'is(program, "instruction_name") takes the program and the instruction name as a string literal');
970
+ const program = this.expect(this.expr(args[0], PUBKEY), PUBKEY, args[0]);
971
+ const disc = this.tmp("disc");
972
+ this.line(`static const uint8_t ${disc}[8] = ${cBytes(discriminator(`global:${snakeCase(args[1].text)}`))};`);
973
+ return { c: `ts_ix_is(&${ix.c}, ${program.c}, ${disc})`, type: BOOL };
974
+ }
975
+ this.fail(node, "a TransactionInstruction has programId, accountsLength, dataLength, accountKey/accountIsSigner/accountIsWritable(i), readU8/U16/U32/U64/readPubkey(offset) and is(program, name)");
976
+ }
881
977
  handle(node) {
882
978
  if (ts.isParenthesizedExpression(node))
883
979
  return this.handle(node.expression);
@@ -984,6 +1080,38 @@ export class CallEmitter extends ExpressionEmitter {
984
1080
  }
985
1081
  return { c: `((TSS_${s.name} *)(${h}->data + 8))`, type: { kind: "structRef", struct: s.name, writable: mut } };
986
1082
  }
1083
+ /**
1084
+ * `acct.migrate(payer, value)` on a Migrate<From, To> account: the new value is built in full first (it may read
1085
+ * acct.from, whose bytes the rewrite overwrites), then the account is resized (the payer tops up the rent when it
1086
+ * grows; shrinking keeps the lamports) and rewritten with To's discriminator and the value.
1087
+ */
1088
+ migrate(node, a, topLevel) {
1089
+ const k = a.kind;
1090
+ const call = `${a.name}.migrate`;
1091
+ if (!topLevel)
1092
+ this.fail(node, `${call}(…) must be a top-level statement of the instruction`);
1093
+ if (this.created.has(a.name))
1094
+ this.fail(node, `${a.name} is already migrated`);
1095
+ if (node.arguments.length !== 2)
1096
+ this.fail(node, `${call}(payer, value) takes the payer and the new ${k.to.name}`);
1097
+ const payer = this.accountParam(node.arguments[0], "the payer");
1098
+ if (payer.kind.kind !== "signer" || !payer.kind.mut)
1099
+ this.fail(node.arguments[0], "the payer must be a Mut<Signer>");
1100
+ this.assertNotAliased([a.name], node, "migrate");
1101
+ const To = k.to.name;
1102
+ const tmp = this.tmp("next");
1103
+ this.line(`TSS_${To} *${tmp} = NULL;`);
1104
+ this.line(`TS_TRY(ts_alloc(sizeof(TSS_${To}), (uint8_t **)&${tmp}));`);
1105
+ this.writeAggregate({ lv: `(*${tmp})`, type: { kind: "struct", name: To }, writable: true }, node.arguments[1]);
1106
+ this.line(`TS_TRY(ts_resize(params, a_${payer.name}, a_${a.name}, 8 + sizeof(TSS_${To}), entry_${a.name}));`);
1107
+ this.line(`ts_memcpy(a_${a.name}->data, TS_DISC_${To}, 8);`);
1108
+ this.line(`ts_memcpy(a_${a.name}->data + 8, ${tmp}, sizeof(TSS_${To}));`);
1109
+ this.line(`TSS_${To} *dt_${a.name} = (TSS_${To} *)(a_${a.name}->data + 8);`);
1110
+ this.line(`(void)dt_${a.name};`);
1111
+ this.created.add(a.name);
1112
+ this.pendingInit.delete(a.name);
1113
+ return undefined;
1114
+ }
987
1115
  /** Whether a helper (or one it calls) grows a Vec, which needs the instruction's SystemProgram for rent. */
988
1116
  helperGrows(body, seen = new Set()) {
989
1117
  if (seen.has(body))
@@ -265,6 +265,14 @@ export class ExpressionEmitter extends StorageEmitter {
265
265
  if (field === "length") {
266
266
  if (this.isRemaining(node.expression))
267
267
  return { c: `((uint64_t)(params->ka_num - ${this.ix.accounts.length}))`, type: U64 };
268
+ const sysvar = this.instructionsParam(node.expression);
269
+ if (sysvar) {
270
+ this.assertUsable(sysvar, node.expression);
271
+ const t = this.tmp("ixn");
272
+ this.line(`uint64_t ${t};`);
273
+ this.line(`TS_TRY(ts_ix_count(a_${sysvar.name}, &${t}));`);
274
+ return { c: t, type: U64 };
275
+ }
268
276
  // The number of elements a map / filter chain gives: counted, nothing stored.
269
277
  if (this.isPipeline(node.expression)) {
270
278
  const n = this.tmp("count");
@@ -284,6 +292,16 @@ export class ExpressionEmitter extends StorageEmitter {
284
292
  }
285
293
  this.fail(node, "use byteLength(s) for strings; .length is available on arrays, Vecs and remainingAccounts()");
286
294
  }
295
+ if (this.isTxIx(node.expression)) {
296
+ const ix = this.expr(node.expression);
297
+ if (field === "programId")
298
+ return { c: `(${ix.c}.program_id)`, type: PUBKEY };
299
+ if (field === "accountsLength")
300
+ return { c: `((uint64_t)${ix.c}.num_accounts)`, type: U64 };
301
+ if (field === "dataLength")
302
+ return { c: `((uint64_t)${ix.c}.data_len)`, type: U64 };
303
+ this.fail(node.name, `a TransactionInstruction has programId, accountsLength and dataLength, not ${field}`);
304
+ }
287
305
  const h = this.handle(node.expression);
288
306
  if (h) {
289
307
  switch (field) {
@@ -90,6 +90,9 @@ export class FunctionEmitter extends StatementEmitter {
90
90
  }
91
91
  head.push(` TS_TRY(ts_check_executable(${v}));`);
92
92
  }
93
+ // Migrate<From, To>: an existing From account (owner, discriminator, size), kept to be rewritten as To.
94
+ if (k.kind === "migrate")
95
+ 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;`);
93
96
  if (k.kind === "zeroed")
94
97
  head.push(` TS_TRY(ts_init_zeroed(${v}, params->program_id, TS_DISC_${k.struct.name}, sizeof(TSS_${k.struct.name})));`);
95
98
  if (k.kind === "initIfNeeded" && k.struct.vec)
@@ -152,9 +155,30 @@ export class FunctionEmitter extends StatementEmitter {
152
155
  // The loader passes each account once and marks repeats as duplicates, which share its record (ts_deserialize),
153
156
  // so two parameters name the same account exactly when their pointers are equal.
154
157
  const writable = ix.accounts.filter((a) => isWritable(a.kind));
158
+ // Dup<…> (Anchor's `dup`) lifts the check for a pair when both accounts are read and written in place: not created
159
+ // here, not closed or a close destination, not a @layout borsh heap copy (a lost update when packed back).
160
+ const closing = new Set(ix.accounts.flatMap((a) => (a.constraints.closeTo ? [a.name, a.constraints.closeTo] : [])));
161
+ const inPlace = (a) => {
162
+ const k = a.kind;
163
+ if (closing.has(a.name))
164
+ return false;
165
+ if (k.kind === "account")
166
+ return k.struct.layout !== "borsh";
167
+ if (k.kind === "token" || k.kind === "mint")
168
+ return !k.init;
169
+ return k.kind === "signer" || k.kind === "info";
170
+ };
171
+ for (const a of writable)
172
+ if (a.constraints.dup && !inPlace(a))
173
+ this.reporter.fail(a.node, "Dup<…> is for accounts read and written in place: not created, closed, a close destination or @layout borsh");
174
+ for (const a of ix.accounts)
175
+ if (a.constraints.dup && !isWritable(a.kind))
176
+ this.reporter.fail(a.node, "Dup<…> lets a writable account alias another; this one is not writable");
155
177
  const pairs = [];
156
178
  for (let i = 0; i < writable.length; i++)
157
179
  for (let j = i + 1; j < writable.length; j++) {
180
+ if ((writable[i].constraints.dup || writable[j].constraints.dup) && inPlace(writable[i]) && inPlace(writable[j]))
181
+ continue;
158
182
  const present = [writable[i], writable[j]]
159
183
  .filter((a) => a.optional)
160
184
  .map((a) => `a_${a.name} && `)
@@ -331,6 +355,10 @@ export class FunctionEmitter extends StatementEmitter {
331
355
  for (const name of this.pendingInit) {
332
356
  const k = this.accounts.get(name).kind;
333
357
  const a = this.accounts.get(name);
358
+ if (k.kind === "migrate") {
359
+ this.reporter.error(a.node, `Migrate account "${name}" is never migrated; call ${name}.migrate(payer, value) (Anchor fails such an instruction with AccountNotMigrated)`);
360
+ continue;
361
+ }
334
362
  const how = k.kind === "token" && a.constraints.ata
335
363
  ? `${name}.${k.init === "create" ? "create" : "createIfNeeded"}(payer)`
336
364
  : k.kind === "token"
@@ -61,6 +61,8 @@ export function valueC(t) {
61
61
  return "TS_VecArg";
62
62
  case "account":
63
63
  return "TsAccount *";
64
+ case "txIx":
65
+ return "TsIx";
64
66
  case "structRef":
65
67
  return `TSS_${t.struct} *`;
66
68
  case "enumRef":
@@ -277,6 +277,24 @@ export class StorageEmitter extends EmitterBase {
277
277
  }
278
278
  if (ts.isPropertyAccessExpression(node)) {
279
279
  const field = node.name.text;
280
+ // Migrate<From, To>: acct.from (the old data, read-only, until migrate()) and acct.to (after it).
281
+ if (ts.isIdentifier(node.expression)) {
282
+ const m = this.accounts.get(node.expression.text);
283
+ if (m?.kind.kind === "migrate") {
284
+ const migrated = this.created.has(m.name);
285
+ if (field === "from") {
286
+ if (migrated)
287
+ this.fail(node, `${m.name}.from is gone after ${m.name}.migrate(…): read what you need from it before`);
288
+ return { lv: `(*df_${m.name})`, type: { kind: "struct", name: m.kind.from.name }, writable: false, account: m, path: ["from"] };
289
+ }
290
+ if (field === "to") {
291
+ if (!migrated)
292
+ this.fail(node, `${m.name}.to exists after ${m.name}.migrate(payer, value)`);
293
+ return { lv: `(*dt_${m.name})`, type: { kind: "struct", name: m.kind.to.name }, writable: true, account: m, path: ["to"] };
294
+ }
295
+ this.fail(node.name, `a Migrate account has .from (before migrate), .to (after), key, lamports and migrate(payer, value)`);
296
+ }
297
+ }
280
298
  // acct.field on a struct account
281
299
  if (ts.isIdentifier(node.expression)) {
282
300
  const a = this.accounts.get(node.expression.text);
@@ -63,6 +63,10 @@ export type ValueType = ScalarType | {
63
63
  | {
64
64
  kind: "remaining";
65
65
  }
66
+ /** One instruction of the transaction (InstructionsSysvar.get(i)): a `TsIx` view into the sysvar. */
67
+ | {
68
+ kind: "txIx";
69
+ }
66
70
  /** A struct loaded from a remaining account with loadAccount / loadAccountMut. */
67
71
  | {
68
72
  kind: "structRef";
@@ -190,6 +194,12 @@ export type ParamKind = {
190
194
  kind: "zeroed";
191
195
  struct: StructDef;
192
196
  }
197
+ /** Anchor's Migration<From, To>: an existing `from` account rewritten as `to` by `acct.migrate(payer, value)`. */
198
+ | {
199
+ kind: "migrate";
200
+ from: StructDef;
201
+ to: StructDef;
202
+ }
193
203
  /** `init`: an associated token account this instruction creates (`Ata<Init<TokenAccount>, …>`, create) or creates when
194
204
  * missing (`Ata<InitIfNeeded<TokenAccount>, …>`, createIfNeeded); it is a TokenAccount once that call has run. */
195
205
  | {
@@ -263,6 +273,8 @@ export interface Constraints {
263
273
  };
264
274
  /** Executable<A>: the account must be an executable program. */
265
275
  executable?: boolean;
276
+ /** Dup<A> (Anchor's `dup`): may be the same account as another writable parameter (see function.ts). */
277
+ dup?: boolean;
266
278
  /** Close this account into that parameter when the instruction succeeds. */
267
279
  closeTo?: string;
268
280
  /** Address<A, "base58">: the account must be exactly this address. */
@@ -20,6 +20,7 @@ export function isWritable(k) {
20
20
  case "init":
21
21
  case "zeroed":
22
22
  case "initIfNeeded":
23
+ case "migrate":
23
24
  return true;
24
25
  case "signer":
25
26
  case "info":
@@ -67,6 +68,8 @@ export function typeName(t) {
67
68
  return "AccountInfo";
68
69
  case "remaining":
69
70
  return "RemainingAccounts";
71
+ case "txIx":
72
+ return "TransactionInstruction";
70
73
  case "structRef":
71
74
  return t.struct;
72
75
  }
@@ -184,9 +184,9 @@ Both implementations refuse the hostile calls the comparison makes:
184
184
 
185
185
  TypeSOL checks each account's constraints in Anchor's order, so a refused transaction usually gets the same error
186
186
  code (see [which error a transaction gets](errors.md#which-error-a-transaction-gets)). The second call differs on
187
- purpose: taker and maker are then the same writable account, which TypeSOL refuses for any two writable parameters,
188
- where Anchor only compares program and token accounts and fails later, on the maker's token account. A client that
189
- matches on this code needs to accept either.
187
+ purpose: taker and maker are then the same writable account, which TypeSOL refuses for any two writable parameters
188
+ unless one is marked `Dup<…>`, where Anchor only compares program and token accounts and fails later, on the maker's
189
+ token account. A client that matches on this code needs to accept either.
190
190
 
191
191
  ## Cost
192
192
 
package/docs/errors.md CHANGED
@@ -70,5 +70,5 @@ Where TypeSOL's order differs, only a transaction that breaks two of these check
70
70
  - Too few accounts fails with 3005 before any account is checked; Anchor fails on the first account it cannot find.
71
71
  - Accounts the instruction creates (`Init<…>`, `create(…)`) are created in the body, after every other check, where
72
72
  Anchor creates them before checking the other accounts.
73
- - TypeSOL refuses any two writable parameters naming one account (2040), where Anchor only compares program and token
73
+ - TypeSOL refuses any two writable parameters naming one account (2040) unless one is `Dup<…>`, where Anchor only compares program and token
74
74
  accounts: a signer passed as two writable parameters fails with 2040 here and later, or not at all, under Anchor.
package/docs/language.md CHANGED
@@ -38,7 +38,7 @@ The full reference. For a tour, start with the [README](../README.md).
38
38
  | Option of structs | `home: Option<Point>` in account data — `=== null`, `p.home.x` after narrowing, `p.home = null` / `{ x, y }` |
39
39
  | Strings at runtime | `"hello, " + name`, `` `#${n}` ``, `` `owner ${acct.owner}` `` (a Pubkey or Hash as base58) — built on the 32 KiB heap (5110 when full): a template is joined in one allocation, text known at compile time (literals, string and Pubkey constants) is folded in, and `s += …` grows the newest heap string in place |
40
40
  | `switch` | on integers (128-bit ones too), enums and data-enum `.kind`, with JS `break` / fallthrough; case labels are literals, constants or enum members |
41
- | Hashing & signatures | `sha256(...)`, `keccak256(...)` → `Hash`; `findProgramAddress(seeds, program)` → the canonical PDA of any program (Rust `Pubkey::find_program_address(..).0`, e.g. a Bubblegum asset id); `merkleVerify(leaf, proof: Vec<Hash>, root)` (sorted-pair keccak, OpenZeppelin convention; inner nodes and leaves are hashed alike, so hash leaves as OpenZeppelin does, `keccak256(keccak256(data))`, and never take the leaf itself from the arguments: an inner node would pass as a leaf); `verifyEd25519(instructionsSysvar, signer, message)` checks an Ed25519 precompile instruction in the same transaction; `verifySecp256k1(keccak256(...message) | ethMessageHash(message), recoveryId, r, s, ethAddress)` (the hash must be computed in the program; a passed-in hash could be forged) |
41
+ | Hashing & signatures | `sha256(...)`, `keccak256(...)` → `Hash`; `findProgramAddress(seeds, program)` → the canonical PDA of any program (Rust `Pubkey::find_program_address(..).0`, e.g. a Bubblegum asset id); `merkleVerify(leaf, proof: Vec<Hash>, root)` (sorted-pair keccak, OpenZeppelin convention; inner nodes and leaves are hashed alike, so hash leaves as OpenZeppelin does, `keccak256(keccak256(data))`, and never take the leaf itself from the arguments: an inner node would pass as a leaf); `verifyEd25519(instructionsSysvar, signer, message)` checks an Ed25519 precompile instruction in the same transaction; `stackHeight()` is 1 when the transaction calls the program directly, more under a CPI (`require(stackHeight() === 1n, …)` refuses CPI callers); `verifySecp256k1(keccak256(...message) | ethMessageHash(message), recoveryId, r, s, ethAddress)` (the hash must be computed in the program; a passed-in hash could be forged) |
42
42
  | Oracles | `pythPrice(update: PythPriceUpdate, feedId, maxAgeSeconds, exponent?)` (fully verified, feed-matched, fresh; with `exponent`, a feed at another scale is refused); raw `update.price / conf / exponent / publishTime` are unchecked (any feed, partially verified or stale: check `feedId`, `fullyVerified` and the age yourself, or use `pythPrice`); `switchboardValue(feed: SwitchboardFeed, expectedFeed, maxStaleSlots, decimals, minSamples?)` (the feed must be the expected one: anyone can create feeds; `minSamples`, default 1, the fewest oracle samples accepted) (mainnet program unless `SwitchboardFeed<"devnet">`) |
43
43
  | Multiple files | `import { Side, min } from "./math"` — structs, enums, constants and helpers can live in other files; only the entry file's exported functions are instructions; names are program-wide, so imports from TypeSOL files cannot be renamed; error codes number imported files first |
44
44
  | Loops | `for (let i = 0n; i < n; i++)`, `while`, `do … while`, `for (const x of list)` over a `Vec` or `FixedArray` (each `x` is what `list.get(i)` gives: a value, or a reference into account data for structs; the length is read again every step, as in JS), `break`, `continue` — bounded by the compute budget. Remaining accounts are iterated by index |
@@ -76,6 +76,7 @@ The full reference. For a tour, start with the [README](../README.md).
76
76
  | `A \| null` / `Optional<A>` | optional account (Anchor `Option<Account>`): the client passes this program's id to leave it out; its checks and constraints run only when present; `x !== null` before use, or `x!` / `x!.field` to assert it was passed (5115 otherwise); `optional` in the IDL |
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
+ | `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 { … }` |
79
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
81
  | `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) |
81
82
  | `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) |
@@ -86,6 +87,7 @@ The full reference. For a tour, start with the [README](../README.md).
86
87
  | `AssociatedTokenProgram` | `create` / `createIdempotent` CPIs; `associatedTokenAddress(owner, mint, tokenProgram)` |
87
88
  | `TokenMetadataProgram` | Metaplex `createV3(...)` (CreateMetadataAccountV3), optionally with `{ creators: [{ address, verified, share }], collection: mint }` after `isMutable` (1–5 creators, shares adding up to 100; only the update authority, signing, can be `verified: true`; the collection is written unverified; `isCollection: true` makes the NFT a sized collection); `createMasterEditionV3(edition, mint, updateAuthority, mintAuthority, payer, metadata, tokenProgram, systemProgram, maxSupply \| null)`; `verifyCollection(authority, metadata, collectionMint, collectionMetadata, collectionMasterEdition, systemProgram, instructionsSysvar)` (Verify with CollectionV1); `metadataAddress(mint)`, `masterEditionAddress(mint)` |
88
89
  | `InstructionsSysvar`, `PythPriceUpdate`, `SwitchboardFeed` | exact address / owner program + discriminator |
90
+ | `InstructionsSysvar` (introspection) | `ixs.length`, `ixs.currentIndex()` (the top-level instruction being executed), `ixs.get(i)` → a `TransactionInstruction`: `programId`, `accountsLength`, `dataLength`, `accountKey(j)` / `accountIsSigner(j)` / `accountIsWritable(j)`, `readU8/U16/U32/U64/readPubkey(offset)` (little-endian), `is(program, "name")` (that program's instruction by its default Anchor discriminator, sha256("global:<name>"); an instruction with its own `@discriminator` is matched with `programId` and `readU64(0)` instead). Every read is bounds-checked (5104). It is the transaction author's data: require things of the transaction with it (a repay instruction after a flash loan), never treat it as proof |
89
91
  | `Program<"Address…">` | exact program address + executable |
90
92
  | `UncheckedProgram` | executable only — check `.key` yourself |
91
93
  | `AccountInfo` / `Mut<AccountInfo>` | nothing (writable for `Mut`); `owner`, `executable` |
@@ -94,7 +96,10 @@ The full reference. For a tour, start with the [README](../README.md).
94
96
  Every account exposes `key`, `lamports`, `dataLen`, `isSigner`, `isWritable`. At most 32 accounts per instruction (more is refused, 5113).
95
97
 
96
98
  Two writable parameters can never be the same account: aliasing is refused with error 2040 (also when a remaining
97
- account written through `.lamports` or `loadAccountMut` is another account of the instruction). A remaining account
99
+ account written through `.lamports` or `loadAccountMut` is another account of the instruction). `Dup<Mut<T>>`
100
+ (Anchor's `dup`) lifts that for one parameter when both accounts are read and written in place (not created, closed,
101
+ a close destination or `@layout borsh`): a write through either is seen through the other, so read what you need
102
+ before writing. A remaining account
98
103
  can be loaded with `loadAccountMut` once per instruction: loading the same one again (`rest.get(i)` and `rest.get(j)`
99
104
  with `i == j`) is refused with 2040 too, since the two views would alias.
100
105
 
@@ -46,6 +46,8 @@ and error codes. Its notes (on stderr) list what an existing Anchor client would
46
46
  | `Option<Account<'info, T>>` | `Mut<T> \| null` (the client passes the program id to leave it out) |
47
47
  | `UncheckedAccount` / `AccountInfo` | `AccountInfo` / `Mut<AccountInfo>`: check what you rely on yourself |
48
48
  | `SystemAccount<'info>` | `SystemAccount` / `Mut<SystemAccount>` |
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
+ | `#[account(discriminator = …)]` | `/** @discriminator <preimage or 0x8 bytes> */` on the struct |
49
51
  | `AccountLoader<T>` with `#[account(zero)]` | `Zeroed<T>` (TypeSOL reads every account in place: no copy to avoid) |
50
52
  | `Program<'info, System>` | `SystemProgram` |
51
53
  | `Program<'info, Token>` / `Token2022` / `Interface<'info, TokenInterface>` | `TokenProgram` / `Token2022Program` / `TokenInterface` |
@@ -57,6 +59,8 @@ and error codes. Its notes (on stderr) list what an existing Anchor client would
57
59
  | `#[account(init, payer = p, seeds = […], bump, token::mint = m, token::authority = a)]` | `Init<TokenAccount>`, then `vault.create(p, […], m, a.key)` (`InitIfNeeded<TokenAccount>` + `createIfNeeded`) |
58
60
  | `associated_token::mint = m, associated_token::authority = a` | `Ata<TokenAccount, "a", "m">`; `Ata<Init<TokenAccount>, "a", "m">` creates it |
59
61
  | `ctx.remaining_accounts` | `remainingAccounts()` |
62
+ | `#[account(mut, dup)]` | `Dup<Mut<T>>` (in-place accounts only) |
63
+ | `sysvar::instructions` (`load_current_index_checked`, `load_instruction_at_checked`), `get_stack_height()` | `InstructionsSysvar`: `currentIndex()`, `get(i)`, `.length`; `stackHeight()` |
60
64
  | `ctx.bumps.vault` | the value `create` returns, or `acct.pdaBump` |
61
65
 
62
66
  ## Account data
@@ -31,7 +31,8 @@ Each holds for every instruction of every program the compiler accepts. A break
31
31
  initialized account of that kind (3007 / 3003); program parameters at their fixed address and executable
32
32
  (3008 / 3009); `SystemAccount` owned by the System Program (3011); `Zeroed<T>` owned, all-zero discriminator and
33
33
  rent-exempt.
34
- 3. **No aliasing.** Two writable parameters are never the same account (2040), whatever slots the transaction uses.
34
+ 3. **No aliasing.** Two writable parameters are never the same account (2040), whatever slots the transaction uses,
35
+ unless the program marks one `Dup<…>`, and then only for accounts read and written in place (see below).
35
36
  4. **Arguments.** Every argument is length-checked before it is read; strings are valid UTF-8; booleans, enums and
36
37
  options have valid tags (102).
37
38
  5. **Constraints.** `Seeds` (2006, with the stored or the canonical bump), `Ata` (2015 / 2009 / 2014), `HasOne`
@@ -50,7 +51,9 @@ Each holds for every instruction of every program the compiler accepts. A break
50
51
  written before it (compile error). Creation refuses an account that already exists (3000), and an address someone
51
52
  pre-funded still gets created. `createIfNeeded` on an existing account re-derives the canonical PDA and checks
52
53
  owner, discriminator and size (token accounts: mint and owner; mints: decimals, authorities and the extensions it
53
- names).
54
+ names). A `Migrate<From, To>` account is checked as an existing `From`; `migrate(…)` is a top-level statement every
55
+ path reaches, builds the new value before rewriting the bytes, and nothing reads the old data after it (compile
56
+ errors).
54
57
  9. **Writes through references.** A place is never written after the code that computes its value may have moved it:
55
58
  replacing or resizing what a live reference points into is a compile error, as is writing a Vec element with a
56
59
  right side that may resize the Vec.
@@ -78,6 +81,10 @@ Each holds for every instruction of every program the compiler accepts. A break
78
81
  - **`AccountInfo` / `UncheckedProgram`.** Unchecked by design, as in Anchor: check what you rely on.
79
82
  - **`requirePda(account, seeds, bump)`.** Any valid bump passes (Anchor's `bump = expr`). Use a stored bump, never one
80
83
  from the arguments.
84
+ - **`Dup<…>`.** A parameter marked `Dup` may be the same account as another writable one. Reads see every write
85
+ (both are the same bytes), so a value read before a write through the other slot is stale: read, then write.
86
+ - **Instruction introspection.** The Instructions sysvar holds what the transaction's author put there. Require things
87
+ of the transaction with it (that a repay instruction follows), never treat it as proof of who did what.
81
88
  - **Oracles.** `pythPrice` checks verification, feed and age (pass the exponent you scale by); the raw `update.*`
82
89
  fields are unchecked. `switchboardValue` needs the expected feed; set `minSamples`.
83
90
  - **`merkleVerify`.** Leaves and inner nodes hash alike: build leaves as `keccak256(keccak256(data))` in the program.
@@ -87,7 +94,8 @@ Each holds for every instruction of every program the compiler accepts. A break
87
94
 
88
95
  ## Where TypeSOL is stricter than Anchor
89
96
 
90
- - Any two writable parameters naming one account are refused (Anchor compares only program and token accounts).
97
+ - Any two writable parameters naming one account are refused unless one is `Dup<…>` (Anchor compares only program
98
+ and token accounts, and `dup` lifts that).
91
99
  - Arithmetic overflow returns an error code (5100) in every build; Anchor aborts the program, and only when
92
100
  overflow checks are enabled.
93
101
 
package/lib/typesol.d.ts CHANGED
@@ -186,10 +186,43 @@ export declare function keccak256(...parts: (string | Pubkey | Hash | bigint | b
186
186
  */
187
187
  export declare function merkleVerify(leaf: Hash, proof: Vec<Hash>, root: Hash): boolean;
188
188
 
189
- /** The Instructions sysvar (Sysvar1nstructions…). Its address is checked. */
189
+ /**
190
+ * The Instructions sysvar (Sysvar1nstructions…): every instruction of the transaction being executed. Its address is
191
+ * checked. What it holds is the transaction author's: use it to require something of the transaction (a repay
192
+ * instruction after a flash loan, a fee instruction before), never as proof of anything else.
193
+ */
190
194
  export interface InstructionsSysvar extends AccountMeta {
191
195
  readonly __instructions_sysvar: void;
196
+ /** How many instructions the transaction has. */
197
+ readonly length: u64;
198
+ /** The index of the top-level instruction being executed (this one, or the one that CPI'd into this program). */
199
+ currentIndex(): u64;
200
+ /** The transaction's instruction at `index` (5104 when there is none). */
201
+ get(index: u64): TransactionInstruction;
192
202
  }
203
+ /** One instruction of the transaction, read from the Instructions sysvar. Reads past its end fail with 5104. */
204
+ export interface TransactionInstruction {
205
+ readonly __transaction_instruction: void;
206
+ readonly programId: Pubkey;
207
+ readonly accountsLength: u64;
208
+ readonly dataLength: u64;
209
+ accountKey(i: u64): Pubkey;
210
+ accountIsSigner(i: u64): boolean;
211
+ accountIsWritable(i: u64): boolean;
212
+ /** Little-endian integers and keys from the instruction data. */
213
+ readU8(offset: u64): u8;
214
+ readU16(offset: u64): u16;
215
+ readU32(offset: u64): u32;
216
+ readU64(offset: u64): u64;
217
+ readPubkey(offset: u64): Pubkey;
218
+ /** `program`'s instruction `name` (snake_case or camelCase): its program id and 8-byte Anchor discriminator match. */
219
+ is(program: Pubkey, name: string): boolean;
220
+ }
221
+ /**
222
+ * How deep in cross-program calls this program runs: 1 when the transaction calls it directly, more when another
223
+ * program invoked it. `require(stackHeight() === 1n, …)` refuses CPI callers.
224
+ */
225
+ export declare function stackHeight(): u64;
193
226
 
194
227
  /**
195
228
  * True when this transaction contains an Ed25519 precompile instruction that verified `message`
@@ -597,6 +630,27 @@ export type TokenOf<A, MintParam extends string, AuthorityParam extends string =
597
630
  export type MintOf<A, AuthorityParam extends string, FreezeAuthorityParam extends string = never, TokenProgramParam extends string = never> = A & {
598
631
  readonly __mintOf?: [AuthorityParam, FreezeAuthorityParam, TokenProgramParam];
599
632
  };
633
+ /**
634
+ * Anchor's `dup`: this writable account may be the same account as another writable parameter (TypeSOL refuses that
635
+ * otherwise, 2040). Both are read and written in place, so a write through one is seen through the other: read
636
+ * what you need before writing. Not for accounts that are created, closed, a close destination or `@layout borsh`.
637
+ */
638
+ export type Dup<A> = A & { readonly __dup?: true };
639
+ /**
640
+ * Anchor 1.0's `Migration<From, To>`: an existing `From` account (owner, discriminator and size checked) that this
641
+ * instruction rewrites as `To` with `acct.migrate(payer, value)`, a top-level statement every path must reach. Read
642
+ * the old data through `acct.from` before migrating (the new value may use it: it is built in full before the account
643
+ * is rewritten), and the new data through `acct.to` after. The account is resized to `To`: the payer tops up the rent
644
+ * when it grows (at most 10 KiB), and shrinking keeps the lamports. `From` usually keeps the old type's discriminator
645
+ * under a new name: `/** @discriminator account:Counter *\/ interface CounterV1 { … }`. An account already migrated
646
+ * fails with 3002, as under Anchor.
647
+ */
648
+ export interface Migrate<From, To> extends AccountMeta {
649
+ readonly __migrate: [From, To];
650
+ readonly from: Readonly<From>;
651
+ readonly to: To;
652
+ migrate(payer: Mut<Signer>, value: To): void;
653
+ }
600
654
  export type Seeds<A, Names extends readonly string[], Bump extends string = never, ProgramId extends string = never> = A & {
601
655
  readonly __seeds?: [Names, Bump, ProgramId];
602
656
  /** The PDA's bump (Anchor `ctx.bumps.<account>`): the stored bump field when one is named, else the canonical bump found. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@typesol/cli",
3
- "version": "0.26.0",
3
+ "version": "0.27.0",
4
4
  "description": "Compile a TypeScript-syntax language directly to Solana SBF programs.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1121,6 +1121,81 @@ static inline bool ts_merkle_verify(const SolPubkey *leaf, const uint8_t *proof,
1121
1121
  return ts_mem_eq(node, root, 32);
1122
1122
  }
1123
1123
 
1124
+ /*
1125
+ * The Instructions sysvar: u16 count, u16 offsets[count], then per instruction u16 num_accounts,
1126
+ * num_accounts * (u8 flags: bit 0 signer, bit 1 writable; 32-byte key), 32-byte program id, u16 data_len, data; the
1127
+ * last two bytes of the account are the index of the instruction being executed. Everything here is the transaction
1128
+ * author's data: bounds-checked, never trusted for anything but what it says.
1129
+ */
1130
+ typedef struct {
1131
+ const uint8_t *accounts; /* num_accounts * 33 bytes */
1132
+ const SolPubkey *program_id;
1133
+ const uint8_t *data;
1134
+ uint16_t num_accounts;
1135
+ uint16_t data_len;
1136
+ } TsIx;
1137
+
1138
+ /* Instruction `index` of the transaction; false when out of range or malformed. */
1139
+ static inline bool ts_ix_at(const TsAccount *ixs, uint64_t index, TsIx *out) {
1140
+ const uint8_t *d = ixs->data;
1141
+ uint64_t len = ixs->data_len;
1142
+ if (len < 2) return false;
1143
+ uint16_t count;
1144
+ ts_memcpy(&count, d, 2);
1145
+ if (index >= count || 2 + (uint64_t)count * 2 > len) return false;
1146
+ uint16_t at;
1147
+ ts_memcpy(&at, d + 2 + index * 2, 2);
1148
+ uint64_t p = at;
1149
+ if (p + 2 > len) return false;
1150
+ ts_memcpy(&out->num_accounts, d + p, 2);
1151
+ p += 2;
1152
+ out->accounts = d + p;
1153
+ p += (uint64_t)out->num_accounts * 33;
1154
+ if (p + 34 > len) return false;
1155
+ out->program_id = (const SolPubkey *)(d + p);
1156
+ p += 32;
1157
+ ts_memcpy(&out->data_len, d + p, 2);
1158
+ p += 2;
1159
+ if (p + out->data_len > len) return false;
1160
+ out->data = d + p;
1161
+ return true;
1162
+ }
1163
+ static inline uint64_t ts_ix_load(const TsAccount *ixs, uint64_t index, TsIx *out) {
1164
+ if (!ts_ix_at(ixs, index, out)) TS_FAIL(TS_ERR_INDEX, "TypeSOL: no such instruction in the transaction");
1165
+ return SUCCESS;
1166
+ }
1167
+ static inline uint64_t ts_ix_count(const TsAccount *ixs, uint64_t *out) {
1168
+ if (ixs->data_len < 2) TS_FAIL(TS_ERR_INDEX, "TypeSOL: empty instructions sysvar");
1169
+ uint16_t n;
1170
+ ts_memcpy(&n, ixs->data, 2);
1171
+ *out = n;
1172
+ return SUCCESS;
1173
+ }
1174
+ static inline uint64_t ts_ix_current(const TsAccount *ixs, uint64_t *out) {
1175
+ if (ixs->data_len < 4) TS_FAIL(TS_ERR_INDEX, "TypeSOL: empty instructions sysvar");
1176
+ uint16_t i;
1177
+ ts_memcpy(&i, ixs->data + ixs->data_len - 2, 2);
1178
+ *out = i;
1179
+ return SUCCESS;
1180
+ }
1181
+ static inline uint64_t ts_ix_account(const TsIx *ix, uint64_t i, const uint8_t **out) {
1182
+ if (i >= ix->num_accounts) TS_FAIL(TS_ERR_INDEX, "TypeSOL: the instruction has no account at that index");
1183
+ *out = ix->accounts + i * 33;
1184
+ return SUCCESS;
1185
+ }
1186
+ /* `n` bytes of the instruction's data at `off` (little-endian integers, keys). */
1187
+ static inline uint64_t ts_ix_read(const TsIx *ix, uint64_t off, uint64_t n, void *out) {
1188
+ if (off > ix->data_len || n > ix->data_len - off) TS_FAIL(TS_ERR_INDEX, "TypeSOL: read past the end of the instruction's data");
1189
+ ts_memcpy(out, ix->data + off, n);
1190
+ return SUCCESS;
1191
+ }
1192
+ /* Is it `program`'s instruction with this 8-byte discriminator (Anchor's sha256("global:<name>")[..8])? */
1193
+ static inline bool ts_ix_is(const TsIx *ix, const SolPubkey *program, const uint8_t disc[8]) {
1194
+ return ts_key_eq(ix->program_id, program) && ix->data_len >= 8 && ts_mem_eq(ix->data, disc, 8);
1195
+ }
1196
+ /* How deep in CPIs the program runs: 1 when the transaction calls it directly. */
1197
+ extern uint64_t sol_get_stack_height(void);
1198
+
1124
1199
  /*
1125
1200
  * Did an Ed25519 precompile instruction in this transaction verify `message` signed by `signer`?
1126
1201
  * Reads the Instructions sysvar (data: u16 count, u16 offsets[count], then per instruction
@@ -1129,29 +1204,15 @@ static inline bool ts_merkle_verify(const SolPubkey *leaf, const uint8_t *proof,
1129
1204
  * message, with offsets pointing into the precompile's own data (instruction index 0xFFFF).
1130
1205
  */
1131
1206
  static inline bool ts_ed25519_verified(const TsAccount *ixs, const SolPubkey *signer, TS_Str message) {
1132
- const uint8_t *d = ixs->data;
1133
- uint64_t len = ixs->data_len;
1134
- if (len < 2) return false;
1207
+ if (ixs->data_len < 2) return false;
1135
1208
  uint16_t count;
1136
- ts_memcpy(&count, d, 2);
1137
- if (2 + (uint64_t)count * 2 > len) return false;
1209
+ ts_memcpy(&count, ixs->data, 2);
1138
1210
  for (uint16_t i = 0; i < count; i++) {
1139
- uint16_t at;
1140
- ts_memcpy(&at, d + 2 + (uint64_t)i * 2, 2);
1141
- uint64_t p = at;
1142
- if (p + 2 > len) return false;
1143
- uint16_t nacc;
1144
- ts_memcpy(&nacc, d + p, 2);
1145
- p += 2 + (uint64_t)nacc * 33;
1146
- if (p + 34 > len) return false;
1147
- bool is_ed = ts_mem_eq(d + p, &TS_ED25519_PROGRAM_ID, 32);
1148
- p += 32;
1149
- uint16_t dlen;
1150
- ts_memcpy(&dlen, d + p, 2);
1151
- p += 2;
1152
- if (p + dlen > len) return false;
1153
- if (!is_ed || dlen < 2) continue;
1154
- const uint8_t *ix = d + p;
1211
+ TsIx t;
1212
+ if (!ts_ix_at(ixs, i, &t)) return false;
1213
+ if (!ts_key_eq(t.program_id, &TS_ED25519_PROGRAM_ID) || t.data_len < 2) continue;
1214
+ uint16_t dlen = t.data_len;
1215
+ const uint8_t *ix = t.data;
1155
1216
  uint8_t nsig = ix[0];
1156
1217
  for (uint8_t k = 0; k < nsig; k++) {
1157
1218
  uint64_t o = 2 + (uint64_t)k * 14;