Преглед на файлове

add create token functions (#30)

* refactor: renamed create transaction files

* feat: add getTokenAccountAddress

* feat: create token instructions

* feat: token22 metadata

* feat: create token transaction

* fix: type based on latest blockhash

* refactor: renamed file

* fix: signable check

* test: typecheck

* refactor: make sync

* docs: add example to comments

* chore: changeset
Nick Frostbutter преди 9 месеца
родител
ревизия
446a9d1a4c

+ 5 - 0
.changeset/clever-pens-swim.md

@@ -0,0 +1,5 @@
+---
+"gill": minor
+---
+
+added create token helpers

+ 484 - 0
packages/gill/src/__tests__/create-token.ts

@@ -0,0 +1,484 @@
+import { generateKeyPairSigner, KeyPairSigner } from "@solana/signers";
+import { Address } from "@solana/addresses";
+import { IInstruction } from "@solana/instructions";
+import { getCreateAccountInstruction } from "@solana-program/system";
+import { getMinimumBalanceForRentExemption } from "../core";
+import {
+  getCreateMetadataAccountV3Instruction,
+  getTokenMetadataAddress,
+} from "../programs/token-metadata";
+import {
+  createTokenInstructions,
+  CreateTokenInstructionsArgs,
+} from "../programs/create-token-instructions";
+
+import { TOKEN_PROGRAM_ADDRESS, getInitializeMintInstruction } from "@solana-program/token";
+import {
+  TOKEN_2022_PROGRAM_ADDRESS,
+  getMintSize,
+  getInitializeMintInstruction as getInitializeMintInstructionToken22,
+  extension,
+  getInitializeTokenMetadataInstruction,
+  getInitializeMetadataPointerInstruction,
+} from "@solana-program/token-2022";
+
+const MOCK_SPACE = 122n;
+const MOCK_RENT = 10000n;
+
+jest.mock("../core", () => ({
+  getMinimumBalanceForRentExemption: jest.fn(),
+}));
+
+jest.mock("../programs/token-metadata", () => ({
+  getTokenMetadataAddress: jest.fn(),
+  getCreateMetadataAccountV3Instruction: jest.fn(),
+}));
+
+jest.mock("@solana-program/system", () => ({
+  getCreateAccountInstruction: jest.fn(),
+}));
+
+jest.mock("@solana-program/token", () => ({
+  TOKEN_PROGRAM_ADDRESS: "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
+  getInitializeMintInstruction: jest.fn(),
+}));
+
+jest.mock("@solana-program/token-2022", () => ({
+  TOKEN_2022_PROGRAM_ADDRESS: "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb",
+  getMintSize: jest.fn(),
+  getInitializeMintInstruction: jest.fn(),
+  extension: jest.fn(),
+  getInitializeMetadataPointerInstruction: jest.fn(),
+  getInitializeTokenMetadataInstruction: jest.fn(),
+}));
+
+describe("createTokenInstructions", () => {
+  let mockPayer: KeyPairSigner;
+  let mockMint: KeyPairSigner;
+
+  let mockMetadataAddress = "mockMetadataAddress" as Address;
+
+  let mockMintAuthority: KeyPairSigner;
+  let mockFreezeAuthority: KeyPairSigner;
+
+  let mockCreateAccountInstruction: IInstruction;
+  let mockInitializeMintInstruction: IInstruction;
+  let mockCreateMetadataInstruction: IInstruction;
+
+  let mockInitializeMintToken22Instruction: IInstruction;
+  let mockInitializeMetadataPointerInstruction: IInstruction;
+  let mockInitializeTokenMetadataInstruction: IInstruction;
+
+  const metadata: CreateTokenInstructionsArgs["metadata"] = {
+    name: "Test Token",
+    symbol: "TEST",
+    uri: "https://example.com/metadata.json",
+    isMutable: true,
+  };
+
+  beforeAll(async () => {
+    [mockPayer, mockMint, mockMintAuthority, mockFreezeAuthority] = await Promise.all([
+      generateKeyPairSigner(),
+      generateKeyPairSigner(),
+      generateKeyPairSigner(),
+      generateKeyPairSigner(),
+    ]);
+  });
+
+  beforeEach(() => {
+    mockCreateAccountInstruction = {
+      programAddress: "system" as Address,
+      data: new Uint8Array([1]),
+    };
+    mockInitializeMintInstruction = {
+      programAddress: "token" as Address,
+      data: new Uint8Array([2]),
+    };
+    mockInitializeMintToken22Instruction = {
+      programAddress: "token22" as Address,
+      data: new Uint8Array([3]),
+    };
+    mockCreateMetadataInstruction = {
+      programAddress: "metadata" as Address,
+      data: new Uint8Array([4]),
+    };
+    mockInitializeTokenMetadataInstruction = {
+      programAddress: "initMetadata" as Address,
+      data: new Uint8Array([5]),
+    };
+    mockInitializeMetadataPointerInstruction = {
+      programAddress: "initMetadataPointer" as Address,
+      data: new Uint8Array([6]),
+    };
+
+    (getCreateAccountInstruction as jest.Mock).mockReturnValue(mockCreateAccountInstruction);
+    (getInitializeMintInstruction as jest.Mock).mockReturnValue(mockInitializeMintInstruction);
+    (getCreateMetadataAccountV3Instruction as jest.Mock).mockReturnValue(
+      mockCreateMetadataInstruction,
+    );
+    (getInitializeMintInstructionToken22 as jest.Mock).mockReturnValue(
+      mockInitializeMintToken22Instruction,
+    );
+    (getInitializeMetadataPointerInstruction as jest.Mock).mockReturnValue(
+      mockInitializeMetadataPointerInstruction,
+    );
+    (getInitializeTokenMetadataInstruction as jest.Mock).mockReturnValue(
+      mockInitializeTokenMetadataInstruction,
+    );
+    (extension as jest.Mock).mockReturnValue("");
+
+    (getMinimumBalanceForRentExemption as jest.Mock).mockReturnValue(MOCK_RENT);
+    (getMintSize as jest.Mock).mockReturnValue(MOCK_SPACE);
+    (getTokenMetadataAddress as jest.Mock).mockResolvedValue("metadataAddress123");
+  });
+
+  afterEach(() => {
+    jest.clearAllMocks();
+  });
+
+  it("should create basic token instructions with default values", () => {
+    const args: CreateTokenInstructionsArgs = {
+      payer: mockPayer,
+      mint: mockMint,
+      metadataAddress: mockMetadataAddress,
+      metadata,
+    };
+
+    const instructions = createTokenInstructions(args);
+
+    expect(instructions).toHaveLength(3);
+    expect(instructions[0]).toBe(mockCreateAccountInstruction);
+    expect(instructions[1]).toBe(mockInitializeMintInstruction);
+    expect(instructions[2]).toBe(mockCreateMetadataInstruction);
+
+    expect(getCreateAccountInstruction).toHaveBeenCalledWith({
+      payer: mockPayer,
+      newAccount: mockMint,
+      lamports: MOCK_RENT,
+      space: MOCK_SPACE,
+      programAddress: TOKEN_PROGRAM_ADDRESS,
+    });
+
+    expect(getInitializeMintInstruction).toHaveBeenCalledWith({
+      mint: mockMint.address,
+      decimals: 9,
+      mintAuthority: mockPayer.address,
+      freezeAuthority: null,
+    });
+
+    expect(getCreateMetadataAccountV3Instruction).toHaveBeenCalledWith(
+      expect.objectContaining({
+        metadata: mockMetadataAddress,
+        mint: mockMint.address,
+        mintAuthority: mockPayer,
+        payer: mockPayer,
+        updateAuthority: mockPayer,
+        data: {
+          name: metadata.name,
+          symbol: metadata.symbol,
+          uri: metadata.uri,
+          sellerFeeBasisPoints: 0,
+          creators: null,
+          collection: null,
+          uses: null,
+        },
+        isMutable: true,
+        collectionDetails: null,
+      }),
+    );
+  });
+
+  it("should throw error for unsupported token program", () => {
+    const args: CreateTokenInstructionsArgs = {
+      payer: mockPayer,
+      mint: mockMint,
+      metadataAddress: mockMetadataAddress,
+      metadata,
+      tokenProgram: "UnsupportedProgramId" as Address,
+    };
+
+    expect(() => createTokenInstructions(args)).toThrow(
+      "Unsupported token program. Try 'TOKEN_PROGRAM_ADDRESS' or 'TOKEN_2022_PROGRAM_ADDRESS'",
+    );
+  });
+
+  describe("should use original token program", () => {
+    it("should use original token program when specified", () => {
+      const args: CreateTokenInstructionsArgs = {
+        payer: mockPayer,
+        mint: mockMint,
+        metadataAddress: mockMetadataAddress,
+        tokenProgram: TOKEN_PROGRAM_ADDRESS,
+        metadata,
+      };
+
+      createTokenInstructions(args);
+
+      expect(getCreateAccountInstruction).toHaveBeenCalledWith(
+        expect.objectContaining({
+          space: MOCK_SPACE,
+          programAddress: TOKEN_PROGRAM_ADDRESS,
+        }),
+      );
+
+      expect(getInitializeMintInstruction).toHaveBeenCalledWith(
+        expect.objectContaining({
+          mint: mockMint.address,
+        }),
+      );
+    });
+
+    it("should use custom decimals when provided", () => {
+      const args: CreateTokenInstructionsArgs = {
+        payer: mockPayer,
+        metadataAddress: mockMetadataAddress,
+        mint: mockMint,
+        decimals: 6,
+        metadata,
+      };
+
+      createTokenInstructions(args);
+
+      expect(getInitializeMintInstruction).toHaveBeenCalledWith(
+        expect.objectContaining({
+          mint: mockMint.address,
+          decimals: 6,
+        }),
+      );
+    });
+
+    it("should use custom mint and freeze authorities when provided", () => {
+      const args: CreateTokenInstructionsArgs = {
+        payer: mockPayer,
+        mint: mockMint,
+        metadataAddress: mockMetadataAddress,
+        metadata,
+        mintAuthority: mockMintAuthority,
+        freezeAuthority: mockFreezeAuthority.address,
+      };
+
+      createTokenInstructions(args);
+
+      expect(getInitializeMintInstruction).toHaveBeenCalledWith(
+        expect.objectContaining({
+          mintAuthority: mockMintAuthority.address,
+          freezeAuthority: mockFreezeAuthority.address,
+        }),
+      );
+    });
+
+    it("should add metadata instruction when metadata is provided", () => {
+      const metadata: CreateTokenInstructionsArgs["metadata"] = {
+        name: "Test Token",
+        symbol: "TEST",
+        uri: "https://example.com/metadata.json",
+        isMutable: false,
+      };
+
+      const args: CreateTokenInstructionsArgs = {
+        payer: mockPayer,
+        mint: mockMint,
+        metadataAddress: mockMetadataAddress,
+        metadata,
+      };
+
+      const instructions = createTokenInstructions(args);
+
+      expect(instructions).toHaveLength(3);
+      expect(instructions[2]).toBe(mockCreateMetadataInstruction);
+
+      expect(getCreateMetadataAccountV3Instruction).toHaveBeenCalledWith(
+        expect.objectContaining({
+          metadata: mockMetadataAddress,
+          mint: mockMint.address,
+          mintAuthority: mockPayer,
+          payer: mockPayer,
+          updateAuthority: mockPayer,
+          data: {
+            name: metadata.name,
+            symbol: metadata.symbol,
+            uri: metadata.uri,
+            sellerFeeBasisPoints: 0,
+            creators: null,
+            collection: null,
+            uses: null,
+          },
+          isMutable: false,
+          collectionDetails: null,
+        }),
+      );
+    });
+
+    it("should use custom metadata update authority", () => {
+      const customUpdateAuthority = { address: "customUpdateAuth" } as KeyPairSigner;
+
+      const args: CreateTokenInstructionsArgs = {
+        payer: mockPayer,
+        mint: mockMint,
+        metadataAddress: mockMetadataAddress,
+        updateAuthority: customUpdateAuthority,
+        metadata,
+      };
+
+      createTokenInstructions(args);
+
+      expect(getCreateMetadataAccountV3Instruction).toHaveBeenCalledWith(
+        expect.objectContaining({
+          updateAuthority: customUpdateAuthority,
+        }),
+      );
+    });
+  });
+
+  describe("should use token22 program", () => {
+    it("should use Token-2022 program when specified", () => {
+      const args: CreateTokenInstructionsArgs = {
+        payer: mockPayer,
+        mint: mockMint,
+        metadataAddress: mockMint.address,
+        tokenProgram: TOKEN_2022_PROGRAM_ADDRESS,
+        metadata,
+      };
+
+      createTokenInstructions(args);
+
+      expect(getCreateAccountInstruction).toHaveBeenCalledWith(
+        expect.objectContaining({
+          space: MOCK_SPACE,
+          programAddress: TOKEN_2022_PROGRAM_ADDRESS,
+        }),
+      );
+
+      expect(getInitializeMintInstructionToken22).toHaveBeenCalledWith(
+        expect.objectContaining({
+          mint: mockMint.address,
+        }),
+      );
+    });
+
+    it("should use custom decimals when provided", () => {
+      const args: CreateTokenInstructionsArgs = {
+        payer: mockPayer,
+        mint: mockMint,
+        metadataAddress: mockMint.address,
+        decimals: 6,
+        metadata,
+        tokenProgram: TOKEN_2022_PROGRAM_ADDRESS,
+      };
+
+      createTokenInstructions(args);
+
+      expect(getInitializeMintInstructionToken22).toHaveBeenCalledWith(
+        expect.objectContaining({
+          mint: mockMint.address,
+          decimals: 6,
+        }),
+      );
+    });
+
+    it("should use custom mint and freeze authorities when provided", () => {
+      const args: CreateTokenInstructionsArgs = {
+        payer: mockPayer,
+        mint: mockMint,
+        metadataAddress: mockMint.address,
+        metadata,
+        mintAuthority: mockMintAuthority,
+        freezeAuthority: mockFreezeAuthority.address,
+        tokenProgram: TOKEN_2022_PROGRAM_ADDRESS,
+      };
+
+      createTokenInstructions(args);
+
+      expect(getInitializeMintInstructionToken22).toHaveBeenCalledWith(
+        expect.objectContaining({
+          mintAuthority: mockMintAuthority.address,
+          freezeAuthority: mockFreezeAuthority.address,
+        }),
+      );
+    });
+
+    it("should add metadata instruction when metadata is provided", () => {
+      const metadata: CreateTokenInstructionsArgs["metadata"] = {
+        name: "Test Token22",
+        symbol: "TEST",
+        uri: "https://example.com/metadata.json",
+        isMutable: false,
+      };
+
+      const args: CreateTokenInstructionsArgs = {
+        payer: mockPayer,
+        mint: mockMint,
+        metadataAddress: mockMint.address,
+        metadata,
+        tokenProgram: TOKEN_2022_PROGRAM_ADDRESS,
+      };
+
+      const instructions = createTokenInstructions(args);
+
+      expect(instructions).toHaveLength(4);
+      expect(instructions[1]).toBe(mockInitializeMetadataPointerInstruction);
+      expect(instructions[2]).toBe(mockInitializeMintToken22Instruction);
+      expect(instructions[3]).toBe(mockInitializeTokenMetadataInstruction);
+
+      expect(getInitializeMetadataPointerInstruction).toHaveBeenCalledWith(
+        expect.objectContaining({
+          mint: mockMint.address,
+          metadataAddress: mockMint.address,
+          authority: mockPayer.address,
+        }),
+      );
+
+      expect(getInitializeMintInstructionToken22).toHaveBeenCalledWith(
+        expect.objectContaining({
+          mint: mockMint.address,
+          mintAuthority: mockPayer.address,
+        }),
+      );
+
+      expect(getInitializeTokenMetadataInstruction).toHaveBeenCalledWith(
+        expect.objectContaining({
+          mint: mockMint.address,
+          metadata: mockMint.address,
+          mintAuthority: mockPayer,
+          updateAuthority: mockPayer.address,
+          name: metadata.name,
+          symbol: metadata.symbol,
+          uri: metadata.uri,
+        }),
+      );
+    });
+
+    it("should use custom metadata update authority", () => {
+      const customUpdateAuthority = { address: "customUpdateAuth" } as KeyPairSigner;
+
+      const args: CreateTokenInstructionsArgs = {
+        payer: mockPayer,
+        mint: mockMint,
+        metadataAddress: mockMint.address,
+        updateAuthority: customUpdateAuthority,
+        metadata,
+        tokenProgram: TOKEN_2022_PROGRAM_ADDRESS,
+      };
+
+      const instructions = createTokenInstructions(args);
+
+      expect(instructions).toHaveLength(4);
+      expect(instructions[1]).toBe(mockInitializeMetadataPointerInstruction);
+      expect(instructions[2]).toBe(mockInitializeMintToken22Instruction);
+      expect(instructions[3]).toBe(mockInitializeTokenMetadataInstruction);
+
+      expect(getInitializeMetadataPointerInstruction).toHaveBeenCalledWith(
+        expect.objectContaining({
+          mint: mockMint.address,
+          metadataAddress: mockMint.address,
+        }),
+      );
+
+      expect(getInitializeTokenMetadataInstruction).toHaveBeenCalledWith(
+        expect.objectContaining({
+          updateAuthority: customUpdateAuthority.address,
+        }),
+      );
+    });
+  });
+});

+ 0 - 0
packages/gill/src/__tests__/transactions.ts → packages/gill/src/__tests__/create-transaction.ts


+ 81 - 0
packages/gill/src/__typetests__/create-token-transaction.ts

@@ -0,0 +1,81 @@
+/* eslint-disable @typescript-eslint/ban-ts-comment */
+
+import type { KeyPairSigner } from "@solana/signers";
+import type {
+  BaseTransactionMessage,
+  TransactionMessageWithBlockhashLifetime,
+} from "@solana/transaction-messages";
+import { signTransactionMessageWithSigners } from "@solana/signers";
+
+import { CreateTokenInstructionsArgs, createTokenTransaction } from "../programs";
+
+// [DESCRIBE] createTokenTransaction
+async () => {
+  const signer = null as unknown as KeyPairSigner;
+  const latestBlockhash =
+    null as unknown as TransactionMessageWithBlockhashLifetime["lifetimeConstraint"];
+  const metadata = {} as unknown as CreateTokenInstructionsArgs["metadata"];
+
+  // Legacy transaction
+  {
+    (await createTokenTransaction({
+      payer: signer,
+      metadata,
+    })) satisfies BaseTransactionMessage<"legacy">;
+
+    (await createTokenTransaction({
+      version: "legacy",
+      payer: signer,
+      metadata,
+    })) satisfies BaseTransactionMessage<"legacy">;
+
+    const txNotSignable = (await createTokenTransaction({
+      version: "legacy",
+      payer: signer,
+      metadata,
+      // @ts-expect-error Should not have a Lifetime
+    })) satisfies TransactionMessageWithBlockhashLifetime;
+
+    // @ts-expect-error Should not be a signable transaction
+    signTransactionMessageWithSigners(txNotSignable);
+
+    const txSignable = (await createTokenTransaction({
+      version: "legacy",
+      payer: signer,
+      metadata,
+      latestBlockhash,
+    })) satisfies BaseTransactionMessage<"legacy"> & TransactionMessageWithBlockhashLifetime;
+
+    // Should be a signable transaction
+    signTransactionMessageWithSigners(txSignable);
+  }
+
+  // Version 0 transaction
+  {
+    (await createTokenTransaction({
+      version: 0,
+      payer: signer,
+      metadata,
+    })) satisfies BaseTransactionMessage<0>;
+
+    const txNotSignable = (await createTokenTransaction({
+      version: 0,
+      payer: signer,
+      metadata,
+      // @ts-expect-error Should not have a Lifetime
+    })) satisfies TransactionMessageWithBlockhashLifetime;
+
+    // @ts-expect-error Should not be a signable transaction
+    signTransactionMessageWithSigners(txNotSignable);
+
+    const txSignable = (await createTokenTransaction({
+      version: 0,
+      payer: signer,
+      metadata,
+      latestBlockhash,
+    })) satisfies BaseTransactionMessage<0> & TransactionMessageWithBlockhashLifetime;
+
+    // Should be a signable transaction
+    signTransactionMessageWithSigners(txSignable);
+  }
+};

+ 21 - 9
packages/gill/src/__typetests__/transactions.ts → packages/gill/src/__typetests__/create-transaction.ts

@@ -2,13 +2,13 @@
 
 import type { Address } from "@solana/addresses";
 import type { ITransactionMessageWithFeePayerSigner, KeyPairSigner } from "@solana/signers";
-
-import {
+import type {
   BaseTransactionMessage,
   ITransactionMessageWithFeePayer,
   TransactionMessageWithBlockhashLifetime,
 } from "@solana/transaction-messages";
-import { IInstruction } from "@solana/instructions";
+import type { IInstruction } from "@solana/instructions";
+import { signTransactionMessageWithSigners } from "@solana/signers";
 
 import { createTransaction } from "../core";
 
@@ -44,13 +44,16 @@ import { createTransaction } from "../core";
       // @ts-expect-error Should not have a Lifetime
     }) satisfies TransactionMessageWithBlockhashLifetime;
 
