@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.
@@ -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}, params->program_id, TS_DISC_${k.struct.name}, sizeof(TSS_${k.struct.name})));`);
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
- out.push(` ${seeds}[${parts.length}] = (SolSignerSeed){&d_${a.name}->${c.seeds.bump}, 1};`);
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, `d_${a.name}->${c.seeds.bump}`);
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};`);
@@ -61,6 +61,8 @@ export function valueC(t) {
61
61
  return "TS_VecArg";
62
62
  case "account":
63
63
  return "TsAccount *";
64
+ case "txIx":
65
+ return "TsIx";
64
66
  case "structRef":
65
67
  return `TSS_${t.struct} *`;
66
68
  case "enumRef":
@@ -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 or another ${typeName(target.type)}`);
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
  }
@@ -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;
@@ -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. */
@@ -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
  }
@@ -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 token account. A client that
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 / initialized (AccountDiscriminatorAlreadySet) |
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), where Anchor only compares program and token
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, `Zeroed<T>`, `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). 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
- | `Zeroed<T>` | large accounts (up to 10 MiB) the client allocated with System `CreateAccount`: owned by this program, big enough, rent-exempt, discriminator still zero — then initialized; like Anchor's zero-copy `zero` |
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). A remaining account
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])`