Skip to content

add pinocchio token-2022 non-transferable example#630

Open
MarkFeder wants to merge 4 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 4 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 existing tokens/token-2022/non-transferable example, completing the anchor / native / pinocchio trio. Because no pinocchio-token-2022 crate exists, all three Token-2022 CPIs are built by hand from raw discriminators and serialized byte slices.

  • Program (create_mint.rs): sequences CreateAccount (170 bytes, Token-2022 owner) → InitializeNonTransferableMint (variant 32, mint-only account list) → InitializeMint (variant 0, with explicit rent sysvar) in the required order; instruction data layout matches the native wire format exactly.
  • Test (test.ts): LiteSVM integration test validates Token-2022 ownership, the 170-byte size, correct decimals, and the NonTransferable TLV entry via the official @solana-program/token-2022 decoder.
  • Sizing: MINT_SIZE = 170 is derived correctly as 165-byte padded base + 1 account-type byte + 4-byte TLV header; one doc comment sentence mis-states the reason for the padding.

Confidence Score: 5/5

Safe to merge — all three CPIs are ordered correctly, account lists and instruction data match the Token-2022 wire format, and the LiteSVM test exercises the full on-chain flow against the real Token-2022 binary.

The change is a self-contained new example with no modifications to existing logic. The CPI sequencing, discriminator values, account roles, and 170-byte size calculation are all correct. The only finding is a misleading sentence in a doc comment that does not affect runtime behaviour.

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 — correctly sequences CreateAccount → InitializeNonTransferableMint (variant 32, 1 account) → InitializeMint (variant 0, 2 accounts) with proper instruction data layout
tokens/token-2022/non-transferable/pinocchio/program/src/instructions/mod.rs Defines MINT_SIZE=170, TOKEN_2022_PROGRAM_ID, and CreateTokenArgs; the inline doc comment explaining why the base is padded to 165 bytes contains a misleading claim about size uniqueness
tokens/token-2022/non-transferable/pinocchio/tests/test.ts LiteSVM integration test; structurally validates ownership, 170-byte size, decimals, and NonTransferable TLV presence using the official Token-2022 decoder
tokens/token-2022/non-transferable/pinocchio/program/src/lib.rs Standard pinocchio entrypoint wiring — no_std, alloc extern, entrypoint! and nostd_panic_handler! macros, single instruction forwarded to processor
tokens/token-2022/non-transferable/pinocchio/program/src/processor.rs Thin dispatch layer; correctly forwards all data to create_mint without a leading discriminator byte, consistent with the design note in the doc comment
tokens/token-2022/non-transferable/pinocchio/program/Cargo.toml Declares minimal dependencies (pinocchio, pinocchio-log, pinocchio-system) from workspace; standard Solana BPF crate-type configuration
tokens/token-2022/non-transferable/pinocchio/package.json Standard JS test harness; uses litesvm, @solana/kit, and @solana-program/token-2022 — consistent with sibling pinocchio examples

Sequence Diagram

sequenceDiagram
    participant Client
    participant PinocchioProgram
    participant SystemProgram
    participant Token2022

    Client->>PinocchioProgram: create_mint(decimals, accounts)
    PinocchioProgram->>SystemProgram: "CreateAccount(payer to mint, 170 bytes, owner=Token2022)"
    SystemProgram-->>PinocchioProgram: Ok
    PinocchioProgram->>Token2022: InitializeNonTransferableMint (variant 32, mint only)
    Token2022-->>PinocchioProgram: Ok (NonTransferable TLV written)
    PinocchioProgram->>Token2022: InitializeMint (variant 0, decimals + authorities)
    Token2022-->>PinocchioProgram: Ok (mint initialized)
    PinocchioProgram-->>Client: Ok
Loading

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

@dev-jodee dev-jodee left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Will require changes based on #624 final version

Apply the kit + litesvm template from the mint-close-authority example:
build and sign the transaction with @solana/kit and run it on litesvm,
dropping @solana/web3.js and solana-bankrun entirely.

- 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 type-clean under tsc --noEmit
- LiteSVM's standard runtime bundles Token-2022, so no fixture load is needed
  beyond the program .so
@MarkFeder

Copy link
Copy Markdown
Contributor Author

Applied the kit + litesvm template from #624 here as well. The test now builds and signs transactions with @solana/kit and runs them on litesvm, so @solana/web3.js and solana-bankrun are dropped entirely. Pinned @solana/kit to ^6.10.0 (litesvm's kit major) for a single kit in the tree, and bumped typescript/tsconfig (es2022 + dom, @types/node) so the suite is type-clean under tsc --noEmit. Verified locally: cargo build-sbf + ts-mocha1 passing, plus tsc --noEmit and biome clean. Commit is signed.

Mirror the solana-foundation#624 review refinements here:

- program: compute rent with Rent::get()?.try_minimum_balance() now that the
  tests run on litesvm instead of bankrun.
- test: source the program ids and rent sysvar from the official packages
  (@solana-program/token-2022, @solana-program/system, @solana/sysvars) and
  decode the mint + its NonTransferable extension with getMintDecoder()
  instead of hand-rolled address constants and raw byte offsets.
- tsconfig: moduleResolution 'bundler' so the packages' subpath exports
  type-check.
@MarkFeder

Copy link
Copy Markdown
Contributor Author

Applied the same refinements @dev-jodee asked for on #624:

  • program: rent is now computed with `Rent::get()?.try_minimum_balance(MINT_SIZE)?` (the integer-math workaround was only needed to dodge the f64 opcode the old bankrun VM rejected; litesvm runs the real syscall).
  • test: program ids and the rent sysvar now come from the official packages (`@solana-program/token-2022`, `@solana-program/system`, `@solana/sysvars`), and the mint + its `NonTransferable` extension are read with `getMintDecoder().decode(...)` instead of hand-rolled address constants and raw byte offsets.
  • tsconfig: bumped `moduleResolution` to `bundler` for the packages' subpath exports.

Pinned `@solana-program/*@^0.12` / `@solana/sysvars@^6.10` to stay on the single `@solana/kit@6.10` that `litesvm@1.3` requires (the `0.13`/`7.x` lines peer-require kit 7). `tsc`, biome, and `ts-mocha` (1 passing) all green.

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