Skip to content

add pinocchio token-2022 non-transferable example#630

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

add pinocchio token-2022 non-transferable example#630
MarkFeder wants to merge 2 commits 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 tokens/token-2022/non-transferable example alongside the existing anchor and native implementations. Because no pinocchio-token-2022 crate exists, the three Token-2022 instructions (CreateAccount, InitializeNonTransferableMint, InitializeMint) are hand-serialized and CPI'd.

  • Instruction data serialization — the InitializeMint payload is built to 67 bytes using the token-interface's 1-byte COption discriminant (matching the pack_pubkey_option format in solana-program/token), which is correct.
  • MINT_SIZE = 170 — correctly derived as 165 (base padded to token-Account length) + 1 (account-type byte) + 4 (TLV header for zero-value NonTransferable), matching ExtensionType::try_calculate_account_len::<Mint>(&[NonTransferable]).
  • Bankrun test — verifies Token-2022 ownership, exact account size, decimals, and the full NonTransferable TLV header (type=9, length=0); all byte offsets are correct.

Confidence Score: 5/5

Safe 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

Filename Overview
tokens/token-2022/non-transferable/pinocchio/program/src/instructions/create_mint.rs Core instruction handler: creates Token-2022 account, initializes NonTransferable extension, then calls InitializeMint. Hand-serialized instruction data and lamport calculation are correct. COption freeze_authority uses 1-byte discriminant (per token-interface pack format), yielding 67 bytes total — matches Vec::with_capacity(67). Account lists for each CPI match what Token-2022 requires.
tokens/token-2022/non-transferable/pinocchio/program/src/instructions/mod.rs Defines TOKEN_2022_PROGRAM_ID, MINT_SIZE=170, and CreateTokenArgs. The 170-byte size is correctly derived (165 base + 1 account-type + 4 TLV header for zero-value NonTransferable). The parse function is a safe single-byte read equivalent to Borsh.
tokens/token-2022/non-transferable/pinocchio/tests/test.ts Bankrun test verifies Token-2022 ownership, 170-byte size, correct decimals at offset 44, and the extension header (account-type=1, TLV type=9, TLV length=0). All byte offsets are correct.
tokens/token-2022/non-transferable/pinocchio/program/src/lib.rs Standard no_std pinocchio entrypoint with bump allocator and panic handler. Correct for on-chain BPF environment.
tokens/token-2022/non-transferable/pinocchio/program/src/processor.rs Simple single-instruction dispatcher: no discriminator prefix, passes raw instruction_data to create_mint — consistent with the native example's wire format.
tokens/token-2022/non-transferable/pinocchio/program/Cargo.toml Workspace-pinned pinocchio, pinocchio-log, pinocchio-system dependencies. cdylib + lib crate-type for both on-chain deployment and testing. No issues.

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"
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 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"
Loading

Reviews (2): Last reviewed commit: "token-2022 non-transferable pinocchio: m..." | 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 🙏

@dev-jodee

Copy link
Copy Markdown
Collaborator

@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).
@MarkFeder

Copy link
Copy Markdown
Contributor Author

@dev-jodee done — mirrored the create-token (#599) fixes here in e4e5da1:

  • Non-async describe: moved the async bankrun start() setup into a before hook so Mocha actually collects the it block (an async describe body registers tests after the suite is already collected → nothing ran).
  • Integer rent: with the test now executing, it surfaced the same Rent::try_minimum_balance float-path issue — its floating-point exemption-threshold branch emits an opcode the bankrun VM rejects ("unsupported BPF instruction"). Swapped to the integer formula (ACCOUNT_STORAGE_OVERHEAD + MINT_SIZE) * DEFAULT_LAMPORTS_PER_BYTE, same as add pinocchio create-token example #599.
  • Signed commit: the new commit is SSH-signed/Verified.

Verified locally with cargo build-sbf + ts-mocha: 1 passing, program executes all three CPIs (CreateAccount → InitializeNonTransferableMint → InitializeMint) and the mint-layout assertions hold. fmt/clippy/biome all clean. Ready for review 🙏

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.

2 participants