-    createTransaction({
+    const txNotSignable = createTransaction({
       version: "legacy",
       feePayer: signer,
       instructions: [ix],
       // @ts-expect-error Should not have a Lifetime
     }) satisfies TransactionMessageWithBlockhashLifetime;
 
+    // @ts-expect-error Should not be a signable transaction
+    signTransactionMessageWithSigners(txNotSignable);
+
     // Should be legacy with a Lifetime and Signer
     createTransaction({
       version: "legacy",
@@ -62,7 +65,7 @@ import { createTransaction } from "../core";
       ITransactionMessageWithFeePayerSigner;
 
     // Should be legacy with a Lifetime and address (aka non Signer)
-    createTransaction({
+    const txSignable = createTransaction({
       version: "legacy",
       feePayer: feePayer,
       instructions: [ix],
@@ -70,6 +73,9 @@ import { createTransaction } from "../core";
     }) satisfies BaseTransactionMessage<"legacy"> &
       TransactionMessageWithBlockhashLifetime &
       ITransactionMessageWithFeePayer;
+
+    // Should be a signable transaction
+    signTransactionMessageWithSigners(txSignable);
   }
 
   // Version 0 transactions
@@ -88,13 +94,16 @@ import { createTransaction } from "../core";
       instructions: [ix],
     }) satisfies BaseTransactionMessage<0> & ITransactionMessageWithFeePayerSigner;
 
-    createTransaction({
+    const txNotSignable = createTransaction({
       version: 0,
       feePayer: feePayer,
       instructions: [ix],
       // @ts-expect-error Should not have a Lifetime
     }) satisfies TransactionMessageWithBlockhashLifetime;
 
+    // @ts-expect-error Should not be a signable transaction
+    signTransactionMessageWithSigners(txNotSignable);
+
     createTransaction({
       version: 0,
       feePayer: signer,
@@ -102,7 +111,7 @@ import { createTransaction } from "../core";
       // @ts-expect-error Should not have a Lifetime
     }) satisfies TransactionMessageWithBlockhashLifetime;
 
-    // Should be legacy with a Lifetime and Signer
+    // Should be version 0 with a Lifetime and Signer
     createTransaction({
       version: 0,
       feePayer: signer,
@@ -112,8 +121,8 @@ import { createTransaction } from "../core";
       TransactionMessageWithBlockhashLifetime &
       ITransactionMessageWithFeePayerSigner;
 
-    // Should be legacy with a Lifetime and address (aka non Signer)
-    createTransaction({
+    // Should be version 0 with a Lifetime and address (aka non Signer)
+    const txSignable = createTransaction({
       version: 0,
       feePayer: feePayer,
       instructions: [ix],
@@ -121,5 +130,8 @@ import { createTransaction } from "../core";
     }) satisfies BaseTransactionMessage<0> &
       TransactionMessageWithBlockhashLifetime &
       ITransactionMessageWithFeePayer;
+
+    // Should be a signable transaction
+    signTransactionMessageWithSigners(txSignable);
   }
 }

+ 0 - 0
packages/gill/src/core/transactions.ts → packages/gill/src/core/create-transaction.ts


+ 1 - 1
packages/gill/src/core/index.ts

@@ -3,7 +3,7 @@ export * from "./const";
 export * from "./utils";
 export * from "./rpc";
 export * from "./explorer";
-export * from "./transactions";
+export * from "./create-transaction";
 export * from "./base64-transactions";
 export * from "./prepare-transaction";
 export * from "./create-solana-client";

+ 212 - 0
packages/gill/src/programs/create-token-instructions.ts

@@ -0,0 +1,212 @@
+import type { IInstruction } from "@solana/instructions";
+import type { Address } from "@solana/addresses";
+import type { KeyPairSigner } from "@solana/signers";
+import { getCreateAccountInstruction } from "@solana-program/system";
+import { getMinimumBalanceForRentExemption } from "../core";
+import { getTokenMetadataAddress, getCreateMetadataAccountV3Instruction } from "./token-metadata";
+
+import { TOKEN_PROGRAM_ADDRESS, getInitializeMintInstruction } from "@solana-program/token";
+import {
+  TOKEN_2022_PROGRAM_ADDRESS,
+  getMintSize,
+  getInitializeMintInstruction as getInitializeMintInstructionToken22,
+  extension,
+  getInitializeMetadataPointerInstruction,
+  getInitializeTokenMetadataInstruction,
+} from "@solana-program/token-2022";
+
+export type CreateTokenInstructionsArgs = {
+  /** Signer that will pay for the rent storage deposit fee */
+  payer: KeyPairSigner;
+  /** Token Mint to be created (aka token address) */
+  mint: KeyPairSigner;
+  /**
+   * The number of decimal places this token should have
+   *
+   * @default `9` - the most commonly used decimals value
+   **/
+  decimals?: bigint | number;
+  /**
+   * Authority address that is allowed to mint new tokens
+   *
+   * When not provided, defaults to: `payer`
+   **/
+  mintAuthority?: KeyPairSigner;
+  /**
+   * Authority address that is able to freeze (and thaw) user owned token accounts.
+   * When a user's token account is frozen, they will not be able to transfer their tokens.
+   *
+   * When not provided, defaults to: `null`
+   **/
+  freezeAuthority?: Address | KeyPairSigner;
+  /**
+   * Authority address that is allowed to update the metadata
+   *
+   * When not provided, defaults to: `payer`
+   **/
+  updateAuthority?: KeyPairSigner;
+  /**
+   * Optional (but highly recommended) metadata to attach to this token
+   */
+  metadata: {
+    /** Name of this token */
+    name: string;
+    /** Symbol for this token */
+    symbol: string;
+    /** URI pointing to additional metadata for this token. Typically an offchain json file. */
+    uri: string;
+    /** Whether or not the onchain metadata will be editable after minting */
+    isMutable: boolean;
+  };
+  /**
+   * Metadata address for this token
+   *
+   * @example
+   * For `TOKEN_PROGRAM_ADDRESS` use the {@link getTokenMetadataAddress} function:
+   * ```
+   * metadataAddress: await getTokenMetadataAddress(mint.address);
+   * ```
+   *
+   * @example
+   * For `TOKEN_2022_PROGRAM_ADDRESS` use the Mint's address:
+   * ```
+   * metadataAddress: mint.address;
+   * ```
+   * */
+  metadataAddress: Address;
+  /**
+   * Token program used to create the token
+   *
+   * @default `TOKEN_PROGRAM_ADDRESS` - the original SPL Token Program
+   *
+   * Supported token programs:
+   * - `TOKEN_PROGRAM_ADDRESS` for the original SPL Token Program
+   * - `TOKEN_2022_PROGRAM_ADDRESS` for the SPL Token Extension Program (aka Token22)
+   **/
+  tokenProgram?: Address;
+  // extensions // todo
+};
+
+/**
+ * Create the instructions required to initialize a new token's Mint
+ */
+export function createTokenInstructions(args: CreateTokenInstructionsArgs): IInstruction[] {
+  if (!args.tokenProgram) args.tokenProgram = TOKEN_PROGRAM_ADDRESS;
+  if (
+    args.tokenProgram !== TOKEN_PROGRAM_ADDRESS &&
+    args.tokenProgram !== TOKEN_2022_PROGRAM_ADDRESS
+  ) {
+    throw Error(
+      "Unsupported token program. Try 'TOKEN_PROGRAM_ADDRESS' or 'TOKEN_2022_PROGRAM_ADDRESS'",
+    );
+  }
+
+  if (!args.decimals) args.decimals = 9;
+  if (!args.mintAuthority) args.mintAuthority = args.payer;
+  if (!args.updateAuthority) args.updateAuthority = args.payer;
+
+  if (args.tokenProgram === TOKEN_2022_PROGRAM_ADDRESS) {
+    // @ts-ignore FIXME(nick): errors due to not finding the valid overload
+    const metadataPointer = extension("MetadataPointer", {
+      metadataAddress: args.mint.address,
+      authority: args.updateAuthority.address,
+    });
+
+    // @ts-ignore FIXME(nick): errors due to not finding the valid overload
+    const metadataExtensionData = extension("TokenMetadata", {
+      updateAuthority: args.updateAuthority.address,
+      mint: args.mint.address,
+      name: args.metadata.name,
+      symbol: args.metadata.symbol,
+      uri: args.metadata.uri,
+      // todo: support token22 additional metadata
+      additionalMetadata: new Map(),
+    });
+
+    return [
+      getCreateAccountInstruction({
+        payer: args.payer,
+        newAccount: args.mint,
+        /**
+         * token22 requires only the pre-mint-initialization extensions (like metadata pointer)
+         * to be the `space`. then it will extend the account's space for each applicable extension
+         * */
+        space: BigInt(getMintSize([metadataPointer])),
+        /**
+         * token22 requires the total lamport balance for all extensions,
+         * including pre-initialization and post-initialization
+         */
+        lamports: getMinimumBalanceForRentExemption(
+          BigInt(getMintSize([metadataPointer, metadataExtensionData])),
+        ),
+        programAddress: args.tokenProgram,
+      }),
+      getInitializeMetadataPointerInstruction({
+        authority: args.mintAuthority.address,
+        metadataAddress: args.metadataAddress,
+        mint: args.mint.address,
+      }),
+      getInitializeMintInstructionToken22({
+        mint: args.mint.address,
+        decimals: Number(args.decimals),
+        mintAuthority: args.mintAuthority.address,
+        freezeAuthority: args.freezeAuthority
+          ? typeof args.freezeAuthority == "string"
+            ? args.freezeAuthority
+            : args.freezeAuthority.address
+          : null,
+      }),
+      getInitializeTokenMetadataInstruction({
+        metadata: args.mint.address,
+        mint: args.mint.address,
+        mintAuthority: args.mintAuthority,
+        name: args.metadata.name,
+        symbol: args.metadata.symbol,
+        uri: args.metadata.uri,
+        updateAuthority: args.updateAuthority.address,
+      }),
+      // todo: support token22 additional metadata by adding that instruction(s) here
+    ];
+  } else {
+    // the token22 `getMintSize` is fully compatible with the original token program
+    const space: bigint = BigInt(getMintSize());
+
+    return [
+      getCreateAccountInstruction({
+        payer: args.payer,
+        newAccount: args.mint,
+        lamports: getMinimumBalanceForRentExemption(space),
+        space,
+        programAddress: args.tokenProgram,
+      }),
+      getInitializeMintInstruction({
+        mint: args.mint.address,
+        decimals: Number(args.decimals),
+        mintAuthority: args.mintAuthority.address,
+        freezeAuthority: args.freezeAuthority
+          ? typeof args.freezeAuthority == "string"
+            ? args.freezeAuthority
+            : args.freezeAuthority.address
+          : null,
+      }),
+      getCreateMetadataAccountV3Instruction({
+        metadata: args.metadataAddress,
+        mint: args.mint.address,
+        mintAuthority: args.mintAuthority,
+        payer: args.payer,
+        updateAuthority: args.updateAuthority,
+        data: {
+          name: args.metadata.name,
+          symbol: args.metadata.symbol,
+          uri: args.metadata.uri,
+          sellerFeeBasisPoints: 0,
+          creators: null,
+          collection: null,
+          uses: null,
+        },
+        isMutable: args.metadata.isMutable,
+        collectionDetails: null,
+      }),
+    ];
+  }
+}

+ 144 - 0
packages/gill/src/programs/create-token.ts

@@ -0,0 +1,144 @@
+import type {
+  ITransactionMessageWithFeePayer,
+  TransactionMessageWithBlockhashLifetime,
+  TransactionVersion,
+} from "@solana/transaction-messages";
+import { createTransaction } from "../core";
+import type { CreateTransactionInput, FullTransaction, Simplify } from "../types";
+import {
+  createTokenInstructions,
+  type CreateTokenInstructionsArgs,
+} from "./create-token-instructions";
+import { generateKeyPairSigner, type KeyPairSigner, type TransactionSigner } from "@solana/signers";
+import { TOKEN_PROGRAM_ADDRESS } from "@solana-program/token";
+import { TOKEN_2022_PROGRAM_ADDRESS } from "@solana-program/token-2022";
+import { getTokenMetadataAddress } from "./token-metadata";
+
+type TransactionInput<
+  TVersion extends TransactionVersion = "legacy",
+  TFeePayer extends TransactionSigner = TransactionSigner,
+  TLifetimeConstraint extends
+    | TransactionMessageWithBlockhashLifetime["lifetimeConstraint"]
+    | undefined = undefined,
+> = Simplify<
+  Omit<
+    CreateTransactionInput<TVersion, TFeePayer, TLifetimeConstraint>,
+    "version" | "instructions" | "feePayer"
+  > &
+    Partial<Pick<CreateTransactionInput<TVersion, TFeePayer, TLifetimeConstraint>, "version">>
+>;
+
+type CreateTokenInput = Simplify<
+  Omit<CreateTokenInstructionsArgs, "mint" | "metadataAddress"> &
+    Partial<Pick<CreateTokenInstructionsArgs, "mint" | "metadataAddress">>
+>;
+
+/**
+ * Create a transaction to create a token with metadata
+ *
+ * @argument transaction - Base transaction configuration
+ * - Default `version` = `legacy`
+ * - Default `computeUnitLimit`
+ *    - for TOKEN_PROGRAM_ADDRESS => `60_000`
+ *    - for TOKEN_2022_PROGRAM_ADDRESS => `10_000`
+ *
+ * @argument token - Information required to create a Solana Token
+ * - `mint` will be auto generated if not provided
+ *
+ * @example
+ *
+ * ```
+ * const transaction = await createTokenTransaction({
+ *   payer: signer,
+ *   latestBlockhash,
+ *   metadata: {
+ *     name: "Test Token",
+ *     symbol: "TEST",
+ *     uri: "https://example.com/metadata.json",
+ *     isMutable: true,
+ *   },
+ *   // tokenProgram: TOKEN_PROGRAM_ADDRESS, // default
+ *   // tokenProgram: TOKEN_2022_PROGRAM_ADDRESS,
+ * });
+ * ```
+ */
+export async function createTokenTransaction<
+  TVersion extends TransactionVersion = "legacy",
+  TFeePayer extends TransactionSigner = TransactionSigner,
+>(
+  input: TransactionInput<TVersion, TFeePayer> & CreateTokenInput,
+): Promise<FullTransaction<TVersion, ITransactionMessageWithFeePayer>>;
+export async function createTokenTransaction<
+  TVersion extends TransactionVersion = "legacy",
+  TFeePayer extends TransactionSigner = TransactionSigner,
+  TLifetimeConstraint extends
+    TransactionMessageWithBlockhashLifetime["lifetimeConstraint"] = TransactionMessageWithBlockhashLifetime["lifetimeConstraint"],
+>(
+  input: TransactionInput<TVersion, TFeePayer, TLifetimeConstraint> & CreateTokenInput,
+): Promise<
+  FullTransaction<
+    TVersion,
+    ITransactionMessageWithFeePayer,
+    TransactionMessageWithBlockhashLifetime
+  >
+>;
+export async function createTokenTransaction<
+  TVersion extends TransactionVersion,
+  TFeePayer extends TransactionSigner,
+  TLifetimeConstraint extends TransactionMessageWithBlockhashLifetime["lifetimeConstraint"],
+>(input: TransactionInput<TVersion, TFeePayer, TLifetimeConstraint> & CreateTokenInput) {
+  if (!input.mint) input.mint = await generateKeyPairSigner();
+
+  let metadataAddress = input.mint.address;
+
+  if (input.tokenProgram === TOKEN_PROGRAM_ADDRESS) {
+    metadataAddress = await getTokenMetadataAddress(input.mint);
+
+    // default a reasonably low computeUnitLimit based on simulation data
+    if (!input.computeUnitLimit) {
+      // creating the token's mint is around 3219cu (and stable?)
+      // token metadata is the rest... and fluctuates a lot based on the pda and amount of metadata
+      input.computeUnitLimit = 60_000;
+    }
+  } else if (input.tokenProgram === TOKEN_2022_PROGRAM_ADDRESS) {
+    if (!input.computeUnitLimit) {
+      // token22 token creation, with metadata is (seemingly stable) around 7647cu,
+      // but consume more with more metadata provided
+      input.computeUnitLimit = 10_000;
+    }
+  }
+
+  const instructions = createTokenInstructions(
+    (({
+      decimals,
+      mintAuthority,
+      freezeAuthority,
+      updateAuthority,
+      metadata,
+      payer,
+      tokenProgram,
+      mint,
+    }: typeof input) => ({
+      mint: mint as KeyPairSigner,
+      payer,
+      metadataAddress,
+      metadata,
+      decimals,
+      mintAuthority,
+      freezeAuthority,
+      updateAuthority,
+      tokenProgram,
+    }))(input),
+  );
+
+  return createTransaction(
+    (({ payer, version, computeUnitLimit, computeUnitPrice, latestBlockhash }: typeof input) => ({
+      feePayer: payer,
+      version: version || "legacy",
+      computeUnitLimit,
+      computeUnitPrice,
+      latestBlockhash,
+      instructions,
+    }))(input),
+  );
+}

+ 4 - 0
packages/gill/src/programs/index.ts

@@ -9,8 +9,12 @@ export * from "@solana-program/address-lookup-table";
 export * from "@solana-program/compute-budget";
 export * from "./compute-budget";
 
+export * from "./token-shared";
+
 /**
  * Codama generated clients, stored internally in this package
  * (and associated helpers for them)
  */
 export * from "./token-metadata";
+export * from "./create-token-instructions";
+export * from "./create-token";

+ 29 - 0
packages/gill/src/programs/token-shared.ts

@@ -0,0 +1,29 @@
+import type { Address } from "@solana/addresses";
+import type { KeyPairSigner } from "@solana/signers";
+import { findAssociatedTokenPda } from "@solana-program/token";
+
+/**
+ * Derive the Token Metadata address from a token's Mint address
+ *
+ * @argument `mint` - `Address` or `KeyPairSigner` of the token Mint
+ * @argument `owner` - `Address` or `KeyPairSigner` of the owner's wallet address
+ * @argument `tokenProgram` - `Address` of the owner's wallet address
+ *
+ * @summary
+ * Commonly used token programs:
+ * - `TOKEN_PROGRAM_ADDRESS` for the original SPL Token Program
+ * - `TOKEN_2022_PROGRAM_ADDRESS` for the SPL Token Extension Program (aka Token22)
+ */
+export async function getTokenAccountAddress(
+  mint: Address | KeyPairSigner,
+  owner: Address | KeyPairSigner,
+  tokenProgram: Address,
+): Promise<Address> {
+  return (
+    await findAssociatedTokenPda({
+      owner: "address" in owner ? owner.address : owner,
+      mint: "address" in mint ? mint.address : mint,
+      tokenProgram,
+    })
+  )[0];
+}

+ 1 - 1
packages/gill/src/types/transactions.ts

@@ -11,7 +11,7 @@ import { Simplify } from ".";
 
 export type CreateTransactionInput<
   TVersion extends TransactionVersion,
-  TFeePayer extends Address | TransactionSigner,
+  TFeePayer extends Address | TransactionSigner = TransactionSigner,
   TLifetimeConstraint extends
     | TransactionMessageWithBlockhashLifetime["lifetimeConstraint"]
     | undefined = undefined,