@typesol/cli 0.15.0 → 0.17.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 (50) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/README.md +11 -7
  3. package/dist/cli.js +147 -20
  4. package/dist/clientgen.js +37 -0
  5. package/dist/compiler/analyze.d.ts +11 -1
  6. package/dist/compiler/analyze.js +307 -37
  7. package/dist/compiler/emit/base.d.ts +7 -0
  8. package/dist/compiler/emit/base.js +62 -2
  9. package/dist/compiler/emit/borsh.d.ts +13 -0
  10. package/dist/compiler/emit/borsh.js +232 -0
  11. package/dist/compiler/emit/calls.js +50 -15
  12. package/dist/compiler/emit/cpi.d.ts +32 -8
  13. package/dist/compiler/emit/cpi.js +706 -25
  14. package/dist/compiler/emit/expressions.d.ts +11 -0
  15. package/dist/compiler/emit/expressions.js +126 -4
  16. package/dist/compiler/emit/function.js +45 -12
  17. package/dist/compiler/emit/program.js +24 -6
  18. package/dist/compiler/emit/shared.d.ts +12 -0
  19. package/dist/compiler/emit/shared.js +22 -4
  20. package/dist/compiler/emit/statements.d.ts +13 -0
  21. package/dist/compiler/emit/statements.js +141 -7
  22. package/dist/compiler/emit/storage.d.ts +6 -1
  23. package/dist/compiler/emit/storage.js +82 -17
  24. package/dist/compiler/idl.js +61 -18
  25. package/dist/compiler/model.d.ts +56 -4
  26. package/dist/compiler/model.js +8 -2
  27. package/dist/compiler/toolchain.d.ts +2 -0
  28. package/dist/compiler/toolchain.js +27 -1
  29. package/dist/onchain.d.ts +30 -0
  30. package/dist/onchain.js +53 -0
  31. package/dist/portgen.d.ts +87 -0
  32. package/dist/portgen.js +367 -0
  33. package/dist/templates.js +49 -154
  34. package/dist/testing.d.ts +66 -0
  35. package/dist/testing.js +337 -0
  36. package/dist/workspace.js +11 -1
  37. package/docs/architecture.md +3 -0
  38. package/docs/cli.md +16 -2
  39. package/docs/errors.md +3 -2
  40. package/docs/language.md +19 -14
  41. package/docs/mainnet.md +5 -4
  42. package/docs/migrate-from-anchor.md +113 -0
  43. package/docs/test-your-program.md +52 -0
  44. package/docs/testing.md +1 -1
  45. package/docs/troubleshooting.md +7 -0
  46. package/lib/typesol.d.ts +325 -13
  47. package/package.json +19 -1
  48. package/runtime/typesol_rt.h +54 -9
  49. package/ts-plugin/index.js +112 -0
  50. package/ts-plugin/package.json +4 -0
