@typesol/cli 0.22.0 → 0.24.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 (40) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/README.md +13 -12
  3. package/SECURITY.md +1 -1
  4. package/dist/clientgen.js +190 -41
  5. package/dist/compiler/analyze.js +195 -25
  6. package/dist/compiler/emit/base.d.ts +28 -5
  7. package/dist/compiler/emit/base.js +81 -15
  8. package/dist/compiler/emit/calls.d.ts +18 -1
  9. package/dist/compiler/emit/calls.js +159 -7
  10. package/dist/compiler/emit/cpi.d.ts +23 -0
  11. package/dist/compiler/emit/cpi.js +274 -5
  12. package/dist/compiler/emit/expressions.d.ts +9 -1
  13. package/dist/compiler/emit/expressions.js +25 -12
  14. package/dist/compiler/emit/function.js +8 -4
  15. package/dist/compiler/emit/program.js +9 -2
  16. package/dist/compiler/emit/shared.d.ts +43 -3
  17. package/dist/compiler/emit/shared.js +43 -0
  18. package/dist/compiler/emit/statements.d.ts +39 -2
  19. package/dist/compiler/emit/statements.js +346 -87
  20. package/dist/compiler/emit/storage.d.ts +6 -2
  21. package/dist/compiler/emit/storage.js +75 -45
  22. package/dist/compiler/model.d.ts +26 -1
  23. package/dist/idlpublish.d.ts +6 -1
  24. package/dist/idlpublish.js +47 -5
  25. package/dist/portgen.js +1 -1
  26. package/dist/svmrpc.d.ts +7 -1
  27. package/dist/svmrpc.js +133 -19
  28. package/dist/testing.js +23 -3
  29. package/docs/cli.md +2 -2
  30. package/docs/client.md +13 -10
  31. package/docs/errors.md +9 -3
  32. package/docs/language.md +21 -20
  33. package/docs/mainnet.md +1 -1
  34. package/docs/migrate-from-anchor.md +8 -9
  35. package/docs/test-your-program.md +6 -2
  36. package/docs/testing.md +1 -1
  37. package/docs/troubleshooting.md +3 -2
  38. package/lib/typesol.d.ts +127 -20
  39. package/package.json +1 -1
  40. package/runtime/typesol_rt.h +73 -18
@@ -1,7 +1,7 @@
1
1
  import ts from "typescript";
2
2
  import { resolveScalarType } from "../analyze.js";
3
3
  import { INT_INFO, isScalar, isWritable, sameType, scalarSize, typeName } from "../model.js";
4
- import { U64, BOOL, PUBKEY, STRING, optC, fieldC, fixedVecC, storageDecl, vecCount } from "./shared.js";
4
+ import { U64, BOOL, PUBKEY, STRING, optC, fieldC, fixedVecC, storageDecl, vecCount, vecPlace, emptySizeC } from "./shared.js";
5
5
  import { EmitterBase } from "./base.js";
