add pinocchio token-2022 non-transferable example#630
Conversation
Greptile SummaryThis PR adds a Pinocchio port of the
Confidence Score: 5/5Safe to merge — the new example is self-contained, adds no risk to existing programs, and the hand-serialized CPI data has been verified against the token-interface pack format. The three hand-built CPI payloads (CreateAccount, InitializeNonTransferableMint, InitializeMint) are all correctly serialized and match what Token-2022 expects. The MINT_SIZE = 170 constant, the lamport calculation using DEFAULT_LAMPORTS_PER_BYTE, and all test byte offsets are accurate. The change is purely additive and has no interaction with existing code paths. No files require special attention. Important Files Changed
Sequence Diagram%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant Test as Bankrun Test
participant Program as Pinocchio Program
participant System as System Program
participant Token22 as Token-2022 Program
Test->>Program: "CreateMint(decimals=9)"
Note over Program: Unpack 6 accounts,<br/>parse CreateTokenArgs
Program->>System: "CreateAccount(payer→mint, 170 bytes, owner=Token-2022)"
System-->>Program: OK
Program->>Token22: "InitializeNonTransferableMint(variant 32)<br/>[accounts: mint]"
Token22-->>Program: OK
Program->>Token22: "InitializeMint(variant 0)<br/>[accounts: mint, rent_sysvar]<br/>[data: decimals, mint_auth, freeze_auth]"
Token22-->>Program: OK
Program-->>Test: ProgramResult::Ok
Test->>Test: "Assert owner=Token-2022<br/>size=170, decimals=9<br/>account_type=1, TLV type=9, len=0"
%%{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 Test as Bankrun Test
participant Program as Pinocchio Program
participant System as System Program
participant Token22 as Token-2022 Program
Test->>Program: "CreateMint(decimals=9)"
Note over Program: Unpack 6 accounts,<br/>parse CreateTokenArgs
Program->>System: "CreateAccount(payer→mint, 170 bytes, owner=Token-2022)"
System-->>Program: OK
Program->>Token22: "InitializeNonTransferableMint(variant 32)<br/>[accounts: mint]"
Token22-->>Program: OK
Program->>Token22: "InitializeMint(variant 0)<br/>[accounts: mint, rent_sysvar]<br/>[data: decimals, mint_auth, freeze_auth]"
Token22-->>Program: OK
Program-->>Test: ProgramResult::Ok
Test->>Test: "Assert owner=Token-2022<br/>size=170, decimals=9<br/>account_type=1, TLV type=9, len=0"
Reviews (2): Last reviewed commit: "token-2022 non-transferable pinocchio: m..." | Re-trigger Greptile |
| it("Creates a Token-2022 non-transferable mint", 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: 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 valueless 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 NonTransferable, whose | ||
| // TLV value is empty (length 0). | ||
| assert.equal(mintData[ACCOUNT_TYPE_OFFSET], ACCOUNT_TYPE_MINT); | ||
| assert.equal(mintData.readUInt16LE(TLV_TYPE_OFFSET), NON_TRANSFERABLE_EXTENSION); | ||
| assert.equal(mintData.readUInt16LE(TLV_LENGTH_OFFSET), 0); | ||
|
|
||
| console.log("Mint address:", mintKeypair.publicKey.toBase58()); | ||
| }); |
There was a problem hiding this comment.
Test does not exercise the non-transferable constraint
The test confirms the mint account is sized and structured correctly, but never attempts an actual token transfer against this mint. The whole point of the NonTransferable extension is that Token-2022 rejects every transfer instruction; a reader expecting the test to demonstrate that property will not find it here. Adding a second it block that mints some tokens and then asserts that a transfer instruction fails (e.g., checks that processTransaction rejects with the NonTransferableError error code) would make the example self-evidently correct and more educational.
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.
Fair point educationally. I’ve deliberately kept the test scoped to structurally verifying the extension (Token-2022 ownership, the 170-byte size, decimals, the account-type byte, and the NonTransferable TLV header at type 9 / length 0), which mirrors the native counterpart and the sibling mint-close-authority pinocchio example in this series. Asserting a rejected transfer would require minting to an ATA and hand-building MintTo/Transfer CPIs — or pulling in @solana/spl-token, which none of the pinocchio examples in this repo depend on — diverging from the established pattern. I’d prefer to keep the trio consistent here; if maintainers want a transfer-rejection assertion, it’d be worth adding uniformly across the token-2022 pinocchio examples rather than just this one.
| let mint_accounts = [ | ||
| InstructionAccount::writable(mint_account.address()), | ||
| InstructionAccount::readonly(rent_sysvar.address()), | ||
| ]; | ||
| invoke( | ||
| &InstructionView { | ||
| program_id: &TOKEN_2022_PROGRAM_ID, | ||
| accounts: &mint_accounts, | ||
| data: &mint_data, | ||
| }, | ||
| &[mint_account, rent_sysvar], | ||
| )?; |
There was a problem hiding this comment.
InitializeMint could use variant 20 (InitializeMint2) to drop the rent sysvar
InitializeMint (variant 0) is the legacy instruction that requires an explicit rent sysvar account. Token-2022 also supports InitializeMint2 (variant 20), which derives rent internally and removes that dependency. The native example uses variant 0 with the sysvar, so parity is preserved, but switching to 20 would align the CPI with how modern Token-2022 code is written and would eliminate one entry from the account list. This is a style observation rather than a correctness issue — variant 0 works fine.
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.
Intentional — the goal of these ports is parity with the existing native example, which uses InitializeMint (variant 0) with an explicit rent sysvar. Keeping variant 0 makes the anchor/native/pinocchio trio directly comparable line-for-line. As you note, variant 0 is correct and this is purely stylistic, so I’ll leave it as-is for parity. If the repo later wants to modernize to InitializeMint2 (variant 20), that’d be better done as a sweep across all three implementations at once.
|
@dev-jodee — this is the 2nd entry in the Token-2022 extension sub-series (after #624, mint-close-authority). Branched off latest main, CI fully green. The two Greptile P2s are style/enhancement suggestions (transfer-rejection test, |
|
@MarkFeder before I review this one, would need to redo the same you did on the other pr (describe not async, signed commits, etc.) lmk when it's done and ill review |
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 (solana-foundation#599).
|
@dev-jodee done — mirrored the create-token (#599) fixes here in e4e5da1:
Verified locally with |
Adds a Pinocchio port of the
tokens/token-2022/non-transferableexample, alongside the existinganchorandnativeversions. Second entry in the Token-2022 extension sub-series, aftermint-close-authority(#624).What it does
A single instruction creates an SPL Token-2022 mint carrying the
NonTransferableextension, so tokens minted from it can never be transferred — every move (short of burning or closing the account) is rejected by the Token-2022 program.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 to170bytes (baseAccountlength 165 + account-type byte + oneNonTransferableTLV entry). UnlikeMintCloseAuthority, this extension stores no value, so its TLV entry is just the 4-byte header.InitializeNonTransferableMint(variant32) — carries no data and must run before the mint is initialized.InitializeMint(variant0).The bankrun test asserts the resulting mint is owned by Token-2022, is exactly 170 bytes, has the correct decimals, and that the extension header (account type
Mint, TLV typeNonTransferable, length0) was written correctly.