@typesol/cli 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/README.md +35 -5
  3. package/dist/cli.js +161 -24
  4. package/dist/compiler/analyze.d.ts +4 -0
  5. package/dist/compiler/analyze.js +51 -9
  6. package/dist/compiler/emit/base.js +1 -1
  7. package/dist/compiler/emit/calls.js +7 -0
  8. package/dist/compiler/emit/cpi.js +188 -9
  9. package/dist/compiler/emit/expressions.d.ts +11 -0
  10. package/dist/compiler/emit/expressions.js +107 -2
  11. package/dist/compiler/emit/function.js +11 -1
  12. package/dist/compiler/emit/shared.js +3 -2
  13. package/dist/compiler/emit/statements.d.ts +13 -0
  14. package/dist/compiler/emit/statements.js +131 -5
  15. package/dist/compiler/emit/storage.d.ts +2 -1
  16. package/dist/compiler/emit/storage.js +24 -7
  17. package/dist/compiler/idl.js +28 -14
  18. package/dist/compiler/model.d.ts +16 -3
  19. package/dist/compiler/toolchain.d.ts +2 -0
  20. package/dist/compiler/toolchain.js +27 -1
  21. package/dist/idlpublish.js +43 -9
  22. package/dist/onchain.d.ts +30 -0
  23. package/dist/onchain.js +53 -0
  24. package/dist/portgen.d.ts +87 -0
  25. package/dist/portgen.js +311 -0
  26. package/dist/templates.d.ts +9 -0
  27. package/dist/templates.js +324 -0
  28. package/dist/testing.d.ts +66 -0
  29. package/dist/testing.js +337 -0
  30. package/dist/workspace.d.ts +3 -2
  31. package/dist/workspace.js +18 -106
  32. package/docs/architecture.md +3 -0
  33. package/docs/cli.md +17 -3
  34. package/docs/language.md +11 -10
  35. package/docs/mainnet.md +100 -0
  36. package/docs/migrate-from-anchor.md +104 -0
  37. package/docs/test-your-program.md +52 -0
  38. package/docs/testing.md +1 -1
  39. package/docs/troubleshooting.md +10 -2
  40. package/lib/typesol.d.ts +68 -12
  41. package/package.json +19 -1
  42. package/runtime/typesol_rt.h +25 -5
  43. package/ts-plugin/index.js +112 -0
  44. package/ts-plugin/package.json +4 -0
@@ -50,6 +50,8 @@ export class StatementEmitter extends CpiEmitter {
50
50
  return this.switchStatement(stmt);
51
51
  if (ts.isForStatement(stmt))
52
52
  return this.forLoop(stmt);
53
+ if (ts.isForOfStatement(stmt))
54
+ return this.forOf(stmt);
53
55
  if (ts.isWhileStatement(stmt))
54
56
  return this.loop(stmt.expression, stmt.statement, undefined);
55
57
  if (ts.isBreakStatement(stmt) || ts.isContinueStatement(stmt)) {
@@ -63,8 +65,8 @@ export class StatementEmitter extends CpiEmitter {
63
65
  this.line(ts.isBreakStatement(stmt) ? `break;` : `goto ${this.loops.at(-1)};`);
64
66
  return;
65
67
  }
66
- if (ts.isDoStatement(stmt) || ts.isForOfStatement(stmt) || ts.isForInStatement(stmt))
67
- this.fail(stmt, "only for (init; cond; step) and while loops are supported");
68
+ if (ts.isDoStatement(stmt) || ts.isForInStatement(stmt))
69
+ this.fail(stmt, "only for (init; cond; step), for (const x of list) and while loops are supported");
68
70
  this.fail(stmt, `${ts.SyntaxKind[stmt.kind]} is not supported in TypeSOL`);
69
71
  }
70
72
  returnStatement(stmt) {
@@ -77,6 +79,25 @@ export class StatementEmitter extends CpiEmitter {
77
79
  this.line(`return SUCCESS;`);
78
80
  return;
79
81
  }
82
+ if (returns.kind === "struct") {
83
+ if (!stmt.expression)
84
+ this.fail(stmt, `return a ${returns.name}`);
85
+ if (this.helper) {
86
+ // Write the struct through the caller's out pointer (a literal, a place or another helper's result).
87
+ this.writeAggregate({ lv: "(*ret)", type: returns, writable: true }, stmt.expression);
88
+ this.line(`return SUCCESS;`);
89
+ return;
90
+ }
91
+ // Return data: the struct's fixed layout, which the IDL describes field by field.
92
+ const t = this.tmp("ret");
93
+ this.line(`TSS_${returns.name} ${t};`);
94
+ this.writeAggregate({ lv: t, type: returns, writable: true }, stmt.expression);
95
+ this.line(`sol_set_return_data((const uint8_t *)&${t}, sizeof(${t}));`);
96
+ for (const l of this.epilogue())
97
+ this.line(l);
98
+ this.line(`return SUCCESS;`);
99
+ return;
100
+ }
80
101
  if (!stmt.expression)
81
102
  this.fail(stmt, `return a ${typeName(returns)}`);
82
103
  const v = this.expect(this.expr(stmt.expression, returns), returns, stmt.expression);
@@ -170,6 +191,65 @@ export class StatementEmitter extends CpiEmitter {
170
191
  });
171
192
  this.line(`}`);
172
193
  }
194
+ /**
195
+ * `for (const x of list)` over a Vec or FixedArray: an index loop that binds `x` as `const x = list.get(i)` would,
196
+ * so elements are values (scalars, strings) or references into the data (structs), with the same aliasing rules.
197
+ * As in JS, the length is read again before every step, so a body that pushes or pops sees the new length.
198
+ */
199
+ forOf(stmt) {
200
+ if (stmt.awaitModifier)
201
+ this.fail(stmt, "for await is not supported");
202
+ const list = stmt.initializer;
203
+ const d = ts.isVariableDeclarationList(list) && list.declarations.length === 1 ? list.declarations[0] : undefined;
204
+ if (!d || !(list.flags & ts.NodeFlags.Const) || !ts.isIdentifier(d.name) || d.type)
205
+ this.fail(stmt.initializer, "write for (const x of list): one const name, no type annotation");
206
+ if (this.isRemaining(stmt.expression))
207
+ this.fail(stmt.expression, "iterate remaining accounts with for (let i = 0n; i < rest.length; i++) and rest.get(i)");
208
+ // Synthetic `list.length` / `list.get(i)` nodes placed at the loop, so errors and checks point at the source.
209
+ const f = ts.factory;
210
+ const index = `__of${this.temp++}`;
211
+ const place = (n) => {
212
+ ts.setTextRange(n, stmt.expression);
213
+ n.parent = stmt;
214
+ return n;
215
+ };
216
+ const indexId = place(f.createIdentifier(index));
217
+ const length = place(f.createPropertyAccessExpression(stmt.expression, "length"));
218
+ const get = place(f.createCallExpression(place(f.createPropertyAccessExpression(stmt.expression, "get")), undefined, [indexId]));
219
+ // The element's declaration: the source's own name (so aliasing checks find its uses) bound to list.get(i).
220
+ const decl = f.createVariableDeclaration(d.name, undefined, undefined, get);
221
+ ts.setTextRange(decl, d);
222
+ decl.parent = list;
223
+ const declList = { ...list, flags: ts.NodeFlags.Const, declarations: [decl] };
224
+ const ci = this.tmp("of_i");
225
+ const label = this.tmp("ts_continue_");
226
+ this.line(`{`);
227
+ this.block(() => {
228
+ this.line(`uint64_t ${ci} = 0;`);
229
+ this.scopes.at(-1).set(index, { c: ci, type: U64, mutable: false });
230
+ this.line(`while (1) {`);
231
+ this.block(() => {
232
+ const n = this.expect(this.expr(length, U64), U64, stmt.expression);
233
+ this.line(`if (!(${ci} < ${n.c})) break;`);
234
+ this.variable(declList);
235
+ this.loops.push(label);
236
+ this.line(`{`);
237
+ this.block(() => this.body(stmt.statement));
238
+ this.line(`}`);
239
+ this.loops.pop();
240
+ this.line(`${label}:;`);
241
+ this.line(`${ci}++;`);
242
+ });
243
+ this.line(`}`);
244
+ });
245
+ this.line(`}`);
246
+ }
247
+ /** Whether `e` is the remaining accounts (or a const bound to them). */
248
+ isRemaining(e) {
249
+ if (ts.isCallExpression(e) && this.exportName(e.expression) === "remainingAccounts")
250
+ return true;
251
+ return ts.isIdentifier(e) && this.lookup(e.text)?.type.kind === "remaining";
252
+ }
173
253
  /**
174
254
  * `while (1) { cond; if (!cond) break; body; continue_label: step }`. The condition is lowered
175
255
  * inside the loop so its checked-arithmetic temporaries are recomputed every iteration.
@@ -204,6 +284,10 @@ export class StatementEmitter extends CpiEmitter {
204
284
  if (!(list.flags & (ts.NodeFlags.Const | ts.NodeFlags.Let)))
205
285
  this.fail(list, "use const or let, not var");
206
286
  for (const d of list.declarations) {
287
+ if (ts.isObjectBindingPattern(d.name) && d.initializer) {
288
+ this.destructure(list, d, d.name, d.initializer, topLevel);
289
+ continue;
290
+ }
207
291
  if (!ts.isIdentifier(d.name) || !d.initializer)
208
292
  this.fail(d, "declare one plain name with an initializer");
209
293
  const name = d.name.text;
@@ -312,6 +396,39 @@ export class StatementEmitter extends CpiEmitter {
312
396
  this.scopes.at(-1).set(name, { c, type, mutable });
313
397
  }
314
398
  }
399
+ /**
400
+ * `const { a, b: c } = s`: one declaration per name, `const a = s.a`, in source order. The source must be a name or
401
+ * a field path (no calls), so reading it once per name reads the same thing JS reads once.
402
+ */
403
+ destructure(list, d, pattern, init, topLevel) {
404
+ if (d.type)
405
+ this.fail(d.type, "destructuring takes the types from the source; drop the annotation");
406
+ const plain = (e) => ts.isIdentifier(e) ||
407
+ (ts.isPropertyAccessExpression(e) && !e.questionDotToken && plain(e.expression)) ||
408
+ (ts.isParenthesizedExpression(e) && plain(e.expression));
409
+ if (!plain(init))
410
+ this.fail(init, "destructure a variable or a field (a.b.c); bind a call to a const first");
411
+ const f = ts.factory;
412
+ for (const el of pattern.elements) {
413
+ if (el.dotDotDotToken)
414
+ this.fail(el, "...rest is not supported in destructuring");
415
+ if (el.initializer)
416
+ this.fail(el, "default values are not supported in destructuring");
417
+ if (!ts.isIdentifier(el.name))
418
+ this.fail(el, "nested destructuring is not supported; destructure in two steps");
419
+ const prop = el.propertyName ?? el.name;
420
+ if (!ts.isIdentifier(prop))
421
+ this.fail(el, "use plain field names");
422
+ const access = f.createPropertyAccessExpression(init, prop.text);
423
+ ts.setTextRange(access, el);
424
+ access.parent = d;
425
+ // The element's own name node, so aliasing checks and the type checker find its uses.
426
+ const decl = f.createVariableDeclaration(el.name, undefined, undefined, access);
427
+ ts.setTextRange(decl, el);
428
+ decl.parent = list;
429
+ this.variable({ ...list, declarations: [decl] }, topLevel);
430
+ }
431
+ }
315
432
  expressionStatement(e, topLevel) {
316
433
  if (ts.isBinaryExpression(e)) {
317
434
  const op = e.operatorToken.kind;
@@ -332,6 +449,8 @@ export class StatementEmitter extends CpiEmitter {
332
449
  const base = compound[op];
333
450
  if (base !== undefined)
334
451
  return this.assign(e.left, e.right, base);
452
+ if (op === ts.SyntaxKind.AsteriskAsteriskEqualsToken)
453
+ this.fail(e.operatorToken, "write x = x ** n instead of **=");
335
454
  }
336
455
  if ((ts.isPostfixUnaryExpression(e) || ts.isPrefixUnaryExpression(e)) &&
337
456
  (e.operator === ts.SyntaxKind.PlusPlusToken || e.operator === ts.SyntaxKind.MinusMinusToken)) {
@@ -384,8 +503,14 @@ export class StatementEmitter extends CpiEmitter {
384
503
  let raw = createsFirst ? this.call(valueNode, topLevel) : undefined;
385
504
  if (createsFirst && !raw)
386
505
  this.fail(valueNode, "this call does not produce a value");
506
+ // A helper's struct result is copied like any struct (the target first, as JS evaluates it first).
507
+ const structCall = isCall && ts.isIdentifier(valueNode.expression)
508
+ ? this.model.helpers.get(valueNode.expression.text)?.returns?.kind === "struct"
509
+ : false;
510
+ if (structCall && this.writesThroughVec(target) && this.helperTakesMutAccounts(valueNode))
511
+ this.fail(target, "the right side calls a helper that may change this Vec; store its result in a local first");
387
512
  // Whole arrays / structs: copy between identical layouts.
388
- const targetRef = isCall ? undefined : this.ref(target);
513
+ const targetRef = isCall && !structCall ? undefined : this.ref(target);
389
514
  if (targetRef &&
390
515
  (targetRef.type.kind === "array" ||
391
516
  targetRef.type.kind === "struct" ||
@@ -407,8 +532,9 @@ export class StatementEmitter extends CpiEmitter {
407
532
  this.fail(valueNode, "this call does not produce a value");
408
533
  }
409
534
  let value = this.expect(raw ?? this.expr(valueNode, place.type), place.type, valueNode);
410
- if (ts.isIdentifier(target))
411
- value = this.ownedString(value); // a local keeps its own copy of account text
535
+ // A local, or a string field of a variable enum (a view), keeps its own copy of account text.
536
+ if (ts.isIdentifier(target) || (value.type.kind === "string" && this.peekKind(target) === "string"))
537
+ value = this.ownedString(value);
412
538
  this.line(place.write(value.c) + ";");
413
539
  }
414
540
  /** Whether a place goes through a Vec element (`v.get(i).x`, `v.get(i)`). */
@@ -1,5 +1,5 @@
1
1
  import ts from "typescript";
2
- import { type FieldType, type ValueType } from "../model.js";
2
+ import { type FieldType, type Helper, type ValueType } from "../model.js";
3
3
  import { type Value, type Ref } from "./shared.js";
4
4
  import { EmitterBase } from "./base.js";
5
5
  /**
@@ -7,6 +7,7 @@ import { EmitterBase } from "./base.js";
7
7
  * argument validation.
8
8
  */
9
9
  export declare abstract class StorageEmitter extends EmitterBase {
10
+ protected abstract callHelper(node: ts.CallExpression, h: Helper): Value | undefined;
10
11
  /** C lines rejecting malformed fixed-layout data at `lv`: bools, enums, option tags, string lengths, enum tags. */
11
12
  protected validation(lv: string, t: FieldType, pad: string, fail?: string): string[];
12
13
  /** The variants TypeScript has narrowed a data-enum expression to (e.g. inside `if (a.kind === "X")`). */
@@ -73,12 +73,13 @@ export class StorageEmitter extends EmitterBase {
73
73
  return r?.type.kind === "dataEnum" ? r : undefined;
74
74
  }
75
75
  looksLikeDataEnum(node) {
76
- const saved = { lines: this.lines.length, temp: this.temp, diagnostics: this.reporter.diagnostics.length };
76
+ const saved = { lines: this.lines.length, hoisted: this.hoisted.length, temp: this.temp, diagnostics: this.reporter.diagnostics.length };
77
77
  try {
78
78
  return this.reporter.guard(() => this.ref(node))?.type.kind === "dataEnum";
79
79
  }
80
80
  finally {
81
81
  this.lines.length = saved.lines;
82
+ this.hoisted.length = saved.hoisted; // a probed helper call may hoist declarations
82
83
  this.temp = saved.temp;
83
84
  this.reporter.diagnostics.length = saved.diagnostics;
84
85
  }
@@ -144,7 +145,7 @@ export class StorageEmitter extends EmitterBase {
144
145
  // Loops around `at` that do not also contain the declaration run their earlier statements again later.
145
146
  let loop;
146
147
  for (let n = at.parent; n && n !== decl.parent; n = n.parent)
147
- if ((ts.isForStatement(n) || ts.isWhileStatement(n) || ts.isDoStatement(n)) && !(decl.pos >= n.pos && decl.end <= n.end))
148
+ if ((ts.isForStatement(n) || ts.isForOfStatement(n) || ts.isWhileStatement(n) || ts.isDoStatement(n)) && !(decl.pos >= n.pos && decl.end <= n.end))
148
149
  loop = n;
149
150
  let found = false;
150
151
  const scope = decl.parent.parent.parent; // VariableDeclaration -> List -> Statement -> enclosing block
@@ -207,13 +208,14 @@ export class StorageEmitter extends EmitterBase {
207
208
  return this.looksLikeStructPlace(node.expression);
208
209
  if (ts.isConditionalExpression(node))
209
210
  return this.looksLikeStructPlace(node.whenTrue) && this.looksLikeStructPlace(node.whenFalse);
210
- const saved = { lines: this.lines.length, temp: this.temp, diagnostics: this.reporter.diagnostics.length };
211
+ const saved = { lines: this.lines.length, hoisted: this.hoisted.length, temp: this.temp, diagnostics: this.reporter.diagnostics.length };
211
212
  try {
212
213
  const r = this.reporter.guard(() => this.ref(node));
213
214
  return r?.type.kind === "struct";
214
215
  }
215
216
  finally {
216
217
  this.lines.length = saved.lines;
218
+ this.hoisted.length = saved.hoisted; // a probed helper call may hoist declarations
217
219
  this.temp = saved.temp;
218
220
  this.reporter.diagnostics.length = saved.diagnostics;
219
221
  }
@@ -241,6 +243,14 @@ export class StorageEmitter extends EmitterBase {
241
243
  if (ts.isParenthesizedExpression(node))
242
244
  return this.ref(node.expression);
243
245
  this.noOptionalChain(node);
246
+ // A helper's struct result: a local of the caller's, read and copied like any struct.
247
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
248
+ const h = this.model.helpers.get(node.expression.text);
249
+ if (h?.returns?.kind === "struct" && !this.lookup(node.expression.text)) {
250
+ const v = this.callHelper(node, h);
251
+ return { lv: `(*${v.c})`, type: { kind: "struct", name: h.returns.name }, writable: true };
252
+ }
253
+ }
244
254
  if (ts.isIdentifier(node)) {
245
255
  const l = this.lookup(node.text);
246
256
  if (l && l.type.kind === "array")
@@ -284,7 +294,9 @@ export class StorageEmitter extends EmitterBase {
284
294
  const vi = e.variants.findIndex((v) => v.name === n);
285
295
  const fields = e.variants[vi]?.fields ?? [];
286
296
  const at = fields.findIndex((x) => x.name === field);
287
- const offset = fields.slice(0, Math.max(at, 0)).reduce((sum, x) => sum + this.fieldBytes(x.type), 0);
297
+ // In the C payload a string is a TS_Str view (16 bytes); everything else has its stored size.
298
+ const cBytes = (t) => (t.kind === "string" ? 16 : this.fieldBytes(t));
299
+ const offset = fields.slice(0, Math.max(at, 0)).reduce((sum, x) => sum + cBytes(x.type), 0);
288
300
  return { vi, name: n, f: fields[at], offset };
289
301
  });
290
302
  if (located.some((l) => !l.f))
@@ -412,6 +424,8 @@ export class StorageEmitter extends EmitterBase {
412
424
  return { c: `(&${r.lv})`, type: r.type };
413
425
  case "fstring":
414
426
  return { c: `ts_fstr_view((const uint8_t *)&${r.lv}, ${r.type.cap})`, type: STRING };
427
+ case "string":
428
+ return { c: r.lv, type: STRING }; // a variable enum's text: already a view
415
429
  case "enum":
416
430
  return { c: r.lv, type: r.type };
417
431
  case "option":
@@ -439,6 +453,7 @@ export class StorageEmitter extends EmitterBase {
439
453
  return `${r.lv} = *(${c})`;
440
454
  case "fstring":
441
455
  return `TS_TRY(ts_fstr_set((uint8_t *)&${r.lv}, ${r.type.cap}, ${c}))`;
456
+ case "string":
442
457
  case "enum":
443
458
  case "option":
444
459
  return `${r.lv} = ${c}`;
@@ -449,7 +464,7 @@ export class StorageEmitter extends EmitterBase {
449
464
  storageValueType(t) {
450
465
  if (isScalar(t))
451
466
  return t;
452
- if (t.kind === "fstring")
467
+ if (t.kind === "fstring" || t.kind === "string")
453
468
  return STRING;
454
469
  if (t.kind === "option" && t.inner.kind !== "struct")
455
470
  return t;
@@ -510,8 +525,9 @@ export class StorageEmitter extends EmitterBase {
510
525
  const f = variant.fields.find((x) => x.name === name);
511
526
  const sub = { lv: `${tmp}.v.${variant.name}.${f.name}`, type: f.type, writable: true };
512
527
  const vt = this.storageValueType(f.type);
528
+ // A string kept in a variable enum holds its own copy of account text (the account may change later).
513
529
  if (vt)
514
- this.line(this.writeStorage(sub, this.expect(this.expr(ex, vt), vt, ex).c) + ";");
530
+ this.line(this.writeStorage(sub, this.ownedString(this.expect(this.expr(ex, vt), vt, ex)).c) + ";");
515
531
  else
516
532
  this.writeAggregate(sub, ex);
517
533
  }
@@ -570,12 +586,13 @@ export class StorageEmitter extends EmitterBase {
570
586
  }
571
587
  /** The kind of storage an expression names (struct, array, dataEnum…), without emitting code or failing. */
572
588
  peekKind(node) {
573
- const saved = { lines: this.lines.length, temp: this.temp, diagnostics: this.reporter.diagnostics.length };
589
+ const saved = { lines: this.lines.length, hoisted: this.hoisted.length, temp: this.temp, diagnostics: this.reporter.diagnostics.length };
574
590
  try {
575
591
  return this.reporter.guard(() => this.ref(node))?.type.kind;
576
592
  }
577
593
  finally {
578
594
  this.lines.length = saved.lines;
595
+ this.hoisted.length = saved.hoisted; // a probed helper call may hoist declarations
579
596
  this.temp = saved.temp;
580
597
  this.reporter.diagnostics.length = saved.diagnostics;
581
598
  }
@@ -153,28 +153,42 @@ export function buildIdl(model, address, version = "0.1.0") {
153
153
  },
154
154
  })),
155
155
  ...[...model.enums.values()].map((e) => ({ name: e.name, type: { kind: "enum", variants: e.variants.map((v) => ({ name: v })) } })),
156
+ // A variable enum (it holds strings) only exists in Borsh: no fixed account-data form to describe.
156
157
  ...[...model.dataEnums.values()].flatMap((e) => [
157
158
  {
158
159
  name: e.name,
159
160
  ...docsOf(e.node),
160
161
  type: {
161
162
  kind: "enum",
162
- variants: e.variants.map((v) => v.fields.length ? { name: v.name, fields: v.fields.map((f) => ({ name: snakeCase(f.name), type: idlType(f.type) })) } : { name: v.name }),
163
- },
164
- },
165
- {
166
- name: fixedEnumName(e.name),
167
- docs: [
168
- `TypeSOL ${e.name} in account data: \`tag\` is the variant index, \`payload\` the variant's fields (Borsh layout) padded to the largest variant.`,
169
- ],
170
- type: {
171
- kind: "struct",
172
- fields: [
173
- { name: "tag", type: "u8" },
174
- { name: "payload", type: { array: ["u8", e.payload] } },
175
- ],
163
+ variants: e.variants.map((v) => v.fields.length
164
+ ? {
165
+ name: v.name,
166
+ // A nested variable enum only exists in Borsh; fixed fields keep the fixed forms the bytes use.
167
+ fields: v.fields.map((f) => ({
168
+ name: snakeCase(f.name),
169
+ type: f.type.kind === "dataEnum" && model.dataEnums.get(f.type.name)?.variable ? { defined: { name: f.type.name } } : idlType(f.type),
170
+ })),
171
+ }
172
+ : { name: v.name }),
176
173
  },
177
174
  },
175
+ ...(e.variable
176
+ ? []
177
+ : [
178
+ {
179
+ name: fixedEnumName(e.name),
180
+ docs: [
181
+ `TypeSOL ${e.name} in account data: \`tag\` is the variant index, \`payload\` the variant's fields (Borsh layout) padded to the largest variant.`,
182
+ ],
183
+ type: {
184
+ kind: "struct",
185
+ fields: [
186
+ { name: "tag", type: "u8" },
187
+ { name: "payload", type: { array: ["u8", e.payload] } },
188
+ ],
189
+ },
190
+ },
191
+ ]),
178
192
  ]),
179
193
  ...[...model.options.values()].map((inner) => ({
180
194
  name: fixedOptionName(inner),
@@ -278,7 +278,11 @@ export interface Instruction {
278
278
  args: ArgParam[];
279
279
  /** Fixed bytes of the arguments; strings add their length on top. */
280
280
  minArgsSize: number;
281
- returns: ScalarType | undefined;
281
+ /** Sent as return data: a scalar in Borsh, or a fixed-size struct in its fixed layout (as the IDL describes it). */
282
+ returns: ScalarType | {
283
+ kind: "struct";
284
+ name: string;
285
+ } | undefined;
282
286
  /** account name -> seeds, recorded when the body creates or verifies a PDA with describable seeds. */
283
287
  pdas: Map<string, IdlSeed[]>;
284
288
  /** account name -> the program its PDA is derived under, when not this program (e.g. associated token accounts). */
@@ -294,11 +298,15 @@ export interface HelperParam {
294
298
  account?: ParamKind;
295
299
  node: ts.ParameterDeclaration;
296
300
  }
297
- /** A non-exported function (or any function of an imported file): values and accounts in, scalar or void out. */
301
+ /** A non-exported function (or any function of an imported file): values and accounts in; void, a scalar or a struct out. */
298
302
  export interface Helper {
299
303
  name: string;
300
304
  params: HelperParam[];
301
- returns: ScalarType | undefined;
305
+ /** A struct result is written through an out pointer (`TSS_<name> *ret`) that the caller provides. */
306
+ returns: ScalarType | {
307
+ kind: "struct";
308
+ name: string;
309
+ } | undefined;
302
310
  node: ts.FunctionDeclaration;
303
311
  /** The declaring file's `typesol` imports (local name -> export name). */
304
312
  imports: Map<string, string>;
@@ -322,6 +330,11 @@ export interface DataEnumDef {
322
330
  }[];
323
331
  /** Largest variant size: the fixed payload in account data. */
324
332
  payload: number;
333
+ /**
334
+ * A variant holds a `string` (directly or in a nested enum): such an enum is Borsh in instruction arguments and
335
+ * events and a local value otherwise, never account data or part of a fixed layout. Its C payload holds TS_Str views.
336
+ */
337
+ variable?: boolean;
325
338
  node: ts.TypeAliasDeclaration;
326
339
  }
327
340
  /** A unit-variant enum: `export enum Status { Open, Closed }`. */
@@ -14,6 +14,8 @@ export interface Toolchain {
14
14
  /** Locate the Solana C SDK that ships with the Agave CLI's platform-tools. */
15
15
  export declare function findToolchain(sbpf?: Sbpf): Toolchain;
16
16
  /** Compile generated C to an SBF shared object (same flags as the SDK's sbf.mk for the chosen SBPF version). */
17
+ /** The SBF stack frame of one function. */
18
+ export declare const STACK_FRAME = 4096;
17
19
  export declare function buildSharedObject(cFile: string, outSo: string, sbpf?: Sbpf, tc?: Toolchain): void;
18
20
  /** The clang version string, for build manifests. */
19
21
  export declare function toolchainVersion(tc?: Toolchain): string;
@@ -51,6 +51,8 @@ export function findToolchain(sbpf = "v0") {
51
51
  };
52
52
  }
53
53
  /** Compile generated C to an SBF shared object (same flags as the SDK's sbf.mk for the chosen SBPF version). */
54
+ /** The SBF stack frame of one function. */
55
+ export const STACK_FRAME = 4096;
54
56
  export function buildSharedObject(cFile, outSo, sbpf = "v0", tc = findToolchain(sbpf)) {
55
57
  const obj = outSo.replace(/\.so$/, ".o");
56
58
  const cflags = [
@@ -58,6 +60,9 @@ export function buildSharedObject(cFile, outSo, sbpf = "v0", tc = findToolchain(
58
60
  "-Wno-parentheses-equality",
59
61
  // Generated checks may be always true/false (`a === a`, `enumByte >= 256` for a 256-variant enum); that is not an error.
60
62
  "-Wno-tautological-compare",
63
+ // SBPF v0 gives every function a fixed 4 KiB stack frame; past it the program corrupts memory instead of failing.
64
+ // v1 and v2 grow frames dynamically, so the limit does not apply there.
65
+ ...(sbpf === "v0" ? [`-Wframe-larger-than=${STACK_FRAME}`] : []),
61
66
  "-O2",
62
67
  "-fno-builtin",
63
68
  // One section per function and constant, so the linker's --gc-sections can drop what nothing reaches.
@@ -70,7 +75,12 @@ export function buildSharedObject(cFile, outSo, sbpf = "v0", tc = findToolchain(
70
75
  ...(sbpf === "v0" ? [] : [`-mcpu=${sbpf}`]),
71
76
  ];
72
77
  const incs = tc.includes.flatMap((i, n) => (n < 2 ? ["-isystem", i] : ["-I", i]));
73
- run(tc.clang, [...cflags, ...incs, "-o", obj, "-c", cFile]);
78
+ try {
79
+ run(tc.clang, [...cflags, ...incs, "-o", obj, "-c", cFile]);
80
+ }
81
+ catch (e) {
82
+ throw stackError(e.message) ?? e;
83
+ }
74
84
  run(tc.lld, [
75
85
  "-z",
76
86
  "notext",
@@ -99,6 +109,22 @@ export function buildSharedObject(cFile, outSo, sbpf = "v0", tc = findToolchain(
99
109
  export function toolchainVersion(tc = findToolchain()) {
100
110
  return execFileSync(tc.clang, ["--version"], { encoding: "utf8" }).split("\n")[0].trim();
101
111
  }
112
+ /** clang's frame-size errors, in the program's terms: which instruction or helper, how much, what to do. */
113
+ function stackError(message) {
114
+ const over = [...message.matchAll(/stack frame size \((\d+)\) exceeds limit \((\d+)\) in '(\w+)'/g)];
115
+ if (!over.length)
116
+ return undefined;
117
+ // clang inlines helpers into their callers, so an instruction's frame includes theirs.
118
+ const what = (fn) => fn.startsWith("ix_") ? `instruction ${fn.slice(3)} (with the helpers inlined into it)` : fn.startsWith("fn_") ? `helper ${fn.slice(3)}()` : fn;
119
+ // Anything else clang reported in the same run stays in the message.
120
+ const rest = message
121
+ .split("\n")
122
+ .filter((l) => / error: /.test(l) && !/-Wframe-larger-than/.test(l))
123
+ .join("\n");
124
+ return new Error(over.map((m) => `${what(m[3])} needs ${m[1]} bytes of stack; SBPF v0 gives each function ${m[2]}`).join("\n") +
125
+ "\nKeep large structs and arrays in account data: bind them with const (a reference) instead of a typed local copy, or split the work into helpers." +
126
+ (rest ? `\n${rest}` : ""));
127
+ }
102
128
  function run(cmd, args) {
103
129
  try {
104
130
  execFileSync(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
@@ -62,17 +62,51 @@ export async function publishIdl(chain, opts) {
62
62
  seeds: [getAddressEncoder().encode(opts.program)],
63
63
  }))[0]
64
64
  : undefined;
65
- await writeMetadata(client, {
66
- payer: opts.payer,
67
- authority,
68
- program: opts.program,
69
- ...(programData ? { programData } : {}),
70
- seed: "idl",
71
- format: Format.Json,
72
- ...packed,
73
- });
65
+ try {
66
+ await writeMetadata(client, {
67
+ payer: opts.payer,
68
+ authority,
69
+ program: opts.program,
70
+ ...(programData ? { programData } : {}),
71
+ seed: "idl",
72
+ format: Format.Json,
73
+ ...packed,
74
+ });
75
+ }
76
+ catch (e) {
77
+ throw new Error(planFailure(e));
78
+ }
74
79
  return metadataAddress(opts.program, canonical ? null : authority.address);
75
80
  }
81
+ /**
82
+ * The transaction planner reports a failed write as "The provided transaction plan failed to execute" and keeps the
83
+ * reason inside: name the first failed transaction's own error instead.
84
+ */
85
+ function planFailure(e) {
86
+ const seen = new Set();
87
+ const messages = [];
88
+ let logs = [];
89
+ const walk = (v, depth) => {
90
+ if (!v || typeof v !== "object" || seen.has(v) || depth > 8)
91
+ return;
92
+ seen.add(v);
93
+ const o = v;
94
+ if (Array.isArray(o.logs) && !logs.length)
95
+ logs = o.logs.map(String);
96
+ if (v instanceof Error && v.message && !/transaction plan failed to execute/.test(v.message))
97
+ messages.push(v.message);
98
+ for (const k of ["transactionPlanResult", "plans", "error", "cause", "context"]) {
99
+ const c = o[k];
100
+ if (Array.isArray(c))
101
+ c.forEach((x) => walk(x, depth + 1));
102
+ else
103
+ walk(c, depth + 1);
104
+ }
105
+ };
106
+ walk(e, 0);
107
+ const tail = logs.length ? `\n ${logs.slice(-6).join("\n ")}` : "";
108
+ return messages.length ? `IDL write failed: ${messages[0]}${tail}` : e instanceof Error ? e.message : String(e);
109
+ }
76
110
  export async function metadataAddress(program, authority) {
77
111
  return (await findMetadataPda({ program, authority, seed: "idl" }))[0];
78
112
  }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * A deployed program read straight from a cluster, for `typesol verify --address`: its ELF bytes and who can still
3
+ * change them. Handles the three loaders a program can live under:
4
+ *
5
+ * - upgradeable (loader v3, what `solana program deploy` uses): the program account points at a ProgramData account
6
+ * holding the deploy slot, the upgrade authority (none once frozen) and then the ELF;
7
+ * - loader v4: one account holding the slot, the authority, the status and then the ELF;
8
+ * - the old non-upgradeable loader (v2): the account data is the ELF itself.
9
+ */
10
+ import type { Rpc, SolanaRpcApi } from "@solana/kit";
11
+ export declare const LOADER_V2 = "BPFLoader2111111111111111111111111111111111";
12
+ export declare const LOADER_V3 = "BPFLoaderUpgradeab1e11111111111111111111111";
13
+ export declare const LOADER_V4 = "LoaderV411111111111111111111111111111111111";
14
+ export interface Deployed {
15
+ loader: "v2" | "v3" | "v4";
16
+ /** The ELF as stored (loader v3 keeps it zero-padded to the account's allocation). */
17
+ elf: Buffer;
18
+ /** Who can upgrade or close it; null when the program is immutable. */
19
+ authority: string | null;
20
+ /** The slot of the last deploy or upgrade (v3, v4). */
21
+ slot?: bigint;
22
+ /** Where the bytes live (the ProgramData account for v3). */
23
+ dataAccount: string;
24
+ }
25
+ type AccountRpc = Pick<Rpc<SolanaRpcApi>, "getAccountInfo">;
26
+ /** The deployed program at `programId`, or an error that says why there is none. */
27
+ export declare function fetchDeployed(rpc: AccountRpc, programId: string): Promise<Deployed>;
28
+ /** JSON with object keys sorted, so two IDLs compare by content and not by key order. */
29
+ export declare function canonicalJson(v: unknown): string;
30
+ export {};
@@ -0,0 +1,53 @@
1
+ import { getAddressDecoder } from "@solana/kit";
2
+ export const LOADER_V2 = "BPFLoader2111111111111111111111111111111111";
3
+ export const LOADER_V3 = "BPFLoaderUpgradeab1e11111111111111111111111";
4
+ export const LOADER_V4 = "LoaderV411111111111111111111111111111111111";
5
+ async function account(rpc, address) {
6
+ const { value } = await rpc.getAccountInfo(address, { encoding: "base64", commitment: "confirmed" }).send();
7
+ if (!value)
8
+ return null;
9
+ return { owner: value.owner, data: Buffer.from(value.data[0], "base64") };
10
+ }
11
+ const key = (data, offset) => getAddressDecoder().decode(data.subarray(offset, offset + 32));
12
+ /** The deployed program at `programId`, or an error that says why there is none. */
13
+ export async function fetchDeployed(rpc, programId) {
14
+ const program = await account(rpc, programId);
15
+ if (!program)
16
+ throw new Error(`no account at ${programId} on this cluster`);
17
+ if (program.owner === LOADER_V2)
18
+ return { loader: "v2", elf: program.data, authority: null, dataAccount: programId };
19
+ if (program.owner === LOADER_V4) {
20
+ // u64 slot, authority (or next version), u64 status (0 retracted, 1 deployed, 2 finalized), then the ELF.
21
+ if (program.data.length < 48)
22
+ throw new Error(`${programId} is not a loader-v4 program`);
23
+ const status = program.data.readBigUInt64LE(40);
24
+ if (status === 0n)
25
+ throw new Error(`${programId} is retracted (loader v4): it has no executable code`);
26
+ return {
27
+ loader: "v4",
28
+ elf: program.data.subarray(48),
29
+ authority: status === 2n ? null : key(program.data, 8),
30
+ slot: program.data.readBigUInt64LE(0),
31
+ dataAccount: programId,
32
+ };
33
+ }
34
+ if (program.owner !== LOADER_V3)
35
+ throw new Error(`${programId} is not a program (owner ${program.owner})`);
36
+ // Program account: u32 state = 2, ProgramData address.
37
+ if (program.data.length < 36 || program.data.readUInt32LE(0) !== 2)
38
+ throw new Error(`${programId} is an upgradeable-loader account but not a program`);
39
+ const dataAccount = key(program.data, 4);
40
+ const pd = await account(rpc, dataAccount);
41
+ // ProgramData: u32 state = 3, u64 slot, Option<Pubkey> authority (1 + 32 bytes), then the ELF.
42
+ if (!pd || pd.owner !== LOADER_V3 || pd.data.length < 45 || pd.data.readUInt32LE(0) !== 3)
43
+ throw new Error(`${programId} has no program data (closed or never deployed)`);
44
+ return { loader: "v3", elf: pd.data.subarray(45), authority: pd.data[12] ? key(pd.data, 13) : null, slot: pd.data.readBigUInt64LE(4), dataAccount };
45
+ }
46
+ /** JSON with object keys sorted, so two IDLs compare by content and not by key order. */
47
+ export function canonicalJson(v) {
48
+ return JSON.stringify(v, (_k, x) => x && typeof x === "object" && !Array.isArray(x)
49
+ ? Object.fromEntries(Object.keys(x)
50
+ .sort()
51
+ .map((k) => [k, x[k]]))
52
+ : x);
53
+ }