@typesol/cli 0.26.0 → 0.28.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 +3 -1
- package/dist/compiler/analyze.d.ts +2 -0
- package/dist/compiler/analyze.js +93 -12
- package/dist/compiler/emit/base.d.ts +3 -0
- package/dist/compiler/emit/base.js +4 -1
- package/dist/compiler/emit/calls.d.ts +16 -1
- package/dist/compiler/emit/calls.js +131 -1
- package/dist/compiler/emit/cpi.d.ts +9 -0
- package/dist/compiler/emit/cpi.js +63 -0
- package/dist/compiler/emit/expressions.js +18 -0
- package/dist/compiler/emit/function.js +41 -5
- package/dist/compiler/emit/shared.js +2 -0
- package/dist/compiler/emit/storage.js +40 -1
- package/dist/compiler/idl.js +1 -1
- package/dist/compiler/model.d.ts +14 -0
- package/dist/compiler/model.js +3 -0
- package/docs/architecture.md +2 -1
- package/docs/audit.md +1 -0
- package/docs/case-study-escrow.md +3 -3
- package/docs/case-study-multisig.md +167 -0
- package/docs/errors.md +4 -3
- package/docs/language.md +26 -6
- package/docs/migrate-from-anchor.md +9 -0
- package/docs/security-model.md +15 -3
- package/lib/typesol.d.ts +92 -5
- package/package.json +1 -1
- package/runtime/typesol_rt.h +90 -23
|
@@ -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) {
|
|
@@ -40,8 +40,14 @@ export class FunctionEmitter extends StatementEmitter {
|
|
|
40
40
|
head.push(` TS_TRY(ts_check_signer(${v}));`);
|
|
41
41
|
// Mut<…>: Anchor's `mut` (2000). Anchor checks it with the constraints, after seeds and associated token; here it
|
|
42
42
|
// sits with the type checks, which keeps binaries smaller (only a transaction breaking both sees the difference).
|
|
43
|
+
// Zeroed<T> (Anchor's `zero`): its discriminator, owner and size come before `mut`, as under Anchor.
|
|
44
|
+
if (k.kind === "zeroed")
|
|
45
|
+
head.push(` TS_TRY(ts_check_zeroed(${v}, params->program_id, ${k.struct.layout === "borsh" ? borshMinSize(k.struct, this.model) : `sizeof(TSS_${k.struct.name})`}));`);
|
|
43
46
|
if (isWritable(k))
|
|
44
47
|
head.push(` TS_TRY(ts_check_mut(${v}));`);
|
|
48
|
+
// Signed<…>: Anchor's `signer` constraint (2002), right after `mut`.
|
|
49
|
+
if (a.constraints.signed)
|
|
50
|
+
head.push(` if (!${v}->is_signer) TS_FAIL(TS_ERR_CONSTRAINT_SIGNER, "TypeSOL: ${a.name} must sign");`);
|
|
45
51
|
// A @layout borsh account only has to hold its smallest Borsh form (unpacking checks the rest).
|
|
46
52
|
if (k.kind === "account")
|
|
47
53
|
head.push(` TS_TRY(ts_check_program_account(${v}, params->program_id, TS_DISC_${k.struct.name}, ${k.struct.layout === "borsh" ? borshMinSize(k.struct, this.model) : emptySizeC(k.struct)}));`);
|
|
@@ -90,11 +96,14 @@ export class FunctionEmitter extends StatementEmitter {
|
|
|
90
96
|
}
|
|
91
97
|
head.push(` TS_TRY(ts_check_executable(${v}));`);
|
|
92
98
|
}
|
|
99
|
+
// Migrate<From, To>: an existing From account (owner, discriminator, size), kept to be rewritten as To.
|
|
100
|
+
if (k.kind === "migrate")
|
|
101
|
+
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
102
|
if (k.kind === "zeroed")
|
|
94
|
-
head.push(` TS_TRY(ts_init_zeroed(${v},
|
|
103
|
+
head.push(` TS_TRY(ts_init_zeroed(${v}, TS_DISC_${k.struct.name}));`);
|
|
95
104
|
if (k.kind === "initIfNeeded" && k.struct.vec)
|
|
96
105
|
this.reporter.fail(a.node, "InitIfNeeded accounts cannot end in a Vec yet");
|
|
97
|
-
if ((k.kind === "account" || k.kind === "init" || k.kind === "initIfNeeded") && k.struct.layout === "borsh") {
|
|
106
|
+
if ((k.kind === "account" || k.kind === "init" || k.kind === "initIfNeeded" || k.kind === "zeroed") && k.struct.layout === "borsh") {
|
|
98
107
|
// Worked on as its fixed-layout struct on the heap, packed back when the instruction returns. The heap is zero at
|
|
99
108
|
// entry and these are its first allocations (before the body uses it), so a string's padding and a list's
|
|
100
109
|
// unused slots start as zero: ts_unpack_* only writes what the Borsh bytes hold.
|
|
@@ -152,9 +161,30 @@ export class FunctionEmitter extends StatementEmitter {
|
|
|
152
161
|
// The loader passes each account once and marks repeats as duplicates, which share its record (ts_deserialize),
|
|
153
162
|
// so two parameters name the same account exactly when their pointers are equal.
|
|
154
163
|
const writable = ix.accounts.filter((a) => isWritable(a.kind));
|
|
164
|
+
// Dup<…> (Anchor's `dup`) lifts the check for a pair when both accounts are read and written in place: not created
|
|
165
|
+
// here, not closed or a close destination, not a @layout borsh heap copy (a lost update when packed back).
|
|
166
|
+
const closing = new Set(ix.accounts.flatMap((a) => (a.constraints.closeTo ? [a.name, a.constraints.closeTo] : [])));
|
|
167
|
+
const inPlace = (a) => {
|
|
168
|
+
const k = a.kind;
|
|
169
|
+
if (closing.has(a.name))
|
|
170
|
+
return false;
|
|
171
|
+
if (k.kind === "account")
|
|
172
|
+
return k.struct.layout !== "borsh";
|
|
173
|
+
if (k.kind === "token" || k.kind === "mint")
|
|
174
|
+
return !k.init;
|
|
175
|
+
return k.kind === "signer" || k.kind === "info";
|
|
176
|
+
};
|
|
177
|
+
for (const a of writable)
|
|
178
|
+
if (a.constraints.dup && !inPlace(a))
|
|
179
|
+
this.reporter.fail(a.node, "Dup<…> is for accounts read and written in place: not created, closed, a close destination or @layout borsh");
|
|
180
|
+
for (const a of ix.accounts)
|
|
181
|
+
if (a.constraints.dup && !isWritable(a.kind))
|
|
182
|
+
this.reporter.fail(a.node, "Dup<…> lets a writable account alias another; this one is not writable");
|
|
155
183
|
const pairs = [];
|
|
156
184
|
for (let i = 0; i < writable.length; i++)
|
|
157
185
|
for (let j = i + 1; j < writable.length; j++) {
|
|
186
|
+
if ((writable[i].constraints.dup || writable[j].constraints.dup) && inPlace(writable[i]) && inPlace(writable[j]))
|
|
187
|
+
continue;
|
|
158
188
|
const present = [writable[i], writable[j]]
|
|
159
189
|
.filter((a) => a.optional)
|
|
160
190
|
.map((a) => `a_${a.name} && `)
|
|
@@ -331,6 +361,10 @@ export class FunctionEmitter extends StatementEmitter {
|
|
|
331
361
|
for (const name of this.pendingInit) {
|
|
332
362
|
const k = this.accounts.get(name).kind;
|
|
333
363
|
const a = this.accounts.get(name);
|
|
364
|
+
if (k.kind === "migrate") {
|
|
365
|
+
this.reporter.error(a.node, `Migrate account "${name}" is never migrated; call ${name}.migrate(payer, value) (Anchor fails such an instruction with AccountNotMigrated)`);
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
334
368
|
const how = k.kind === "token" && a.constraints.ata
|
|
335
369
|
? `${name}.${k.init === "create" ? "create" : "createIfNeeded"}(payer)`
|
|
336
370
|
: k.kind === "token"
|
|
@@ -351,7 +385,7 @@ export class FunctionEmitter extends StatementEmitter {
|
|
|
351
385
|
return [];
|
|
352
386
|
// @layout borsh accounts go back to their Borsh bytes (unless closed, or never created).
|
|
353
387
|
const packs = this.ix.accounts
|
|
354
|
-
.filter((a) => (a.kind.kind === "account" ? a.kind.mut : a.kind.kind === "init" || a.kind.kind === "initIfNeeded"))
|
|
388
|
+
.filter((a) => (a.kind.kind === "account" ? a.kind.mut : a.kind.kind === "init" || a.kind.kind === "initIfNeeded" || a.kind.kind === "zeroed"))
|
|
355
389
|
.filter((a) => a.kind.struct.layout === "borsh")
|
|
356
390
|
.map((a) => {
|
|
357
391
|
const v = `a_${a.name}`;
|
|
@@ -560,9 +594,11 @@ export class FunctionEmitter extends StatementEmitter {
|
|
|
560
594
|
const seeds = `cs_${a.name}`;
|
|
561
595
|
out.push(` SolSignerSeed ${seeds}[${parts.length + 1}] = {${parts.join(", ")}};`);
|
|
562
596
|
if (c.seeds.bump) {
|
|
563
|
-
|
|
597
|
+
// The account's own bump field, or "@acct.field": one stored in another struct account.
|
|
598
|
+
const bump = c.seeds.bump.startsWith("@") ? `d_${c.seeds.bump.slice(1).replace(".", "->")}` : `d_${a.name}->${c.seeds.bump}`;
|
|
599
|
+
out.push(` ${seeds}[${parts.length}] = (SolSignerSeed){&${bump}, 1};`);
|
|
564
600
|
out.push(` TS_TRY(ts_require_pda(a_${a.name}, ${seeds}, ${parts.length + 1}, ${program}));`);
|
|
565
|
-
this.bumps.set(a.name,
|
|
601
|
+
this.bumps.set(a.name, bump);
|
|
566
602
|
}
|
|
567
603
|
else {
|
|
568
604
|
bumpDecls.push(` uint8_t sb_${a.name}; (void)sb_${a.name};`);
|
|
@@ -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);
|
|
@@ -667,9 +685,30 @@ export class StorageEmitter extends EmitterBase {
|
|
|
667
685
|
this.line(`}`);
|
|
668
686
|
return;
|
|
669
687
|
}
|
|
688
|
+
// `list = owners`: a Vec argument, or a Vec / FixedVec of the same elements, copied whole into a FixedVec (Anchor's
|
|
689
|
+
// `multisig.owners = owners`); more elements than it holds fail with 5104, as push does.
|
|
690
|
+
if (target.type.kind === "vec" && target.type.cap !== undefined && target.type.elem.kind !== "string" && target.type.elem.kind !== "fstring") {
|
|
691
|
+
const t = target.type;
|
|
692
|
+
const l = ts.isIdentifier(valueNode) ? this.lookup(valueNode.text) : undefined;
|
|
693
|
+
const r = l?.type.kind === "vecArg" ? undefined : this.reporter.guard(() => this.ref(valueNode));
|
|
694
|
+
const from = l?.type.kind === "vecArg" && sameType(l.type.elem, t.elem)
|
|
695
|
+
? { items: `${l.c}.ptr`, count: `((uint64_t)${l.c}.len)` }
|
|
696
|
+
: r?.vec && r.type.kind === "vec" && sameType(r.type.elem, t.elem) && !sameType(r.type, t)
|
|
697
|
+
? { items: r.vec.items, count: vecCount(r.vec) }
|
|
698
|
+
: undefined;
|
|
699
|
+
if (from) {
|
|
700
|
+
const n = this.tmp("vn");
|
|
701
|
+
this.line(`uint64_t ${n} = ${from.count};`);
|
|
702
|
+
this.line(`if (${n} > ${t.cap}) TS_FAIL(TS_ERR_INDEX, "TypeSOL: more elements than the ${typeName(t)} holds");`);
|
|
703
|
+
// Slots past the new length keep what they held, as after pop() / clear().
|
|
704
|
+
this.line(`ts_memcpy(${target.lv}.items, (const uint8_t *)${from.items}, ${n} * ${this.fieldBytes(t.elem)});`);
|
|
705
|
+
this.line(`${target.lv}.len = (uint32_t)${n};`);
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
670
709
|
const src = this.ref(valueNode) ?? this.wholeAccount(valueNode);
|
|
671
710
|
if (!src || !sameType(src.type, target.type))
|
|
672
|
-
this.fail(valueNode, `expected a ${typeName(target.type)}: an object literal
|
|
711
|
+
this.fail(valueNode, `expected a ${typeName(target.type)}: an object literal, another ${typeName(target.type)}${target.type.kind === "vec" && target.type.cap !== undefined ? " or a Vec of the same elements" : ""}`);
|
|
673
712
|
// Same-typed places are either distinct or identical (`a.p = a.p`); ts_copy skips the latter.
|
|
674
713
|
this.line(`ts_copy(&${target.lv}, &${src.lv}, sizeof(${target.lv}));`);
|
|
675
714
|
}
|
package/dist/compiler/idl.js
CHANGED
|
@@ -93,7 +93,7 @@ function accountEntry(ix, name, k, programAddress) {
|
|
|
93
93
|
entry.writable = true;
|
|
94
94
|
if (param?.optional)
|
|
95
95
|
entry.optional = true;
|
|
96
|
-
if (k.kind === "signer" || param?.signsInit)
|
|
96
|
+
if (k.kind === "signer" || param?.signsInit || param?.constraints.signed)
|
|
97
97
|
entry.signer = true;
|
|
98
98
|
if (k.kind === "system")
|
|
99
99
|
entry.address = SYSTEM_PROGRAM;
|
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,10 @@ 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;
|
|
278
|
+
/** Signed<A> (Anchor's `signer` constraint on an account of another type): it must sign (2002). */
|
|
279
|
+
signed?: boolean;
|
|
266
280
|
/** Close this account into that parameter when the instruction succeeds. */
|
|
267
281
|
closeTo?: string;
|
|
268
282
|
/** 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/docs/architecture.md
CHANGED
|
@@ -102,7 +102,8 @@ These invariants are easy to break. Each one has tests.
|
|
|
102
102
|
pointer per account, and lengths, lamports and owners a CPI changes are seen without copying anything back. CPIs
|
|
103
103
|
go through `ts_invoke`, which builds `SolAccountInfo` records only for the accounts the call lists. It finds each
|
|
104
104
|
record from its meta (the key sits 8 bytes into it), so every `SolAccountMeta` must point at `&acct->key` of an
|
|
105
|
-
instruction account, never at a derived or local `SolPubkey`.
|
|
105
|
+
instruction account, never at a derived or local `SolPubkey`. `invokeInstruction` is the one exception: its metas
|
|
106
|
+
point into data, so it calls `sol_invoke_signed` directly with records for every account it was handed.
|
|
106
107
|
- **Runtime messages carry the source location.** `line()` appends `(file.ts:line)` to every `"TypeSOL: …"`
|
|
107
108
|
message.
|
|
108
109
|
- **No SDK byte loops.** Generated code and the runtime compare, copy and zero memory with `ts_key_eq`, `ts_mem_eq`,
|
package/docs/audit.md
CHANGED
|
@@ -40,6 +40,7 @@ accepts. Areas where a bug would be most damaging, and where earlier internal re
|
|
|
40
40
|
the Vecs that follow, `@layout borsh` accounts.
|
|
41
41
|
- **Arithmetic**: every operator and cast at every width, compound assignment, `mulDiv`, rent.
|
|
42
42
|
- **CPIs and signing**: signer-seed arrays (including their reuse across statements), account flags in `invoke`,
|
|
43
|
+
`invokeInstruction` (metas from data, the infos it hands over, `ts_alloc` of the meta array),
|
|
43
44
|
account creation on pre-funded addresses, closing.
|
|
44
45
|
- **The runtime's parsing**: instruction data, the serialized accounts, Token-2022 extension data, oracle accounts and
|
|
45
46
|
the instructions sysvar.
|
|
@@ -184,9 +184,9 @@ Both implementations refuse the hostile calls the comparison makes:
|
|
|
184
184
|
|
|
185
185
|
TypeSOL checks each account's constraints in Anchor's order, so a refused transaction usually gets the same error
|
|
186
186
|
code (see [which error a transaction gets](errors.md#which-error-a-transaction-gets)). The second call differs on
|
|
187
|
-
purpose: taker and maker are then the same writable account, which TypeSOL refuses for any two writable parameters
|
|
188
|
-
where Anchor only compares program and token accounts and fails later, on the maker's
|
|
189
|
-
matches on this code needs to accept either.
|
|
187
|
+
purpose: taker and maker are then the same writable account, which TypeSOL refuses for any two writable parameters
|
|
188
|
+
unless one is marked `Dup<…>`, where Anchor only compares program and token accounts and fails later, on the maker's
|
|
189
|
+
token account. A client that matches on this code needs to accept either.
|
|
190
190
|
|
|
191
191
|
## Cost
|
|
192
192
|
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# Case study: porting coral-xyz/multisig
|
|
2
|
+
|
|
3
|
+
This page ports a program that holds real authority on mainnet:
|
|
4
|
+
[coral-xyz/multisig](https://github.com/coral-xyz/multisig) (`programs/multisig/src/lib.rs`, Anchor 0.25, Apache-2.0),
|
|
5
|
+
deployed at `msigUdDBsR4zSUYqYEDrc1LcgtmuSDDM7KxpRUXNC6U`. The port is
|
|
6
|
+
[`examples/multisig.ts`](../examples/multisig.ts). Instead of a twin built for the occasion, it is checked against the
|
|
7
|
+
binary that runs on mainnet: [`test/examples/multisig.test.ts`](../test/examples/multisig.test.ts) runs both under the
|
|
8
|
+
same program id and compares every outcome and every account byte. See
|
|
9
|
+
[case-study-escrow.md](case-study-escrow.md) for a token program and [Migrate from Anchor](migrate-from-anchor.md) for
|
|
10
|
+
the general reference.
|
|
11
|
+
|
|
12
|
+
## The program
|
|
13
|
+
|
|
14
|
+
A multisig executes arbitrary instructions once `threshold` of its owners approve them. Its PDA (`[multisig]`, bump
|
|
15
|
+
`multisig.nonce`) signs the instruction, so it can hold anything a key can: a program's upgrade authority, a mint
|
|
16
|
+
authority, a treasury.
|
|
17
|
+
|
|
18
|
+
- `create_multisig(owners, threshold, nonce)` initializes an account the client allocated in the same transaction.
|
|
19
|
+
- `create_transaction(pid, accs, data)` stores a proposed instruction; the proposer, an owner, approves it.
|
|
20
|
+
- `approve()` adds an owner's approval.
|
|
21
|
+
- `execute_transaction()` runs the stored instruction through a CPI signed by the PDA once enough owners approved.
|
|
22
|
+
- `set_owners(owners)`, `change_threshold(threshold)` and `set_owners_and_change_threshold(...)` change the multisig;
|
|
23
|
+
only its PDA can sign them, so they run as stored transactions the multisig executes on itself.
|
|
24
|
+
|
|
25
|
+
Upstream it is 337 lines of Rust; the port is 184 lines of TypeScript, comments included.
|
|
26
|
+
|
|
27
|
+
## What it needed
|
|
28
|
+
|
|
29
|
+
The port needed five features TypeSOL did not have. They are general features now, each with tests of its own
|
|
30
|
+
(`test/language/dynamic-cpi.test.ts`):
|
|
31
|
+
|
|
32
|
+
| Anchor | TypeSOL | |
|
|
33
|
+
| --- | --- | --- |
|
|
34
|
+
| `invoke_signed(&ix, ctx.remaining_accounts, …)` with `ix` built from account data | `invokeInstruction(programId, metas, data, remainingAccounts(), seeds)` | A CPI decided at run time. Each meta is a struct with `pubkey`, `isSigner`, `isWritable`. The runtime finds each account among the infos by key and checks privileges, as it does for Rust. |
|
|
35
|
+
| `#[account(zero, signer)] multisig: Box<Account<'info, Multisig>>` | `Signed<Zeroed<Multisig>>` of a `/** @layout borsh */` struct | `Zeroed` now takes Borsh structs (they start empty and are written as Borsh on return). `Signed<…>` is Anchor's `signer` constraint (2002). `Zeroed` now reports an already-set discriminator first, with Anchor's 2013. |
|
|
36
|
+
| `seeds = [multisig.key().as_ref()], bump = multisig.nonce` | `Seeds<Signer, ["@multisig"], "@multisig.nonce">` | A bump stored in another account. |
|
|
37
|
+
| `multisig.owners = owners` | the same, into a `FixedVec<Pubkey, 34>` | A `Vec` argument copied whole into a list in account data. |
|
|
38
|
+
| `TransactionAccount { pubkey, is_signer, is_writable }` | `interface TransactionAccount { pubkey; isSigner; isWritable }` | `isSigner` / `isWritable` were reserved field names everywhere; now only in structs held in an account, where they would shadow `acct.isSigner`. |
|
|
39
|
+
|
|
40
|
+
## Side by side
|
|
41
|
+
|
|
42
|
+
The heart of the program, executing an approved transaction:
|
|
43
|
+
|
|
44
|
+
```rust
|
|
45
|
+
pub fn execute_transaction(ctx: Context<ExecuteTransaction>) -> Result<()> {
|
|
46
|
+
if ctx.accounts.transaction.did_execute {
|
|
47
|
+
return Err(ErrorCode::AlreadyExecuted.into());
|
|
48
|
+
}
|
|
49
|
+
let sig_count = ctx.accounts.transaction.signers.iter().filter(|&did_sign| *did_sign).count() as u64;
|
|
50
|
+
if sig_count < ctx.accounts.multisig.threshold {
|
|
51
|
+
return Err(ErrorCode::NotEnoughSigners.into());
|
|
52
|
+
}
|
|
53
|
+
let mut ix: Instruction = (*ctx.accounts.transaction).deref().into();
|
|
54
|
+
ix.accounts = ix.accounts.iter().map(|acc| {
|
|
55
|
+
let mut acc = acc.clone();
|
|
56
|
+
if &acc.pubkey == ctx.accounts.multisig_signer.key {
|
|
57
|
+
acc.is_signer = true;
|
|
58
|
+
}
|
|
59
|
+
acc
|
|
60
|
+
}).collect();
|
|
61
|
+
let multisig_key = ctx.accounts.multisig.key();
|
|
62
|
+
let seeds = &[multisig_key.as_ref(), &[ctx.accounts.multisig.nonce]];
|
|
63
|
+
let signer = &[&seeds[..]];
|
|
64
|
+
let accounts = ctx.remaining_accounts;
|
|
65
|
+
solana_program::program::invoke_signed(&ix, accounts, signer)?;
|
|
66
|
+
ctx.accounts.transaction.did_execute = true;
|
|
67
|
+
Ok(())
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
export function executeTransaction(
|
|
73
|
+
multisig: Account<Multisig>,
|
|
74
|
+
multisigSigner: Seeds<AccountInfo, ["@multisig"], "@multisig.nonce">,
|
|
75
|
+
transaction: HasOne<Mut<Transaction>, "multisig">,
|
|
76
|
+
): void {
|
|
77
|
+
require(multisig.ownerSetSeqno === transaction.ownerSetSeqno, AnchorError.ConstraintRaw);
|
|
78
|
+
require(!transaction.didExecute, ErrorCode.AlreadyExecuted);
|
|
79
|
+
let sigCount = 0n;
|
|
80
|
+
for (const didSign of transaction.signers) if (didSign) sigCount++;
|
|
81
|
+
require(sigCount >= multisig.threshold, ErrorCode.NotEnoughSigners);
|
|
82
|
+
const metas: FixedVec<TransactionAccount, 32> = transaction.accounts.map((acc) => ({
|
|
83
|
+
pubkey: acc.pubkey,
|
|
84
|
+
isSigner: acc.isSigner || acc.pubkey === multisigSigner.key,
|
|
85
|
+
isWritable: acc.isWritable,
|
|
86
|
+
}));
|
|
87
|
+
invokeInstruction(transaction.programId, metas, transaction.data, remainingAccounts(), [multisig.key, multisig.nonce]);
|
|
88
|
+
transaction.didExecute = true;
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The `#[derive(Accounts)]` structs become parameter types. Anchor's `constraint = multisig.owner_set_seqno ==
|
|
93
|
+
transaction.owner_set_seqno` becomes the first `require`, raising the same 2003. The error enum keeps its order and
|
|
94
|
+
messages, so the codes stay 6000–6008. Account and instruction names and the Borsh layout are the original's, so
|
|
95
|
+
existing clients and existing accounts work with the port unchanged.
|
|
96
|
+
|
|
97
|
+
## How it was checked
|
|
98
|
+
|
|
99
|
+
`test/programs/coral_multisig.so` is the program dumped from mainnet on 2026-10-29 (sha256 in
|
|
100
|
+
[`test/programs/SOURCES.md`](../test/programs/SOURCES.md)). The test loads it into one LiteSVM and the port into
|
|
101
|
+
another, both at `msigUdDB…`, then sends each transaction to both. After each one it requires the same outcome (success,
|
|
102
|
+
or the same error down to the code) and the same lamports, owner and data bytes for every account involved.
|
|
103
|
+
|
|
104
|
+
There are 43 transactions:
|
|
105
|
+
|
|
106
|
+
- **The upstream test suite** (`tests/multisig.js`): create a 2-of-3 multisig, propose `set_owners`, approve, and
|
|
107
|
+
execute through the PDA. Then `Assert Unique Owners` (6008).
|
|
108
|
+
- **Every `create_multisig` refusal**: threshold 0 or above the owner count (6007), no owners, the account not signing
|
|
109
|
+
(2002), not rent-exempt (2005), owned by another program (3007), too small for the empty value (3003) or for the
|
|
110
|
+
owners (3004), already initialized (2013).
|
|
111
|
+
- **Proposals, approvals and executions**:
|
|
112
|
+
- a proposer or approver outside the owner set (6000);
|
|
113
|
+
- another multisig's transaction (2001);
|
|
114
|
+
- too few approvals (6002);
|
|
115
|
+
- a wrong PDA (2006);
|
|
116
|
+
- executing twice (6006);
|
|
117
|
+
- a changed owner set invalidating a proposal (2003);
|
|
118
|
+
- `set_owners` sent directly, which the PDA cannot sign (3010);
|
|
119
|
+
- a Transaction passed as a Multisig (3002);
|
|
120
|
+
- the target program missing from the remaining accounts (the runtime's error).
|
|
121
|
+
- **Self-calls through `execute_transaction`**:
|
|
122
|
+
- `change_threshold`;
|
|
123
|
+
- `set_owners` shrinking the owner set below the threshold, which drops the threshold;
|
|
124
|
+
- `set_owners_and_change_threshold`;
|
|
125
|
+
- a threshold too high, which fails inside the CPI and fails the whole execution with 6007.
|
|
126
|
+
- **A real payout**: the PDA sends 1.5 SOL through a System transfer. A stored meta claiming the recipient signed is
|
|
127
|
+
refused by the runtime under both programs.
|
|
128
|
+
|
|
129
|
+
## Cost
|
|
130
|
+
|
|
131
|
+
These are compute units per transaction, original versus port, from that test. Each `execute` figure includes the
|
|
132
|
+
instruction it runs.
|
|
133
|
+
|
|
134
|
+
| Instruction | Anchor 0.25 (mainnet) | TypeSOL | |
|
|
135
|
+
| --- | ---: | ---: | ---: |
|
|
136
|
+
| `create_multisig` | 3,591 | 564 | −84% |
|
|
137
|
+
| `create_transaction` | 6,591 | 1,077 | −84% |
|
|
138
|
+
| `approve` | 5,515 | 1,003 | −82% |
|
|
139
|
+
| `execute_transaction` → `set_owners` | 15,227 | 4,133 | −73% |
|
|
140
|
+
| `execute_transaction` → `change_threshold` | 14,723 | 3,781 | −74% |
|
|
141
|
+
| `execute_transaction` → `set_owners_and_change_threshold` | 15,129 | 4,095 | −73% |
|
|
142
|
+
| `execute_transaction` → System transfer | 9,961 | 3,182 | −68% |
|
|
143
|
+
|
|
144
|
+
Refusals cost less too. For example, an already-initialized account costs 3,378 against 160, and a missing signature
|
|
145
|
+
4,229 against 177. The binary is 526,688 bytes on mainnet and 29,984 bytes for the port, 18 times smaller. Deploying it
|
|
146
|
+
needs about 3.5 SOL less rent.
|
|
147
|
+
|
|
148
|
+
The original was built with Anchor 0.25; a rebuild with a current Anchor would cost less than the mainnet figures. The
|
|
149
|
+
escrow case study and the benchmarks compare against Anchor 1.x.
|
|
150
|
+
|
|
151
|
+
## Where they differ
|
|
152
|
+
|
|
153
|
+
- **List capacities.** Anchor's `Vec` fields are bounded only by the account's size. The port's lists are `FixedVec`s:
|
|
154
|
+
- 34 owners;
|
|
155
|
+
- 32 accounts and 1,100 data bytes per stored instruction;
|
|
156
|
+
- 34 approvals.
|
|
157
|
+
|
|
158
|
+
A transaction (1,232 bytes) cannot carry more than these, so a client never meets them. A program calling the
|
|
159
|
+
multisig through a CPI can send more (CPI data may reach 10 KiB). The original would accept that if the account were
|
|
160
|
+
large enough; the port refuses with 5104. Reading an account the original wrote with more elements fails with 3003.
|
|
161
|
+
- **Check order on one double fault.** In `approve` and `execute_transaction` the original checks the owner-set
|
|
162
|
+
sequence number (2003) with the multisig account, before the transaction's `has_one` (2001). The port checks
|
|
163
|
+
`has_one` in the prologue and the sequence number first in the body. So a transaction that belongs to another
|
|
164
|
+
multisig *and* has a stale sequence number gets 2001 here and 2003 there. A transaction breaking only one of the two
|
|
165
|
+
gets the same code under both.
|
|
166
|
+
- **Logs.** Error messages are logged in TypeSOL's words and the original's error names are not logged; codes are the
|
|
167
|
+
same.
|
package/docs/errors.md
CHANGED
|
@@ -7,19 +7,20 @@ Framework errors use Anchor's numbers where Anchor has the same check, so client
|
|
|
7
7
|
| 100 / 101 / 102 | instruction data missing / unknown instruction / malformed arguments (InstructionMissing / FallbackNotFound / DidNotDeserialize) |
|
|
8
8
|
| 2000 | a `Mut<…>` / `Init<…>` account passed read-only (ConstraintMut) |
|
|
9
9
|
| 2001 | HasOne constraint failed (ConstraintHasOne) |
|
|
10
|
-
| 2002 | event authority did not sign (ConstraintSigner) |
|
|
10
|
+
| 2002 | a `Signed<…>` account (or the event authority) did not sign (ConstraintSigner) |
|
|
11
11
|
| 2004 | Owner constraint failed (ConstraintOwner) |
|
|
12
12
|
| 2005 | Zeroed account is not rent-exempt (ConstraintRentExempt) |
|
|
13
13
|
| 2006 | PDA seeds mismatch (ConstraintSeeds) |
|
|
14
14
|
| 2007 | Executable constraint failed (ConstraintExecutable) |
|
|
15
15
|
| 2009 | not the associated token account (ConstraintAssociated) |
|
|
16
16
|
| 2012 | not the expected address (ConstraintAddress) |
|
|
17
|
+
| 2013 | a `Zeroed<…>` account whose discriminator is already set (ConstraintZero; checked before its owner, as under Anchor) |
|
|
17
18
|
| 2014 / 2015 | token account mint / owner mismatch (ConstraintTokenMint / ConstraintTokenOwner) |
|
|
18
19
|
| 2016 / 2017 / 2018 | mint authority / freeze authority / decimals mismatch (2018: an existing mint `createIfNeeded` found) |
|
|
19
20
|
| 2021 / 2022 | token account / mint not owned by the token program `TokenOf` / `MintOf` names (ConstraintTokenTokenProgram / ConstraintMintTokenProgram) |
|
|
20
21
|
| 2024–2039 | an existing mint `createIfNeeded` found lacks a Token-2022 extension it was given, or holds other keys in it: group pointer 2024–2026, group member pointer 2027–2029, metadata pointer 2030–2032, close authority 2033–2034, permanent delegate 2035–2036, transfer hook 2037–2039 (the first code of each: the extension is missing; the next: the authority / address differs) (ConstraintMint…Extension…) |
|
|
21
22
|
| 2040 | the same account passed in two writable slots (ConstraintDuplicateMutableAccount) |
|
|
22
|
-
| 3000 | account already exists
|
|
23
|
+
| 3000 | account already exists (AccountDiscriminatorAlreadySet; an `Init` account) |
|
|
23
24
|
| 3002 | discriminator mismatch (AccountDiscriminatorMismatch) |
|
|
24
25
|
| 3003 | data too small, not an initialized token account / mint, a read past the end, or a `@layout borsh` account whose bytes are not a valid Borsh value (AccountDidNotDeserialize) |
|
|
25
26
|
| 3004 | a `@layout borsh` account too small for the value it would now hold (AccountDidNotSerialize) |
|
|
@@ -70,5 +71,5 @@ Where TypeSOL's order differs, only a transaction that breaks two of these check
|
|
|
70
71
|
- Too few accounts fails with 3005 before any account is checked; Anchor fails on the first account it cannot find.
|
|
71
72
|
- Accounts the instruction creates (`Init<…>`, `create(…)`) are created in the body, after every other check, where
|
|
72
73
|
Anchor creates them before checking the other accounts.
|
|
73
|
-
- TypeSOL refuses any two writable parameters naming one account (2040)
|
|
74
|
+
- TypeSOL refuses any two writable parameters naming one account (2040) unless one is `Dup<…>`, where Anchor only compares program and token
|
|
74
75
|
accounts: a signer passed as two writable parameters fails with 2040 here and later, or not at all, under Anchor.
|
package/docs/language.md
CHANGED
|
@@ -25,8 +25,8 @@ The full reference. For a tour, start with the [README](../README.md).
|
|
|
25
25
|
| Enums | `export enum Status { Pending, Approved }` — unit variants stored as the Borsh u8 index; `Status.Approved`, `===` |
|
|
26
26
|
| Options | `best: Option<u64>` (or `u64 \| null`) — `null`, `=== null`, TypeScript narrowing; `===` between options or with a value follows JS (null equals null, empty never equals a value); empty use aborts (5108); `acct?.field` on an optional account and `x.home?.field` on an `Option<struct>` give an Option (empty when the account was left out / the option is empty), `a ?? b` takes b only when a is empty (b runs only then; the result is an Option when b can be null), `x!` is the value or aborts with 5108; `${opt}` prints the value or `null`. Borsh `option` in arguments/events, fixed `{ is_some, value }` in account data |
|
|
27
27
|
| Vecs | `entries: Vec<Entry>` in account data, any number of them, declared anywhere: they are stored after the fixed-size fields in the order declared (so every other field keeps its offset; the IDL lists that order), and a push, pop or clear moves the Vecs after it (a reference into one of those cannot be used across it). In a `@layout borsh` struct a Vec is the last field (Anchor's order) — `get`, `set`, `push(v, payer)`, `pop()`, `clear()`, `.length`; the account is resized (realloc) and rent topped up by `payer`; `Vec<u64>` arguments are read-only views; `Vec<string>` arguments (Anchor's `Vec<String>`) are views of the instruction data, each checked as UTF-8 on entry (102), and go back out as Borsh through `invoke`; `pop()` of a Vec of structs works as a statement |
|
|
28
|
-
| Anchor's account bytes | `/** @layout borsh */` on an account struct stores it as Anchor does (Borsh: a `FixedString<N>` is a String, a `FixedVec<T, N>` a Vec, an `Option` one byte when empty, and the fields after them move), so a TypeSOL program can take over accounts an Anchor program wrote and the IDL lists Anchor's own types. The instruction unpacks it into the usual fixed layout when it starts (bad bytes fail with 3003) and packs it back when it returns (3004 when the account is too small for the new value), so everything else works on it unchanged. Programs invoked from the body (CPIs) see the account's bytes as they were when the instruction started. Costs more compute than the fixed layout (the whole account is read and written each time; see the benchmarks); nested structs and list elements must be fixed-size (FixedString elements allowed). The same struct is Borsh as a whole instruction argument (checked like Borsh deserialization, UTF-8 included), as return data and as an event; as those (not as account data) it may also hold `string` fields and data enums carrying strings, Anchor's `String` and enums in argument structs (account data takes data enums without strings); not inside list or enum arguments, `
|
|
29
|
-
| Fixed-capacity lists | `owners: FixedVec<Pubkey, 8>` — any field, any number of them, in nested structs too: a u32 length and room for N elements (always 4 + N × size bytes), the same `get`, `set`, `push(v)`, `pop()`, `clear()`, `.length` and `for … of`; `push` on a full one fails (5104); `acct.owners = fixedVec([a, b])` writes a whole one (unused slots zeroed). In account data, returned structs and events; as an argument (`ids: FixedVec<u64, 8>`) it is a Borsh Vec of at most N elements, read like a `Vec` argument (more fail with 102) |
|
|
28
|
+
| Anchor's account bytes | `/** @layout borsh */` on an account struct stores it as Anchor does (Borsh: a `FixedString<N>` is a String, a `FixedVec<T, N>` a Vec, an `Option` one byte when empty, and the fields after them move), so a TypeSOL program can take over accounts an Anchor program wrote and the IDL lists Anchor's own types. The instruction unpacks it into the usual fixed layout when it starts (bad bytes fail with 3003) and packs it back when it returns (3004 when the account is too small for the new value), so everything else works on it unchanged. Programs invoked from the body (CPIs) see the account's bytes as they were when the instruction started. Costs more compute than the fixed layout (the whole account is read and written each time; see the benchmarks); nested structs and list elements must be fixed-size (FixedString elements allowed). The same struct is Borsh as a whole instruction argument (checked like Borsh deserialization, UTF-8 included), as return data and as an event; as those (not as account data) it may also hold `string` fields and data enums carrying strings, Anchor's `String` and enums in argument structs (account data takes data enums without strings); not inside list or enum arguments, `loadAccount` or a growable `Vec<T>` |
|
|
29
|
+
| Fixed-capacity lists | `owners: FixedVec<Pubkey, 8>` — any field, any number of them, in nested structs too: a u32 length and room for N elements (always 4 + N × size bytes), the same `get`, `set`, `push(v)`, `pop()`, `clear()`, `.length` and `for … of`; `push` on a full one fails (5104); `acct.owners = fixedVec([a, b])` writes a whole one (unused slots zeroed), and `acct.owners = owners` copies a `Vec` argument (or another list of the same elements) into it, Anchor's `multisig.owners = owners` (more than N fail with 5104). In account data, returned structs and events; as an argument (`ids: FixedVec<u64, 8>`) it is a Borsh Vec of at most N elements, read like a `Vec` argument (more fail with 102) |
|
|
30
30
|
| Struct arguments | `order: Order`, `ids: FixedArray<u8, 4>`, `orders: Vec<Order>` — fixed layouts, Borsh-identical, validated on entry (bools, enums, option tags, string lengths, enum tags; 102) and read-only. A struct holding a FixedVec that is only ever an argument is read as Anchor sends it (its FixedVec a Borsh Vec, as `@layout borsh`) |
|
|
31
31
|
| Remaining accounts | `remainingAccounts()` — `.length`, `.get(i)` → `key`, `owner`, `lamports` (writes check writability), `isSigner`, `isWritable`; `loadAccount<T>` / `loadAccountMut<T>` give checked struct views; pass them to `invoke`, one at a time or spread: `...remainingAccounts()` (each with the flags it was passed with), `...readonly(rest)` / `...writable(rest)` (all with that role), `rest.slice(a, b)` for part of them; a CPI lists at most 32 accounts (5113) |
|
|
32
32
|
| Struct literals | `book.entries.push({ who: who.key, amount, status: Status.Pending, note }, who)` — every field required |
|
|
@@ -38,7 +38,7 @@ The full reference. For a tour, start with the [README](../README.md).
|
|
|
38
38
|
| Option of structs | `home: Option<Point>` in account data — `=== null`, `p.home.x` after narrowing, `p.home = null` / `{ x, y }` |
|
|
39
39
|
| Strings at runtime | `"hello, " + name`, `` `#${n}` ``, `` `owner ${acct.owner}` `` (a Pubkey or Hash as base58) — built on the 32 KiB heap (5110 when full): a template is joined in one allocation, text known at compile time (literals, string and Pubkey constants) is folded in, and `s += …` grows the newest heap string in place |
|
|
40
40
|
| `switch` | on integers (128-bit ones too), enums and data-enum `.kind`, with JS `break` / fallthrough; case labels are literals, constants or enum members |
|
|
41
|
-
| Hashing & signatures | `sha256(...)`, `keccak256(...)` → `Hash`; `findProgramAddress(seeds, program)` → the canonical PDA of any program (Rust `Pubkey::find_program_address(..).0`, e.g. a Bubblegum asset id); `merkleVerify(leaf, proof: Vec<Hash>, root)` (sorted-pair keccak, OpenZeppelin convention; inner nodes and leaves are hashed alike, so hash leaves as OpenZeppelin does, `keccak256(keccak256(data))`, and never take the leaf itself from the arguments: an inner node would pass as a leaf); `verifyEd25519(instructionsSysvar, signer, message)` checks an Ed25519 precompile instruction in the same transaction; `verifySecp256k1(keccak256(...message) | ethMessageHash(message), recoveryId, r, s, ethAddress)` (the hash must be computed in the program; a passed-in hash could be forged) |
|
|
41
|
+
| Hashing & signatures | `sha256(...)`, `keccak256(...)` → `Hash`; `findProgramAddress(seeds, program)` → the canonical PDA of any program (Rust `Pubkey::find_program_address(..).0`, e.g. a Bubblegum asset id); `merkleVerify(leaf, proof: Vec<Hash>, root)` (sorted-pair keccak, OpenZeppelin convention; inner nodes and leaves are hashed alike, so hash leaves as OpenZeppelin does, `keccak256(keccak256(data))`, and never take the leaf itself from the arguments: an inner node would pass as a leaf); `verifyEd25519(instructionsSysvar, signer, message)` checks an Ed25519 precompile instruction in the same transaction; `stackHeight()` is 1 when the transaction calls the program directly, more under a CPI (`require(stackHeight() === 1n, …)` refuses CPI callers); `verifySecp256k1(keccak256(...message) | ethMessageHash(message), recoveryId, r, s, ethAddress)` (the hash must be computed in the program; a passed-in hash could be forged) |
|
|
42
42
|
| Oracles | `pythPrice(update: PythPriceUpdate, feedId, maxAgeSeconds, exponent?)` (fully verified, feed-matched, fresh; with `exponent`, a feed at another scale is refused); raw `update.price / conf / exponent / publishTime` are unchecked (any feed, partially verified or stale: check `feedId`, `fullyVerified` and the age yourself, or use `pythPrice`); `switchboardValue(feed: SwitchboardFeed, expectedFeed, maxStaleSlots, decimals, minSamples?)` (the feed must be the expected one: anyone can create feeds; `minSamples`, default 1, the fewest oracle samples accepted) (mainnet program unless `SwitchboardFeed<"devnet">`) |
|
|
43
43
|
| Multiple files | `import { Side, min } from "./math"` — structs, enums, constants and helpers can live in other files; only the entry file's exported functions are instructions; names are program-wide, so imports from TypeSOL files cannot be renamed; error codes number imported files first |
|
|
44
44
|
| Loops | `for (let i = 0n; i < n; i++)`, `while`, `do … while`, `for (const x of list)` over a `Vec` or `FixedArray` (each `x` is what `list.get(i)` gives: a value, or a reference into account data for structs; the length is read again every step, as in JS), `break`, `continue` — bounded by the compute budget. Remaining accounts are iterated by index |
|
|
@@ -66,7 +66,7 @@ The full reference. For a tour, start with the [README](../README.md).
|
|
|
66
66
|
| `Account<T>` / `Mut<T>` | owned by this program, 8-byte discriminator matches, data large enough (and writable) |
|
|
67
67
|
| `Init<T>` | writable; `create(payer, seeds)` derives the PDA, verifies it, funds rent, writes the discriminator (up to 10 KiB); `create(payer, null)` creates a new keypair account that signs the transaction instead (3010 if it did not; a signer in the IDL); a third argument is Anchor's `space = …` (discriminator included, at least 8 + the struct's size) |
|
|
68
68
|
| `InitIfNeeded<T>` | `const created = acct.createIfNeeded(payer, seeds)` — creates once; afterwards re-derives the PDA and checks owner, discriminator and size (Anchor `init_if_needed`); `acct.pdaBump` after create. An existing account is used as it is: check the fields you rely on |
|
|
69
|
-
| `HasOne<A, "owner">`, `Seeds<A, ["vault", "@owner"], "bump">`, `CloseTo<A, "owner">` | declarative constraints like Anchor's `has_one`, `seeds`/`bump` and `close`: checked before the body (2001 / 2006), close runs when the instruction succeeds; `"@name"` = account key or argument bytes, `"@acct.field"` = a Pubkey, integer or unit enum field of a struct account parameter (also the account's own: `["offer", "@maker", "@offer.id"]`), other strings are literals; recorded as `relations` / `pda` in the IDL. `acct.pdaBump` is the bump (Anchor `ctx.bumps.acct`): the stored field when one is named, else the canonical bump the check found — use it to sign as the PDA |
|
|
69
|
+
| `HasOne<A, "owner">`, `Seeds<A, ["vault", "@owner"], "bump">`, `CloseTo<A, "owner">` | declarative constraints like Anchor's `has_one`, `seeds`/`bump` and `close`: checked before the body (2001 / 2006), close runs when the instruction succeeds; `"@name"` = account key or argument bytes, `"@acct.field"` = a Pubkey, integer or unit enum field of a struct account parameter (also the account's own: `["offer", "@maker", "@offer.id"]`), other strings are literals; recorded as `relations` / `pda` in the IDL. a bump stored in another struct account is named `"@acct.field"` (Anchor `bump = multisig.nonce`); `acct.pdaBump` is the bump (Anchor `ctx.bumps.acct`): the stored field when one is named, else the canonical bump the check found — use it to sign as the PDA |
|
|
70
70
|
| `HasOne<TokenAccount, "mint" \| "owner">`, `HasOne<Mint, "mintAuthority" \| "freezeAuthority">` | token constraints (2014 / 2015 / 2016 / 2017) against the parameter with that name; `TokenOf<TokenAccount, "mintParam", "authorityParam", "tokenProgramParam">` / `MintOf<Mint, "authorityParam", "freezeParam", "tokenProgramParam">` name the parameters, as Anchor's `token::mint = …` / `mint::authority = …` / `…::token_program = …` do (`never` leaves one out; the token program one: owned by that token program, 2021 / 2022) |
|
|
71
71
|
| `Ata<TokenAccount, "owner", "mint">` | the account is the associated token account of those parameters (2009; ATA `pda` in the IDL) |
|
|
72
72
|
| `Ata<Init<TokenAccount>, "owner", "mint">`, `Ata<InitIfNeeded<TokenAccount>, "owner", "mint">` | Anchor `init` / `init_if_needed` with `associated_token::…`: `acct.create(payer)` creates the ATA (fails if it exists); `const created = acct.createIfNeeded(payer)` creates it only when missing, otherwise checks it is that ATA (2009). Then it is a `TokenAccount` of that mint and owner (2014 / 2015). Needs `AssociatedTokenProgram`, `SystemProgram` and a token program parameter; call it at the top level before using the account |
|
|
@@ -76,7 +76,9 @@ The full reference. For a tour, start with the [README](../README.md).
|
|
|
76
76
|
| `A \| null` / `Optional<A>` | optional account (Anchor `Option<Account>`): the client passes this program's id to leave it out; its checks and constraints run only when present; `x !== null` before use, or `x!` / `x!.field` to assert it was passed (5115 otherwise); `optional` in the IDL |
|
|
77
77
|
| `ProgramData` | this program's ProgramData (address derived from the program id, upgradeable-loader owner and state checked); `upgradeAuthority`, `deploySlot` — gate admin setup on `programData.upgradeAuthority === admin.key` |
|
|
78
78
|
| `EventAuthority`, `SelfProgram` | Anchor's `["__event_authority"]` PDA, and this program's own address, for `emitCpi` |
|
|
79
|
-
| `
|
|
79
|
+
| `Migrate<From, To>` | Anchor 1.0's `Migration`: an existing `From` account (owner, discriminator, size) rewritten as `To` by `acct.migrate(payer, value)`, a top-level statement the instruction must reach. `acct.from` (read-only) before it, `acct.to` after; the value is built in full before the rewrite, so it may read `acct.from`. Resized to `To` (the payer tops up rent when it grows, at most 10 KiB; shrinking keeps the lamports; growing needs `SystemProgram`). Fixed-layout structs without growable Vecs; no constraints yet; the account cannot be passed to a helper (use `acct.to` fields in the instruction). An account already migrated fails with 3002. Keep the old struct under a new name with its discriminator: `/** @discriminator account:Counter */ interface CounterV1 { … }` |
|
|
80
|
+
| `Zeroed<T>` | accounts (up to 10 MiB) the client allocated with System `CreateAccount`, Anchor's `zero`: discriminator still zero (2013, checked first as under Anchor), owned by this program, big enough, rent-exempt — then initialized. A `@layout borsh` struct starts empty and is written as Borsh when the instruction returns |
|
|
81
|
+
| `Signed<A>` | the account must sign the transaction (Anchor's `signer` constraint, 2002), e.g. `Signed<Zeroed<Multisig>>` for an account the client creates with a fresh keypair; the IDL marks it a signer |
|
|
80
82
|
| `Init<Mint>`, `InitIfNeeded<Mint>` | Anchor `init` / `init_if_needed` with `mint::decimals` / `mint::authority` / `mint::freeze_authority`: `const bump = mint.create(payer, ["mint", payer.key], 6n, authority, freezeAuthority \| null)` creates the mint at that PDA of this program, or with `null` seeds as a new keypair account that signs (3010 if it did not), under the instruction's token program (its only token program parameter, or one passed as the last argument of `create` / `createIfNeeded`; plus `SystemProgram`). Token-2022 extensions (Anchor's `extensions::…`) go after the freeze authority: `mint.create(payer, null, 6n, authority, null, { metadataPointer: { authority, metadataAddress: mint.key }, transferHook: { authority, programId }, closeAuthority, permanentDelegate, nonTransferable: true, defaultAccountState: true, interestBearing: { rateAuthority, rate }, transferFee: { configAuthority, withdrawAuthority, basisPoints, maximumFee }, groupPointer, groupMemberPointer })` sizes the mint for them and initializes each before InitializeMint2. With `createIfNeeded` an existing mint must carry groupPointer / groupMemberPointer / metadataPointer / closeAuthority / permanentDelegate / transferHook with those keys (Anchor's 2024–2039); extensions not listed are not looked at, so an existing mint may carry others (a permanent delegate, say). `createIfNeeded(…)` returns whether it created it; an existing one must be that PDA and have those decimals and authorities (2018 / 2016 / 2017) |
|
|
81
83
|
| `Init<TokenAccount>`, `InitIfNeeded<TokenAccount>` (no `Ata<…>`) | Anchor `init` / `init_if_needed` with `token::mint` / `token::authority`: `vault.create(payer, ["vault", mint.key], mint, authority)` (the authority may be `vault.key` itself) creates a token account at that PDA (or a keypair account with `null`), sized for the mint's extensions under Token-2022. `createIfNeeded` checks an existing one has that mint and owner (2014 / 2015), not its delegate or close authority (as Anchor) |
|
|
82
84
|
| `TokenAccount` / `Mut<TokenAccount>` | owned by SPL Token or Token-2022 (extensions allowed, multisig accounts refused), initialized; `mint`, `owner`, `amount`, `delegate`, `delegatedAmount`, `isFrozen`, `isNative`, `closeAuthority`, `tokenProgram` |
|
|
@@ -86,6 +88,7 @@ The full reference. For a tour, start with the [README](../README.md).
|
|
|
86
88
|
| `AssociatedTokenProgram` | `create` / `createIdempotent` CPIs; `associatedTokenAddress(owner, mint, tokenProgram)` |
|
|
87
89
|
| `TokenMetadataProgram` | Metaplex `createV3(...)` (CreateMetadataAccountV3), optionally with `{ creators: [{ address, verified, share }], collection: mint }` after `isMutable` (1–5 creators, shares adding up to 100; only the update authority, signing, can be `verified: true`; the collection is written unverified; `isCollection: true` makes the NFT a sized collection); `createMasterEditionV3(edition, mint, updateAuthority, mintAuthority, payer, metadata, tokenProgram, systemProgram, maxSupply \| null)`; `verifyCollection(authority, metadata, collectionMint, collectionMetadata, collectionMasterEdition, systemProgram, instructionsSysvar)` (Verify with CollectionV1); `metadataAddress(mint)`, `masterEditionAddress(mint)` |
|
|
88
90
|
| `InstructionsSysvar`, `PythPriceUpdate`, `SwitchboardFeed` | exact address / owner program + discriminator |
|
|
91
|
+
| `InstructionsSysvar` (introspection) | `ixs.length`, `ixs.currentIndex()` (the top-level instruction being executed), `ixs.get(i)` → a `TransactionInstruction`: `programId`, `accountsLength`, `dataLength`, `accountKey(j)` / `accountIsSigner(j)` / `accountIsWritable(j)`, `readU8/U16/U32/U64/readPubkey(offset)` (little-endian), `is(program, "name")` (that program's instruction by its default Anchor discriminator, sha256("global:<name>"); an instruction with its own `@discriminator` is matched with `programId` and `readU64(0)` instead). Every read is bounds-checked (5104). It is the transaction author's data: require things of the transaction with it (a repay instruction after a flash loan), never treat it as proof |
|
|
89
92
|
| `Program<"Address…">` | exact program address + executable |
|
|
90
93
|
| `UncheckedProgram` | executable only — check `.key` yourself |
|
|
91
94
|
| `AccountInfo` / `Mut<AccountInfo>` | nothing (writable for `Mut`); `owner`, `executable` |
|
|
@@ -94,7 +97,10 @@ The full reference. For a tour, start with the [README](../README.md).
|
|
|
94
97
|
Every account exposes `key`, `lamports`, `dataLen`, `isSigner`, `isWritable`. At most 32 accounts per instruction (more is refused, 5113).
|
|
95
98
|
|
|
96
99
|
Two writable parameters can never be the same account: aliasing is refused with error 2040 (also when a remaining
|
|
97
|
-
account written through `.lamports` or `loadAccountMut` is another account of the instruction).
|
|
100
|
+
account written through `.lamports` or `loadAccountMut` is another account of the instruction). `Dup<Mut<T>>`
|
|
101
|
+
(Anchor's `dup`) lifts that for one parameter when both accounts are read and written in place (not created, closed,
|
|
102
|
+
a close destination or `@layout borsh`): a write through either is seen through the other, so read what you need
|
|
103
|
+
before writing. A remaining account
|
|
98
104
|
can be loaded with `loadAccountMut` once per instruction: loading the same one again (`rest.get(i)` and `rest.get(j)`
|
|
99
105
|
with `i == j`) is refused with 2040 too, since the two views would alias.
|
|
100
106
|
|
|
@@ -146,6 +152,20 @@ export function payOut(admin: Signer, treasury: Mut<SystemAccount>, recipient: M
|
|
|
146
152
|
- `signerSeeds` (with the bump) signs for this program's PDA.
|
|
147
153
|
- After the call, `returnData<u64>(program)` reads what the callee returned, checking the size and that it came from `program`.
|
|
148
154
|
|
|
155
|
+
A call decided at run time, such as one a multisig stored in account data, is `invokeInstruction` (Rust's
|
|
156
|
+
`invoke_signed(&Instruction { program_id, accounts, data }, infos, seeds)`):
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
invokeInstruction(transaction.programId, transaction.accounts, transaction.data, remainingAccounts(), [multisig.key, multisig.nonce]);
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
- the accounts are a list of structs with `pubkey`, `isSigner` and `isWritable` (a Vec or FixedVec in account data, a
|
|
163
|
+
Vec argument, or a stored `map` result); the data a list of u8.
|
|
164
|
+
- every account in the fourth argument (`remainingAccounts()` or a slice) is handed to the runtime, which finds each
|
|
165
|
+
meta's account there by key: one that is missing fails the call, as under Rust.
|
|
166
|
+
- the runtime checks the privileges: a meta may be a signer or writable only as the transaction passed that account,
|
|
167
|
+
or as a PDA of this program the seeds (fifth argument) sign for. See `examples/multisig.ts`.
|
|
168
|
+
|
|
149
169
|
Typed calls from an IDL: `typesol cpi other.json --out other_cpi.ts` writes one helper per instruction
|
|
150
170
|
(`vaultDeposit(program, owner, vault, systemProgram, amount, seeds)`), with the account flags, discriminator and
|
|
151
171
|
argument encoding taken from the IDL (Anchor 0.30+ / 1.x, or TypeSOL's own). Call it with `signer([...seeds, bump])`
|