Skip to content

add pinocchio token-2022 non-transferable example#630

Open
MarkFeder wants to merge 1 commit into
solana-foundation:mainfrom
MarkFeder:tokens-token-2022-non-transferable-pinocchio
Open

add pinocchio token-2022 non-transferable example#630
MarkFeder wants to merge 1 commit into
solana-foundation:mainfrom
MarkFeder:tokens-token-2022-non-transferable-pinocchio

Conversation

@MarkFeder

Copy link
Copy Markdown
Contributor

Adds a Pinocchio port of the tokens/token-2022/non-transferable example, alongside the existing anchor and native versions. Second entry in the Token-2022 extension sub-series, after mint-close-authority (#624).

What it does

A single instruction creates an SPL Token-2022 mint carrying the NonTransferable extension, 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-token targets the legacy SPL Token program), so the Token-2022 instructions are built by hand and CPI'd:

  1. CreateAccount for the mint, sized to 170 bytes (base Account length 165 + account-type byte + one NonTransferable TLV entry). Unlike MintCloseAuthority, this extension stores no value, so its TLV entry is just the 4-byte header.
  2. InitializeNonTransferableMint (variant 32) — carries no data and must run before the mint is initialized.
  3. InitializeMint (variant 0).

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 type NonTransferable, length 0) was written correctly.

@MarkFeder MarkFeder requested a review from dev-jodee as a code owner July 9, 2026 22:41
@greptile-apps

greptile-apps Bot commented Jul 9, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds a Pinocchio port of the Token-2022 NonTransferable extension example, building Token-2022 CPI instructions by hand (since no pinocchio-token-2022 crate exists) and verifying the result with a bankrun test.

  • Program: Three hand-crafted CPIs — system::CreateAccount (170-byte mint), InitializeNonTransferableMint (variant 32, no data beyond the discriminant, called before InitializeMint), and InitializeMint (variant 0, 67-byte payload with a 1-byte COption prefix). All sizes, orderings, and encodings match what the native counterpart derives from ExtensionType::try_calculate_account_len.
  • Test: Bankrun assertions cover mint ownership, byte length, decimals offset, account-type byte (Mint = 1), TLV type (NonTransferable = 9), and TLV length (0). A follow-up transfer-rejection test would complete the story.
  • Dependencies: Correctly uses only pinocchio, pinocchio-log, and pinocchio-system; no stray crates introduced.

Confidence Score: 4/5

The 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

Filename Overview
tokens/token-2022/non-transferable/pinocchio/program/src/instructions/create_mint.rs Core implementation: hand-builds Token-2022 CPI instruction data for InitializeNonTransferableMint (variant 32) and InitializeMint (variant 0); encoding looks correct and operations are ordered correctly (extension before mint init)
tokens/token-2022/non-transferable/pinocchio/program/src/instructions/mod.rs Defines TOKEN_2022_PROGRAM_ID, MINT_SIZE=170 (correctly calculated as 165+1+4), and CreateTokenArgs parsing; all values are accurate
tokens/token-2022/non-transferable/pinocchio/program/src/processor.rs Simple single-instruction dispatch with no discriminator byte, consistent with the native counterpart and well-documented
tokens/token-2022/non-transferable/pinocchio/tests/test.ts Bankrun test verifies mint size, owner, decimals, account-type byte, and TLV header (type=9/NonTransferable, length=0); does not assert that an actual token transfer is rejected by Token-2022
tokens/token-2022/non-transferable/pinocchio/program/Cargo.toml Minimal dependency set (pinocchio, pinocchio-log, pinocchio-system); correct workspace references, no unnecessary crates
tokens/token-2022/non-transferable/pinocchio/package.json Standard test tooling; scripts mirror the mint-close-authority sibling example

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
Loading
%%{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
Loading

Reviews (1): Last reviewed commit: "add pinocchio token-2022 non-transferabl..." | Re-trigger Greptile

Comment on lines +40 to +84
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());
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +76 to +87
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],
)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@MarkFeder

Copy link
Copy Markdown
Contributor Author

@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, InitializeMint2 variant 20); I’ve replied to both — both intentionally keep byte-for-byte parity with the existing native example, so no code change. Ready for review whenever you have a chance 🙏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant