@typesol/cli 0.21.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/README.md +1 -1
- package/dist/clientgen.js +357 -53
- package/dist/svmrpc.d.ts +26 -0
- package/dist/svmrpc.js +150 -0
- package/dist/testing.d.ts +4 -1
- package/dist/testing.js +3 -0
- package/docs/client.md +14 -12
- package/docs/test-your-program.md +4 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,28 @@ until 1.0, a minor version may change the language, the IDL layout or error code
|
|
|
5
5
|
|
|
6
6
|
## Unreleased
|
|
7
7
|
|
|
8
|
+
## 0.22.0 — 2026-10-28
|
|
9
|
+
|
|
10
|
+
Generated clients fill in the accounts Anchor's client still asked you to pass (signers from a default signer, PDAs
|
|
11
|
+
seeded from string, byte and argument fields, PDAs under a program given as an argument, a helper for PDAs seeded from
|
|
12
|
+
their own fields), read Anchor zero-copy accounts and generic types, and run in tests over LiteSVM. Programs and their
|
|
13
|
+
bytes are unchanged. Not externally audited (see SECURITY.md).
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- Generated clients fill in the accounts Anchor's client still asks for:
|
|
17
|
+
- `resolve.<instruction>(rpc, accounts, args?, { signer })` fills signer accounts that were left out with a default
|
|
18
|
+
signer (the wallet), as Anchor's client uses the provider's wallet.
|
|
19
|
+
- PDAs seeded from another account's string, byte and `FixedString` fields are derived by `resolve`, as scalar fields
|
|
20
|
+
already were.
|
|
21
|
+
- A PDA seeded from its own fields gets a `pdas.<name>({ field values })` helper (its address is needed to read it).
|
|
22
|
+
- A PDA whose program is a `Pubkey` argument is derived by the instruction builder.
|
|
23
|
+
- Clients generated from Anchor IDLs read and write zero-copy (`bytemuck`, `AccountLoader`) accounts at their
|
|
24
|
+
`#[repr(C)]` offsets on SBF (padding, `packed` and `align` included), checked byte for byte against an Anchor
|
|
25
|
+
program, and turn generic types into one concrete type per use (`Pair<u64, 2>` → `PairU64N2`).
|
|
26
|
+
- PDAs seeded from fields of a struct argument (`seeds = [params.id]`) are derived by the instruction builder.
|
|
27
|
+
- `typesol/testing`: `svmConnection(svm)` (and `t.connection`), a kit RPC and subscriptions pair answered by LiteSVM,
|
|
28
|
+
so a generated client sends, fetches, simulates, resolves and watches in tests as it does against a cluster.
|
|
29
|
+
|
|
8
30
|
## 0.21.0 — 2026-10-28
|
|
9
31
|
|
|
10
32
|
Clients from a program's address alone, and the last pieces Anchor's client resolves for you: `has_one` accounts,
|
package/README.md
CHANGED
|
@@ -306,7 +306,7 @@ Details are in [docs/testing.md](docs/testing.md).
|
|
|
306
306
|
|
|
307
307
|
## Status
|
|
308
308
|
|
|
309
|
-
**TypeSOL is pre-1.0 (0.
|
|
309
|
+
**TypeSOL is pre-1.0 (0.22).** Releases follow the organization's
|
|
310
310
|
[versioning policy](https://github.com/typesol/.github/blob/main/VERSIONING.md); [CHANGELOG.md](CHANGELOG.md) lists every
|
|
311
311
|
change.
|
|
312
312
|
- The compiler, runtime and tooling have been through internal security reviews and are covered by the tests above.
|
package/dist/clientgen.js
CHANGED
|
@@ -419,9 +419,123 @@ class Names {
|
|
|
419
419
|
}
|
|
420
420
|
}
|
|
421
421
|
}
|
|
422
|
+
/**
|
|
423
|
+
* Generic types (Anchor `struct Wrap<T, const N: usize>`) as concrete ones: each use with type and const arguments
|
|
424
|
+
* (`Wrap<u64, 4>`) becomes its own type definition (`WrapU64N4`), and the uses point at it. Generic definitions
|
|
425
|
+
* themselves are left as they are (and out of the client); uses whose arguments are still generic are kept.
|
|
426
|
+
*/
|
|
427
|
+
function monomorphize(idl) {
|
|
428
|
+
const defs = new Map((idl.types ?? []).map((t) => [t.name, t]));
|
|
429
|
+
if (![...defs.values()].some((d) => d.generics?.length))
|
|
430
|
+
return idl;
|
|
431
|
+
const taken = new Set(defs.keys());
|
|
432
|
+
const made = new Map(); // instantiation key → new type name
|
|
433
|
+
const extra = [];
|
|
434
|
+
const label = (t) => {
|
|
435
|
+
if (typeof t === "number")
|
|
436
|
+
return `N${t}`;
|
|
437
|
+
if (typeof t === "string")
|
|
438
|
+
return pascal(t.replace(/[^A-Za-z0-9_]/g, ""));
|
|
439
|
+
if (t.vec !== undefined)
|
|
440
|
+
return `Vec${label(t.vec)}`;
|
|
441
|
+
if (t.option !== undefined)
|
|
442
|
+
return `Option${label(t.option)}`;
|
|
443
|
+
if (t.coption !== undefined)
|
|
444
|
+
return `COption${label(t.coption)}`;
|
|
445
|
+
if (t.array)
|
|
446
|
+
return `Array${label(t.array[0])}N${String(t.array[1]).replace(/[^0-9]/g, "")}`;
|
|
447
|
+
return pascal((definedName(t) ?? "T").replace(/[^A-Za-z0-9_]/g, ""));
|
|
448
|
+
};
|
|
449
|
+
const hasGeneric = (t) => JSON.stringify(t ?? null).includes('"generic"');
|
|
450
|
+
const inst = (t, env) => {
|
|
451
|
+
if (typeof t === "string")
|
|
452
|
+
return t;
|
|
453
|
+
if (t.generic !== undefined) {
|
|
454
|
+
const v = env.get(t.generic);
|
|
455
|
+
return v === undefined || typeof v === "number" ? t : v;
|
|
456
|
+
}
|
|
457
|
+
if (t.vec !== undefined)
|
|
458
|
+
return { vec: inst(t.vec, env) };
|
|
459
|
+
if (t.option !== undefined)
|
|
460
|
+
return { option: inst(t.option, env) };
|
|
461
|
+
if (t.coption !== undefined)
|
|
462
|
+
return { coption: inst(t.coption, env) };
|
|
463
|
+
if (t.array) {
|
|
464
|
+
const len = t.array[1];
|
|
465
|
+
const g = typeof len === "object" && len !== null ? len.generic : undefined;
|
|
466
|
+
const n = g !== undefined ? env.get(g) : len;
|
|
467
|
+
return { array: [inst(t.array[0], env), typeof n === "number" ? n : len] };
|
|
468
|
+
}
|
|
469
|
+
const name = definedName(t);
|
|
470
|
+
const args = typeof t.defined === "object" ? (t.defined.generics ?? []) : [];
|
|
471
|
+
if (name === undefined || !args.length)
|
|
472
|
+
return t;
|
|
473
|
+
const values = args.map((a) => a?.kind === "const"
|
|
474
|
+
? Number.isSafeInteger(Number(a.value)) && /^\d+$/.test(String(a.value))
|
|
475
|
+
? Number(a.value)
|
|
476
|
+
: undefined
|
|
477
|
+
: a?.type === undefined
|
|
478
|
+
? undefined
|
|
479
|
+
: inst(a.type, env));
|
|
480
|
+
const def = defs.get(name);
|
|
481
|
+
const params = (def?.generics ?? []);
|
|
482
|
+
if (!def ||
|
|
483
|
+
values.some((v) => v === undefined || hasGeneric(v)) ||
|
|
484
|
+
params.length !== values.length ||
|
|
485
|
+
params.some((p) => typeof p?.name !== "string" || !NAME.test(p.name)))
|
|
486
|
+
return { defined: { name, generics: args } };
|
|
487
|
+
const key = JSON.stringify([name, values]);
|
|
488
|
+
let made1 = made.get(key);
|
|
489
|
+
if (!made1) {
|
|
490
|
+
let base = `${name}${values.map((v) => label(v)).join("")}`;
|
|
491
|
+
if (!NAME.test(base))
|
|
492
|
+
base = `${name}Instance`;
|
|
493
|
+
made1 = base;
|
|
494
|
+
for (let i = 2; taken.has(made1); i++)
|
|
495
|
+
made1 = `${base}${i}`;
|
|
496
|
+
taken.add(made1);
|
|
497
|
+
made.set(key, made1);
|
|
498
|
+
const env2 = new Map(params.map((p, i) => [p.name, values[i]]));
|
|
499
|
+
const body = def.type;
|
|
500
|
+
const field = (f) => (isField(f) ? { ...f, type: inst(f.type, env2) } : inst(f, env2));
|
|
501
|
+
extra.push({
|
|
502
|
+
...def,
|
|
503
|
+
name: made1,
|
|
504
|
+
generics: undefined,
|
|
505
|
+
type: {
|
|
506
|
+
...body,
|
|
507
|
+
...(body.fields ? { fields: body.fields.map(field) } : {}),
|
|
508
|
+
...(body.variants ? { variants: body.variants.map((v) => ({ ...v, ...(v.fields ? { fields: v.fields.map(field) } : {}) })) } : {}),
|
|
509
|
+
...(body.alias !== undefined ? { alias: inst(body.alias, env2) } : {}),
|
|
510
|
+
},
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
return { defined: made1 };
|
|
514
|
+
};
|
|
515
|
+
const none = new Map();
|
|
516
|
+
const field = (f) => (isField(f) ? { ...f, type: inst(f.type, none) } : inst(f, none));
|
|
517
|
+
const types = (idl.types ?? []).map((d) => d.generics?.length
|
|
518
|
+
? d
|
|
519
|
+
: {
|
|
520
|
+
...d,
|
|
521
|
+
type: {
|
|
522
|
+
...d.type,
|
|
523
|
+
...(d.type.fields ? { fields: d.type.fields.map(field) } : {}),
|
|
524
|
+
...(d.type.variants ? { variants: d.type.variants.map((v) => ({ ...v, ...(v.fields ? { fields: v.fields.map(field) } : {}) })) } : {}),
|
|
525
|
+
...(d.type.alias !== undefined ? { alias: inst(d.type.alias, none) } : {}),
|
|
526
|
+
},
|
|
527
|
+
});
|
|
528
|
+
const instructions = idl.instructions.map((ix) => ({
|
|
529
|
+
...ix,
|
|
530
|
+
args: ix.args.map((a) => ({ ...a, type: inst(a.type, none) })),
|
|
531
|
+
...(ix.returns !== undefined ? { returns: inst(ix.returns, none) } : {}),
|
|
532
|
+
}));
|
|
533
|
+
return { ...idl, instructions, types: [...types, ...extra] };
|
|
534
|
+
}
|
|
422
535
|
export function generateClient(idl, opts = {}) {
|
|
423
536
|
const programAddress = opts.address ?? idl.address;
|
|
424
537
|
validate(idl, programAddress);
|
|
538
|
+
idl = monomorphize(idl);
|
|
425
539
|
if (!programAddress)
|
|
426
540
|
throw new ClientIdlError("the IDL has no program address; pass one");
|
|
427
541
|
const typesol = idl.metadata?.description === "Built with TypeSOL";
|
|
@@ -448,6 +562,70 @@ export function generateClient(idl, opts = {}) {
|
|
|
448
562
|
return { kind: "fenum", base: n.slice("FixedEnum_".length), size: 1 + arrayLen(f[1].type) };
|
|
449
563
|
return undefined;
|
|
450
564
|
};
|
|
565
|
+
// ---- Zero-copy (bytemuck) layouts: #[repr(C)] in memory, as the program sees it on SBF ------------------------
|
|
566
|
+
const zeroCopy = (d) => d?.serialization === "bytemuck" || d?.serialization === "bytemuckunsafe";
|
|
567
|
+
const layouts = new Map();
|
|
568
|
+
/** Size and alignment of a type inside a zero-copy struct, or why it cannot be in one. */
|
|
569
|
+
function cLayout(t) {
|
|
570
|
+
if (typeof t === "string") {
|
|
571
|
+
if (t in INTS)
|
|
572
|
+
return { size: INTS[t] / 8, align: Math.min(INTS[t] / 8, 8) }; // SBF aligns u128/i128 to 8, not 16
|
|
573
|
+
if (t in FLOATS)
|
|
574
|
+
return { size: FLOATS[t] / 8, align: FLOATS[t] / 8 };
|
|
575
|
+
if (t === "bool")
|
|
576
|
+
return { size: 1, align: 1 };
|
|
577
|
+
if (t === "pubkey" || t === "publicKey")
|
|
578
|
+
return { size: 32, align: 1 };
|
|
579
|
+
return `"${t}" has no fixed size`;
|
|
580
|
+
}
|
|
581
|
+
if (t.array) {
|
|
582
|
+
const e = cLayout(t.array[0]);
|
|
583
|
+
const n = Number(t.array[1]);
|
|
584
|
+
return typeof e === "string" ? e : { size: e.size * n, align: e.align };
|
|
585
|
+
}
|
|
586
|
+
const n = definedName(t);
|
|
587
|
+
const d = defs.get(n ?? "");
|
|
588
|
+
if (!n || !d || !zeroCopy(d))
|
|
589
|
+
return `${n ?? "the type"} is not a zero-copy type`;
|
|
590
|
+
const l = structLayout(d);
|
|
591
|
+
return typeof l === "string" ? l : { size: l.size, align: l.align };
|
|
592
|
+
}
|
|
593
|
+
function structLayout(d) {
|
|
594
|
+
const known = layouts.get(d.name);
|
|
595
|
+
if (known)
|
|
596
|
+
return known;
|
|
597
|
+
layouts.set(d.name, `type ${d.name} contains itself`);
|
|
598
|
+
const repr = (d.repr ?? {});
|
|
599
|
+
const fields = d.type.kind === "struct" ? (d.type.fields ?? []) : undefined;
|
|
600
|
+
let out;
|
|
601
|
+
if (!fields || !fields.every(isField))
|
|
602
|
+
out = `zero-copy type ${d.name} is not a struct with named fields`;
|
|
603
|
+
else if (repr.kind === "rust")
|
|
604
|
+
out = `zero-copy type ${d.name} has Rust's unspecified layout`;
|
|
605
|
+
else {
|
|
606
|
+
let offset = 0;
|
|
607
|
+
let align = 1;
|
|
608
|
+
const offsets = [];
|
|
609
|
+
let why;
|
|
610
|
+
for (const f of fields) {
|
|
611
|
+
const l = cLayout(f.type);
|
|
612
|
+
if (typeof l === "string") {
|
|
613
|
+
why = `${d.name}.${f.name}: ${l}`;
|
|
614
|
+
break;
|
|
615
|
+
}
|
|
616
|
+
const a = repr.packed ? 1 : l.align;
|
|
617
|
+
offset = Math.ceil(offset / a) * a;
|
|
618
|
+
offsets.push(offset);
|
|
619
|
+
offset += l.size;
|
|
620
|
+
align = Math.max(align, a);
|
|
621
|
+
}
|
|
622
|
+
if (Number.isInteger(repr.align) && repr.align > 0)
|
|
623
|
+
align = Math.max(align, repr.align);
|
|
624
|
+
out = why ?? { size: Math.ceil(offset / align) * align, align, offsets };
|
|
625
|
+
}
|
|
626
|
+
layouts.set(d.name, out);
|
|
627
|
+
return out;
|
|
628
|
+
}
|
|
451
629
|
// ---- What can be expressed --------------------------------------------------------------------------------
|
|
452
630
|
const typeIssues = new Map();
|
|
453
631
|
/** Why a type cannot be generated, or undefined when it can. */
|
|
@@ -483,9 +661,14 @@ export function generateClient(idl, opts = {}) {
|
|
|
483
661
|
if (!d)
|
|
484
662
|
why = `type ${n} is not defined in the IDL`;
|
|
485
663
|
else if (d.generics?.length)
|
|
486
|
-
why = `type ${n} is generic`;
|
|
664
|
+
why = `type ${n} is generic (each use with concrete arguments is generated as its own type)`;
|
|
665
|
+
else if (zeroCopy(d)) {
|
|
666
|
+
const l = structLayout(d);
|
|
667
|
+
if (typeof l === "string")
|
|
668
|
+
why = l;
|
|
669
|
+
}
|
|
487
670
|
else if (d.serialization && d.serialization !== "borsh")
|
|
488
|
-
why = `type ${n} uses ${d.serialization} serialization
|
|
671
|
+
why = `type ${n} uses ${d.serialization} serialization, which is not supported`;
|
|
489
672
|
else if (special(n)?.kind === "foption")
|
|
490
673
|
why = issue(special(n).inner);
|
|
491
674
|
else if (d.type.kind === "struct")
|
|
@@ -547,6 +730,7 @@ export function generateClient(idl, opts = {}) {
|
|
|
547
730
|
let needsFixedOption = false;
|
|
548
731
|
let needsFixedVec = false;
|
|
549
732
|
let needsUnitEnum = false;
|
|
733
|
+
let needsCStruct = false;
|
|
550
734
|
function codec(t) {
|
|
551
735
|
if (typeof t === "string") {
|
|
552
736
|
if (t in INTS || t in FLOATS) {
|
|
@@ -640,6 +824,10 @@ export function generateClient(idl, opts = {}) {
|
|
|
640
824
|
const d = defs.get(n);
|
|
641
825
|
if (!d)
|
|
642
826
|
return undefined;
|
|
827
|
+
if (zeroCopy(d)) {
|
|
828
|
+
const l = structLayout(d);
|
|
829
|
+
return typeof l === "string" ? undefined : l.size;
|
|
830
|
+
}
|
|
643
831
|
if (isUnitEnum(d))
|
|
644
832
|
return 1;
|
|
645
833
|
if (d.type.kind !== "struct")
|
|
@@ -676,6 +864,16 @@ export function generateClient(idl, opts = {}) {
|
|
|
676
864
|
continue;
|
|
677
865
|
}
|
|
678
866
|
const named = fields;
|
|
867
|
+
if (zeroCopy(d)) {
|
|
868
|
+
// Fields at their #[repr(C)] offsets (padding written as zeros), not one after another as Borsh would.
|
|
869
|
+
const l = structLayout(d);
|
|
870
|
+
needsCStruct = true;
|
|
871
|
+
typeOut.push(`${doc(d.docs)}export interface ${T} {\n${named.map((f) => `${doc(f.docs, " ")} ${prop(camel(f.name))}: ${tsType(f.type, "out")};`).join("\n")}\n}\n` +
|
|
872
|
+
`export interface ${T}Args {\n${named.map((f) => ` ${prop(camel(f.name))}: ${tsType(f.type, "in")};`).join("\n")}\n}\n` +
|
|
873
|
+
`/** Zero-copy (#[repr(C)]) layout: ${l.size} bytes. */\n` +
|
|
874
|
+
`export function get${T}Codec(): Codec<${T}Args, ${T}> {\n return cStruct([${named.map((f, i) => `[${JSON.stringify(camel(f.name))}, ${codec(f.type)}, ${l.offsets[i]}]`).join(", ")}], ${l.size}) ${cast};\n}\n`);
|
|
875
|
+
continue;
|
|
876
|
+
}
|
|
679
877
|
use("getStructCodec");
|
|
680
878
|
typeOut.push(`${doc(d.docs)}export interface ${T} {\n${named.map((f) => `${doc(f.docs, " ")} ${prop(camel(f.name))}: ${tsType(f.type, "out")};`).join("\n")}\n}\n` +
|
|
681
879
|
`export interface ${T}Args {\n${named.map((f) => ` ${prop(camel(f.name))}: ${tsType(f.type, "in")};`).join("\n")}\n}\n` +
|
|
@@ -839,12 +1037,20 @@ export function generateClient(idl, opts = {}) {
|
|
|
839
1037
|
if (s.kind === "const")
|
|
840
1038
|
return bytes(s.value);
|
|
841
1039
|
if (s.kind === "arg") {
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
1040
|
+
// `params.id`: a field (of a field…) of a struct argument.
|
|
1041
|
+
const [head, ...rest] = s.path.split(".");
|
|
1042
|
+
let t = argType.get(head);
|
|
845
1043
|
if (t === undefined)
|
|
846
|
-
return { why: `the seed argument ${
|
|
847
|
-
|
|
1044
|
+
return { why: `the seed argument ${head} does not exist` };
|
|
1045
|
+
let v = `args.${camel(head)}`;
|
|
1046
|
+
for (const part of rest) {
|
|
1047
|
+
const d = defs.get(definedName(t) ?? "");
|
|
1048
|
+
const f = d?.type.kind === "struct" ? (d.type.fields ?? []).find((x) => isField(x) && x.name === part) : undefined;
|
|
1049
|
+
if (!f || !NAME.test(part))
|
|
1050
|
+
return { why: `the seed ${s.path} is not a field of a struct argument` };
|
|
1051
|
+
t = f.type;
|
|
1052
|
+
v += `[${JSON.stringify(camel(part))}]`;
|
|
1053
|
+
}
|
|
848
1054
|
if (t === "string")
|
|
849
1055
|
return (use("getUtf8Encoder"), `getUtf8Encoder().encode(${v})`);
|
|
850
1056
|
if (t === "bytes" || (typeof t === "object" && t.vec !== undefined && isU8(t.vec)))
|
|
@@ -874,8 +1080,10 @@ export function generateClient(idl, opts = {}) {
|
|
|
874
1080
|
const target = p.path.includes(".") ? undefined : byPath(p.path, a.group);
|
|
875
1081
|
program = target ? `key($${target.key})` : { why: `the PDA program ${p.path} is not an account of the instruction` };
|
|
876
1082
|
}
|
|
877
|
-
else if (p?.kind === "arg")
|
|
878
|
-
|
|
1083
|
+
else if (p?.kind === "arg") {
|
|
1084
|
+
const t = p.path.includes(".") ? undefined : argType.get(p.path);
|
|
1085
|
+
program = t === "pubkey" || t === "publicKey" ? `args.${camel(p.path)}` : { why: "the PDA program comes from an argument that is not a Pubkey" };
|
|
1086
|
+
}
|
|
879
1087
|
const why = [...seeds, ...(program === undefined ? [] : [program])].find((x) => typeof x === "object");
|
|
880
1088
|
if (why) {
|
|
881
1089
|
notes.push(`instruction ${ix.name}: ${a.name} is not filled in (${why.why}); pass it`);
|
|
@@ -942,8 +1150,10 @@ export function generateClient(idl, opts = {}) {
|
|
|
942
1150
|
const inputs = [...a.pda.seeds, ...(a.pda.program ? [a.pda.program] : [])].filter((s) => s.kind !== "const");
|
|
943
1151
|
const params = [
|
|
944
1152
|
...new Map(inputs.map((s) => {
|
|
945
|
-
if (s.kind === "arg")
|
|
946
|
-
|
|
1153
|
+
if (s.kind === "arg") {
|
|
1154
|
+
const head = s.path.split(".")[0];
|
|
1155
|
+
return [camel(head), `${camel(head)}: ${tsType(argType.get(head), "in")}`];
|
|
1156
|
+
}
|
|
947
1157
|
const k = byPath(s.path, a.group).key;
|
|
948
1158
|
return [k, `${k}: Address | TransactionSigner`];
|
|
949
1159
|
})).values(),
|
|
@@ -978,8 +1188,38 @@ export function generateClient(idl, opts = {}) {
|
|
|
978
1188
|
lines.join("\n") +
|
|
979
1189
|
`\n return {\n programAddress: PROGRAM_ADDRESS,\n accounts: [\n${metas.join("\n")}\n ...remaining,\n ],\n data: ${data},\n } as Instruction;\n}\n`);
|
|
980
1190
|
ixKeys.push(`${prop(fn)}: build${P}`);
|
|
981
|
-
//
|
|
982
|
-
//
|
|
1191
|
+
// `resolve.<instruction>`: what needs the chain or the caller's wallet, filled in on a copy of the accounts in this
|
|
1192
|
+
// order: signers (a default signer), has_one accounts, then PDAs seeded from other accounts' fields.
|
|
1193
|
+
const acc = (f) => `acc${NAME.test(f.key) && !JS_RESERVED.has(f.key) ? `.${f.key}` : `[${JSON.stringify(f.key)}]`}`;
|
|
1194
|
+
// A field's value as seed bytes: scalars by their codec, strings as UTF-8, byte fields as they are.
|
|
1195
|
+
const seedOfField = (t, v) => {
|
|
1196
|
+
if (typeof t === "string") {
|
|
1197
|
+
if (t in INTS || t === "bool" || t === "pubkey" || t === "publicKey")
|
|
1198
|
+
return `${codec(t)}.encode(${v})`;
|
|
1199
|
+
if (t === "string")
|
|
1200
|
+
return (use("getUtf8Encoder"), `getUtf8Encoder().encode(${v})`);
|
|
1201
|
+
if (t === "bytes")
|
|
1202
|
+
return `Uint8Array.from(${v})`;
|
|
1203
|
+
return undefined;
|
|
1204
|
+
}
|
|
1205
|
+
if ((t.vec !== undefined && isU8(t.vec)) || (t.array && isU8(t.array[0])))
|
|
1206
|
+
return `Uint8Array.from(${v})`;
|
|
1207
|
+
const name = definedName(t);
|
|
1208
|
+
if (name === undefined)
|
|
1209
|
+
return undefined;
|
|
1210
|
+
if (special(name)?.kind === "fstring")
|
|
1211
|
+
return (use("getUtf8Encoder"), `getUtf8Encoder().encode(${v})`);
|
|
1212
|
+
const d = defs.get(name);
|
|
1213
|
+
return d?.type.kind === "enum" && (d.type.variants ?? []).every((x) => !x.fields?.length) ? `${codec(t)}.encode(${v})` : undefined;
|
|
1214
|
+
};
|
|
1215
|
+
// The builder's "pass it" note for an account, pointing at what fills it in instead.
|
|
1216
|
+
const renote = (a, how) => {
|
|
1217
|
+
const i = notes.findIndex((n) => n.startsWith(`instruction ${ix.name}: ${a.name} is not filled in (`));
|
|
1218
|
+
if (i >= 0)
|
|
1219
|
+
notes[i] = `${notes[i].replace(/; pass it$/, "")}; ${how}`;
|
|
1220
|
+
};
|
|
1221
|
+
const fieldOf = (accountType, field) => (defs.get(accountType ?? "")?.type.fields ?? []).find((f) => typeof f === "object" && f !== null && f.name === field);
|
|
1222
|
+
const signers = flat.filter((a) => a.signer && !auto(a) && !a.optional);
|
|
983
1223
|
const rels = [];
|
|
984
1224
|
for (const a of flat) {
|
|
985
1225
|
if (auto(a) || a.optional || a.signer || !a.relations?.length)
|
|
@@ -997,30 +1237,30 @@ export function generateClient(idl, opts = {}) {
|
|
|
997
1237
|
break;
|
|
998
1238
|
}
|
|
999
1239
|
}
|
|
1000
|
-
// PDAs seeded from fields of another account of the instruction (Anchor `seeds = [b"vault", book.owner]`): the
|
|
1001
|
-
// account is read on chain, then the PDA derived. Only scalar fields (integers, bool, Pubkey, unit enums).
|
|
1002
|
-
const inputOf = (f) => `input${NAME.test(f.key) && !JS_RESERVED.has(f.key) ? `.${f.key}` : `[${JSON.stringify(f.key)}]`}`;
|
|
1003
|
-
const scalarSeed = (t) => {
|
|
1004
|
-
if (t === undefined)
|
|
1005
|
-
return false;
|
|
1006
|
-
if (typeof t === "string")
|
|
1007
|
-
return t in INTS || t === "bool" || t === "pubkey" || t === "publicKey";
|
|
1008
|
-
if (!t.defined)
|
|
1009
|
-
return false;
|
|
1010
|
-
const d = defs.get(definedName(t) ?? "");
|
|
1011
|
-
return d?.type.kind === "enum" && (d.type.variants ?? []).every((v) => !v.fields?.length);
|
|
1012
|
-
};
|
|
1013
1240
|
const fieldPdas = [];
|
|
1014
1241
|
for (const a of flat) {
|
|
1015
1242
|
if (!a.pda || a.address || resolvable.has(a.key) || a.optional)
|
|
1016
1243
|
continue;
|
|
1017
1244
|
const p = a.pda.program;
|
|
1018
|
-
if (p && p.kind
|
|
1245
|
+
if (p && p.kind === "account")
|
|
1019
1246
|
continue;
|
|
1247
|
+
let program = "PROGRAM_ADDRESS";
|
|
1248
|
+
if (p?.kind === "const")
|
|
1249
|
+
program = `programFromBytes(${bytes(p.value)})`;
|
|
1250
|
+
else if (p?.kind === "arg") {
|
|
1251
|
+
const t = argType.get(p.path ?? "");
|
|
1252
|
+
if (t !== "pubkey" && t !== "publicKey")
|
|
1253
|
+
continue;
|
|
1254
|
+
program = `args.${camel(p.path)}`;
|
|
1255
|
+
}
|
|
1020
1256
|
const bases = new Map();
|
|
1021
1257
|
const seeds = [];
|
|
1258
|
+
const own = [];
|
|
1259
|
+
// Inputs of a `pdas` helper for a PDA seeded from its own fields: its own field values, accounts and arguments.
|
|
1260
|
+
const params = new Map();
|
|
1261
|
+
let autoDep = false;
|
|
1022
1262
|
let ok = true;
|
|
1023
|
-
let usesArgs =
|
|
1263
|
+
let usesArgs = p?.kind === "arg";
|
|
1024
1264
|
for (const sd of a.pda.seeds) {
|
|
1025
1265
|
if (sd.kind === "const")
|
|
1026
1266
|
seeds.push(bytes(sd.value));
|
|
@@ -1028,62 +1268,109 @@ export function generateClient(idl, opts = {}) {
|
|
|
1028
1268
|
const e = seedExpr(sd, a.group);
|
|
1029
1269
|
if (typeof e !== "string")
|
|
1030
1270
|
ok = false;
|
|
1031
|
-
else
|
|
1032
|
-
|
|
1271
|
+
else {
|
|
1272
|
+
seeds.push(e);
|
|
1273
|
+
usesArgs = true;
|
|
1274
|
+
const head = sd.path.split(".")[0];
|
|
1275
|
+
params.set(camel(head), `${camel(head)}: ${tsType(argType.get(head), "in")}`);
|
|
1276
|
+
}
|
|
1033
1277
|
}
|
|
1034
1278
|
else if (!sd.path.includes(".")) {
|
|
1035
1279
|
const t = byPath(sd.path, a.group);
|
|
1036
|
-
if (!t || t === a ||
|
|
1280
|
+
if (!t || t === a || t.optional)
|
|
1037
1281
|
ok = false;
|
|
1038
|
-
else
|
|
1039
|
-
|
|
1282
|
+
else {
|
|
1283
|
+
use("getAddressEncoder");
|
|
1284
|
+
seeds.push(`getAddressEncoder().encode(key(${acc(t)}!))`);
|
|
1285
|
+
params.set(t.key, `${t.key}: Address | TransactionSigner`);
|
|
1286
|
+
if (auto(t))
|
|
1287
|
+
autoDep = true;
|
|
1288
|
+
}
|
|
1040
1289
|
}
|
|
1041
1290
|
else {
|
|
1042
1291
|
const parts = sd.path.split(".");
|
|
1043
1292
|
const base = parts.length === 2 ? byPath(parts[0], a.group) : undefined;
|
|
1044
|
-
const
|
|
1045
|
-
const
|
|
1046
|
-
|
|
1293
|
+
const field = fieldOf(sd.account, parts[1]);
|
|
1294
|
+
const consts = accountConsts.find((c) => c.name === sd.account);
|
|
1295
|
+
const enc = field && seedOfField(field.type, base === a ? `seeds.${camel(parts[1])}` : `${"$$" + (base?.key ?? "")}[${JSON.stringify(camel(parts[1]))}]`);
|
|
1296
|
+
if (!base || !field || !enc || base.optional)
|
|
1297
|
+
ok = false;
|
|
1298
|
+
else if (base === a) {
|
|
1299
|
+
own.push({ field: parts[1], type: field.type });
|
|
1300
|
+
params.set(camel(parts[1]), `${camel(parts[1])}: ${tsType(field.type, "in")}`);
|
|
1301
|
+
seeds.push(enc);
|
|
1302
|
+
}
|
|
1303
|
+
else if (!consts || auto(base))
|
|
1047
1304
|
ok = false;
|
|
1048
1305
|
else {
|
|
1049
|
-
bases.set(base.key, { base, A:
|
|
1050
|
-
seeds.push(
|
|
1306
|
+
bases.set(base.key, { base, A: consts.A });
|
|
1307
|
+
seeds.push(enc);
|
|
1051
1308
|
}
|
|
1052
1309
|
}
|
|
1053
1310
|
if (!ok)
|
|
1054
1311
|
break;
|
|
1055
1312
|
}
|
|
1056
|
-
if (!ok
|
|
1313
|
+
if (!ok)
|
|
1314
|
+
continue;
|
|
1315
|
+
if (own.length) {
|
|
1316
|
+
// Seeded from its own fields: the address is needed to read them, so the caller gives the values instead.
|
|
1317
|
+
if (bases.size)
|
|
1318
|
+
continue;
|
|
1319
|
+
const json = JSON.stringify(a.pda);
|
|
1320
|
+
let pn = camel(a.name);
|
|
1321
|
+
if (pdaFns.has(pn) && pdaFns.get(pn).json !== json) {
|
|
1322
|
+
pn = `${fn}${pascal(a.name)}`;
|
|
1323
|
+
for (let i = 2; pdaFns.has(pn) && pdaFns.get(pn).json !== json; i++)
|
|
1324
|
+
pn = `${fn}${pascal(a.name)}${i}`;
|
|
1325
|
+
}
|
|
1326
|
+
renote(a, `pass it (pdas.${pn} derives it from the field values)`);
|
|
1327
|
+
if (pdaFns.has(pn))
|
|
1328
|
+
continue;
|
|
1329
|
+
if (p?.kind === "arg")
|
|
1330
|
+
params.set(camel(p.path), `${camel(p.path)}: Address`);
|
|
1331
|
+
const rewrite = (e) => e
|
|
1332
|
+
.replace(/\bargs\.([A-Za-z0-9_$]+)/g, "seeds.$1")
|
|
1333
|
+
.replace(/key\(acc\.([A-Za-z0-9_$]+)!\)/g, "key(seeds.$1)")
|
|
1334
|
+
.replace(/key\(acc\["([^"]+)"\]!\)/g, 'key(seeds["$1"])');
|
|
1335
|
+
use("type ProgramDerivedAddress", "getProgramDerivedAddress");
|
|
1336
|
+
pdaFns.set(pn, {
|
|
1337
|
+
json,
|
|
1338
|
+
source: ` /** The ${a.name} PDA (as ${ix.name} derives it), from the values of its own fields it is seeded with (${own.map((o) => camel(o.field)).join(", ")}). */\n ${prop(pn)}(seeds: { ${[...params.values()].join("; ")} }): Promise<ProgramDerivedAddress> {\n return getProgramDerivedAddress({ programAddress: ${rewrite(program)}, seeds: [${seeds.map(rewrite).join(", ")}] });\n },`,
|
|
1339
|
+
});
|
|
1340
|
+
continue;
|
|
1341
|
+
}
|
|
1342
|
+
if (bases.size === 0 || autoDep)
|
|
1057
1343
|
continue;
|
|
1058
|
-
|
|
1344
|
+
renote(a, `resolve.${fn} fills it in`);
|
|
1059
1345
|
fieldPdas.push({
|
|
1060
1346
|
a,
|
|
1061
1347
|
usesArgs,
|
|
1062
1348
|
lines: [
|
|
1063
|
-
`
|
|
1064
|
-
` ${
|
|
1065
|
-
` (await (
|
|
1066
|
-
|
|
1067
|
-
` return (await getProgramDerivedAddress({ programAddress: ${program}, seeds: [${seeds.join(", ")}] }))[0];`,
|
|
1068
|
-
` })());`,
|
|
1349
|
+
` if (${acc(a)} === undefined) {`,
|
|
1350
|
+
...[...bases.values()].map(({ base, A }) => ` const ${"$$" + base.key} = await ${A}.fetch(rpc, key(${acc(base)}!));`),
|
|
1351
|
+
` ${acc(a)} = (await getProgramDerivedAddress({ programAddress: ${program}, seeds: [${seeds.join(", ")}] }))[0];`,
|
|
1352
|
+
` }`,
|
|
1069
1353
|
],
|
|
1070
1354
|
});
|
|
1071
1355
|
}
|
|
1072
|
-
if (rels.length || fieldPdas.length) {
|
|
1073
|
-
const filled = [...rels.map((r) => r.a), ...fieldPdas.map((f) => f.a)];
|
|
1074
|
-
const keysOf = filled.map((a) => JSON.stringify(a.key)).join(" | ");
|
|
1356
|
+
if (rels.length || fieldPdas.length || signers.length) {
|
|
1357
|
+
const filled = [...signers, ...rels.map((r) => r.a), ...fieldPdas.map((f) => f.a)];
|
|
1358
|
+
const keysOf = [...new Set(filled.map((a) => JSON.stringify(a.key)))].join(" | ");
|
|
1075
1359
|
const withArgs = fieldPdas.some((f) => f.usesArgs);
|
|
1076
1360
|
const what = [
|
|
1361
|
+
...(signers.length ? [`${signers.map((a) => a.key).join(", ")} (options.signer when left out)`] : []),
|
|
1077
1362
|
...rels.map((r) => `${r.a.key} (from ${r.source.key}.${camel(r.a.name)})`),
|
|
1078
1363
|
...fieldPdas.map((f) => `${f.a.key} (a PDA seeded from account fields)`),
|
|
1079
1364
|
].join(", ");
|
|
1080
|
-
resolveKeys.push(` /** ${P}Accounts with ${what}
|
|
1081
|
-
` async ${prop(fn)}(rpc: Rpc<GetAccountInfoApi>, input: Omit<${P}Accounts, ${keysOf}> & Partial<Pick<${P}Accounts, ${keysOf}>>${withArgs ? `, args: ${P}Args` : ""}): Promise<${P}Accounts> {\n` +
|
|
1365
|
+
resolveKeys.push(` /** ${P}Accounts with ${what} filled in. */\n` +
|
|
1366
|
+
` async ${prop(fn)}(rpc: Rpc<GetAccountInfoApi>, input: Omit<${P}Accounts, ${keysOf}> & Partial<Pick<${P}Accounts, ${keysOf}>>${withArgs ? `, args: ${P}Args` : ""}, options: { signer?: TransactionSigner } = {}): Promise<${P}Accounts> {\n` +
|
|
1367
|
+
` const acc: Partial<${P}Accounts> = { ...input };\n` +
|
|
1082
1368
|
[
|
|
1083
|
-
...
|
|
1369
|
+
...signers.map((a) => ` if (${acc(a)} === undefined) {\n if (!options.signer) throw new Error(${JSON.stringify(`${ix.name}: pass ${a.key}, or a default signer as options.signer`)});\n ${acc(a)} = options.signer;\n }`),
|
|
1370
|
+
...rels.map((r) => ` ${acc(r.a)} ??= await readRelation(rpc, key(${acc(r.source)}!), ${JSON.stringify(r.a.key)}, [${r.readers.join(", ")}]);`),
|
|
1084
1371
|
...fieldPdas.flatMap((f) => f.lines),
|
|
1085
1372
|
].join("\n") +
|
|
1086
|
-
`\n return
|
|
1373
|
+
`\n void rpc;\n return acc as ${P}Accounts;\n },`);
|
|
1087
1374
|
}
|
|
1088
1375
|
if (ix.returns !== undefined) {
|
|
1089
1376
|
const why = issue(ix.returns);
|
|
@@ -1237,6 +1524,23 @@ export function generateClient(idl, opts = {}) {
|
|
|
1237
1524
|
` });`,
|
|
1238
1525
|
],
|
|
1239
1526
|
});
|
|
1527
|
+
if (needsCStruct)
|
|
1528
|
+
helperDefs.push({
|
|
1529
|
+
name: "cStruct",
|
|
1530
|
+
lines: [
|
|
1531
|
+
`/** A zero-copy struct: each field at its #[repr(C)] offset in a block of \`size\` bytes (padding is zero). */`,
|
|
1532
|
+
`const cStruct = (fields: [string, Codec<any, any>, number][], size: number): Codec<Record<string, unknown>, Record<string, unknown>> =>`,
|
|
1533
|
+
` createCodec<Record<string, unknown>, Record<string, unknown>>({`,
|
|
1534
|
+
` fixedSize: size,`,
|
|
1535
|
+
` write: (value, bytes, offset) => {`,
|
|
1536
|
+
` bytes.fill(0, offset, offset + size);`,
|
|
1537
|
+
` for (const [k, c, at] of fields) c.write(value[k], bytes, offset + at);`,
|
|
1538
|
+
` return offset + size;`,
|
|
1539
|
+
` },`,
|
|
1540
|
+
` read: (bytes, offset) => [Object.fromEntries(fields.map(([k, c, at]) => [k, c.read(bytes, offset + at)[0]])), offset + size],`,
|
|
1541
|
+
` });`,
|
|
1542
|
+
],
|
|
1543
|
+
});
|
|
1240
1544
|
if (needsUnitEnum)
|
|
1241
1545
|
helperDefs.push({
|
|
1242
1546
|
name: "unitEnum",
|
package/dist/svmrpc.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A kit RPC and subscriptions pair answered by LiteSVM, so generated clients run in tests as they run against a
|
|
3
|
+
* cluster: `sendInstructions`, `simulate`, `views`, `accounts.<Name>.fetch*` and `watch`, `resolve`, `eventsOf` and
|
|
4
|
+
* `watchEvents`. Only the methods those helpers call are answered.
|
|
5
|
+
*
|
|
6
|
+
* const { rpc, rpcSubscriptions } = svmConnection(svm);
|
|
7
|
+
* await counter.sendInstructions({ rpc, rpcSubscriptions }, alice, [await counter.initialize({ authority: alice })]);
|
|
8
|
+
* await counter.accounts.Counter.fetch(rpc, address);
|
|
9
|
+
*
|
|
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`.
|
|
12
|
+
*/
|
|
13
|
+
import { type Address, type Rpc, type RpcSubscriptions, type SolanaRpcApi, type SolanaRpcSubscriptionsApi } from "@solana/kit";
|
|
14
|
+
import { type LiteSVM, type TransactionMetadata } from "litesvm";
|
|
15
|
+
export interface SvmConnection {
|
|
16
|
+
rpc: Rpc<SolanaRpcApi>;
|
|
17
|
+
rpcSubscriptions: RpcSubscriptions<SolanaRpcSubscriptionsApi>;
|
|
18
|
+
/** Every transaction sent through `rpc` that succeeded, in order. */
|
|
19
|
+
transactions: {
|
|
20
|
+
signature: string;
|
|
21
|
+
meta: TransactionMetadata;
|
|
22
|
+
}[];
|
|
23
|
+
/** Add accounts for `getProgramAccounts` to scan (accounts the transactions named are scanned already). */
|
|
24
|
+
track(...addresses: Address[]): void;
|
|
25
|
+
}
|
|
26
|
+
export declare function svmConnection(svm: LiteSVM): SvmConnection;
|
package/dist/svmrpc.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A kit RPC and subscriptions pair answered by LiteSVM, so generated clients run in tests as they run against a
|
|
3
|
+
* cluster: `sendInstructions`, `simulate`, `views`, `accounts.<Name>.fetch*` and `watch`, `resolve`, `eventsOf` and
|
|
4
|
+
* `watchEvents`. Only the methods those helpers call are answered.
|
|
5
|
+
*
|
|
6
|
+
* const { rpc, rpcSubscriptions } = svmConnection(svm);
|
|
7
|
+
* await counter.sendInstructions({ rpc, rpcSubscriptions }, alice, [await counter.initialize({ authority: alice })]);
|
|
8
|
+
* await counter.accounts.Counter.fetch(rpc, address);
|
|
9
|
+
*
|
|
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`.
|
|
12
|
+
*/
|
|
13
|
+
import { getAddressDecoder, getBase58Decoder, getBase58Encoder, getBase64Decoder, getBase64Encoder, getCompiledTransactionMessageDecoder, getTransactionDecoder, } from "@solana/kit";
|
|
14
|
+
import { FailedTransactionMetadata } from "litesvm";
|
|
15
|
+
const b64 = (bytes) => getBase64Decoder().decode(bytes);
|
|
16
|
+
const reply = (value) => ({ send: async () => value });
|
|
17
|
+
export function svmConnection(svm) {
|
|
18
|
+
const transactions = [];
|
|
19
|
+
const known = new Set();
|
|
20
|
+
const decode = (wire) => {
|
|
21
|
+
const tx = getTransactionDecoder().decode(getBase64Encoder().encode(wire));
|
|
22
|
+
return { tx, keys: getCompiledTransactionMessageDecoder().decode(tx.messageBytes).staticAccounts };
|
|
23
|
+
};
|
|
24
|
+
const encoded = (a) => {
|
|
25
|
+
const acct = svm.getAccount(a);
|
|
26
|
+
if (!acct.exists)
|
|
27
|
+
return null;
|
|
28
|
+
return {
|
|
29
|
+
data: [b64(acct.data), "base64"],
|
|
30
|
+
executable: acct.executable,
|
|
31
|
+
lamports: acct.lamports,
|
|
32
|
+
owner: acct.programAddress,
|
|
33
|
+
space: BigInt(acct.data.length),
|
|
34
|
+
rentEpoch: 0n,
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
const rpc = {
|
|
38
|
+
getLatestBlockhash: () => reply({ context: { slot: 0n }, value: { blockhash: svm.latestBlockhash(), lastValidBlockHeight: 1000000n } }),
|
|
39
|
+
getEpochInfo: () => reply({ absoluteSlot: 0n, blockHeight: 0n, epoch: 0n, slotIndex: 0n, slotsInEpoch: 432000n, transactionCount: null }),
|
|
40
|
+
getSignatureStatuses: (sigs) => reply({
|
|
41
|
+
context: { slot: 0n },
|
|
42
|
+
value: sigs.map((s) => transactions.some((t) => t.signature === s)
|
|
43
|
+
? { confirmationStatus: "confirmed", confirmations: null, err: null, slot: 0n, status: { Ok: null } }
|
|
44
|
+
: null),
|
|
45
|
+
}),
|
|
46
|
+
getMinimumBalanceForRentExemption: (space) => reply(svm.minimumBalanceForRentExemption(space)),
|
|
47
|
+
getBalance: (a) => reply({ context: { slot: 0n }, value: svm.getBalance(a) ?? 0n }),
|
|
48
|
+
sendTransaction: (wire) => ({
|
|
49
|
+
send: async () => {
|
|
50
|
+
const { tx, keys } = decode(wire);
|
|
51
|
+
const res = svm.sendTransaction(tx);
|
|
52
|
+
svm.expireBlockhash();
|
|
53
|
+
if (res instanceof FailedTransactionMetadata)
|
|
54
|
+
throw new Error(`transaction failed: ${String(res.err())}\n${res.meta().logs().join("\n")}`);
|
|
55
|
+
for (const k of keys)
|
|
56
|
+
known.add(k);
|
|
57
|
+
const signature = getBase58Decoder().decode(res.signature());
|
|
58
|
+
transactions.push({ signature, meta: res });
|
|
59
|
+
return signature;
|
|
60
|
+
},
|
|
61
|
+
}),
|
|
62
|
+
simulateTransaction: (wire, config = {}) => ({
|
|
63
|
+
send: async () => {
|
|
64
|
+
// Simulations are usually unsigned (sigVerify: false): turn LiteSVM's check off for this one only.
|
|
65
|
+
const inner = svm.inner;
|
|
66
|
+
const restore = config.sigVerify === false && inner?.getSigverify?.() === true;
|
|
67
|
+
if (restore)
|
|
68
|
+
svm.withSigverify(false);
|
|
69
|
+
let res;
|
|
70
|
+
try {
|
|
71
|
+
res = svm.simulateTransaction(decode(wire).tx);
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
if (restore)
|
|
75
|
+
svm.withSigverify(true);
|
|
76
|
+
}
|
|
77
|
+
if (res instanceof FailedTransactionMetadata)
|
|
78
|
+
return {
|
|
79
|
+
context: { slot: 0n },
|
|
80
|
+
value: { err: String(res.err()), logs: res.meta().logs(), unitsConsumed: res.meta().computeUnitsConsumed(), returnData: null },
|
|
81
|
+
};
|
|
82
|
+
const meta = res.meta();
|
|
83
|
+
const ret = meta.returnData();
|
|
84
|
+
return {
|
|
85
|
+
context: { slot: 0n },
|
|
86
|
+
value: {
|
|
87
|
+
err: null,
|
|
88
|
+
logs: meta.logs(),
|
|
89
|
+
unitsConsumed: meta.computeUnitsConsumed(),
|
|
90
|
+
returnData: ret.data().length ? { programId: getAddressDecoder().decode(ret.programId()), data: [b64(ret.data()), "base64"] } : null,
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
},
|
|
94
|
+
}),
|
|
95
|
+
getAccountInfo: (a) => reply({ context: { slot: 0n }, value: encoded(a) }),
|
|
96
|
+
getMultipleAccounts: (addresses) => reply({ context: { slot: 0n }, value: addresses.map(encoded) }),
|
|
97
|
+
getProgramAccounts: (program, config = {}) => reply([...known]
|
|
98
|
+
.map((a) => ({ pubkey: a, account: encoded(a) }))
|
|
99
|
+
.filter(({ account }) => account && account.owner === program)
|
|
100
|
+
.filter(({ account }) => {
|
|
101
|
+
const data = getBase64Encoder().encode(account.data[0]);
|
|
102
|
+
return (config.filters ?? []).every(({ memcmp, dataSize }) => {
|
|
103
|
+
if (dataSize !== undefined)
|
|
104
|
+
return BigInt(data.length) === BigInt(dataSize);
|
|
105
|
+
if (!memcmp)
|
|
106
|
+
return true;
|
|
107
|
+
const want = getBase58Encoder().encode(memcmp.bytes);
|
|
108
|
+
return want.every((b, i) => data[Number(memcmp.offset) + i] === b);
|
|
109
|
+
});
|
|
110
|
+
})),
|
|
111
|
+
// The json shape: log messages, and inner instructions with base58 data (what `eventsOf` reads).
|
|
112
|
+
getTransaction: (signature) => {
|
|
113
|
+
const t = transactions.find((x) => x.signature === signature);
|
|
114
|
+
return reply(t
|
|
115
|
+
? {
|
|
116
|
+
slot: 0n,
|
|
117
|
+
blockTime: null,
|
|
118
|
+
meta: {
|
|
119
|
+
err: null,
|
|
120
|
+
logMessages: t.meta.logs(),
|
|
121
|
+
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()) })) })),
|
|
125
|
+
},
|
|
126
|
+
}
|
|
127
|
+
: null);
|
|
128
|
+
},
|
|
129
|
+
};
|
|
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
|
+
})(),
|
|
137
|
+
});
|
|
138
|
+
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 } }]),
|
|
143
|
+
};
|
|
144
|
+
return {
|
|
145
|
+
rpc: rpc,
|
|
146
|
+
rpcSubscriptions: rpcSubscriptions,
|
|
147
|
+
transactions,
|
|
148
|
+
track: (...addresses) => addresses.forEach((a) => known.add(a)),
|
|
149
|
+
};
|
|
150
|
+
}
|
package/dist/testing.d.ts
CHANGED
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
import anchor, { type Idl } from "@anchor-lang/core";
|
|
20
20
|
import { type Address, type Instruction, type KeyPairSigner } from "@solana/kit";
|
|
21
21
|
import { FailedTransactionMetadata, LiteSVM, TransactionMetadata } from "litesvm";
|
|
22
|
+
import { type SvmConnection, svmConnection } from "./svmrpc.js";
|
|
23
|
+
export { type SvmConnection, svmConnection };
|
|
22
24
|
declare const BorshCoder: typeof anchor.BorshCoder;
|
|
23
25
|
type BorshCoder = InstanceType<typeof BorshCoder>;
|
|
24
26
|
export type Result = TransactionMetadata | FailedTransactionMetadata;
|
|
@@ -58,9 +60,10 @@ export interface TestProgram {
|
|
|
58
60
|
returned<T = unknown>(instruction: string, res: Result): T;
|
|
59
61
|
/** Lamports of any account (0 when it does not exist). */
|
|
60
62
|
balance(address: Address): bigint;
|
|
63
|
+
/** A kit RPC and subscriptions pair over this LiteSVM, for the generated client (`svmConnection`). */
|
|
64
|
+
connection: SvmConnection;
|
|
61
65
|
}
|
|
62
66
|
/** Build `program` of the project around `from` (a file URL such as import.meta.url, or a directory) and load it. */
|
|
63
67
|
export declare function testProgram(program: string, from: string | URL): Promise<TestProgram>;
|
|
64
68
|
/** The same helpers for a program already loaded into `svm` at `programId` with this IDL. */
|
|
65
69
|
export declare function programHarness(svm: LiteSVM, programId: Address, idl: Idl): TestProgram;
|
|
66
|
-
export {};
|
package/dist/testing.js
CHANGED
|
@@ -22,7 +22,9 @@ import { FailedTransactionMetadata, LiteSVM } from "litesvm";
|
|
|
22
22
|
import { createHash } from "node:crypto";
|
|
23
23
|
import path from "node:path";
|
|
24
24
|
import { fileURLToPath } from "node:url";
|
|
25
|
+
import { svmConnection } from "./svmrpc.js";
|
|
25
26
|
import { buildWorkspace, findWorkspace } from "./workspace.js";
|
|
27
|
+
export { svmConnection };
|
|
26
28
|
// @anchor-lang/core is CommonJS: Node 22 cannot import its classes by name from an installed ES module.
|
|
27
29
|
const { BN, BorshCoder } = anchor;
|
|
28
30
|
const BIG = new Set(["u64", "i64", "u128", "i128"]);
|
|
@@ -332,6 +334,7 @@ export function programHarness(svm, programId, idl) {
|
|
|
332
334
|
balance(address) {
|
|
333
335
|
return svm.getBalance(address) ?? 0n;
|
|
334
336
|
},
|
|
337
|
+
connection: svmConnection(svm),
|
|
335
338
|
};
|
|
336
339
|
return harness;
|
|
337
340
|
}
|
package/docs/client.md
CHANGED
|
@@ -48,7 +48,7 @@ for await (const { signature, event } of ledgerbook.watchEvents(rpcSubscriptions
|
|
|
48
48
|
await ledgerbook.approveMany({ approver: signer, book }, { indices: [0n, 1n] }, [{ address: extra, role: AccountRole.WRITABLE }]);
|
|
49
49
|
|
|
50
50
|
// has_one accounts that do not sign, read from the account that names them (Anchor resolves `relations` the same way)
|
|
51
|
-
const accounts = await ledgerbook.resolve.someInstruction(rpc, { book }); //
|
|
51
|
+
const accounts = await ledgerbook.resolve.someInstruction(rpc, { book }, { signer }); // signers, and what book's fields name
|
|
52
52
|
|
|
53
53
|
// Events of a fetched transaction (getTransaction, json): log events and emitCpi events from inner instructions
|
|
54
54
|
ledgerbook.eventsOf(await rpc.getTransaction(signature, { maxSupportedTransactionVersion: 0 }).send());
|
|
@@ -64,9 +64,9 @@ Kit's own transaction functions work with the instructions as well; `sendInstruc
|
|
|
64
64
|
| `<program>.<instruction>(accounts, args)` | Builds the `Instruction`. Accounts with a fixed address and PDAs whose seeds are constants, arguments or other accounts' keys are optional and filled in; pass one to override. An optional account passed as `null` is left out (sent as the program id, the convention TypeSOL and Anchor share); left undefined, it is filled in like any other. |
|
|
65
65
|
| `<program>.<instruction>(accounts, args, remaining?)` | The same, with remaining accounts appended in order (kit `AccountMeta`s). |
|
|
66
66
|
| `<program>.accounts.<Name>` | `decode(bytes)`, `encode(value)`, `fetch(rpc, address)`, `fetchMaybe(rpc, address)`, `fetchMultiple(rpc, addresses)`, `fetchAll(rpc, filters?)` (getProgramAccounts by discriminator, plus `{ offset, bytes }` filters), `watch(rpcSubscriptions, address, { abortSignal })`, `discriminator`. `decode` checks the discriminator. |
|
|
67
|
-
| `<program>.pdas.<account>(seeds)` | The PDA (address and bump) for each account an instruction derives. |
|
|
67
|
+
| `<program>.pdas.<account>(seeds)` | The PDA (address and bump) for each account an instruction derives, also one seeded from its own fields (from the field values). |
|
|
68
68
|
| `<program>.returns.<instruction>(bytes)` | Decodes return data. |
|
|
69
|
-
| `<program>.resolve.<instruction>(rpc, accounts, args?)` | Fills in what needs the chain: `has_one` accounts that do not sign (read from the account that names them) and PDAs seeded from another account's fields (`seeds = [b"vault", book.owner]`: the account is read, then the PDA derived). Given accounts are kept. |
|
|
69
|
+
| `<program>.resolve.<instruction>(rpc, accounts, args?, { signer? })` | Fills in what needs the chain or the wallet: signer accounts left out (`options.signer`, the default signer, like Anchor's provider wallet), `has_one` accounts that do not sign (read from the account that names them), and PDAs seeded from another account's fields, scalars, strings or bytes (`seeds = [b"vault", book.owner]`: the account is read, then the PDA derived). Given accounts are kept. |
|
|
70
70
|
| `<program>.views.<instruction>(rpc, feePayer, accounts, args)` | Simulates an instruction that returns a value and decodes it (throws with the logs if it fails). |
|
|
71
71
|
| `<program>.sendInstructions(connection, feePayer, instructions)`, `simulate(rpc, feePayer, instructions)` | One transaction sent and confirmed (kit's sendAndConfirm), or simulated without signatures (error, logs, units, this program's return data). |
|
|
72
72
|
| `<program>.errors`, `errorCode(e)`, `isError(e, name)` | The program's errors (6000+), and for TypeSOL programs also every Anchor framework error (`AnchorError` and the constraints raise them) and TypeSOL's runtime errors. |
|
|
@@ -89,12 +89,14 @@ Anchor IDLs are refused with a hint to convert them (`anchor idl convert`).
|
|
|
89
89
|
|
|
90
90
|
- **Errors and events are matched by code and discriminator, not by program.** A program this one calls can raise the
|
|
91
91
|
same error code, and `parseEvents` reads every `Program data:` line that decodes as one of this program's events.
|
|
92
|
-
- **Not filled in automatically** (pass them):
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
92
|
+
- **Not filled in automatically** (pass them): PDAs seeded from struct fields (Anchor cannot seed with them either),
|
|
93
|
+
PDAs whose program is another account's field, and a PDA seeded from its own fields (derive it with its `pdas`
|
|
94
|
+
helper from the values). `typesol client` prints a note for each.
|
|
95
|
+
- **Anchor IDLs**: zero-copy (`bytemuck`) accounts decode and encode at their `#[repr(C)]` offsets as the program sees
|
|
96
|
+
them on SBF (padding included; `packed` and `align` honored; 128-bit integers align to 8). Generic types become one
|
|
97
|
+
type per use with concrete arguments (`Pair<u64, 2>` is `PairU64N2`). Left out, with a note: zero-copy types with
|
|
98
|
+
Rust's own layout (`repr(Rust)`) or holding a non-zero-copy type, and the generic definitions themselves.
|
|
99
|
+
- **In tests**: `svmConnection(svm)` from `typesol/testing` answers the client's RPC calls from LiteSVM, and
|
|
100
|
+
`testProgram(...)` has it as `t.connection` ([test-your-program.md](test-your-program.md)).
|
|
101
|
+
- `watchEvents` reads log events; with `rpc` it also fetches each transaction in which the program invoked itself and
|
|
102
|
+
reads its `emitCpi` events (log subscriptions do not carry inner instructions).
|
|
@@ -44,9 +44,12 @@ it("counts", async () => {
|
|
|
44
44
|
| `t.account(type, address)` | the account decoded by its IDL type (bigints, address strings), or `null` once closed. TypeSOL fixed layouts come back as their IDL structs: a `FixedString<N>` as `{ len, bytes }`, a `FixedVec<T, N>` as `{ len, items }` (the generated client gives strings and arrays) |
|
|
45
45
|
| `t.returned(instruction, res)` | the instruction's return data: an integer, boolean, Pubkey or struct |
|
|
46
46
|
| `t.balance(address)` | lamports |
|
|
47
|
+
| `t.connection` | `{ rpc, rpcSubscriptions }` answered by this LiteSVM, for the generated client: `counter.sendInstructions(t.connection, alice, [ix])`, `counter.accounts.Counter.fetch(t.connection.rpc, address)`, views, `resolve`, `watch` and `watchEvents` |
|
|
47
48
|
|
|
48
49
|
`t.svm`, `t.programId`, `t.idl` and `t.coder` are there for anything else, e.g. writing a mint straight into the VM
|
|
49
50
|
with `t.svm.setAccount` (see the escrow template). `programHarness(svm, programId, idl)` gives the same helpers for a
|
|
50
|
-
program you loaded yourself.
|
|
51
|
+
program you loaded yourself, and `svmConnection(svm)` the RPC pair alone (for any LiteSVM, e.g. one with an Anchor
|
|
52
|
+
program loaded). Transactions sent through it run at once; `getProgramAccounts` (`fetchAll`) sees the accounts those
|
|
53
|
+
transactions named, plus any you `track(...)`.
|
|
51
54
|
|
|
52
55
|
The module needs `litesvm` and `@anchor-lang/core`, which `typesol init` adds to `devDependencies`.
|