add pinocchio token-2022 non-transferable example#630
Conversation
Greptile SummaryThis PR adds a Pinocchio port of the Token-2022
Confidence Score: 4/5The program logic is correct and the CPI encodings match what Token-2022 expects; the only gap is that the test does not assert a transfer actually fails. The hand-built CPI payloads (sizes, discriminants, COption encoding, operation ordering) are all consistent with the native example and the Token-2022 program spec. The test covers structural correctness but leaves the transfer-rejection behaviour unverified. The test file would benefit from a second it block demonstrating that a token transfer is rejected, completing the educational goal of the example. Important Files Changed
Sequence Diagram%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant Client
participant Program as non-transferable pinocchio program
participant System as System Program
participant T22 as Token-2022 Program
Client->>Program: CreateMint(decimals)
Program->>System: "CreateAccount(payer to mint, 170 bytes, owner=Token-2022)"
System-->>Program: OK
Program->>T22: InitializeNonTransferableMint(mint) [variant 32]
T22-->>Program: OK NonTransferable TLV written
Program->>T22: InitializeMint(mint, rent_sysvar, decimals, authority) [variant 0]
T22-->>Program: OK base mint fields set
Program-->>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 Program as non-transferable pinocchio program
participant System as System Program
participant T22 as Token-2022 Program
Client->>Program: CreateMint(decimals)
Program->>System: "CreateAccount(payer to mint, 170 bytes, owner=Token-2022)"
System-->>Program: OK
Program->>T22: InitializeNonTransferableMint(mint) [variant 32]
T22-->>Program: OK NonTransferable TLV written
Program->>T22: InitializeMint(mint, rent_sysvar, decimals, authority) [variant 0]
T22-->>Program: OK base mint fields set
Program-->>Client: OK
Reviews (1): Last reviewed commit: "add pinocchio token-2022 non-transferabl..." | 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, |
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.