@typesol/cli 0.25.0 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -0
- package/README.md +32 -14
- package/SECURITY.md +2 -0
- package/dist/cli.js +52 -37
- package/dist/compiler/analyze.js +62 -5
- package/dist/compiler/emit/base.d.ts +2 -0
- package/dist/compiler/emit/base.js +4 -1
- package/dist/compiler/emit/calls.d.ts +16 -1
- package/dist/compiler/emit/calls.js +129 -1
- package/dist/compiler/emit/expressions.js +18 -0
- package/dist/compiler/emit/function.js +28 -0
- package/dist/compiler/emit/shared.js +2 -0
- package/dist/compiler/emit/storage.js +18 -0
- package/dist/compiler/model.d.ts +12 -0
- package/dist/compiler/model.js +3 -0
- package/dist/templates.js +1 -1
- package/dist/testing.d.ts +1 -1
- package/dist/testing.js +1 -1
- package/dist/{clientgen.js → tools/clientgen.js} +1 -1
- package/dist/tools/doctor.d.ts +45 -0
- package/dist/tools/doctor.js +229 -0
- package/dist/{portgen.js → tools/portgen.js} +1 -1
- package/dist/workspace.js +1 -1
- package/docs/architecture.md +17 -17
- package/docs/audit.md +90 -0
- package/docs/case-study-escrow.md +4 -4
- package/docs/cli.md +3 -2
- package/docs/errors.md +1 -1
- package/docs/language.md +7 -2
- package/docs/migrate-from-anchor.md +4 -0
- package/docs/runtime.md +1 -1
- package/docs/security-model.md +105 -0
- package/docs/testing.md +5 -4
- package/lib/typesol.d.ts +55 -1
- package/package.json +13 -13
- package/runtime/typesol_rt.h +82 -21
- /package/dist/{clientgen.d.ts → tools/clientgen.d.ts} +0 -0
- /package/dist/{cpigen.d.ts → tools/cpigen.d.ts} +0 -0
- /package/dist/{cpigen.js → tools/cpigen.js} +0 -0
- /package/dist/{idlpublish.d.ts → tools/idlpublish.d.ts} +0 -0
- /package/dist/{idlpublish.js → tools/idlpublish.js} +0 -0
- /package/dist/{onchain.d.ts → tools/onchain.d.ts} +0 -0
- /package/dist/{onchain.js → tools/onchain.js} +0 -0
- /package/dist/{portgen.d.ts → tools/portgen.d.ts} +0 -0
- /package/dist/{profile.d.ts → tools/profile.d.ts} +0 -0
- /package/dist/{profile.js → tools/profile.js} +0 -0
- /package/dist/{svmrpc.d.ts → tools/svmrpc.d.ts} +0 -0
- /package/dist/{svmrpc.js → tools/svmrpc.js} +0 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import ts from "typescript";
|
|
2
|
-
import { calleeName, resolveScalarType, resolveValueType } from "../analyze.js";
|
|
2
|
+
import { calleeName, discriminator, resolveScalarType, resolveValueType, snakeCase } from "../analyze.js";
|
|
3
3
|
import { ANCHOR_ERRORS } from "../anchor-errors.js";
|
|
4
4
|
import { decodeAddress } from "../../keys.js";
|
|
5
5
|
import { INT_INFO, INT_NAMES, isScalar, isWritable, sameType, scalarSize, typeName, } from "../model.js";
|
|
@@ -21,6 +21,11 @@ export class CallEmitter extends ExpressionEmitter {
|
|
|
21
21
|
return this.cast(args[0], { kind: "int", name: builtin });
|
|
22
22
|
}
|
|
23
23
|
switch (builtin) {
|
|
24
|
+
case "stackHeight": {
|
|
25
|
+
if (args.length)
|
|
26
|
+
this.fail(node, "stackHeight() takes no arguments");
|
|
27
|
+
return { c: "sol_get_stack_height()", type: U64 };
|
|
28
|
+
}
|
|
24
29
|
case "require": {
|
|
25
30
|
if (args.length !== 2)
|
|
26
31
|
this.fail(node, "require(condition, error) takes two arguments");
|
|
@@ -484,6 +489,9 @@ export class CallEmitter extends ExpressionEmitter {
|
|
|
484
489
|
return this.callHelper(node, helper);
|
|
485
490
|
if (ts.isPropertyAccessExpression(callee)) {
|
|
486
491
|
const method = callee.name.text;
|
|
492
|
+
const introspected = this.instructionsCall(node, callee, method);
|
|
493
|
+
if (introspected)
|
|
494
|
+
return introspected;
|
|
487
495
|
// xs.some(…) and friends on a Vec, FixedArray, FixedVec or Vec argument: a loop with the callback expanded in it.
|
|
488
496
|
if (LIST_METHODS.has(method)) {
|
|
489
497
|
const l = ts.isIdentifier(callee.expression) ? this.lookup(callee.expression.text) : undefined;
|
|
@@ -570,6 +578,8 @@ export class CallEmitter extends ExpressionEmitter {
|
|
|
570
578
|
return target.kind.kind === "token" && target.constraints.ata ? this.createAta(node, target, topLevel) : this.createToken(node, target, topLevel);
|
|
571
579
|
if (target?.kind.kind === "init" && method === "create")
|
|
572
580
|
return this.create(node, target, topLevel);
|
|
581
|
+
if (target?.kind.kind === "migrate" && method === "migrate")
|
|
582
|
+
return this.migrate(node, target, topLevel);
|
|
573
583
|
if (target?.kind.kind === "initIfNeeded" && method === "createIfNeeded")
|
|
574
584
|
return this.create(node, target, topLevel, true);
|
|
575
585
|
if (target?.kind.kind === "system" && method === "transfer")
|
|
@@ -878,6 +888,92 @@ export class CallEmitter extends ExpressionEmitter {
|
|
|
878
888
|
return ts.isCallExpression(node) && this.exportName(node.expression) === "remainingAccounts";
|
|
879
889
|
}
|
|
880
890
|
/** C pointer (`TsAccount *`) for an expression naming a remaining account, or undefined. */
|
|
891
|
+
/** Whether `node` is an InstructionsSysvar parameter. */
|
|
892
|
+
instructionsParam(node) {
|
|
893
|
+
const a = ts.isIdentifier(node) ? this.accounts.get(node.text) : undefined;
|
|
894
|
+
return a?.kind.kind === "instructionsSysvar" ? a : undefined;
|
|
895
|
+
}
|
|
896
|
+
/** Whether `node` is a TransactionInstruction: a local holding one, or `ixs.get(i)`. */
|
|
897
|
+
isTxIx(node) {
|
|
898
|
+
if (ts.isParenthesizedExpression(node))
|
|
899
|
+
return this.isTxIx(node.expression);
|
|
900
|
+
if (ts.isIdentifier(node))
|
|
901
|
+
return this.lookup(node.text)?.type.kind === "txIx";
|
|
902
|
+
return (ts.isCallExpression(node) &&
|
|
903
|
+
ts.isPropertyAccessExpression(node.expression) &&
|
|
904
|
+
node.expression.name.text === "get" &&
|
|
905
|
+
!!this.instructionsParam(node.expression.expression));
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* InstructionsSysvar.currentIndex() / .get(i) and the methods of a TransactionInstruction: bounds-checked reads of the
|
|
909
|
+
* sysvar (ts_ix_* in the runtime). Undefined when the call is something else.
|
|
910
|
+
*/
|
|
911
|
+
instructionsCall(node, callee, method) {
|
|
912
|
+
const args = node.arguments;
|
|
913
|
+
const sysvar = this.instructionsParam(callee.expression);
|
|
914
|
+
if (sysvar) {
|
|
915
|
+
this.assertUsable(sysvar, callee.expression);
|
|
916
|
+
if (method === "currentIndex") {
|
|
917
|
+
if (args.length)
|
|
918
|
+
this.fail(node, "currentIndex() takes no arguments");
|
|
919
|
+
const t = this.tmp("ixi");
|
|
920
|
+
this.line(`uint64_t ${t};`);
|
|
921
|
+
this.line(`TS_TRY(ts_ix_current(a_${sysvar.name}, &${t}));`);
|
|
922
|
+
return { c: t, type: U64 };
|
|
923
|
+
}
|
|
924
|
+
if (method === "get") {
|
|
925
|
+
if (args.length !== 1)
|
|
926
|
+
this.fail(node, "get(index) takes the instruction's index");
|
|
927
|
+
const i = this.expect(this.expr(args[0], U64), U64, args[0]);
|
|
928
|
+
const t = this.tmp("ix");
|
|
929
|
+
this.line(`TsIx ${t};`);
|
|
930
|
+
this.line(`TS_TRY(ts_ix_load(a_${sysvar.name}, ${i.c}, &${t}));`);
|
|
931
|
+
return { c: t, type: { kind: "txIx" } };
|
|
932
|
+
}
|
|
933
|
+
this.fail(node, "the Instructions sysvar has .length, currentIndex() and get(index)");
|
|
934
|
+
}
|
|
935
|
+
if (!this.isTxIx(callee.expression))
|
|
936
|
+
return undefined;
|
|
937
|
+
const ix = this.expr(callee.expression);
|
|
938
|
+
const one = (what) => {
|
|
939
|
+
if (args.length !== 1)
|
|
940
|
+
this.fail(node, `${method}(${what}) takes one argument`);
|
|
941
|
+
return this.expect(this.expr(args[0], U64), U64, args[0]).c;
|
|
942
|
+
};
|
|
943
|
+
if (method === "accountKey" || method === "accountIsSigner" || method === "accountIsWritable") {
|
|
944
|
+
const i = one("i");
|
|
945
|
+
const t = this.tmp("ixa");
|
|
946
|
+
this.line(`const uint8_t *${t};`);
|
|
947
|
+
this.line(`TS_TRY(ts_ix_account(&${ix.c}, ${i}, &${t}));`);
|
|
948
|
+
if (method === "accountKey")
|
|
949
|
+
return { c: `((const SolPubkey *)(${t} + 1))`, type: PUBKEY };
|
|
950
|
+
return { c: `((${t}[0] & ${method === "accountIsSigner" ? 1 : 2}) != 0)`, type: BOOL };
|
|
951
|
+
}
|
|
952
|
+
const reads = {
|
|
953
|
+
readU8: ["uint8_t", U8],
|
|
954
|
+
readU16: ["uint16_t", { kind: "int", name: "u16" }],
|
|
955
|
+
readU32: ["uint32_t", { kind: "int", name: "u32" }],
|
|
956
|
+
readU64: ["uint64_t", U64],
|
|
957
|
+
readPubkey: ["SolPubkey", PUBKEY],
|
|
958
|
+
};
|
|
959
|
+
if (reads[method]) {
|
|
960
|
+
const [c, type] = reads[method];
|
|
961
|
+
const off = one("offset");
|
|
962
|
+
const t = this.tmp("ixr");
|
|
963
|
+
this.line(`${c} ${t};`);
|
|
964
|
+
this.line(`TS_TRY(ts_ix_read(&${ix.c}, ${off}, sizeof(${c}), &${t}));`);
|
|
965
|
+
return type.kind === "pubkey" ? { c: `(&${t})`, type } : { c: t, type };
|
|
966
|
+
}
|
|
967
|
+
if (method === "is") {
|
|
968
|
+
if (args.length !== 2 || !ts.isStringLiteralLike(args[1]))
|
|
969
|
+
this.fail(node, 'is(program, "instruction_name") takes the program and the instruction name as a string literal');
|
|
970
|
+
const program = this.expect(this.expr(args[0], PUBKEY), PUBKEY, args[0]);
|
|
971
|
+
const disc = this.tmp("disc");
|
|
972
|
+
this.line(`static const uint8_t ${disc}[8] = ${cBytes(discriminator(`global:${snakeCase(args[1].text)}`))};`);
|
|
973
|
+
return { c: `ts_ix_is(&${ix.c}, ${program.c}, ${disc})`, type: BOOL };
|
|
974
|
+
}
|
|
975
|
+
this.fail(node, "a TransactionInstruction has programId, accountsLength, dataLength, accountKey/accountIsSigner/accountIsWritable(i), readU8/U16/U32/U64/readPubkey(offset) and is(program, name)");
|
|
976
|
+
}
|
|
881
977
|
handle(node) {
|
|
882
978
|
if (ts.isParenthesizedExpression(node))
|
|
883
979
|
return this.handle(node.expression);
|
|
@@ -984,6 +1080,38 @@ export class CallEmitter extends ExpressionEmitter {
|
|
|
984
1080
|
}
|
|
985
1081
|
return { c: `((TSS_${s.name} *)(${h}->data + 8))`, type: { kind: "structRef", struct: s.name, writable: mut } };
|
|
986
1082
|
}
|
|
1083
|
+
/**
|
|
1084
|
+
* `acct.migrate(payer, value)` on a Migrate<From, To> account: the new value is built in full first (it may read
|
|
1085
|
+
* acct.from, whose bytes the rewrite overwrites), then the account is resized (the payer tops up the rent when it
|
|
1086
|
+
* grows; shrinking keeps the lamports) and rewritten with To's discriminator and the value.
|
|
1087
|
+
*/
|
|
1088
|
+
migrate(node, a, topLevel) {
|
|
1089
|
+
const k = a.kind;
|
|
1090
|
+
const call = `${a.name}.migrate`;
|
|
1091
|
+
if (!topLevel)
|
|
1092
|
+
this.fail(node, `${call}(…) must be a top-level statement of the instruction`);
|
|
1093
|
+
if (this.created.has(a.name))
|
|
1094
|
+
this.fail(node, `${a.name} is already migrated`);
|
|
1095
|
+
if (node.arguments.length !== 2)
|
|
1096
|
+
this.fail(node, `${call}(payer, value) takes the payer and the new ${k.to.name}`);
|
|
1097
|
+
const payer = this.accountParam(node.arguments[0], "the payer");
|
|
1098
|
+
if (payer.kind.kind !== "signer" || !payer.kind.mut)
|
|
1099
|
+
this.fail(node.arguments[0], "the payer must be a Mut<Signer>");
|
|
1100
|
+
this.assertNotAliased([a.name], node, "migrate");
|
|
1101
|
+
const To = k.to.name;
|
|
1102
|
+
const tmp = this.tmp("next");
|
|
1103
|
+
this.line(`TSS_${To} *${tmp} = NULL;`);
|
|
1104
|
+
this.line(`TS_TRY(ts_alloc(sizeof(TSS_${To}), (uint8_t **)&${tmp}));`);
|
|
1105
|
+
this.writeAggregate({ lv: `(*${tmp})`, type: { kind: "struct", name: To }, writable: true }, node.arguments[1]);
|
|
1106
|
+
this.line(`TS_TRY(ts_resize(params, a_${payer.name}, a_${a.name}, 8 + sizeof(TSS_${To}), entry_${a.name}));`);
|
|
1107
|
+
this.line(`ts_memcpy(a_${a.name}->data, TS_DISC_${To}, 8);`);
|
|
1108
|
+
this.line(`ts_memcpy(a_${a.name}->data + 8, ${tmp}, sizeof(TSS_${To}));`);
|
|
1109
|
+
this.line(`TSS_${To} *dt_${a.name} = (TSS_${To} *)(a_${a.name}->data + 8);`);
|
|
1110
|
+
this.line(`(void)dt_${a.name};`);
|
|
1111
|
+
this.created.add(a.name);
|
|
1112
|
+
this.pendingInit.delete(a.name);
|
|
1113
|
+
return undefined;
|
|
1114
|
+
}
|
|
987
1115
|
/** Whether a helper (or one it calls) grows a Vec, which needs the instruction's SystemProgram for rent. */
|
|
988
1116
|
helperGrows(body, seen = new Set()) {
|
|
989
1117
|
if (seen.has(body))
|
|
@@ -265,6 +265,14 @@ export class ExpressionEmitter extends StorageEmitter {
|
|
|
265
265
|
if (field === "length") {
|
|
266
266
|
if (this.isRemaining(node.expression))
|
|
267
267
|
return { c: `((uint64_t)(params->ka_num - ${this.ix.accounts.length}))`, type: U64 };
|
|
268
|
+
const sysvar = this.instructionsParam(node.expression);
|
|
269
|
+
if (sysvar) {
|
|
270
|
+
this.assertUsable(sysvar, node.expression);
|
|
271
|
+
const t = this.tmp("ixn");
|
|
272
|
+
this.line(`uint64_t ${t};`);
|
|
273
|
+
this.line(`TS_TRY(ts_ix_count(a_${sysvar.name}, &${t}));`);
|
|
274
|
+
return { c: t, type: U64 };
|
|
275
|
+
}
|
|
268
276
|
// The number of elements a map / filter chain gives: counted, nothing stored.
|
|
269
277
|
if (this.isPipeline(node.expression)) {
|
|
270
278
|
const n = this.tmp("count");
|
|
@@ -284,6 +292,16 @@ export class ExpressionEmitter extends StorageEmitter {
|
|
|
284
292
|
}
|
|
285
293
|
this.fail(node, "use byteLength(s) for strings; .length is available on arrays, Vecs and remainingAccounts()");
|
|
286
294
|
}
|
|
295
|
+
if (this.isTxIx(node.expression)) {
|
|
296
|
+
const ix = this.expr(node.expression);
|
|
297
|
+
if (field === "programId")
|
|
298
|
+
return { c: `(${ix.c}.program_id)`, type: PUBKEY };
|
|
299
|
+
if (field === "accountsLength")
|
|
300
|
+
return { c: `((uint64_t)${ix.c}.num_accounts)`, type: U64 };
|
|
301
|
+
if (field === "dataLength")
|
|
302
|
+
return { c: `((uint64_t)${ix.c}.data_len)`, type: U64 };
|
|
303
|
+
this.fail(node.name, `a TransactionInstruction has programId, accountsLength and dataLength, not ${field}`);
|
|
304
|
+
}
|
|
287
305
|
const h = this.handle(node.expression);
|
|
288
306
|
if (h) {
|
|
289
307
|
switch (field) {
|
|
@@ -90,6 +90,9 @@ export class FunctionEmitter extends StatementEmitter {
|
|
|
90
90
|
}
|
|
91
91
|
head.push(` TS_TRY(ts_check_executable(${v}));`);
|
|
92
92
|
}
|
|
93
|
+
// Migrate<From, To>: an existing From account (owner, discriminator, size), kept to be rewritten as To.
|
|
94
|
+
if (k.kind === "migrate")
|
|
95
|
+
head.push(` TS_TRY(ts_check_program_account(${v}, params->program_id, TS_DISC_${k.from.name}, sizeof(TSS_${k.from.name})));`, ` const TSS_${k.from.name} *df_${a.name} = (const TSS_${k.from.name} *)(${v}->data + 8);`, ` uint64_t entry_${a.name} = ${v}->data_len;`);
|
|
93
96
|
if (k.kind === "zeroed")
|
|
94
97
|
head.push(` TS_TRY(ts_init_zeroed(${v}, params->program_id, TS_DISC_${k.struct.name}, sizeof(TSS_${k.struct.name})));`);
|
|
95
98
|
if (k.kind === "initIfNeeded" && k.struct.vec)
|
|
@@ -152,9 +155,30 @@ export class FunctionEmitter extends StatementEmitter {
|
|
|
152
155
|
// The loader passes each account once and marks repeats as duplicates, which share its record (ts_deserialize),
|
|
153
156
|
// so two parameters name the same account exactly when their pointers are equal.
|
|
154
157
|
const writable = ix.accounts.filter((a) => isWritable(a.kind));
|
|
158
|
+
// Dup<…> (Anchor's `dup`) lifts the check for a pair when both accounts are read and written in place: not created
|
|
159
|
+
// here, not closed or a close destination, not a @layout borsh heap copy (a lost update when packed back).
|
|
160
|
+
const closing = new Set(ix.accounts.flatMap((a) => (a.constraints.closeTo ? [a.name, a.constraints.closeTo] : [])));
|
|
161
|
+
const inPlace = (a) => {
|
|
162
|
+
const k = a.kind;
|
|
163
|
+
if (closing.has(a.name))
|
|
164
|
+
return false;
|
|
165
|
+
if (k.kind === "account")
|
|
166
|
+
return k.struct.layout !== "borsh";
|
|
167
|
+
if (k.kind === "token" || k.kind === "mint")
|
|
168
|
+
return !k.init;
|
|
169
|
+
return k.kind === "signer" || k.kind === "info";
|
|
170
|
+
};
|
|
171
|
+
for (const a of writable)
|
|
172
|
+
if (a.constraints.dup && !inPlace(a))
|
|
173
|
+
this.reporter.fail(a.node, "Dup<…> is for accounts read and written in place: not created, closed, a close destination or @layout borsh");
|
|
174
|
+
for (const a of ix.accounts)
|
|
175
|
+
if (a.constraints.dup && !isWritable(a.kind))
|
|
176
|
+
this.reporter.fail(a.node, "Dup<…> lets a writable account alias another; this one is not writable");
|
|
155
177
|
const pairs = [];
|
|
156
178
|
for (let i = 0; i < writable.length; i++)
|
|
157
179
|
for (let j = i + 1; j < writable.length; j++) {
|
|
180
|
+
if ((writable[i].constraints.dup || writable[j].constraints.dup) && inPlace(writable[i]) && inPlace(writable[j]))
|
|
181
|
+
continue;
|
|
158
182
|
const present = [writable[i], writable[j]]
|
|
159
183
|
.filter((a) => a.optional)
|
|
160
184
|
.map((a) => `a_${a.name} && `)
|
|
@@ -331,6 +355,10 @@ export class FunctionEmitter extends StatementEmitter {
|
|
|
331
355
|
for (const name of this.pendingInit) {
|
|
332
356
|
const k = this.accounts.get(name).kind;
|
|
333
357
|
const a = this.accounts.get(name);
|
|
358
|
+
if (k.kind === "migrate") {
|
|
359
|
+
this.reporter.error(a.node, `Migrate account "${name}" is never migrated; call ${name}.migrate(payer, value) (Anchor fails such an instruction with AccountNotMigrated)`);
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
334
362
|
const how = k.kind === "token" && a.constraints.ata
|
|
335
363
|
? `${name}.${k.init === "create" ? "create" : "createIfNeeded"}(payer)`
|
|
336
364
|
: k.kind === "token"
|
|
@@ -277,6 +277,24 @@ export class StorageEmitter extends EmitterBase {
|
|
|
277
277
|
}
|
|
278
278
|
if (ts.isPropertyAccessExpression(node)) {
|
|
279
279
|
const field = node.name.text;
|
|
280
|
+
// Migrate<From, To>: acct.from (the old data, read-only, until migrate()) and acct.to (after it).
|
|
281
|
+
if (ts.isIdentifier(node.expression)) {
|
|
282
|
+
const m = this.accounts.get(node.expression.text);
|
|
283
|
+
if (m?.kind.kind === "migrate") {
|
|
284
|
+
const migrated = this.created.has(m.name);
|
|
285
|
+
if (field === "from") {
|
|
286
|
+
if (migrated)
|
|
287
|
+
this.fail(node, `${m.name}.from is gone after ${m.name}.migrate(…): read what you need from it before`);
|
|
288
|
+
return { lv: `(*df_${m.name})`, type: { kind: "struct", name: m.kind.from.name }, writable: false, account: m, path: ["from"] };
|
|
289
|
+
}
|
|
290
|
+
if (field === "to") {
|
|
291
|
+
if (!migrated)
|
|
292
|
+
this.fail(node, `${m.name}.to exists after ${m.name}.migrate(payer, value)`);
|
|
293
|
+
return { lv: `(*dt_${m.name})`, type: { kind: "struct", name: m.kind.to.name }, writable: true, account: m, path: ["to"] };
|
|
294
|
+
}
|
|
295
|
+
this.fail(node.name, `a Migrate account has .from (before migrate), .to (after), key, lamports and migrate(payer, value)`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
280
298
|
// acct.field on a struct account
|
|
281
299
|
if (ts.isIdentifier(node.expression)) {
|
|
282
300
|
const a = this.accounts.get(node.expression.text);
|
package/dist/compiler/model.d.ts
CHANGED
|
@@ -63,6 +63,10 @@ export type ValueType = ScalarType | {
|
|
|
63
63
|
| {
|
|
64
64
|
kind: "remaining";
|
|
65
65
|
}
|
|
66
|
+
/** One instruction of the transaction (InstructionsSysvar.get(i)): a `TsIx` view into the sysvar. */
|
|
67
|
+
| {
|
|
68
|
+
kind: "txIx";
|
|
69
|
+
}
|
|
66
70
|
/** A struct loaded from a remaining account with loadAccount / loadAccountMut. */
|
|
67
71
|
| {
|
|
68
72
|
kind: "structRef";
|
|
@@ -190,6 +194,12 @@ export type ParamKind = {
|
|
|
190
194
|
kind: "zeroed";
|
|
191
195
|
struct: StructDef;
|
|
192
196
|
}
|
|
197
|
+
/** Anchor's Migration<From, To>: an existing `from` account rewritten as `to` by `acct.migrate(payer, value)`. */
|
|
198
|
+
| {
|
|
199
|
+
kind: "migrate";
|
|
200
|
+
from: StructDef;
|
|
201
|
+
to: StructDef;
|
|
202
|
+
}
|
|
193
203
|
/** `init`: an associated token account this instruction creates (`Ata<Init<TokenAccount>, …>`, create) or creates when
|
|
194
204
|
* missing (`Ata<InitIfNeeded<TokenAccount>, …>`, createIfNeeded); it is a TokenAccount once that call has run. */
|
|
195
205
|
| {
|
|
@@ -263,6 +273,8 @@ export interface Constraints {
|
|
|
263
273
|
};
|
|
264
274
|
/** Executable<A>: the account must be an executable program. */
|
|
265
275
|
executable?: boolean;
|
|
276
|
+
/** Dup<A> (Anchor's `dup`): may be the same account as another writable parameter (see function.ts). */
|
|
277
|
+
dup?: boolean;
|
|
266
278
|
/** Close this account into that parameter when the instruction succeeds. */
|
|
267
279
|
closeTo?: string;
|
|
268
280
|
/** Address<A, "base58">: the account must be exactly this address. */
|
package/dist/compiler/model.js
CHANGED
|
@@ -20,6 +20,7 @@ export function isWritable(k) {
|
|
|
20
20
|
case "init":
|
|
21
21
|
case "zeroed":
|
|
22
22
|
case "initIfNeeded":
|
|
23
|
+
case "migrate":
|
|
23
24
|
return true;
|
|
24
25
|
case "signer":
|
|
25
26
|
case "info":
|
|
@@ -67,6 +68,8 @@ export function typeName(t) {
|
|
|
67
68
|
return "AccountInfo";
|
|
68
69
|
case "remaining":
|
|
69
70
|
return "RemainingAccounts";
|
|
71
|
+
case "txIx":
|
|
72
|
+
return "TransactionInstruction";
|
|
70
73
|
case "structRef":
|
|
71
74
|
return t.struct;
|
|
72
75
|
}
|
package/dist/templates.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Programs and tests that `typesol init --template <name>` writes. Each test runs its program in LiteSVM and covers its
|
|
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
|
-
// The programs are copies of examples/ (test/workspace.test.ts checks they still match).
|
|
3
|
+
// The programs are copies of examples/ (test/cli/workspace.test.ts checks they still match).
|
|
4
4
|
export const TEMPLATE_NAMES = ["counter", "vault", "escrow"];
|
|
5
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\`.
|
package/dist/testing.d.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
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";
|
|
22
|
+
import { type SvmConnection, svmConnection } from "./tools/svmrpc.js";
|
|
23
23
|
export { type SvmConnection, svmConnection };
|
|
24
24
|
declare const BorshCoder: typeof anchor.BorshCoder;
|
|
25
25
|
type BorshCoder = InstanceType<typeof BorshCoder>;
|
package/dist/testing.js
CHANGED
|
@@ -22,7 +22,7 @@ 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
|
+
import { svmConnection } from "./tools/svmrpc.js";
|
|
26
26
|
import { buildWorkspace, findWorkspace } from "./workspace.js";
|
|
27
27
|
export { svmConnection };
|
|
28
28
|
// @anchor-lang/core is CommonJS: Node 22 cannot import its classes by name from an installed ES module.
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* sanitized comments.
|
|
19
19
|
*/
|
|
20
20
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
21
|
-
import { ANCHOR_ERRORS } from "
|
|
21
|
+
import { ANCHOR_ERRORS } from "../compiler/anchor-errors.js";
|
|
22
22
|
import path from "node:path";
|
|
23
23
|
/** An IDL the generator refuses: malformed, unsafe to embed, or older than the 0.30 IDL format. */
|
|
24
24
|
export class ClientIdlError extends Error {
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export declare const INSTALL_AGAVE = "sh -c \"$(curl -sSfL https://release.anza.xyz/stable/install)\"";
|
|
2
|
+
export interface Check {
|
|
3
|
+
ok: boolean;
|
|
4
|
+
/** A missing optional piece (the Solana CLI is only needed to deploy). */
|
|
5
|
+
optional?: boolean;
|
|
6
|
+
what: string;
|
|
7
|
+
detail: string;
|
|
8
|
+
fix?: string[];
|
|
9
|
+
}
|
|
10
|
+
/** The platform-tools the compiler needs: found, or what to run. */
|
|
11
|
+
export declare function toolchainCheck(): Check;
|
|
12
|
+
/** json_rpc_url / keypair_path from a Solana CLI config (the given file, or the CLI's default one). */
|
|
13
|
+
export declare function solanaConfig(file?: string): {
|
|
14
|
+
url?: string;
|
|
15
|
+
keypair?: string;
|
|
16
|
+
file: string;
|
|
17
|
+
exists: boolean;
|
|
18
|
+
};
|
|
19
|
+
/** The Solana CLI's cluster monikers, as `solana --url` accepts them; anything else is used as given. */
|
|
20
|
+
export declare function clusterUrl(url: string): string;
|
|
21
|
+
/** Only the host: an RPC URL can carry an API key in its path or query, and these messages may be shared. */
|
|
22
|
+
export declare function rpcHost(url: string): string;
|
|
23
|
+
export declare function rpc<T>(url: string, method: string, params?: unknown[]): Promise<T>;
|
|
24
|
+
/** fetch's "fetch failed" says little: the cause's code (ECONNREFUSED, ENOTFOUND, a timeout) says more. */
|
|
25
|
+
export declare const errText: (e: unknown) => string;
|
|
26
|
+
/**
|
|
27
|
+
* Before `solana program deploy`: the RPC answers, and the payer holds enough for the program's rent (a first deploy
|
|
28
|
+
* holds about twice the binary's rent while the buffer exists; an upgrade, the buffer's). Throws with what to do.
|
|
29
|
+
*/
|
|
30
|
+
export declare function deployPreflight(opts: {
|
|
31
|
+
url: string;
|
|
32
|
+
payer?: string;
|
|
33
|
+
programs: {
|
|
34
|
+
soPath: string;
|
|
35
|
+
address: string;
|
|
36
|
+
name: string;
|
|
37
|
+
}[];
|
|
38
|
+
}): Promise<void>;
|
|
39
|
+
/** Everything `typesol doctor` checks, in order. */
|
|
40
|
+
export declare function doctorChecks(opts: {
|
|
41
|
+
cwd: string;
|
|
42
|
+
url?: string;
|
|
43
|
+
config?: string;
|
|
44
|
+
}): Promise<Check[]>;
|
|
45
|
+
export declare function formatChecks(checks: Check[]): string;
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// `typesol doctor` and the checks `init` and `deploy` run before they need something: Node, the platform-tools the
|
|
2
|
+
// compiler builds with, the Solana CLI, its keypair and RPC, the project, and (with an RPC) whether it answers and the
|
|
3
|
+
// payer can afford a deploy. Each problem comes with the command that fixes it.
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { findToolchain } from "../compiler/toolchain.js";
|
|
9
|
+
import { base58, readKeypair } from "../keys.js";
|
|
10
|
+
import { findWorkspace } from "../workspace.js";
|
|
11
|
+
export const INSTALL_AGAVE = 'sh -c "$(curl -sSfL https://release.anza.xyz/stable/install)"';
|
|
12
|
+
const MIN_NODE = [22, 12];
|
|
13
|
+
/** The platform-tools the compiler needs: found, or what to run. */
|
|
14
|
+
export function toolchainCheck() {
|
|
15
|
+
try {
|
|
16
|
+
const tc = findToolchain();
|
|
17
|
+
let version = "";
|
|
18
|
+
try {
|
|
19
|
+
version = execFileSync(tc.clang, ["--version"], { encoding: "utf8" })
|
|
20
|
+
.split("\n")[0]
|
|
21
|
+
.replace(/\s*\(.*$/, "");
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
/* the path is enough */
|
|
25
|
+
}
|
|
26
|
+
return { ok: true, what: "platform-tools", detail: version || tc.sdk };
|
|
27
|
+
}
|
|
28
|
+
catch (e) {
|
|
29
|
+
return {
|
|
30
|
+
ok: false,
|
|
31
|
+
what: "platform-tools",
|
|
32
|
+
detail: e.message.split(". ")[0],
|
|
33
|
+
fix: [INSTALL_AGAVE, "cargo-build-sbf --version # once: downloads the platform-tools TypeSOL compiles with"],
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** json_rpc_url / keypair_path from a Solana CLI config (the given file, or the CLI's default one). */
|
|
38
|
+
export function solanaConfig(file) {
|
|
39
|
+
const configPath = file ?? path.join(os.homedir(), ".config/solana/cli/config.yml");
|
|
40
|
+
if (!existsSync(configPath))
|
|
41
|
+
return { file: configPath, exists: false };
|
|
42
|
+
const text = readFileSync(configPath, "utf8");
|
|
43
|
+
const get = (k) => new RegExp(`^${k}:\\s*["']?([^"'\\n]+?)["']?\\s*$`, "m").exec(text)?.[1];
|
|
44
|
+
const expand = (p) => (p?.startsWith("~/") ? path.join(os.homedir(), p.slice(2)) : p);
|
|
45
|
+
return { url: get("json_rpc_url"), keypair: expand(get("keypair_path")), file: configPath, exists: true };
|
|
46
|
+
}
|
|
47
|
+
/** The Solana CLI's cluster monikers, as `solana --url` accepts them; anything else is used as given. */
|
|
48
|
+
export function clusterUrl(url) {
|
|
49
|
+
const monikers = {
|
|
50
|
+
"mainnet-beta": "https://api.mainnet-beta.solana.com",
|
|
51
|
+
m: "https://api.mainnet-beta.solana.com",
|
|
52
|
+
devnet: "https://api.devnet.solana.com",
|
|
53
|
+
d: "https://api.devnet.solana.com",
|
|
54
|
+
testnet: "https://api.testnet.solana.com",
|
|
55
|
+
t: "https://api.testnet.solana.com",
|
|
56
|
+
localhost: "http://127.0.0.1:8899",
|
|
57
|
+
l: "http://127.0.0.1:8899",
|
|
58
|
+
};
|
|
59
|
+
return monikers[url] ?? url;
|
|
60
|
+
}
|
|
61
|
+
/** Only the host: an RPC URL can carry an API key in its path or query, and these messages may be shared. */
|
|
62
|
+
export function rpcHost(url) {
|
|
63
|
+
try {
|
|
64
|
+
return new URL(url).host;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return "the RPC";
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** The cluster an RPC belongs to, for the airdrop hint (mainnet has none). */
|
|
71
|
+
function clusterOf(url) {
|
|
72
|
+
const host = rpcHost(url);
|
|
73
|
+
if (/devnet/.test(host))
|
|
74
|
+
return "devnet";
|
|
75
|
+
if (/testnet/.test(host))
|
|
76
|
+
return "testnet";
|
|
77
|
+
if (/^(127\.0\.0\.1|localhost|0\.0\.0\.0)(:|$)/.test(host))
|
|
78
|
+
return "localhost";
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
export async function rpc(url, method, params = []) {
|
|
82
|
+
const res = await fetch(url, {
|
|
83
|
+
method: "POST",
|
|
84
|
+
headers: { "Content-Type": "application/json" },
|
|
85
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
|
|
86
|
+
signal: AbortSignal.timeout(10_000),
|
|
87
|
+
});
|
|
88
|
+
const body = (await res.json());
|
|
89
|
+
if (body.error)
|
|
90
|
+
throw new Error(body.error.message);
|
|
91
|
+
return body.result;
|
|
92
|
+
}
|
|
93
|
+
/** fetch's "fetch failed" says little: the cause's code (ECONNREFUSED, ENOTFOUND, a timeout) says more. */
|
|
94
|
+
export const errText = (e) => {
|
|
95
|
+
const err = e;
|
|
96
|
+
return err.cause?.code ?? err.cause?.message ?? (err.name === "TimeoutError" ? "no answer in 10 s" : err.message);
|
|
97
|
+
};
|
|
98
|
+
const sol = (lamports) => lamports === 0 ? "0 SOL" : lamports < 100_000 ? `${lamports} lamports` : `${(lamports / 1e9).toFixed(lamports < 1e7 ? 4 : 2)} SOL`;
|
|
99
|
+
/** A cost, rounded up so the advice is enough. */
|
|
100
|
+
const solUp = (lamports) => `${(Math.ceil(lamports / 1e7) / 100).toFixed(2)} SOL`;
|
|
101
|
+
/**
|
|
102
|
+
* Before `solana program deploy`: the RPC answers, and the payer holds enough for the program's rent (a first deploy
|
|
103
|
+
* holds about twice the binary's rent while the buffer exists; an upgrade, the buffer's). Throws with what to do.
|
|
104
|
+
*/
|
|
105
|
+
export async function deployPreflight(opts) {
|
|
106
|
+
const host = rpcHost(opts.url);
|
|
107
|
+
try {
|
|
108
|
+
await rpc(opts.url, "getVersion");
|
|
109
|
+
}
|
|
110
|
+
catch (e) {
|
|
111
|
+
const local = clusterOf(opts.url) === "localhost";
|
|
112
|
+
throw new Error(`cannot reach ${host} (${errText(e)}). ` +
|
|
113
|
+
(local
|
|
114
|
+
? "Start a local validator in another terminal: `solana-test-validator`, or deploy to devnet with --url devnet."
|
|
115
|
+
: "Check --url (or json_rpc_url in the Solana CLI config); for a test deploy use --url devnet or a local `solana-test-validator`."));
|
|
116
|
+
}
|
|
117
|
+
if (!opts.payer || !existsSync(opts.payer))
|
|
118
|
+
return; // the Solana CLI reports a missing keypair itself
|
|
119
|
+
const payer = base58(readKeypair(opts.payer).subarray(32));
|
|
120
|
+
let need = 0;
|
|
121
|
+
for (const p of opts.programs) {
|
|
122
|
+
const size = statSync(p.soPath).size;
|
|
123
|
+
const exists = (await rpc(opts.url, "getAccountInfo", [p.address, { encoding: "base64", dataSlice: { offset: 0, length: 0 } }])).value;
|
|
124
|
+
const rent = await rpc(opts.url, "getMinimumBalanceForRentExemption", [size + 45]);
|
|
125
|
+
need += exists ? rent : 2 * rent;
|
|
126
|
+
}
|
|
127
|
+
need += 5_000_000; // fees for the buffer writes
|
|
128
|
+
const balance = (await rpc(opts.url, "getBalance", [payer])).value;
|
|
129
|
+
if (balance >= need)
|
|
130
|
+
return;
|
|
131
|
+
const cluster = clusterOf(opts.url);
|
|
132
|
+
const airdrop = cluster ? ` Get test SOL: \`solana airdrop ${Math.max(2, Math.ceil((need - balance) / 1e9))} ${payer} --url ${cluster}\`.` : "";
|
|
133
|
+
throw new Error(`the payer ${payer} has ${sol(balance)} on ${host}; deploying needs about ${solUp(need)} (rent, mostly returned when a buffer closes).${airdrop}`);
|
|
134
|
+
}
|
|
135
|
+
/** Everything `typesol doctor` checks, in order. */
|
|
136
|
+
export async function doctorChecks(opts) {
|
|
137
|
+
const checks = [];
|
|
138
|
+
const [major, minor] = process.versions.node.split(".").map(Number);
|
|
139
|
+
const nodeOk = major > MIN_NODE[0] || (major === MIN_NODE[0] && minor >= MIN_NODE[1]);
|
|
140
|
+
checks.push({
|
|
141
|
+
ok: nodeOk,
|
|
142
|
+
what: "Node.js",
|
|
143
|
+
detail: process.versions.node,
|
|
144
|
+
fix: nodeOk ? undefined : [`install Node.js ${MIN_NODE.join(".")} or later (https://nodejs.org)`],
|
|
145
|
+
});
|
|
146
|
+
checks.push(toolchainCheck());
|
|
147
|
+
let solanaVersion;
|
|
148
|
+
try {
|
|
149
|
+
solanaVersion = execFileSync("solana", ["--version"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] })
|
|
150
|
+
.split(" (")[0]
|
|
151
|
+
.trim();
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
/* not installed */
|
|
155
|
+
}
|
|
156
|
+
checks.push(solanaVersion
|
|
157
|
+
? { ok: true, what: "Solana CLI", detail: solanaVersion }
|
|
158
|
+
: { ok: false, optional: true, what: "Solana CLI", detail: "not on PATH (needed to deploy, not to build or test)", fix: [INSTALL_AGAVE] });
|
|
159
|
+
const cfg = solanaConfig(opts.config);
|
|
160
|
+
const keypair = cfg.keypair ?? path.join(os.homedir(), ".config/solana/id.json");
|
|
161
|
+
if (existsSync(keypair)) {
|
|
162
|
+
let address = "";
|
|
163
|
+
try {
|
|
164
|
+
address = base58(readKeypair(keypair).subarray(32));
|
|
165
|
+
}
|
|
166
|
+
catch (e) {
|
|
167
|
+
checks.push({ ok: false, optional: true, what: "keypair", detail: e.message });
|
|
168
|
+
}
|
|
169
|
+
if (address)
|
|
170
|
+
checks.push({ ok: true, what: "keypair", detail: `${address} (${keypair.replace(os.homedir(), "~")})` });
|
|
171
|
+
}
|
|
172
|
+
else
|
|
173
|
+
checks.push({
|
|
174
|
+
ok: false,
|
|
175
|
+
optional: true,
|
|
176
|
+
what: "keypair",
|
|
177
|
+
detail: `none at ${keypair.replace(os.homedir(), "~")} (the deploy payer)`,
|
|
178
|
+
fix: ["solana-keygen new"],
|
|
179
|
+
});
|
|
180
|
+
const url = opts.url ? clusterUrl(opts.url) : cfg.url;
|
|
181
|
+
if (!url)
|
|
182
|
+
checks.push({ ok: false, optional: true, what: "RPC", detail: "none set (deploy takes --url)", fix: ["solana config set --url devnet"] });
|
|
183
|
+
else {
|
|
184
|
+
const cluster = clusterOf(url);
|
|
185
|
+
try {
|
|
186
|
+
const v = await rpc(url, "getVersion");
|
|
187
|
+
checks.push({ ok: true, what: "RPC", detail: `${rpcHost(url)} (solana-core ${v["solana-core"]})` });
|
|
188
|
+
if (existsSync(keypair)) {
|
|
189
|
+
const balance = (await rpc(url, "getBalance", [base58(readKeypair(keypair).subarray(32))])).value;
|
|
190
|
+
checks.push(balance > 0
|
|
191
|
+
? { ok: true, what: "balance", detail: sol(balance) }
|
|
192
|
+
: {
|
|
193
|
+
ok: false,
|
|
194
|
+
optional: true,
|
|
195
|
+
what: "balance",
|
|
196
|
+
detail: `0 SOL on ${rpcHost(url)}`,
|
|
197
|
+
fix: [
|
|
198
|
+
cluster && cluster !== "localhost"
|
|
199
|
+
? `solana airdrop 2 --url ${cluster}`
|
|
200
|
+
: cluster
|
|
201
|
+
? "solana airdrop 2 --url localhost"
|
|
202
|
+
: "fund the keypair above",
|
|
203
|
+
],
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
catch (e) {
|
|
208
|
+
checks.push({
|
|
209
|
+
ok: false,
|
|
210
|
+
optional: true,
|
|
211
|
+
what: "RPC",
|
|
212
|
+
detail: `${rpcHost(url)} does not answer (${errText(e)})`,
|
|
213
|
+
fix: cluster === "localhost" ? ["solana-test-validator # in another terminal"] : ["pass --url devnet, or fix json_rpc_url in the Solana CLI config"],
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const ws = findWorkspace(opts.cwd);
|
|
218
|
+
checks.push(ws
|
|
219
|
+
? { ok: true, what: "project", detail: `${path.relative(opts.cwd, ws.file) || ws.file}: ${Object.keys(ws.programs).join(", ")}` }
|
|
220
|
+
: { ok: true, optional: true, what: "project", detail: "none here (typesol init <dir> creates one)" });
|
|
221
|
+
return checks;
|
|
222
|
+
}
|
|
223
|
+
export function formatChecks(checks) {
|
|
224
|
+
const mark = (c) => (c.ok ? "ok " : c.optional ? " ! " : "ERR");
|
|
225
|
+
const width = Math.max(...checks.map((c) => c.what.length));
|
|
226
|
+
return checks
|
|
227
|
+
.map((c) => ` ${mark(c)} ${c.what.padEnd(width)} ${c.detail}${(c.fix ?? []).map((f) => `\n${" ".repeat(width + 9)}${f}`).join("")}`)
|
|
228
|
+
.join("\n");
|
|
229
|
+
}
|