@@ -0,0 +1,367 @@
1
+ /**
2
+ * `typesol port <idl.json>`: a TypeSOL program skeleton from an Anchor IDL (0.30+/1.x), to port an Anchor program.
3
+ *
4
+ * It writes the account structs, enums, errors, events and one function per instruction with its accounts and
5
+ * arguments, bodies left to fill in. Instructions keep their discriminators (an explicit `@discriminator` when the
6
+ * name would derive another), account structs keep their names (so their discriminators), errors keep their order —
7
+ * so existing clients keep working. Account structs holding a String, Vec or Option get `@layout borsh`, so they keep
8
+ * Anchor's bytes (existing accounts stay readable); where a layout still differs (a String or Vec nested in a struct, a
9
+ * data enum, a COption) it says so in a note and a comment.
10
+ */
11
+ import { createHash } from "node:crypto";
12
+ import { cNameProblem } from "./compiler/analyze.js";
13
+ const RESERVED = new Set("break case catch class const continue debugger default delete do else enum export extends false finally for function if import in instanceof new null return super switch this throw true try typeof var void while with yield let static implements interface package private protected public await".split(" "));
14
+ const camel = (s) => s.replace(/[_-]+([a-zA-Z0-9])/g, (_, c) => c.toUpperCase()).replace(/^[A-Z]/, (c) => c.toLowerCase());
15
+ const pascal = (s) => camel(s).replace(/^[a-z]/, (c) => c.toUpperCase());
16
+ const snake = (s) => s.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase();
17
+ /** Names TypeSOL source cannot use as written: TypeScript keywords, the generated C's reserved names, the builtins
18
+ * a skeleton imports, names starting with "_". */
19
+ const BUILTINS = new Set(["require"]); // what a skeleton imports as a value
20
+ /** A function, parameter or local name; `member`: a struct field or variant, which also becomes a C member name. */
21
+ const ident = (s, member = false) => {
22
+ let n = camel(s).replace(/^_+/, "") || "value";
23
+ if (/^\d/.test(n))
24
+ n = `v${n}`;
25
+ while (RESERVED.has(n) || BUILTINS.has(n) || (member && cNameProblem(n)))
26
+ n = `${n}Value`;
27
+ return n;
28
+ };
29
+ /** A type name: kept as written when it is a valid one (account names decide account discriminators). */
30
+ const typeName = (s) => (/^[A-Za-z][A-Za-z0-9_]*$/.test(s) && !s.includes("__") && !RESERVED.has(s) ? s : pascal(s).replace(/^(\d)/, "T$1"));
31
+ const sighash = (preimage) => [...createHash("sha256").update(preimage).digest().subarray(0, 8)];
32
+ const sameBytes = (a, b) => a.length === b.length && a.every((x, i) => x === b[i]);
33
+ const INTS = new Set(["u8", "u16", "u32", "u64", "u128", "i8", "i16", "i32", "i64", "i128"]);
34
+ const PROGRAMS = {
35
+ "11111111111111111111111111111111": "SystemProgram",
36
+ TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA: "TokenProgram",
37
+ TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb: "Token2022Program",
38
+ ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL: "AssociatedTokenProgram",
39
+ };
40
+ export function generatePort(idl) {
41
+ const notes = [];
42
+ const imports = new Set();
43
+ const use = (n) => (imports.add(n), n);
44
+ const accountNames = new Set((idl.accounts ?? []).map((a) => a.name));
45
+ const eventNames = new Set((idl.events ?? []).map((e) => e.name));
46
+ const definedName = (d) => (typeof d === "string" ? d : d.name);
47
+ const defs = new Map((idl.types ?? []).map((t) => [t.name, t]));
48
+ // The fixed layouts a TypeSOL-built IDL spells out as helper types map back to TypeSOL's own types.
49
+ const helperDefs = new Map((idl.types ?? []).filter((t) => /^(FixedString\d+|FixedOption_|FixedEnum_)/.test(t.name)).map((t) => [t.name, t]));
50
+ const isTuple = (fields) => !!fields?.length && (typeof fields[0] !== "object" || fields[0] === null || !("name" in fields[0]));
51
+ // Types the skeleton cannot declare: references to them become a placeholder with a note.
52
+ const skipped = new Set((idl.types ?? [])
53
+ .filter((d) => !helperDefs.has(d.name) &&
54
+ (d.generics?.length || (d.type.kind === "struct" && isTuple(d.type.fields)) || (d.type.kind !== "struct" && d.type.kind !== "enum")))
55
+ .map((d) => d.name));
56
+ const dataEnum = (name) => defs.get(name)?.type.kind === "enum" && (defs.get(name)?.type.variants ?? []).some((v) => v.fields?.length);
57
+ // Where each struct is used decides what a field can be: account data and struct arguments keep fixed layouts.
58
+ const usage = new Map();
59
+ const mark = (t, as) => {
60
+ if (!t || typeof t === "string")
61
+ return;
62
+ const inner = t.vec ?? t.option ?? t.coption ?? t.array?.[0];
63
+ if (inner)
64
+ return mark(inner, as);
65
+ if (!t.defined)
66
+ return;
67
+ const name = definedName(t.defined);
68
+ const set = usage.get(name) ?? new Set();
69
+ if (set.has(as))
70
+ return;
71
+ usage.set(name, set.add(as));
72
+ const def = defs.get(name);
73
+ for (const f of (def?.type.fields ?? []))
74
+ if (typeof f === "object" && f && "type" in f)
75
+ mark(f.type, as);
76
+ for (const v of def?.type.variants ?? [])
77
+ for (const f of v.fields ?? [])
78
+ mark(typeof f === "object" && f && "type" in f ? f.type : f, as);
79
+ };
80
+ for (const a of accountNames)
81
+ mark({ defined: a }, "account");
82
+ for (const e of eventNames)
83
+ mark({ defined: e }, "event");
84
+ for (const ix of idl.instructions)
85
+ for (const g of ix.args)
86
+ mark(g.type, "arg");
87
+ const fixed = (w) => w === "account" || w === "struct" || w === "variant";
88
+ const describe = (w) => (w === "account" ? "account data" : w === "struct" ? "a struct argument" : "a data-enum variant");
89
+ const tsType = (t, where, at, last = false) => {
90
+ if (typeof t === "string") {
91
+ if (INTS.has(t))
92
+ return use(t);
93
+ if (t === "bool")
94
+ return "boolean";
95
+ if (t === "pubkey" || t === "publicKey")
96
+ return use("Pubkey");
97
+ if (t === "string") {
98
+ if (!fixed(where))
99
+ return "string";
100
+ notes.push(`${at}: Anchor encodes this String with its length (Borsh); TypeSOL holds FixedString<N> (u32 length + N bytes) in ${describe(where)}, so this layout differs. Set N.`);
101
+ return `${use("FixedString")}<32> /* TODO: capacity; Anchor has a variable String */`;
102
+ }
103
+ if (t === "bytes")
104
+ return tsType({ vec: "u8" }, where, at, last);
105
+ notes.push(`${at}: IDL type "${t}" has no TypeSOL equivalent; left as u64`);
106
+ return `${use("u64")} /* TODO: ${t} */`;
107
+ }
108
+ if (t.array) {
109
+ const n = Number(t.array[1]);
110
+ if (!Number.isInteger(n) || n < 1) {
111
+ notes.push(`${at}: an array of length ${JSON.stringify(t.array[1])}; set it`);
112
+ return `${use("FixedArray")}<${tsType(t.array[0], where, at)}, 1> /* TODO: length */`;
113
+ }
114
+ return `${use("FixedArray")}<${tsType(t.array[0], where, at)}, ${n}>`;
115
+ }
116
+ if (t.coption)
117
+ notes.push(`${at}: a COption (4-byte tag in Anchor); TypeSOL's Option has a 1-byte tag, so this layout differs.`);
118
+ if (t.option || t.coption) {
119
+ const inner = tsType((t.option ?? t.coption), where === "variant" ? "variant" : where, at);
120
+ if (fixed(where))
121
+ notes.push(`${at}: an Option in ${describe(where)} is fixed-size in TypeSOL (1 + size of T), Borsh (1, or 1 + size) in Anchor: the layout differs.`);
122
+ return `${use("Option")}<${inner}>`;
123
+ }
124
+ if (t.vec) {
125
+ const inner = tsType(t.vec, where, at);
126
+ if (where === "variant" || where === "struct" || (where === "account" && !last)) {
127
+ notes.push(`${at}: a Vec here (TypeSOL keeps a Vec only as an account's last field or as an argument); replaced by FixedArray, so the layout differs.`);
128
+ return `${use("FixedArray")}<${inner}, 8> /* TODO: Anchor has a Vec here */`;
129
+ }
130
+ return `${use("Vec")}<${inner}>`;
131
+ }
132
+ if (t.defined) {
133
+ const name = definedName(t.defined);
134
+ const fs = /^FixedString(\d+)$/.exec(name);
135
+ if (fs)
136
+ return `${use("FixedString")}<${fs[1]}>`;
137
+ const helper = helperDefs.get(name);
138
+ if (helper && name.startsWith("FixedOption_")) {
139
+ const value = (helper.type.fields ?? []).find((f) => f.name === "value");
140
+ if (value)
141
+ return `${use("Option")}<${tsType(value.type, "arg", at)}>`;
142
+ }
143
+ if (name.startsWith("FixedEnum_"))
144
+ return typeName(name.slice("FixedEnum_".length));
145
+ if (skipped.has(name)) {
146
+ notes.push(`${at}: ${name} could not be ported (generic, tuple or unsupported type); replaced by a placeholder`);
147
+ return `${use("u8")} /* TODO: ${name} */`;
148
+ }
149
+ if ((where === "account" || where === "struct") && dataEnum(name))
150
+ notes.push(`${at}: ${name} is a data enum: Anchor stores the variant's own size (Borsh), TypeSOL the largest variant's, so this layout differs.`);
151
+ return typeName(name);
152
+ }
153
+ notes.push(`${at}: unsupported IDL type ${JSON.stringify(t)}; left as u64`);
154
+ return `${use("u64")} /* TODO */`;
155
+ };
156
+ /** A String, Vec or Option at a struct's top level: Borsh-sized in Anchor. */
157
+ const borshOnly = (t) => t === "string" || t === "bytes" || (typeof t === "object" && (!!t.vec || !!t.option));
158
+ /** A field's type in a `@layout borsh` struct, or undefined when that layout cannot hold it. Notes the capacities. */
159
+ const borshField = (t, at) => {
160
+ const quiet = at === "";
161
+ const fixedOk = (u) => {
162
+ if (typeof u === "string")
163
+ return INTS.has(u) || u === "bool" || u === "pubkey" || u === "publicKey";
164
+ if (u.array)
165
+ return Number.isInteger(Number(u.array[1])) && fixedOk(u.array[0]);
166
+ if (u.defined) {
167
+ const name = definedName(u.defined);
168
+ const d = defs.get(name);
169
+ if (!d || skipped.has(name) || helperDefs.has(name))
170
+ return false;
171
+ if (d.type.kind === "enum")
172
+ return (d.type.variants ?? []).every((v) => !v.fields?.length);
173
+ return (d.type.fields ?? []).every((f) => fixedOk(f.type));
174
+ }
175
+ return false;
176
+ };
177
+ if (t === "string") {
178
+ if (!quiet)
179
+ notes.push(`${at}: set the String's capacity to Anchor's #[max_len] (Borsh bytes are kept: @layout borsh)`);
180
+ return `${use("FixedString")}<32> /* TODO: #[max_len] */`;
181
+ }
182
+ if (t === "bytes")
183
+ return borshField({ vec: "u8" }, at);
184
+ if (typeof t === "object" && t.vec) {
185
+ const elem = t.vec === "string"
186
+ ? `${use("FixedString")}<32> /* TODO: #[max_len] */`
187
+ : fixedOk(t.vec) && !quiet
188
+ ? tsType(t.vec, "account", at)
189
+ : fixedOk(t.vec)
190
+ ? "x"
191
+ : undefined;
192
+ if (elem === undefined)
193
+ return undefined;
194
+ if (!quiet)
195
+ notes.push(`${at}: set the Vec's capacity to Anchor's #[max_len] (Borsh bytes are kept: @layout borsh)`);
196
+ return `${use("FixedVec")}<${elem}, 8> /* TODO: #[max_len] */`;
197
+ }
198
+ if (typeof t === "object" && t.option) {
199
+ if (!fixedOk(t.option))
200
+ return undefined;
201
+ return quiet ? "x" : `${use("Option")}<${tsType(t.option, "account", at)}>`;
202
+ }
203
+ if (!fixedOk(t))
204
+ return undefined;
205
+ return quiet ? "x" : tsType(t, "account", at);
206
+ };
207
+ const esc = (s) => s.replace(/\*\//g, "* /");
208
+ const docs = (lines, pad = "") => (lines?.length ? `${pad}/** ${esc(lines.join(" "))} */\n` : "");
209
+ const out = [];
210
+ // Constants: integer literals and strings; expressions are left to port by hand.
211
+ for (const c of idl.constants ?? []) {
212
+ const ty = typeof c.type === "string" && INTS.has(c.type) ? c.type : undefined;
213
+ const name = /^[A-Za-z_][A-Za-z0-9_]*$/.test(c.name) ? c.name : ident(c.name);
214
+ if (ty && /^-?\d[\d_]*$/.test(c.value.trim()))
215
+ out.push(`export const ${name}: ${use(ty)} = ${c.value.trim().replace(/_/g, "")}n;`);
216
+ else if (c.type === "string" && /^".*"$/.test(c.value.trim()))
217
+ out.push(`export const ${name} = ${c.value.trim()};`);
218
+ else
219
+ notes.push(`constant ${c.name} = ${c.value}: not a literal; port it by hand`);
220
+ }
221
+ if (out.length)
222
+ out.push("");
223
+ // Types: structs (account data, events or arguments), enums.
224
+ for (const def of idl.types ?? []) {
225
+ if (helperDefs.has(def.name))
226
+ continue;
227
+ if (skipped.has(def.name)) {
228
+ notes.push(`type ${def.name}: ${def.generics?.length ? "generics are" : def.type.kind === "struct" ? "tuple structs are" : `kind "${def.type.kind}" is`} not supported; skipped`);
229
+ continue;
230
+ }
231
+ const used = usage.get(def.name) ?? new Set(["account"]);
232
+ const where = used.has("account") ? "account" : used.has("arg") ? "struct" : used.has("event") ? "event" : "account";
233
+ if (def.type.kind === "struct") {
234
+ const fields = (def.type.fields ?? []);
235
+ // An account struct with a String, Vec or Option of its own: `@layout borsh` keeps Anchor's bytes.
236
+ const borsh = accountNames.has(def.name) && !isTuple(fields) && fields.some((f) => borshOnly(f.type)) && fields.every((f) => borshField(f.type, "") !== undefined);
237
+ const tag = accountNames.has(def.name) ? " (account)" : eventNames.has(def.name) ? ` (event: emit<${typeName(def.name)}>({ … }))` : "";
238
+ const doc = def.docs?.length ? esc(def.docs.join(" ")) : `${def.name}${tag}`;
239
+ out.push(borsh ? `/**\n * ${doc}\n * @layout borsh\n */` : `/** ${doc} */`);
240
+ out.push(`export interface ${typeName(def.name)} {`);
241
+ const seen = new Set();
242
+ fields.forEach((f, i) => {
243
+ let n = ident(f.name, true);
244
+ while (seen.has(n))
245
+ n = `${n}2`;
246
+ seen.add(n);
247
+ const ty = borsh ? borshField(f.type, `${def.name}.${f.name}`) : tsType(f.type, where, `${def.name}.${f.name}`, i === fields.length - 1);
248
+ out.push(`${docs(f.docs, " ")} ${n}: ${ty};`);
249
+ });
250
+ out.push("}", "");
251
+ }
252
+ else {
253
+ const variants = def.type.variants ?? [];
254
+ if (variants.every((v) => !v.fields?.length)) {
255
+ out.push(`${docs(def.docs)}export enum ${typeName(def.name)} {`, ...variants.map((v) => ` ${ident(v.name, true).replace(/^[a-z]/, (c) => c.toUpperCase())},`), "}", "");
256
+ }
257
+ else {
258
+ // Strings in variants are fine in arguments and events (Borsh); account data needs fixed-size variants.
259
+ const vw = used.has("account") || (!used.has("arg") && !used.has("event")) ? "variant" : "event";
260
+ const parts = variants.map((v) => {
261
+ const fs = (v.fields ?? []).map((f, i) => typeof f === "object" && f !== null && "name" in f
262
+ ? `${ident(f.name, true)}: ${tsType(f.type, vw, `${def.name}.${v.name}`)}`
263
+ : `f${i}: ${tsType(f, vw, `${def.name}.${v.name}`)}`);
264
+ return `{ kind: "${pascal(v.name)}"${fs.map((x) => `; ${x}`).join("")} }`;
265
+ });
266
+ out.push(`${docs(def.docs)}export type ${typeName(def.name)} =\n | ${parts.join("\n | ")};`, "");
267
+ }
268
+ }
269
+ }
270
+ // Errors: TypeSOL numbers them 6000 + their index. NotPorted, last, marks the instructions still to write, so the
271
+ // codes of the program's own errors do not move.
272
+ const errors = idl.errors ?? [];
273
+ const program = typeName(pascal(idl.metadata?.name ?? "program"));
274
+ const errorEnum = `${program}Error`;
275
+ if (!errors.every((e, i) => e.code === 6000 + i))
276
+ notes.push(`errors: TypeSOL numbers an error enum from 6000 in order; the IDL's codes (${errors.map((e) => e.code).join(", ")}) differ, so clients see other codes`);
277
+ const memberNames = new Set();
278
+ const member = (name) => {
279
+ // RequireFailedN is TypeSOL's own name for require("message") errors; it is reserved in source.
280
+ let n = /^RequireFailed\d+$/.test(name) ? name.replace("RequireFailed", "Check") : pascal(name);
281
+ while (memberNames.has(n))
282
+ n += "_";
283
+ memberNames.add(n);
284
+ return n;
285
+ };
286
+ const errorLines = errors.map((e) => ` ${member(e.name)} = ${JSON.stringify(e.msg ?? e.name)},`);
287
+ const notPorted = member("NotPorted");
288
+ out.push(`export enum ${errorEnum} {`, ...errorLines, ` ${notPorted} = "This instruction is not ported yet",`, "}", "");
289
+ // Accounts of an instruction, nested groups flattened.
290
+ const flatten = (items, prefix = "") => items.flatMap((a) => (a.accounts ? flatten(a.accounts, `${prefix}${a.name}_`) : [{ ...a, name: `${prefix}${a.name}` }]));
291
+ for (const ix of idl.instructions) {
292
+ const fn = ident(ix.name);
293
+ const params = [];
294
+ const comments = [];
295
+ const taken = new Set();
296
+ const unique = (n, kind) => {
297
+ let m = n;
298
+ while (taken.has(m))
299
+ m = `${m}${kind}`;
300
+ if (m !== n)
301
+ notes.push(`${ix.name}: ${kind.toLowerCase()} "${n}" renamed to "${m}" (an account and an argument share the name)`);
302
+ taken.add(m);
303
+ return m;
304
+ };
305
+ for (const a of flatten(ix.accounts)) {
306
+ let ty;
307
+ if (a.address && PROGRAMS[a.address])
308
+ ty = use(PROGRAMS[a.address]);
309
+ else if (a.signer)
310
+ ty = a.writable ? `${use("Mut")}<${use("Signer")}>` : use("Signer");
311
+ else {
312
+ // The IDL does not say which struct an account holds: AccountInfo until you pick Account<T> / Mut<T> / Init<T>.
313
+ const base = a.writable ? `${use("Mut")}<${use("AccountInfo")}>` : use("AccountInfo");
314
+ ty = a.address ? `${use("Address")}<${base}, "${a.address}">` : base;
315
+ }
316
+ if (a.optional)
317
+ ty = `${ty} | null`;
318
+ params.push(`${unique(ident(a.name), "Account")}: ${ty}`);
319
+ if (a.pda) {
320
+ const seeds = a.pda.seeds.map((s) => s.kind === "const"
321
+ ? JSON.stringify(Buffer.from(s.value ?? []).toString("utf8"))
322
+ : s.kind === "arg"
323
+ ? `@${camel(s.path ?? "")} (argument)`
324
+ : `@${camel(s.path ?? "")}`);
325
+ comments.push(`${ident(a.name)}: PDA [${seeds.join(", ")}]`);
326
+ }
327
+ if (a.relations?.length)
328
+ comments.push(`${ident(a.name)}: has_one ${a.relations.map(camel).join(", ")}`);
329
+ }
330
+ for (const g of ix.args)
331
+ params.push(`${unique(ident(g.name), "Arg")}: ${tsType(g.type, "arg", `${ix.name}(${g.name})`)}`);
332
+ let ret = "void";
333
+ let body = ` ${use("require")}(false, ${errorEnum}.${notPorted}); // TODO: port ${ix.name}`;
334
+ if (ix.returns) {
335
+ const r = typeof ix.returns === "string" ? ix.returns : undefined;
336
+ if (r && INTS.has(r))
337
+ ((ret = use(r)), (body += "\n return 0n;"));
338
+ else if (r === "bool")
339
+ ((ret = "boolean"), (body += "\n return false;"));
340
+ else
341
+ notes.push(`${ix.name}: returns ${JSON.stringify(ix.returns)}; set the return type and value`);
342
+ }
343
+ const derived = sighash(`global:${snake(fn)}`);
344
+ const tags = sameBytes(derived, ix.discriminator) ? [] : [`@discriminator 0x${Buffer.from(ix.discriminator).toString("hex")}`];
345
+ const doc = [...(ix.docs ?? []), ...comments.map((c) => `- ${c}`), ...tags];
346
+ if (doc.length)
347
+ out.push(`/**\n${doc.map((l) => ` * ${esc(l)}`).join("\n")}\n */`);
348
+ out.push(`export function ${fn}(${params.join(", ")}): ${ret} {`, body, "}", "");
349
+ }
350
+ for (const a of idl.accounts ?? []) {
351
+ if (!sameBytes(sighash(`account:${a.name}`), a.discriminator))
352
+ notes.push(`account ${a.name}: its discriminator is not sha256("account:${a.name}"); TypeSOL derives that one, so existing accounts would not load`);
353
+ }
354
+ const header = [
355
+ `// Ported from the ${idl.metadata?.name ?? "program"} IDL by \`typesol port\`. Fill in the instruction bodies;`,
356
+ "// pick Account<T> / Mut<T> / Init<T> for the AccountInfo parameters that hold one of the structs below.",
357
+ `import { ${[
358
+ ...[...imports]
359
+ .filter((n) => n !== "require")
360
+ .sort()
361
+ .map((n) => `type ${n}`),
362
+ ...(imports.has("require") ? ["require"] : []),
363
+ ].join(", ")} } from "typesol";`,
364
+ "",
365
+ ];
366
+ return { source: [...header, ...out].join("\n").replace(/\n{3,}/g, "\n\n"), notes };
367
+ }
package/dist/templates.js CHANGED
@@ -2,71 +2,16 @@
2
2
  // main flow and at least one refused call, so a new project starts from something that passes and shows how to test.
3
3
  // The programs are copies of examples/ (test/workspace.test.ts checks they still match).
4
4
  export const TEMPLATE_NAMES = ["counter", "vault", "escrow"];
5
- /** The part every test shares: build the project's program into a LiteSVM, encode instructions with its IDL, send. */
5
+ /** Every test starts the same way: build the project's program into a LiteSVM with `typesol/testing`. */
6
6
  const testFile = (name, o) => `// Runs the program in LiteSVM (an in-process Solana runtime): \`npm test\`.
7
- import { BN, BorshCoder, type Idl } from "@anchor-lang/core";
8
- ${o.imports}import {
9
- ${[
10
- "AccountRole",
11
- "appendTransactionMessageInstructions",
12
- "createTransactionMessage",
13
- ...o.kit,
14
- "pipe",
15
- "setTransactionMessageFeePayerSigner",
16
- "signTransactionMessageWithSigners",
17
- "type Address",
18
- "type Instruction",
19
- "type KeyPairSigner",
20
- ]
21
- .map((x) => ` ${x},`)
22
- .join("\n")}
23
- } from "@solana/kit";
24
- import { FailedTransactionMetadata, LiteSVM, TransactionMetadata } from "litesvm";
25
- import path from "node:path";
26
- import { fileURLToPath } from "node:url";
27
- import { buildWorkspace, findWorkspace } from "typesol/workspace";
7
+ ${o.imports ?? ""}import { testProgram, type TestProgram } from "typesol/testing";
28
8
  import { beforeAll, describe, expect, it } from "vitest";
29
9
 
30
- const SYSTEM = "11111111111111111111111111111111" as Address;
31
- const ws = findWorkspace(path.dirname(fileURLToPath(import.meta.url)))!;
32
-
33
10
  describe("${name}", () => {
34
- const svm = new LiteSVM();
35
- let programId: Address;
36
- let coder: BorshCoder;
37
- beforeAll(() => {
38
- const [built] = buildWorkspace(ws, ["${name}"]);
39
- programId = built.address as Address;
40
- svm.addProgramFromFile(programId, built.result.soPath);
41
- coder = new BorshCoder(built.result.idl as unknown as Idl);
42
- });
43
-
44
- const ix = (name: string, args: object, accounts: { address: Address; signer?: KeyPairSigner; writable?: boolean }[]): Instruction => ({
45
- programAddress: programId,
46
- accounts: accounts.map((a) => ({
47
- address: a.address,
48
- role: a.signer ? (a.writable ? AccountRole.WRITABLE_SIGNER : AccountRole.READONLY_SIGNER) : a.writable ? AccountRole.WRITABLE : AccountRole.READONLY,
49
- ...(a.signer ? { signer: a.signer } : {}),
50
- })) as Instruction["accounts"],
51
- data: new Uint8Array(coder.instruction.encode(name, args)),
11
+ let t: TestProgram;
12
+ beforeAll(async () => {
13
+ t = await testProgram("${name}", import.meta.url);
52
14
  });
53
- const send = async (payer: KeyPairSigner, ixs: Instruction[]) => {
54
- const tx = await pipe(
55
- createTransactionMessage({ version: 0 }),
56
- (t) => setTransactionMessageFeePayerSigner(payer, t),
57
- (t) => svm.setTransactionMessageLifetimeUsingLatestBlockhash(t),
58
- (t) => appendTransactionMessageInstructions(ixs, t),
59
- (t) => signTransactionMessageWithSigners(t),
60
- );
61
- const res = svm.sendTransaction(tx);
62
- svm.expireBlockhash();
63
- return res;
64
- };
65
- /** The custom error code of a failed transaction: 6000 + the member's index in the program's error enum. */
66
- const errorCode = (res: TransactionMetadata | FailedTransactionMetadata) => {
67
- if (!(res instanceof FailedTransactionMetadata)) throw new Error("expected the transaction to fail");
68
- return Number(/Custom\\D*(\\d+)/.exec(String(res.err()))?.[1]);
69
- };
70
15
  ${o.body}});
71
16
  `;
72
17
  export const TEMPLATES = {
@@ -101,25 +46,17 @@ export function assertAtLeast(counter: Account<Counter>, min: u64): void {
101
46
  }
102
47
  `,
103
48
  test: (name) => testFile(name, {
104
- imports: "",
105
- kit: ["generateKeyPairSigner", "getAddressEncoder", "getProgramDerivedAddress", "lamports"],
106
49
  body: `
107
50
  it("initializes and increments the caller's counter; refuses anyone else", async () => {
108
- const alice = await generateKeyPairSigner();
109
- const mallory = await generateKeyPairSigner();
110
- svm.airdrop(alice.address, lamports(1_000_000_000n));
111
- svm.airdrop(mallory.address, lamports(1_000_000_000n));
112
- const [counter] = await getProgramDerivedAddress({ programAddress: programId, seeds: ["counter", getAddressEncoder().encode(alice.address)] });
113
- const me = { address: alice.address, signer: alice, writable: true };
114
- expect(await send(alice, [ix("initialize", {}, [me, { address: counter, writable: true }, { address: SYSTEM }])])).toBeInstanceOf(TransactionMetadata);
115
- expect(await send(alice, [ix("increment", { by: new BN(5) }, [{ address: alice.address, signer: alice }, { address: counter, writable: true }])])).toBeInstanceOf(TransactionMetadata);
116
- const account = svm.getAccount(counter);
117
- if (!account.exists) throw new Error("counter was not created");
118
- expect(coder.accounts.decode("Counter", Buffer.from(account.data)).count.toString()).toBe("5");
51
+ const alice = await t.wallet();
52
+ const mallory = await t.wallet();
53
+ const counter = await t.pda("counter", alice);
54
+ t.ok(await t.send(alice, t.ix("initialize", {}, { authority: alice, counter })));
55
+ t.ok(await t.send(alice, t.ix("increment", { by: 5n }, { authority: alice, counter })));
56
+ expect((await t.account<{ count: bigint }>("Counter", counter))?.count).toBe(5n);
119
57
 
120
58
  // CounterError.Unauthorized is the enum's first member: 6000.
121
- const stranger = ix("increment", { by: new BN(1) }, [{ address: mallory.address, signer: mallory }, { address: counter, writable: true }]);
122
- expect(errorCode(await send(mallory, [stranger]))).toBe(6000);
59
+ expect(t.errorCode(await t.send(mallory, t.ix("increment", { by: 1n }, { authority: mallory, counter })))).toBe(6000);
123
60
  });
124
61
  `,
125
62
  }),
@@ -182,41 +119,30 @@ export function shut(owner: Mut<Signer>, vault: Mut<Vault>): void {
182
119
  }
183
120
  `,
184
121
  test: (name) => testFile(name, {
185
- imports: "",
186
- kit: ["generateKeyPairSigner", "getAddressEncoder", "getProgramDerivedAddress", "lamports"],
187
122
  body: `
188
123
  it("deposits and withdraws SOL, keeps the rent reserve, then closes", async () => {
189
- const owner = await generateKeyPairSigner();
190
- svm.airdrop(owner.address, lamports(10_000_000_000n));
191
- const [vault] = await getProgramDerivedAddress({ programAddress: programId, seeds: ["vault", getAddressEncoder().encode(owner.address)] });
192
- const me = { address: owner.address, signer: owner, writable: true };
193
- const v = { address: vault, writable: true };
194
-
195
- expect(await send(owner, [ix("open", {}, [me, v, { address: SYSTEM }])])).toBeInstanceOf(TransactionMetadata);
196
- const rent = svm.getBalance(vault)!;
197
- expect(await send(owner, [ix("deposit", { amount: new BN(3_000_000_000) }, [me, v, { address: SYSTEM }])])).toBeInstanceOf(TransactionMetadata);
198
- expect(await send(owner, [ix("withdraw", { amount: new BN(1_000_000_000) }, [me, v])])).toBeInstanceOf(TransactionMetadata);
199
- expect(svm.getBalance(vault)).toBe(rent + 2_000_000_000n);
124
+ const owner = await t.wallet();
125
+ const vault = await t.pda("vault", owner);
126
+ t.ok(await t.send(owner, t.ix("open", {}, { owner, vault })));
127
+ const rent = t.balance(vault);
128
+ t.ok(await t.send(owner, t.ix("deposit", { amount: 3_000_000_000n }, { owner, vault })));
129
+ t.ok(await t.send(owner, t.ix("withdraw", { amount: 1_000_000_000n }, { owner, vault })));
130
+ expect(t.balance(vault)).toBe(rent + 2_000_000_000n);
200
131
 
201
132
  // VaultError.InsufficientFunds (third member, 6002): the rent reserve stays.
202
- expect(errorCode(await send(owner, [ix("withdraw", { amount: new BN(2_000_000_001) }, [me, v])]))).toBe(6002);
133
+ expect(t.errorCode(await t.send(owner, t.ix("withdraw", { amount: 2_000_000_001n }, { owner, vault })))).toBe(6002);
203
134
 
204
- expect(await send(owner, [ix("shut", {}, [me, v])])).toBeInstanceOf(TransactionMetadata);
205
- expect(svm.getAccount(vault).exists).toBe(false);
135
+ t.ok(await t.send(owner, t.ix("shut", {}, { owner, vault })));
136
+ expect(await t.account("Vault", vault)).toBeNull();
206
137
  });
207
138
 
208
139
  it("refuses a stranger", async () => {
209
- const owner = await generateKeyPairSigner();
210
- const thief = await generateKeyPairSigner();
211
- svm.airdrop(owner.address, lamports(2_000_000_000n));
212
- svm.airdrop(thief.address, lamports(1_000_000_000n));
213
- const [vault] = await getProgramDerivedAddress({ programAddress: programId, seeds: ["vault", getAddressEncoder().encode(owner.address)] });
214
- const v = { address: vault, writable: true };
215
- expect(await send(owner, [ix("open", {}, [{ address: owner.address, signer: owner, writable: true }, v, { address: SYSTEM }])])).toBeInstanceOf(
216
- TransactionMetadata,
217
- );
140
+ const owner = await t.wallet();
141
+ const thief = await t.wallet();
142
+ const vault = await t.pda("vault", owner);
143
+ t.ok(await t.send(owner, t.ix("open", {}, { owner, vault })));
218
144
  // VaultError.NotOwner: 6000.
219
- expect(errorCode(await send(thief, [ix("shut", {}, [{ address: thief.address, signer: thief, writable: true }, v])]))).toBe(6000);
145
+ expect(t.errorCode(await t.send(thief, t.ix("shut", {}, { owner: thief, vault })))).toBe(6000);
220
146
  });
221
147
  `,
222
148
  }),
@@ -330,18 +256,17 @@ export function refund(
330
256
  }
331
257
  `,
332
258
  test: (name) => testFile(name, {
333
- imports: `import { AccountState, getMintEncoder, getMintSize, getTokenDecoder, getTokenEncoder, getTokenSize } from "@solana-program/token";\n`,
334
- kit: ["generateKeyPairSigner", "getAddressEncoder", "getProgramDerivedAddress", "lamports"],
259
+ imports: `import { AccountState, getMintEncoder, getMintSize, getTokenDecoder, getTokenEncoder, getTokenSize } from "@solana-program/token";\nimport { type Address, type KeyPairSigner, generateKeyPairSigner, lamports } from "@solana/kit";\n`,
335
260
  body: `
336
261
  const TOKEN = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" as Address;
337
262
  /** A mint, written straight into the VM (a real client sends System CreateAccount + InitializeMint2). */
338
263
  const mint = async (authority: Address) => {
339
264
  const address = (await generateKeyPairSigner()).address;
340
265
  const data = getMintEncoder().encode({ mintAuthority: authority, supply: 0n, decimals: 6, isInitialized: true, freezeAuthority: null });
341
- svm.setAccount({ address, data: new Uint8Array(data), executable: false, lamports: lamports(1_461_600n), programAddress: TOKEN, space: BigInt(getMintSize()) });
266
+ t.svm.setAccount({ address, data: new Uint8Array(data), executable: false, lamports: lamports(1_461_600n), programAddress: TOKEN, space: BigInt(getMintSize()) });
342
267
  return address;
343
268
  };
344
- /** A token account of \`owner\` for \`mint\` holding \`amount\`, written straight into the VM. */
269
+ /** A token account of owner for mint holding amount, written straight into the VM. */
345
270
  const tokenAccount = async (owner: Address, mintAddress: Address, amount: bigint) => {
346
271
  const address = (await generateKeyPairSigner()).address;
347
272
  const data = getTokenEncoder().encode({
@@ -354,73 +279,43 @@ export function refund(
354
279
  delegatedAmount: 0n,
355
280
  closeAuthority: null,
356
281
  });
357
- svm.setAccount({ address, data: new Uint8Array(data), executable: false, lamports: lamports(2_039_280n), programAddress: TOKEN, space: BigInt(getTokenSize()) });
282
+ t.svm.setAccount({ address, data: new Uint8Array(data), executable: false, lamports: lamports(2_039_280n), programAddress: TOKEN, space: BigInt(getTokenSize()) });
358
283
  return address;
359
284
  };
360
285
  const balance = (address: Address) => {
361
- const account = svm.getAccount(address);
286
+ const account = t.svm.getAccount(address);
362
287
  return account.exists ? getTokenDecoder().decode(account.data).amount : null;
363
288
  };
364
289
 
365
290
  it("escrows mint A for mint B; the taker swaps, the maker can refund", async () => {
366
- const maker = await generateKeyPairSigner();
367
- const taker = await generateKeyPairSigner();
368
- svm.airdrop(maker.address, lamports(1_000_000_000n));
369
- svm.airdrop(taker.address, lamports(1_000_000_000n));
291
+ const maker = await t.wallet();
292
+ const taker = await t.wallet();
370
293
  const mintA = await mint(maker.address);
371
294
  const mintB = await mint(taker.address);
372
- const makerA = await tokenAccount(maker.address, mintA, 1_000n);
373
- const makerB = await tokenAccount(maker.address, mintB, 0n);
374
- const takerA = await tokenAccount(taker.address, mintA, 0n);
375
- const takerB = await tokenAccount(taker.address, mintB, 500n);
295
+ const makerTokenA = await tokenAccount(maker.address, mintA, 1_000n);
296
+ const makerTokenB = await tokenAccount(maker.address, mintB, 0n);
297
+ const takerTokenA = await tokenAccount(taker.address, mintA, 0n);
298
+ const takerTokenB = await tokenAccount(taker.address, mintB, 500n);
376
299
 
377
- const open = async (seed: number) => {
378
- const [offer] = await getProgramDerivedAddress({
379
- programAddress: programId,
380
- seeds: ["offer", getAddressEncoder().encode(maker.address), new Uint8Array(new BN(seed).toArray("le", 8))],
381
- });
300
+ const open = async (seed: bigint) => {
301
+ const offer = await t.pda("offer", maker, seed);
382
302
  // The client creates the vault: an empty token account for mint A owned by the offer PDA.
383
303
  const vault = await tokenAccount(offer, mintA, 0n);
384
- const make = ix("make", { seed: new BN(seed), amount_a: new BN(400), want_b: new BN(300), mint_b: { toBuffer: () => Buffer.from(getAddressEncoder().encode(mintB)) } }, [
385
- { address: maker.address, signer: maker, writable: true },
386
- { address: offer, writable: true },
387
- { address: makerA, writable: true },
388
- { address: vault, writable: true },
389
- { address: SYSTEM },
390
- { address: TOKEN },
391
- ]);
392
- expect(await send(maker, [make])).toBeInstanceOf(TransactionMetadata);
304
+ t.ok(await t.send(maker, t.ix("make", { seed, amountA: 400n, wantB: 300n, mintB }, { maker, offer, makerTokenA, vault })));
393
305
  return { offer, vault };
394
306
  };
395
307
 
396
- const first = await open(1);
308
+ const first = await open(1n);
397
309
  expect(balance(first.vault)).toBe(400n);
398
- const take = ix("take", {}, [
399
- { address: taker.address, signer: taker, writable: true },
400
- { address: maker.address, writable: true },
401
- { address: first.offer, writable: true },
402
- { address: takerA, writable: true },
403
- { address: takerB, writable: true },
404
- { address: makerB, writable: true },
405
- { address: first.vault, writable: true },
406
- { address: TOKEN },
407
- ]);
408
- expect(await send(taker, [take])).toBeInstanceOf(TransactionMetadata);
409
- expect([balance(takerA), balance(makerB), balance(first.vault)]).toEqual([400n, 300n, null]);
310
+ t.ok(await t.send(taker, t.ix("take", {}, { taker, maker: maker.address, offer: first.offer, takerTokenA, takerTokenB, makerTokenB, vault: first.vault })));
311
+ expect([balance(takerTokenA), balance(makerTokenB), balance(first.vault)]).toEqual([400n, 300n, null]);
410
312
 
411
- const second = await open(2);
412
- const refund = (signer: KeyPairSigner) =>
413
- ix("refund", {}, [
414
- { address: signer.address, signer, writable: true },
415
- { address: second.offer, writable: true },
416
- { address: makerA, writable: true },
417
- { address: second.vault, writable: true },
418
- { address: TOKEN },
419
- ]);
313
+ const second = await open(2n);
314
+ const refund = (who: KeyPairSigner) => t.ix("refund", {}, { maker: who, offer: second.offer, makerTokenA, vault: second.vault });
420
315
  // EscrowError.WrongMaker (third member, 6002): only the maker refunds.
421
- expect(errorCode(await send(taker, [refund(taker)]))).toBe(6002);
422
- expect(await send(maker, [refund(maker)])).toBeInstanceOf(TransactionMetadata);
423
- expect([balance(makerA), svm.getAccount(second.offer).exists]).toEqual([600n, false]);
316
+ expect(t.errorCode(await t.send(taker, refund(taker)))).toBe(6002);
317
+ t.ok(await t.send(maker, refund(maker)));
318
+ expect([balance(makerTokenA), await t.account("Offer", second.offer)]).toEqual([600n, null]);
424
319
  });
425
320
  `,
426
321
  }),