add pinocchio token-2022 mint-close-authority example#624
Conversation
Greptile SummaryThis PR adds a Pinocchio port of the
Confidence Score: 5/5Safe to merge — all three CPIs are correctly ordered and hand-serialized, the MINT_SIZE constant matches the Token-2022 extension layout, and the test exercises the full TLV header plus a distinct close authority. The hand-built instruction payloads use the correct 1-byte COption encoding that matches SPL Token's pack/unpack format, the 202-byte account size is accurate (165 + 1 + 2 + 2 + 32), and the lamport calculation correctly avoids the floating-point Rent path. Previous reviewer feedback on test coverage has been fully addressed. No files require special attention. Important Files Changed
Sequence Diagram%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant Client
participant OurProgram as Pinocchio Program
participant SystemProgram as System Program
participant Token2022 as Token-2022 Program
Client->>OurProgram: create_mint(decimals, accounts[7])
OurProgram->>SystemProgram: "CreateAccount(mint, 202 bytes, owner=Token2022, lamports)"
SystemProgram-->>OurProgram: mint account created
OurProgram->>Token2022: InitializeMintCloseAuthority(variant 25)
Token2022-->>OurProgram: MintCloseAuthority TLV written
OurProgram->>Token2022: InitializeMint(variant 0)
Token2022-->>OurProgram: base mint fields initialized
OurProgram-->>Client: Ok(())
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant Client
participant OurProgram as Pinocchio Program
participant SystemProgram as System Program
participant Token2022 as Token-2022 Program
Client->>OurProgram: create_mint(decimals, accounts[7])
OurProgram->>SystemProgram: "CreateAccount(mint, 202 bytes, owner=Token2022, lamports)"
SystemProgram-->>OurProgram: mint account created
OurProgram->>Token2022: InitializeMintCloseAuthority(variant 25)
Token2022-->>OurProgram: MintCloseAuthority TLV written
OurProgram->>Token2022: InitializeMint(variant 0)
Token2022-->>OurProgram: base mint fields initialized
OurProgram-->>Client: Ok(())
Reviews (8): Last reviewed commit: "token-2022 mint-close-authority pinocchi..." | Re-trigger Greptile |
| it("Creates a Token-2022 mint with a close authority", async () => { | ||
| const decimals = 9; | ||
| const mintKeypair = Keypair.generate(); | ||
|
|
||
| const data = Buffer.from(borsh.serialize(CreateTokenArgsSchema, { token_decimals: decimals })); | ||
|
|
||
| const ix = new TransactionInstruction({ | ||
| programId: PROGRAM_ID, | ||
| keys: [ | ||
| { pubkey: mintKeypair.publicKey, isSigner: true, isWritable: true }, // mint account | ||
| { pubkey: payer.publicKey, isSigner: false, isWritable: false }, // mint authority | ||
| { pubkey: payer.publicKey, isSigner: false, isWritable: false }, // close authority | ||
| { pubkey: payer.publicKey, isSigner: true, isWritable: true }, // payer | ||
| { pubkey: SYSVAR_RENT_PUBKEY, isSigner: false, isWritable: false }, // rent sysvar | ||
| { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, // system program | ||
| { pubkey: TOKEN_2022_PROGRAM_ID, isSigner: false, isWritable: false }, // Token-2022 program | ||
| ], | ||
| data, | ||
| }); | ||
|
|
||
| const tx = new Transaction(); | ||
| tx.feePayer = payer.publicKey; | ||
| tx.recentBlockhash = context.lastBlockhash; | ||
| tx.add(ix); | ||
| tx.sign(payer, mintKeypair); | ||
| await client.processTransaction(tx); | ||
|
|
||
| const mintAccount = await client.getAccount(mintKeypair.publicKey); | ||
| if (mintAccount === null) throw new Error("Mint account not found"); | ||
| const mintData = Buffer.from(mintAccount.data); | ||
|
|
||
| // Owned by Token-2022, and sized for exactly one extension. | ||
| assert.deepEqual(mintAccount.owner.toBytes(), TOKEN_2022_PROGRAM_ID.toBytes()); | ||
| assert.equal(mintData.length, EXTENDED_MINT_SIZE); | ||
|
|
||
| // Base mint fields were initialized. | ||
| assert.equal(mintData[DECIMALS_OFFSET], decimals); | ||
|
|
||
| // The extension header marks this as a Mint carrying MintCloseAuthority. | ||
| assert.equal(mintData[ACCOUNT_TYPE_OFFSET], ACCOUNT_TYPE_MINT); | ||
| assert.equal(mintData.readUInt16LE(TLV_TYPE_OFFSET), MINT_CLOSE_AUTHORITY_EXTENSION); | ||
|
|
||
| // The configured close authority was stored in the extension. | ||
| const storedCloseAuthority = mintData.subarray(TLV_VALUE_OFFSET, TLV_VALUE_OFFSET + 32); | ||
| assert.deepEqual(new Uint8Array(storedCloseAuthority), payer.publicKey.toBytes()); | ||
|
|
||
| console.log("Mint address:", mintKeypair.publicKey.toBase58()); | ||
| }); |
There was a problem hiding this comment.
Single test case conflates three distinct roles
The test uses payer.publicKey for the mint authority, close authority, and payer simultaneously. This means the test cannot verify that the close authority stored in the TLV is correctly sourced from account index 2 (close authority) rather than index 1 (mint authority) or the payer — all three are identical addresses. A bug that swapped accounts 1 and 2 would go undetected. Using a distinct keypair for at least the close authority would make the assertion at line 87 meaningful.
There was a problem hiding this comment.
Good catch — fixed in 1b2f802. The test now generates a distinct closeAuthority keypair for account index 2, so the stored-authority assertion genuinely verifies the close authority is sourced from index 2 rather than the mint authority/payer (which remain payer).
| const TLV_TYPE_OFFSET = 166; // u16 LE, 3 == MintCloseAuthority | ||
| const TLV_VALUE_OFFSET = 170; // 32-byte close authority pubkey | ||
| const DECIMALS_OFFSET = 44; // in the base mint layout | ||
| const MINT_CLOSE_AUTHORITY_EXTENSION = 3; |
There was a problem hiding this comment.
TLV length field is asserted indirectly, not explicitly
The test validates TLV_TYPE_OFFSET (type = 3) and TLV_VALUE_OFFSET (close authority bytes), but never reads the 2-byte TLV length at offset 168 (TLV_TYPE_OFFSET + 2). Adding assert.equal(mintData.readUInt16LE(168), 32) would fully exercise the on-chain TLV header and catch any future change where Token-2022 alters the MintCloseAuthority value size.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
There was a problem hiding this comment.
Agreed — added assert.equal(mintData.readUInt16LE(168), 32) (TLV_LENGTH_OFFSET) in 1b2f802 so the 2-byte TLV length header is exercised explicitly.
| let [mint_account, mint_authority, close_authority, payer, rent_sysvar, _system_program, _token_program] = | ||
| accounts | ||
| else { | ||
| return Err(ProgramError::NotEnoughAccountKeys); | ||
| }; |
There was a problem hiding this comment.
Token-2022 program ID is never validated
_token_program (account index 6) is accepted and silently forwarded to all three CPIs as the callee program, but its address is never compared against TOKEN_2022_PROGRAM_ID. A caller that passes a spoofed program at index 6 would cause the CPIs to target an arbitrary program. For an educational example this is a known trade-off, but adding even a one-line equality check would demonstrate the secure pattern to learners.
There was a problem hiding this comment.
The premise here isn't quite accurate: _token_program (index 6) is not forwarded to the CPIs as the callee. Each InstructionView sets program_id: &TOKEN_2022_PROGRAM_ID — the hard-coded constant — and the account isn't even passed in the invoke infos slices (&[mint_account] / &[mint_account, rent_sysvar]). So a spoofed account at index 6 has no effect; the CPIs always target the real Token-2022 program by construction. Index 6 is present only to mirror the native example's account layout. No equality check is needed here since dispatch is already pinned to the constant.
There was a problem hiding this comment.
You're completely right — I was wrong. Both InstructionView structs hardcode program_id: &TOKEN_2022_PROGRAM_ID, and _token_program doesn't appear in either invoke accounts slice. A spoofed account at index 6 is entirely inert. Apologies for the noise.
Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.
|
Hi @Perelyn-sama — thanks again for merging my previous pinocchio examples! This one adds the |
1b2f802 to
6a49f60
Compare
Use a distinct close-authority keypair so the stored-authority check verifies sourcing from account index 2, and assert the TLV length field (32) explicitly.
6a49f60 to
b40292f
Compare
|
@Perelyn-sama @dev-jodee — rebased onto latest main (picks up the ASM sbpf/Solana pin from #625), CI is now fully green. Ready for review whenever you have a chance 🙏 |
… run
Move the async bankrun setup out of the `describe` callback and into a
`before` hook so Mocha collects the `it` block (an async `describe` body
registers tests after the suite is already collected, so nothing ran).
With the test now executing, replace `Rent::try_minimum_balance` with the
integer rent formula: its floating-point exemption-threshold path emits an
opcode the bankrun VM rejects ("unsupported BPF instruction"). Matches the
create-token example.
3f941a9 to
87b819a
Compare
| SystemProgram, | ||
| Transaction, | ||
| TransactionInstruction, | ||
| } from "@solana/web3.js"; |
There was a problem hiding this comment.
Hey Mark, thanks for the work, realized (a bit too late for the already merged project) that you're using web3js, this isn't the proper way to do it anymore, I'd encourage you to look at kit (https://github.com/anza-xyz/kit) and if you could update all the other examples as well, make sure we use the latest standards ! (Would be great if you could do it for the already merged pr from yesterday as well)
There was a problem hiding this comment.
Thanks @dev-jodee — done for this one (59d9542). The test now builds everything with @solana/kit: addresses, the instruction, the transaction message, and signing (generateKeyPairSigner/createKeyPairSignerFromBytes, createTransactionMessage + pipe, signTransactionMessageWithSigners, getTransactionEncoder). Bumped the example's tsconfig target/lib to es2020 for kit's BigInt usage; still 1 passing under bankrun.
One thing worth flagging before I roll this out to the rest: solana-bankrun@0.3.1's BanksClient is still typed against web3.js — start() and getAccount() take a PublicKey, and processTransaction() takes a VersionedTransaction — and it exposes no byte-level entrypoint. So a thin, unavoidable web3.js interop shim remains around bankrun itself: kit builds and signs the tx, then I hand its wire bytes to bankrun via VersionedTransaction.deserialize. Everything the example teaches is kit; web3.js only survives as the harness adapter.
Happy to apply this same pattern across the other pinocchio examples + the merged create-token. If you'd rather also move these off bankrun (e.g. to litesvm) so web3.js drops entirely, say the word and I'll factor that in instead.
There was a problem hiding this comment.
Yeah, honestly we should have the examples with kit and litesvm for testing framework, I think with both of those we'll have a good template
Really appreciate the work :)
You can do the change on this one, once we confirm + merge you can apply to the rest (just in case something else comes up, lets make sure this one is perfect and then you can move to the other ones )
There was a problem hiding this comment.
Done — migrated this one fully to litesvm in cd5cf69, so @solana/web3.js is now gone entirely (both from the code and from package.json).
The nice part is that litesvm@1.3 is kit-native, so the two compose with no shim:
- the signed kit transaction from
signTransactionMessageWithSignersgoes straight intosvm.sendTransaction(...)— no moreVersionedTransaction.deserializeround-trip; svm.getAccount()/svm.airdrop()take kitAddresses and return kitEncodedAccounts;svm.setTransactionMessageLifetimeUsingLatestBlockhash(...)slots right into thepipe(...).new LiteSVM()'s standard runtime already bundles Token-2022, so there's no fixture to load beyond the program.so.
A couple of supporting tweaks so it's a clean template to copy from:
- pinned
@solana/kitto^6.10.0(litesvm's kit major) so there's a single kit in the tree — no dual-version type/shape mismatches; - bumped
typescriptto^5andlibtoes2022+dom(kit's types need both) and added@types/node; the suite is now type-clean undertsc --noEmit, not just transpile-only.
Verified locally: cargo build-sbf + ts-mocha → 1 passing (program executes both CPIs, mint layout/close-authority assertions hold), plus tsc --noEmit and biome clean.
Once you're happy with this one and it's merged, I'll roll the same kit + litesvm pattern out across the rest. 🙏
Build the transaction with @solana/kit (addresses, instruction, message, signing) instead of @solana/web3.js. A thin web3.js shim remains only where solana-bankrun@0.3.1's BanksClient still requires it (start()/getAccount() PublicKey, processTransaction() VersionedTransaction). Bump tsconfig target/lib to es2020 for kit's BigInt usage.
Move the test runtime from solana-bankrun to litesvm, which is kit-native, so web3.js is dropped entirely. The kit transaction returned by signTransactionMessageWithSigners is handed straight to LiteSVM.sendTransaction (no VersionedTransaction shim), and getAccount/airdrop take kit Addresses. - deps: drop @solana/web3.js + solana-bankrun, add litesvm; pin @solana/kit to ^6.10.0 (litesvm's kit major) so there is a single kit in the tree - tsconfig: bump typescript to ^5 and lib to es2022+dom (kit's types), add @types/node; the suite is now type-clean under tsc --noEmit - LiteSVM()'s standard runtime bundles Token-2022, so no fixture load is needed beyond the program .so
Adds a Pinocchio port of the
tokens/token-2022/mint-close-authorityexample, alongside the existinganchorandnativeversions.What it does
A single instruction creates an SPL Token-2022 mint carrying the
MintCloseAuthorityextension, so a designated authority can later close the mint and reclaim its rent.Notes
Token-2022 has no Pinocchio wrapper crate (
pinocchio-tokentargets the legacy SPL Token program), so the Token-2022 instructions are built by hand and CPI'd:CreateAccountfor the mint, sized to202bytes (baseAccountlength 165 + account-type byte + oneMintCloseAuthorityTLV entry) and owned by the Token-2022 program.InitializeMintCloseAuthority(variant25) — must run before the mint is initialized.InitializeMint(variant0).The bankrun test asserts the resulting mint is owned by Token-2022, is exactly 202 bytes, has the correct decimals, and that the extension header (account type
Mint, TLV typeMintCloseAuthority) and stored close authority were written correctly.