6
6
  /**
7
7
  * Places in account data and locals: references, reads and writes, Vec/array indexing, aliasing rules and
@@ -73,16 +73,7 @@ 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, hoisted: this.hoisted.length, temp: this.temp, diagnostics: this.reporter.diagnostics.length };
77
- try {
78
- return this.reporter.guard(() => this.ref(node))?.type.kind === "dataEnum";
79
- }
80
- finally {
81
- this.lines.length = saved.lines;
82
- this.hoisted.length = saved.hoisted; // a probed helper call may hoist declarations
83
- this.temp = saved.temp;
84
- this.reporter.diagnostics.length = saved.diagnostics;
85
- }
76
+ return this.probe(() => this.ref(node))?.type.kind === "dataEnum";
86
77
  }
87
78
  variantIndex(enumName, lit) {
88
79
  const e = this.model.dataEnums.get(enumName);
@@ -137,11 +128,37 @@ export class StorageEmitter extends EmitterBase {
137
128
  : `"${name}" still refers to ${hit}; replacing it here would change what "${name}" reads (in JS it keeps the old value). Copy the fields you need into locals first`);
138
129
  }
139
130
  }
140
- /** Whether the variable declared by `decl` is read after `at` (later in its block, or anywhere in a loop around `at`). */
131
+ /**
132
+ * Whether the variable declared by `decl` is read after `at` (later in its block, or anywhere in a loop around `at`).
133
+ * A parameter (of a closure or helper expanded here) is read after `at` when its function reads it after `at`; when
134
+ * `at` is in another function (a closure or helper it calls), any read of it in its own function counts.
135
+ */
141
136
  usedAfter(decl, at) {
142
137
  const symbol = this.checker.getSymbolAtLocation(decl.name);
143
138
  if (!symbol)
144
139
  return true;
140
+ if (ts.isParameter(decl)) {
141
+ const fn = decl.parent;
142
+ const inside = at.getSourceFile() === fn.getSourceFile() && at.pos >= fn.pos && at.end <= fn.end;
143
+ let read = false;
144
+ const visitParam = (n) => {
145
+ if (read)
146
+ return;
147
+ if (ts.isIdentifier(n) && n !== decl.name && this.checker.getSymbolAtLocation(n) === symbol) {
148
+ if (!inside || n.pos >= at.end)
149
+ read = true;
150
+ // A loop inside the function, around `at`, reads it again on its next turn.
151
+ for (let p = at.parent; inside && p && p !== fn; p = p.parent)
152
+ if ((ts.isForStatement(p) || ts.isForOfStatement(p) || ts.isWhileStatement(p) || ts.isDoStatement(p)) && n.pos >= p.pos && n.end <= p.end)
153
+ read = true;
154
+ }
155
+ ts.forEachChild(n, visitParam);
156
+ };
157
+ const body = fn.body;
158
+ if (body)
159
+ visitParam(body);
160
+ return read;
161
+ }
145
162
  // Loops around `at` that do not also contain the declaration run their earlier statements again later.
146
163
  let loop;
147
164
  for (let n = at.parent; n && n !== decl.parent; n = n.parent)
@@ -208,17 +225,8 @@ export class StorageEmitter extends EmitterBase {
208
225
  return this.looksLikeStructPlace(node.expression);
209
226
  if (ts.isConditionalExpression(node))
210
227
  return this.looksLikeStructPlace(node.whenTrue) && this.looksLikeStructPlace(node.whenFalse);
211
- const saved = { lines: this.lines.length, hoisted: this.hoisted.length, temp: this.temp, diagnostics: this.reporter.diagnostics.length };
212
- try {
213
- const r = this.reporter.guard(() => this.ref(node));
214
- return r?.type.kind === "struct";
215
- }
216
- finally {
217
- this.lines.length = saved.lines;
218
- this.hoisted.length = saved.hoisted; // a probed helper call may hoist declarations
219
- this.temp = saved.temp;
220
- this.reporter.diagnostics.length = saved.diagnostics;
221
- }
228
+ const r = this.probe(() => this.ref(node));
229
+ return r?.type.kind === "struct";
222
230
  }
223
231
  /** `fixedArray<T, N>(fill)` as a local declaration. */
224
232
  localArray(init, c) {
@@ -258,6 +266,8 @@ export class StorageEmitter extends EmitterBase {
258
266
  const l = this.lookup(node.text);
259
267
  if (l && l.type.kind === "array")
260
268
  return { lv: l.c, type: l.type, writable: !l.readonly };
269
+ if (l && l.type.kind === "vec")
270
+ return this.withFixedVec({ lv: l.c, type: l.type, writable: true });
261
271
  // A struct loaded from a remaining account.
262
272
  if (l && l.type.kind === "structRef")
263
273
  return { lv: `(*${l.c})`, type: { kind: "struct", name: l.type.struct }, writable: l.type.writable };
@@ -278,15 +288,35 @@ export class StorageEmitter extends EmitterBase {
278
288
  const r = { lv: `d_${a.name}->${f.name}`, type: f.type, writable: isWritable(a.kind), account: a, path: [f.name] };
279
289
  if (f.type.kind === "vec" && f.type.cap !== undefined)
280
290
  r.vec = this.fixedVec(r.lv, f.type.elem, f.type.cap);
281
- else if (f.type.kind === "vec")
291
+ else if (f.type.kind === "vec") {
282
292
  // Growing needs the account's length at instruction entry: instructions have it, helpers get it passed.
283
- r.vec = {
284
- len: `d_${a.name}->${f.name}__len`,
285
- items: r.lv,
286
- fixed: `sizeof(TSS_${a.kind.struct.name})`,
287
- elemSize: a.kind.struct.vec.elemSize,
288
- resizable: isWritable(a.kind),
289
- };
293
+ const s = a.kind.struct;
294
+ const k = s.vecs.findIndex((v) => v.name === f.name);
295
+ if (s.vecs.length === 1)
296
+ r.vec = {
297
+ len: `d_${a.name}->${f.name}__len`,
298
+ items: r.lv,
299
+ fixed: `sizeof(TSS_${s.name})`,
300
+ elemSize: s.vec.elemSize,
301
+ resizable: isWritable(a.kind),
302
+ };
303
+ else {
304
+ const base = `(*d_${a.name})`;
305
+ const at = vecPlace(s, base, k);
306
+ const others = s.vecs.map((v, j) => (j === k ? "" : ` + (uint64_t)${vecPlace(s, base, j).len} * ${v.elemSize}`)).join("");
307
+ r.lv = at.items;
308
+ r.vec = {
309
+ len: at.len,
310
+ items: at.items,
311
+ fixed: `(${emptySizeC(s)}${others})`,
312
+ elemSize: s.vecs[k].elemSize,
313
+ resizable: isWritable(a.kind),
314
+ tail: k < s.vecs.length - 1
315
+ ? { end: at.end, dataEnd: `(a_${a.name}->data + a_${a.name}->data_len)`, paths: s.vecs.slice(k + 1).map((v) => `${a.name}.${v.name}[]`) }
316
+ : undefined,
317
+ };
318
+ }
319
+ }
290
320
  return r;
291
321
  }
292
322
  }
@@ -330,8 +360,12 @@ export class StorageEmitter extends EmitterBase {
330
360
  this.fail(node.name, `${s.name} has no field "${field}"`);
331
361
  const r = { ...base, lv: `${base.lv}.${f.name}`, type: f.type, path: base.path && [...base.path, f.name], vec: undefined };
332
362
  // Vecs of loaded accounts can be read and updated in place but not resized (no entry length known).
333
- if (f.type.kind === "vec" && f.type.cap === undefined)
334
- r.vec = { len: `${base.lv}.${f.name}__len`, items: r.lv, fixed: `sizeof(TSS_${s.name})`, elemSize: s.vec.elemSize, resizable: false };
363
+ if (f.type.kind === "vec" && f.type.cap === undefined) {
364
+ const k = s.vecs.findIndex((v) => v.name === f.name);
365
+ const at = s.vecs.length === 1 ? { len: `${base.lv}.${f.name}__len`, items: r.lv } : vecPlace(s, base.lv, k);
366
+ r.lv = at.items;
367
+ r.vec = { len: at.len, items: at.items, fixed: emptySizeC(s), elemSize: s.vecs[k].elemSize, resizable: false };
368
+ }
335
369
  return this.withFixedVec(r);
336
370
  }
337
371
  return undefined;
@@ -565,6 +599,11 @@ export class StorageEmitter extends EmitterBase {
565
599
  const els = valueNode.elements;
566
600
  if (els.length > t.cap)
567
601
  this.fail(valueNode, `${els.length} elements do not fit in ${typeName(t)}`);
602
+ // Empty: nothing reads the old list, so it is cleared in place (length 0, every slot zero).
603
+ if (els.length === 0) {
604
+ this.line(`ts_memzero(&${target.lv}, sizeof(${target.lv}));`);
605
+ return;
606
+ }
568
607
  const tmp = this.tmp("fv");
569
608
  this.line(`${fixedVecC(t)} ${tmp};`);
570
609
  this.line(`ts_memzero(&${tmp}, sizeof(${tmp}));`);
@@ -655,24 +694,15 @@ export class StorageEmitter extends EmitterBase {
655
694
  }
656
695
  /** The kind of storage an expression names (struct, array, dataEnum…), without emitting code or failing. */
657
696
  peekKind(node) {
658
- const saved = { lines: this.lines.length, hoisted: this.hoisted.length, temp: this.temp, diagnostics: this.reporter.diagnostics.length };
659
- try {
660
- return this.reporter.guard(() => this.ref(node))?.type.kind;
661
- }
662
- finally {
663
- this.lines.length = saved.lines;
664
- this.hoisted.length = saved.hoisted; // a probed helper call may hoist declarations
665
- this.temp = saved.temp;
666
- this.reporter.diagnostics.length = saved.diagnostics;
667
- }
697
+ return this.probe(() => this.ref(node))?.type.kind;
668
698
  }
669
699
  place(node) {
670
700
  if (ts.isIdentifier(node)) {
671
701
  const l = this.lookup(node.text);
672
702
  if (!l)
673
703
  this.fail(node, `"${node.text}" is not a local variable`);
674
- if (l.type.kind === "array")
675
- this.fail(node, `use ${node.text}.set(i, v) to change array elements`);
704
+ if (l.type.kind === "array" || l.type.kind === "vec")
705
+ this.fail(node, `use ${node.text}.set(i, v) to change list elements`);
676
706
  if (!l.mutable)
677
707
  this.fail(node, `"${node.text}" is const or an argument and cannot be reassigned`);
678
708
  if (l.type.kind === "pubkey")
@@ -38,9 +38,11 @@ export type ValueType = ScalarType | {
38
38
  inner: ScalarType;
39
39
  }
40
40
  /** `Vec<T>` instruction argument: a read-only view into instruction data. T is a scalar or a fixed-size struct. */
41
+ /** A Vec argument (Borsh), read in place; `FixedVec<T, N>` arguments are the same with at most `cap` elements. */
41
42
  | {
42
43
  kind: "vecArg";
43
44
  elem: FieldType;
45
+ cap?: number;
44
46
  }
45
47
  /** `FixedArray<T, N>` instruction argument (Borsh [T; N]), parsed into a read-only copy. */
46
48
  | {
@@ -119,6 +121,8 @@ export type FieldType = ScalarType | {
119
121
  export interface StructField {
120
122
  name: string;
121
123
  type: FieldType;
124
+ /** The declaration, while the struct is being analyzed (for messages). */
125
+ node?: ts.Node;
122
126
  }
123
127
  /** Every exported interface. Used as account data, nested in other structs, or emitted as an event. */
124
128
  export interface StructDef {
@@ -129,12 +133,21 @@ export interface StructDef {
129
133
  * or undefined when a field is a variable-length string (event-only structs).
130
134
  */
131
135
  size: number | undefined;
132
- /** The trailing Vec field, if any. Such structs can only be account data. */
136
+ /** The first growable Vec field, if any. Such structs can only be account data. */
133
137
  vec?: {
134
138
  name: string;
135
139
  elem: FieldType;
136
140
  elemSize: number;
137
141
  };
142
+ /**
143
+ * Every growable Vec, in stored order (after the fixed-size fields). The first ends the C struct (its u32 length,
144
+ * then its elements); each further one follows the previous one's elements: a u32 length, then its elements.
145
+ */
146
+ vecs?: {
147
+ name: string;
148
+ elem: FieldType;
149
+ elemSize: number;
150
+ }[];
138
151
  accountDiscriminator: number[];
139
152
  eventDiscriminator: number[];
140
153
  node: ts.InterfaceDeclaration;
@@ -410,6 +423,14 @@ export interface ProgramModel {
410
423
  helperDecls: Map<ts.Node, string>;
411
424
  /** Local closures (they read the enclosing function's variables): their declaration -> name; expanded where called. */
412
425
  closureDecls: Map<ts.Node, string>;
426
+ /**
427
+ * Functions that return a function (`function adder(n: u64): (x: u64) => u64 { return (x) => x + n; }`): no C
428
+ * function; a call binds the returned function, with the arguments, as a closure where it is made.
429
+ */
430
+ factories: Map<string, {
431
+ node: HelperNode;
432
+ imports: Map<string, string>;
433
+ }>;
413
434
  errors: ErrorDef[];
414
435
  /** Enum name -> member name -> error */
415
436
  errorEnums: Map<string, Map<string, ErrorDef>>;
@@ -436,6 +457,10 @@ export interface ProgramModel {
436
457
  fixedVecs: Map<string, FieldType & {
437
458
  kind: "vec";
438
459
  }>;
460
+ /** FixedVec types of local lists (`const ys = xs.map(…)`), filled in while emitting: C typedefs only, not the IDL. */
461
+ localVecs: Map<string, FieldType & {
462
+ kind: "vec";
463
+ }>;
439
464
  }
440
465
  export declare function isWritable(k: ParamKind): boolean;
441
466
  export declare function typeName(t: ValueType | FieldType): string;
@@ -25,7 +25,12 @@ export interface PublishOptions {
25
25
  /** Write (create or update) the IDL metadata account; returns its address. */
26
26
  export declare function publishIdl(chain: Chain, opts: PublishOptions): Promise<Address>;
27
27
  export declare function metadataAddress(program: Address, authority: Address | null): Promise<Address>;
28
- /** Read an IDL back (canonical, or the one `authority` wrote). */
28
+ /** The most an IDL read from the chain may inflate to: compressed data can expand far past the account's size. */
29
+ export declare const MAX_IDL_BYTES: number;
30
+ /**
31
+ * Read an IDL back (canonical, or the one `authority` wrote). Only an IDL stored in the metadata account itself is
32
+ * read: one that links to a URL or points at another account is refused (nothing is fetched from where it points).
33
+ */
29
34
  export declare function fetchIdl(rpc: Chain["rpc"], program: Address, authority?: Address | null): Promise<object>;
30
35
  /** Where `anchor idl init` keeps a program's IDL: an account derived with seed "anchor:idl" from the program's base PDA. */
31
36
  export declare function anchorIdlAddress(program: Address): Promise<Address>;
@@ -5,8 +5,9 @@
5
5
  *
6
6
  * Works against an RPC (`rpcChain`) or anything else that can read accounts and send transactions (tests use LiteSVM).
7
7
  */
8
- import { inflateSync } from "node:zlib";
9
- import { Compression, Encoding, Format, fetchMetadataContent, findMetadataPda, packDirectData, writeMetadata } from "@solana-program/program-metadata";
8
+ import { createHash } from "node:crypto";
9
+ import { gunzipSync, inflateSync } from "node:zlib";
10
+ import { Compression, DataSource, Encoding, Format, decodeData, fetchMaybeMetadata, findMetadataPda, packDirectData, writeMetadata, } from "@solana-program/program-metadata";
10
11
  import { createAddressWithSeed, createClient, createTransactionMessage, createTransactionPlanExecutor, createTransactionPlanner, extendClient, getAddressEncoder, getBase64EncodedWireTransaction, getProgramDerivedAddress, getSignatureFromTransaction, lamports, pipe, setTransactionMessageFeePayerSigner, setTransactionMessageLifetimeUsingBlockhash, signTransactionMessageWithSigners, } from "@solana/kit";
11
12
  export const PROGRAM_METADATA_ID = "ProgM6JCCvbYkfKqJYHePx4xxSUSqJp7rh8Lyv7nk7S";
12
13
  /** A Chain over an RPC endpoint (polls signature status: no websocket needed). */
@@ -111,15 +112,54 @@ function planFailure(e) {
111
112
  export async function metadataAddress(program, authority) {
112
113
  return (await findMetadataPda({ program, authority, seed: "idl" }))[0];
113
114
  }
114
- /** Read an IDL back (canonical, or the one `authority` wrote). */
115
+ /** The most an IDL read from the chain may inflate to: compressed data can expand far past the account's size. */
116
+ export const MAX_IDL_BYTES = 16 * 1024 * 1024;
117
+ /** Decompress `data` (zlib or gzip), refusing output past MAX_IDL_BYTES. */
118
+ function inflateCapped(data, gzip, what) {
119
+ try {
120
+ return (gzip ? gunzipSync : inflateSync)(data, { maxOutputLength: MAX_IDL_BYTES });
121
+ }
122
+ catch (e) {
123
+ if (e.code === "ERR_BUFFER_TOO_LARGE" || e instanceof RangeError)
124
+ throw new Error(`${what} inflates to more than ${MAX_IDL_BYTES / 1024 / 1024} MiB; refusing it`);
125
+ throw new Error(`${what} is not valid compressed data: ${e.message}`);
126
+ }
127
+ }
128
+ /**
129
+ * Read an IDL back (canonical, or the one `authority` wrote). Only an IDL stored in the metadata account itself is
130
+ * read: one that links to a URL or points at another account is refused (nothing is fetched from where it points).
131
+ */
115
132
  export async function fetchIdl(rpc, program, authority = null) {
116
- return JSON.parse(await fetchMetadataContent(rpc, program, "idl", authority));
133
+ const at = await metadataAddress(program, authority);
134
+ const account = await fetchMaybeMetadata(rpc, at);
135
+ if (!account.exists)
136
+ throw new Error(`no IDL metadata account at ${at}`);
137
+ const m = account.data;
138
+ if (m.dataLength > m.data.length)
139
+ throw new Error(`the IDL metadata at ${at} is truncated`);
140
+ const content = (what) => {
141
+ const raw = new Uint8Array(m.data.subarray(0, m.dataLength));
142
+ const bytes = m.compression === Compression.None ? raw : inflateCapped(raw, m.compression === Compression.Gzip, what);
143
+ // Base58 decodes in quadratic time: only small contents.
144
+ if (m.encoding === Encoding.Base58 && bytes.length > 64 * 1024)
145
+ throw new Error(`${what} is too large for base58`);
146
+ return decodeData(bytes, m.encoding);
147
+ };
148
+ if (m.dataSource === DataSource.Url) {
149
+ const url = content(`the IDL link at ${at}`);
150
+ throw new Error(`the IDL at ${at} is not stored on chain but linked: ${JSON.stringify(url.slice(0, 200))}. Fetch it yourself and pass the file (typesol client <idl.json>)`);
151
+ }
152
+ if (m.dataSource !== DataSource.Direct)
153
+ throw new Error(`the IDL at ${at} is stored in another account (external data), which is not read`);
154
+ return JSON.parse(content(`the IDL at ${at}`));
117
155
  }
118
156
  /** Where `anchor idl init` keeps a program's IDL: an account derived with seed "anchor:idl" from the program's base PDA. */
119
157
  export async function anchorIdlAddress(program) {
120
158
  const [base] = await getProgramDerivedAddress({ programAddress: program, seeds: [] });
121
159
  return createAddressWithSeed({ baseAddress: base, programAddress: program, seed: "anchor:idl" });
122
160
  }
161
+ /** Anchor's account discriminator for IdlAccount: sha256("account:IdlAccount")[0..8]. */
162
+ const IDL_ACCOUNT_DISCRIMINATOR = createHash("sha256").update("account:IdlAccount").digest().subarray(0, 8);
123
163
  /** The IDL in Anchor's IDL account (`anchor idl init`), or null when the program has none. */
124
164
  export async function fetchAnchorIdl(rpc, program) {
125
165
  const { value } = await rpc.getAccountInfo(await anchorIdlAddress(program), { encoding: "base64" }).send();
@@ -129,10 +169,12 @@ export async function fetchAnchorIdl(rpc, program) {
129
169
  // IdlAccount: 8-byte discriminator, authority (32 bytes), a u32 length, then the zlib-compressed JSON.
130
170
  if (data.length < 44)
131
171
  throw new Error("Anchor IDL account is too short");
172
+ if (!data.subarray(0, 8).equals(IDL_ACCOUNT_DISCRIMINATOR))
173
+ throw new Error("the account at Anchor's IDL address is not an IdlAccount");
132
174
  const len = data.readUInt32LE(40);
133
175
  if (44 + len > data.length)
134
176
  throw new Error("Anchor IDL account is truncated");
135
- return JSON.parse(inflateSync(data.subarray(44, 44 + len)).toString("utf8"));
177
+ return JSON.parse(inflateCapped(data.subarray(44, 44 + len), false, "the Anchor IDL").toString("utf8"));
136
178
  }
137
179
  /**
138
180
  * A program's IDL from the chain: the canonical Program Metadata IDL (`typesol idl publish`, Codama) first, else
package/dist/portgen.js CHANGED
@@ -152,7 +152,7 @@ export function generatePort(idl) {
152
152
  if (t.vec) {
153
153
  const inner = tsType(t.vec, where, at);
154
154
  if (where === "variant" || where === "struct" || (where === "account" && !last)) {
155
- 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.`);
155
+ notes.push(`${at}: a Vec here (TypeSOL keeps Anchor's bytes for a growable Vec only as an account's last field or as an argument); replaced by FixedArray, so the layout differs.`);
156
156
  return `${use("FixedArray")}<${inner}, 8> /* TODO: Anchor has a Vec here */`;
157
157
  }
158
158
  return `${use("Vec")}<${inner}>`;
package/dist/svmrpc.d.ts CHANGED
@@ -8,7 +8,11 @@
8
8
  * await counter.accounts.Counter.fetch(rpc, address);
9
9
  *
10
10
  * Transactions run as they are sent (confirmed at once). LiteSVM cannot list accounts, so `getProgramAccounts` scans
11
- * the accounts transactions sent through this connection named, plus those passed to `track`.
11
+ * the accounts transactions sent through this connection named, plus those passed to `track`. Subscriptions see the
12
+ * transactions sent through this connection: `logsNotifications` replays those sent so far (failed ones too, with
13
+ * `err` set), then each new one, as `{ mentions: [address] }` filters them; `accountNotifications` yields the account
14
+ * after each later transaction that names it. Errors are in the JSON shape RPC nodes return
15
+ * (`{ InstructionError: [0, { Custom: 6000 }] }`).
12
16
  */
13
17
  import { type Address, type Rpc, type RpcSubscriptions, type SolanaRpcApi, type SolanaRpcSubscriptionsApi } from "@solana/kit";
14
18
  import { type LiteSVM, type TransactionMetadata } from "litesvm";
@@ -23,4 +27,6 @@ export interface SvmConnection {
23
27
  /** Add accounts for `getProgramAccounts` to scan (accounts the transactions named are scanned already). */
24
28
  track(...addresses: Address[]): void;
25
29
  }
30
+ /** A LiteSVM transaction error in the JSON shape RPC nodes return: `{ InstructionError: [0, { Custom: 6000 }] }`. */
31
+ export declare function rpcError(err: unknown): unknown;
26
32
  export declare function svmConnection(svm: LiteSVM): SvmConnection;
package/dist/svmrpc.js CHANGED
@@ -8,15 +8,81 @@
8
8
  * await counter.accounts.Counter.fetch(rpc, address);
9
9
  *
10
10
  * Transactions run as they are sent (confirmed at once). LiteSVM cannot list accounts, so `getProgramAccounts` scans
11
- * the accounts transactions sent through this connection named, plus those passed to `track`.
11
+ * the accounts transactions sent through this connection named, plus those passed to `track`. Subscriptions see the
12
+ * transactions sent through this connection: `logsNotifications` replays those sent so far (failed ones too, with
13
+ * `err` set), then each new one, as `{ mentions: [address] }` filters them; `accountNotifications` yields the account
14
+ * after each later transaction that names it. Errors are in the JSON shape RPC nodes return
15
+ * (`{ InstructionError: [0, { Custom: 6000 }] }`).
12
16
  */
13
17
  import { getAddressDecoder, getBase58Decoder, getBase58Encoder, getBase64Decoder, getBase64Encoder, getCompiledTransactionMessageDecoder, getTransactionDecoder, } from "@solana/kit";
14
18
  import { FailedTransactionMetadata } from "litesvm";
15
19
  const b64 = (bytes) => getBase64Decoder().decode(bytes);
16
20
  const reply = (value) => ({ send: async () => value });
21
+ // LiteSVM's fieldless error enums by value (const enums: their names are not there at run time).
22
+ const TX_ERRORS = ("AccountInUse AccountLoadedTwice AccountNotFound ProgramAccountNotFound InsufficientFundsForFee InvalidAccountForFee " +
23
+ "AlreadyProcessed BlockhashNotFound CallChainTooDeep MissingSignatureForFee InvalidAccountIndex SignatureFailure " +
24
+ "InvalidProgramForExecution SanitizeFailure ClusterMaintenance AccountBorrowOutstanding WouldExceedMaxBlockCostLimit " +
25
+ "UnsupportedVersion InvalidWritableAccount WouldExceedMaxAccountCostLimit WouldExceedAccountDataBlockLimit " +
26
+ "TooManyAccountLocks AddressLookupTableNotFound InvalidAddressLookupTableOwner InvalidAddressLookupTableData " +
27
+ "InvalidAddressLookupTableIndex InvalidRentPayingAccount WouldExceedMaxVoteCostLimit WouldExceedAccountDataTotalLimit " +
28
+ "MaxLoadedAccountsDataSizeExceeded ResanitizationNeeded InvalidLoadedAccountsDataSizeLimit UnbalancedTransaction " +
29
+ "ProgramCacheHitMaxLimit CommitCancelled").split(" ");
30
+ const IX_ERRORS = ("GenericError InvalidArgument InvalidInstructionData InvalidAccountData AccountDataTooSmall InsufficientFunds " +
31
+ "IncorrectProgramId MissingRequiredSignature AccountAlreadyInitialized UninitializedAccount UnbalancedInstruction " +
32
+ "ModifiedProgramId ExternalAccountLamportSpend ExternalAccountDataModified ReadonlyLamportChange ReadonlyDataModified " +
33
+ "DuplicateAccountIndex ExecutableModified RentEpochModified NotEnoughAccountKeys AccountDataSizeChanged " +
34
+ "AccountNotExecutable AccountBorrowFailed AccountBorrowOutstanding DuplicateAccountOutOfSync InvalidError " +
35
+ "ExecutableDataModified ExecutableLamportChange ExecutableAccountNotRentExempt UnsupportedProgramId CallDepth " +
36
+ "MissingAccount ReentrancyNotAllowed MaxSeedLengthExceeded InvalidSeeds InvalidRealloc ComputationalBudgetExceeded " +
37
+ "PrivilegeEscalation ProgramEnvironmentSetupFailure ProgramFailedToComplete ProgramFailedToCompile Immutable " +
38
+ "IncorrectAuthority AccountNotRentExempt InvalidAccountOwner ArithmeticOverflow UnsupportedSysvar IllegalOwner " +
39
+ "MaxAccountsDataAllocationsExceeded MaxAccountsExceeded MaxInstructionTraceLengthExceeded " +
40
+ "BuiltinProgramsMustConsumeComputeUnits BorshIoError").split(" ");
41
+ /** A LiteSVM transaction error in the JSON shape RPC nodes return: `{ InstructionError: [0, { Custom: 6000 }] }`. */
42
+ export function rpcError(err) {
43
+ if (typeof err === "number")
44
+ return TX_ERRORS[err] ?? String(err);
45
+ if (typeof err !== "object" || err === null)
46
+ return String(err);
47
+ const o = err;
48
+ if (typeof o.err === "function" && typeof o.index === "number") {
49
+ const inner = o.err.call(err);
50
+ const fields = (typeof inner === "object" && inner !== null ? inner : {});
51
+ const ix = typeof inner === "number"
52
+ ? (IX_ERRORS[inner] ?? String(inner))
53
+ : typeof fields.code === "number"
54
+ ? { Custom: fields.code }
55
+ : typeof fields.msg === "string"
56
+ ? { BorshIoError: fields.msg }
57
+ : String(inner);
58
+ return { InstructionError: [o.index, ix] };
59
+ }
60
+ if (typeof o.index === "number")
61
+ return { DuplicateInstruction: o.index };
62
+ if (typeof o.accountIndex === "number")
63
+ return /Restricted/.test(String(err))
64
+ ? { ProgramExecutionTemporarilyRestricted: { account_index: o.accountIndex } }
65
+ : { InsufficientFundsForRent: { account_index: o.accountIndex } };
66
+ return String(err);
67
+ }
17
68
  export function svmConnection(svm) {
18
69
  const transactions = [];
19
70
  const known = new Set();
71
+ // Every transaction sent (failed ones too) for log subscriptions, the account keys of each, and live subscribers.
72
+ const sent = [];
73
+ const accountKeys = new Map();
74
+ const logListeners = new Set();
75
+ const accountListeners = new Map();
76
+ const record = (n) => {
77
+ sent.push(n);
78
+ for (const l of [...logListeners])
79
+ l(n);
80
+ // A failed transaction changed no account.
81
+ if (n.err === null)
82
+ for (const k of new Set(n.keys))
83
+ for (const l of [...(accountListeners.get(k) ?? [])])
84
+ l();
85
+ };
20
86
  const decode = (wire) => {
21
87
  const tx = getTransactionDecoder().decode(getBase64Encoder().encode(wire));
22
88
  return { tx, keys: getCompiledTransactionMessageDecoder().decode(tx.messageBytes).staticAccounts };
@@ -50,12 +116,17 @@ export function svmConnection(svm) {
50
116
  const { tx, keys } = decode(wire);
51
117
  const res = svm.sendTransaction(tx);
52
118
  svm.expireBlockhash();
53
- if (res instanceof FailedTransactionMetadata)
54
- throw new Error(`transaction failed: ${String(res.err())}\n${res.meta().logs().join("\n")}`);
119
+ if (res instanceof FailedTransactionMetadata) {
120
+ const meta = res.meta();
121
+ record({ signature: getBase58Decoder().decode(meta.signature()), logs: meta.logs(), err: rpcError(res.err()), keys });
122
+ throw new Error(`transaction failed: ${String(res.err())}\n${meta.logs().join("\n")}`);
123
+ }
55
124
  for (const k of keys)
56
125
  known.add(k);
57
126
  const signature = getBase58Decoder().decode(res.signature());
58
127
  transactions.push({ signature, meta: res });
128
+ accountKeys.set(signature, keys);
129
+ record({ signature, logs: res.logs(), err: null, keys });
59
130
  return signature;
60
131
  },
61
132
  }),
@@ -77,7 +148,7 @@ export function svmConnection(svm) {
77
148
  if (res instanceof FailedTransactionMetadata)
78
149
  return {
79
150
  context: { slot: 0n },
80
- value: { err: String(res.err()), logs: res.meta().logs(), unitsConsumed: res.meta().computeUnitsConsumed(), returnData: null },
151
+ value: { err: rpcError(res.err()), logs: res.meta().logs(), unitsConsumed: res.meta().computeUnitsConsumed(), returnData: null },
81
152
  };
82
153
  const meta = res.meta();
83
154
  const ret = meta.returnData();
@@ -108,38 +179,81 @@ export function svmConnection(svm) {
108
179
  return want.every((b, i) => data[Number(memcmp.offset) + i] === b);
109
180
  });
110
181
  })),
111
- // The json shape: log messages, and inner instructions with base58 data (what `eventsOf` reads).
182
+ // The json shape: account keys, log messages, and inner instructions with their program and base58 data (what
183
+ // `eventsOf` reads).
112
184
  getTransaction: (signature) => {
113
185
  const t = transactions.find((x) => x.signature === signature);
114
186
  return reply(t
115
187
  ? {
116
188
  slot: 0n,
117
189
  blockTime: null,
190
+ transaction: { signatures: [signature], message: { accountKeys: accountKeys.get(signature) ?? [] } },
118
191
  meta: {
119
192
  err: null,
120
193
  logMessages: t.meta.logs(),
121
194
  computeUnitsConsumed: t.meta.computeUnitsConsumed(),
122
- innerInstructions: t.meta
123
- .innerInstructions()
124
- .map((group, index) => ({ index, instructions: group.map((i) => ({ data: getBase58Decoder().decode(i.instruction().data()) })) })),
195
+ innerInstructions: t.meta.innerInstructions().map((group, index) => ({
196
+ index,
197
+ instructions: group.map((i) => ({
198
+ programIdIndex: i.instruction().programIdIndex(),
199
+ accounts: [...i.instruction().accounts()],
200
+ data: getBase58Decoder().decode(i.instruction().data()),
201
+ stackHeight: i.stackHeight(),
202
+ })),
203
+ })),
125
204
  },
126
205
  }
127
206
  : null);
128
207
  },
129
208
  };
130
- /** Notifications that end when the subscriber aborts. */
131
- const stream = (items) => ({
132
- subscribe: async ({ abortSignal }) => (async function* () {
133
- for (const item of items())
134
- yield item;
135
- await new Promise((resolve) => abortSignal.addEventListener("abort", resolve, { once: true }));
136
- })(),
209
+ /**
210
+ * Notifications: `backlog` first, then each item `listen` pushes, until the subscriber aborts. The listener is in
211
+ * place once `subscribe` is called, so nothing sent after that call is missed.
212
+ */
213
+ const stream = (backlog, listen) => ({
214
+ subscribe: async ({ abortSignal }) => {
215
+ const queue = [...backlog()];
216
+ let wake;
217
+ const stop = listen((item) => {
218
+ queue.push(item);
219
+ wake?.();
220
+ });
221
+ const aborted = new Promise((resolve) => abortSignal.addEventListener("abort", () => resolve(), { once: true }));
222
+ void aborted.then(stop);
223
+ return (async function* () {
224
+ while (!abortSignal.aborted) {
225
+ if (queue.length) {
226
+ yield queue.shift();
227
+ continue;
228
+ }
229
+ await Promise.race([aborted, new Promise((resolve) => (wake = resolve))]);
230
+ wake = undefined;
231
+ }
232
+ })();
233
+ },
137
234
  });
235
+ const none = () => () => undefined;
138
236
  const rpcSubscriptions = {
139
- signatureNotifications: () => stream(() => [{ context: { slot: 0n }, value: { err: null } }]),
140
- slotNotifications: () => stream(() => []),
141
- logsNotifications: () => stream(() => transactions.map((t) => ({ context: { slot: 0n }, value: { signature: t.signature, logs: t.meta.logs(), err: null } }))),
142
- accountNotifications: (a) => stream(() => [{ context: { slot: 0n }, value: encoded(a) ?? { data: ["", "base64"], lamports: 0n } }]),
237
+ signatureNotifications: () => stream(() => [{ context: { slot: 0n }, value: { err: null } }], none),
238
+ slotNotifications: () => stream(() => [], none),
239
+ logsNotifications: (filter = "all") => {
240
+ const wanted = (n) => typeof filter !== "object" || filter.mentions.every((a) => n.keys.includes(a));
241
+ const shape = (n) => ({ context: { slot: 0n }, value: { signature: n.signature, logs: n.logs, err: n.err } });
242
+ return stream(() => sent.filter(wanted).map(shape), (push) => {
243
+ const l = (n) => {
244
+ if (wanted(n))
245
+ push(shape(n));
246
+ };
247
+ logListeners.add(l);
248
+ return () => logListeners.delete(l);
249
+ });
250
+ },
251
+ accountNotifications: (a) => stream(() => [], (push) => {
252
+ const l = () => push({ context: { slot: 0n }, value: encoded(a) ?? { data: ["", "base64"], lamports: 0n } });
253
+ const set = accountListeners.get(a) ?? new Set();
254
+ accountListeners.set(a, set.add(l));
255
+ return () => set.delete(l);
256
+ }),
143
257
  };
144
258
  return {
145
259
  rpc: rpc,
package/dist/testing.js CHANGED
@@ -313,13 +313,33 @@ export function programHarness(svm, programId, idl) {
313
313
  throw new Error(`${instruction} returns nothing`);
314
314
  const data = Buffer.from(harness.ok(res).returnData().data());
315
315
  const t = def.returns;
316
+ const short = (n) => {
317
+ if (data.length < n)
318
+ throw new Error(`${instruction} returned ${data.length} bytes; a ${String(t)} needs ${n}`);
319
+ };
316
320
  if (typeof t === "string") {
317
321
  if (t === "pubkey")
318
- return getAddressDecoder().decode(data);
322
+ return (short(32), getAddressDecoder().decode(data));
319
323
  if (t === "bool")
320
- return (data[0] !== 0);
324
+ return (short(1), data[0] !== 0);
325
+ if (t === "f32")
326
+ return (short(4), data.readFloatLE(0));
327
+ if (t === "f64")
328
+ return (short(8), data.readDoubleLE(0));
329
+ if (t === "string" || t === "bytes") {
330
+ // Borsh: a u32 length, then the bytes (UTF-8 for a string).
331
+ short(4);
332
+ const len = data.readUInt32LE(0);
333
+ short(4 + len);
334
+ const body = data.subarray(4, 4 + len);
335
+ return (t === "string" ? body.toString("utf8") : new Uint8Array(body));
336
+ }
337
+ const int = /^[ui](8|16|32|64|128)$/.exec(t);
338
+ if (!int)
339
+ throw new Error(`decode ${instruction}'s return data yourself (${t})`);
321
340
  const signed = t.startsWith("i");
322
- const bytes = Number(t.slice(1)) / 8;
341
+ const bytes = Number(int[1]) / 8;
342
+ short(bytes);
323
343
  let n = 0n;
324
344
  for (let i = bytes - 1; i >= 0; i--)
325
345
  n = (n << 8n) | BigInt(data[i]);