diff --git a/docs/src/token-2022/extensions.mdx b/docs/src/token-2022/extensions.mdx index 810dbaafc97..b4c1bf3a0a4 100644 --- a/docs/src/token-2022/extensions.mdx +++ b/docs/src/token-2022/extensions.mdx @@ -1388,7 +1388,60 @@ Signature: 3ug4Ejs16jJgEm1WyBwDDxzh9xqPzQ3a2cmy1hSYiPFcLQi9U12HYF1Dbhzb2bx75SSyd -Coming soon! +```jsx +import { + clusterApiUrl, + sendAndConfirmTransaction, + Connection, + Keypair, + PublicKey, + SystemProgram, + Transaction, + LAMPORTS_PER_SOL, +} from '@solana/web3.js'; + +import { + ExtensionType, + createInitializeMintInstruction, + createInitializeTransferHookInstruction, + mintTo, + createAccount, + getMintLen, + TOKEN_2022_PROGRAM_ID, +} from '../src'; + +(async () => { + const payer = Keypair.generate(); + + const mintAuthority = Keypair.generate(); + const mintKeypair = Keypair.generate(); + const mint = mintKeypair.publicKey; + + const extensions = [ExtensionType.TransferHook]; + const mintLen = getMintLen(extensions); + const decimals = 9; + const transferHookProgramId = new PublicKey('7N4HggYEJAtCLJdnHGCtFqfxcB5rhQCsQTze3ftYstVj') + + const connection = new Connection(clusterApiUrl('devnet'), 'confirmed'); + + const airdropSignature = await connection.requestAirdrop(payer.publicKey, 2 * LAMPORTS_PER_SOL); + await connection.confirmTransaction({ signature: airdropSignature, ...(await connection.getLatestBlockhash()) }); + + const mintLamports = await connection.getMinimumBalanceForRentExemption(mintLen); + const mintTransaction = new Transaction().add( + SystemProgram.createAccount({ + fromPubkey: payer.publicKey, + newAccountPubkey: mint, + space: mintLen, + lamports: mintLamports, + programId: TOKEN_2022_PROGRAM_ID, + }), + createInitializeTransferHookInstruction(mint, payer.publicKey, transferHookProgramId, TOKEN_2022_PROGRAM_ID), + createInitializeMintInstruction(mint, decimals, mintAuthority.publicKey, null, TOKEN_2022_PROGRAM_ID) + ); + await sendAndConfirmTransaction(connection, mintTransaction, [payer, mintKeypair], undefined); +})(); +``` @@ -1407,7 +1460,17 @@ Signature: 3Ffw6yjseDsL3Az5n2LjdwXXwVPYxDF3JUU1JC1KGAEb1LE68S9VN4ebtAyvKeYMHvhjd -Coming soon! +```js +await updateTransferHook( + connection, + payer, mint, + newTransferHookProgramId, + payer.publicKey, + [], + undefined, + TOKEN_2022_PROGRAM_ID +); +``` diff --git a/token/js/examples/transferHook.ts b/token/js/examples/transferHook.ts new file mode 100644 index 00000000000..a76265bde9b --- /dev/null +++ b/token/js/examples/transferHook.ts @@ -0,0 +1,63 @@ +import { + clusterApiUrl, + sendAndConfirmTransaction, + Connection, + Keypair, + PublicKey, + SystemProgram, + Transaction, + LAMPORTS_PER_SOL, +} from '@solana/web3.js'; + +import { + ExtensionType, + createInitializeMintInstruction, + createInitializeTransferHookInstruction, + getMintLen, + TOKEN_2022_PROGRAM_ID, + updateTransferHook, +} from '../src'; + +(async () => { + const payer = Keypair.generate(); + + const mintAuthority = Keypair.generate(); + const mintKeypair = Keypair.generate(); + const mint = mintKeypair.publicKey; + + const extensions = [ExtensionType.TransferHook]; + const mintLen = getMintLen(extensions); + const decimals = 9; + const transferHookPogramId = new PublicKey('7N4HggYEJAtCLJdnHGCtFqfxcB5rhQCsQTze3ftYstVj'); + const newTransferHookProgramId = new PublicKey('7N4HggYEJAtCLJdnHGCtFqfxcB5rhQCsQTze3ftYstVj'); + + const connection = new Connection(clusterApiUrl('devnet'), 'confirmed'); + + const airdropSignature = await connection.requestAirdrop(payer.publicKey, 2 * LAMPORTS_PER_SOL); + await connection.confirmTransaction({ signature: airdropSignature, ...(await connection.getLatestBlockhash()) }); + + const mintLamports = await connection.getMinimumBalanceForRentExemption(mintLen); + const mintTransaction = new Transaction().add( + SystemProgram.createAccount({ + fromPubkey: payer.publicKey, + newAccountPubkey: mint, + space: mintLen, + lamports: mintLamports, + programId: TOKEN_2022_PROGRAM_ID, + }), + createInitializeTransferHookInstruction(mint, payer.publicKey, transferHookPogramId, TOKEN_2022_PROGRAM_ID), + createInitializeMintInstruction(mint, decimals, mintAuthority.publicKey, null, TOKEN_2022_PROGRAM_ID) + ); + await sendAndConfirmTransaction(connection, mintTransaction, [payer, mintKeypair], undefined); + + await updateTransferHook( + connection, + payer, + mint, + newTransferHookProgramId, + payer.publicKey, + [], + undefined, + TOKEN_2022_PROGRAM_ID + ); +})(); diff --git a/token/js/src/extensions/extensionType.ts b/token/js/src/extensions/extensionType.ts index fabfb8f7499..75172abec48 100644 --- a/token/js/src/extensions/extensionType.ts +++ b/token/js/src/extensions/extensionType.ts @@ -12,6 +12,7 @@ import { MINT_CLOSE_AUTHORITY_SIZE } from './mintCloseAuthority.js'; import { NON_TRANSFERABLE_SIZE, NON_TRANSFERABLE_ACCOUNT_SIZE } from './nonTransferable.js'; import { PERMANENT_DELEGATE_SIZE } from './permanentDelegate.js'; import { TRANSFER_FEE_AMOUNT_SIZE, TRANSFER_FEE_CONFIG_SIZE } from './transferFee/index.js'; +import { TRANSFER_HOOK_ACCOUNT_SIZE, TRANSFER_HOOK_SIZE } from './transferHook/index.js'; export enum ExtensionType { Uninitialized, @@ -28,6 +29,8 @@ export enum ExtensionType { CpiGuard, PermanentDelegate, NonTransferableAccount, + TransferHook, + TransferHookAccount, } export const TYPE_SIZE = 2; @@ -65,6 +68,10 @@ export function getTypeLen(e: ExtensionType): number { return PERMANENT_DELEGATE_SIZE; case ExtensionType.NonTransferableAccount: return NON_TRANSFERABLE_ACCOUNT_SIZE; + case ExtensionType.TransferHook: + return TRANSFER_HOOK_SIZE; + case ExtensionType.TransferHookAccount: + return TRANSFER_HOOK_ACCOUNT_SIZE; default: throw Error(`Unknown extension type: ${e}`); } @@ -79,6 +86,7 @@ export function isMintExtension(e: ExtensionType): boolean { case ExtensionType.NonTransferable: case ExtensionType.InterestBearingConfig: case ExtensionType.PermanentDelegate: + case ExtensionType.TransferHook: return true; case ExtensionType.Uninitialized: case ExtensionType.TransferFeeAmount: @@ -87,6 +95,7 @@ export function isMintExtension(e: ExtensionType): boolean { case ExtensionType.MemoTransfer: case ExtensionType.CpiGuard: case ExtensionType.NonTransferableAccount: + case ExtensionType.TransferHookAccount: return false; default: throw Error(`Unknown extension type: ${e}`); @@ -101,6 +110,7 @@ export function isAccountExtension(e: ExtensionType): boolean { case ExtensionType.MemoTransfer: case ExtensionType.CpiGuard: case ExtensionType.NonTransferableAccount: + case ExtensionType.TransferHookAccount: return true; case ExtensionType.Uninitialized: case ExtensionType.TransferFeeConfig: @@ -110,6 +120,7 @@ export function isAccountExtension(e: ExtensionType): boolean { case ExtensionType.NonTransferable: case ExtensionType.InterestBearingConfig: case ExtensionType.PermanentDelegate: + case ExtensionType.TransferHook: return false; default: throw Error(`Unknown extension type: ${e}`); @@ -124,6 +135,8 @@ export function getAccountTypeOfMintType(e: ExtensionType): ExtensionType { return ExtensionType.ConfidentialTransferAccount; case ExtensionType.NonTransferable: return ExtensionType.NonTransferableAccount; + case ExtensionType.TransferHook: + return ExtensionType.TransferHookAccount; case ExtensionType.TransferFeeAmount: case ExtensionType.ConfidentialTransferAccount: case ExtensionType.CpiGuard: @@ -135,6 +148,7 @@ export function getAccountTypeOfMintType(e: ExtensionType): ExtensionType { case ExtensionType.InterestBearingConfig: case ExtensionType.PermanentDelegate: case ExtensionType.NonTransferableAccount: + case ExtensionType.TransferHookAccount: return ExtensionType.Uninitialized; } } diff --git a/token/js/src/extensions/index.ts b/token/js/src/extensions/index.ts index 05b799502f1..08aa516c5cc 100644 --- a/token/js/src/extensions/index.ts +++ b/token/js/src/extensions/index.ts @@ -9,3 +9,4 @@ export * from './mintCloseAuthority.js'; export * from './nonTransferable.js'; export * from './transferFee/index.js'; export * from './permanentDelegate.js'; +export * from './transferHook/index.js'; diff --git a/token/js/src/extensions/transferHook/actions.ts b/token/js/src/extensions/transferHook/actions.ts new file mode 100644 index 00000000000..6610c72416d --- /dev/null +++ b/token/js/src/extensions/transferHook/actions.ts @@ -0,0 +1,67 @@ +import type { ConfirmOptions, Connection, PublicKey, Signer, TransactionSignature } from '@solana/web3.js'; +import { sendAndConfirmTransaction, Transaction } from '@solana/web3.js'; +import { getSigners } from '../../actions/internal.js'; +import { TOKEN_2022_PROGRAM_ID } from '../../constants.js'; +import { createInitializeTransferHookInstruction, createUpdateTransferHookInstruction } from './instructions.js'; + +/** + * Initialize a transfer hook on a mint + * + * @param connection Connection to use + * @param payer Payer of the transaction fees + * @param mint Mint to initialize with extension + * @param authority Transfer hook authority account + * @param transferHookProgramId The transfer hook program account + * @param confirmOptions Options for confirming the transaction + * @param programId SPL Token program account + * + * @return Signature of the confirmed transaction + */ +export async function initializeTransferHook( + connection: Connection, + payer: Signer, + mint: PublicKey, + authority: PublicKey, + transferHookProgramId: PublicKey, + confirmOptions?: ConfirmOptions, + programId = TOKEN_2022_PROGRAM_ID +): Promise { + const transaction = new Transaction().add( + createInitializeTransferHookInstruction(mint, authority, transferHookProgramId, programId) + ); + + return await sendAndConfirmTransaction(connection, transaction, [payer], confirmOptions); +} + +/** + * Update the transfer hook program on a mint + * + * @param connection Connection to use + * @param payer Payer of the transaction fees + * @param mint Mint to modify + * @param transferHookProgramId New transfer hook program account + * @param authority Transfer hook update authority + * @param multiSigners Signing accounts if `freezeAuthority` is a multisig + * @param confirmOptions Options for confirming the transaction + * @param programId SPL Token program account + * + * @return Signature of the confirmed transaction + */ +export async function updateTransferHook( + connection: Connection, + payer: Signer, + mint: PublicKey, + transferHookProgramId: PublicKey, + authority: PublicKey, + multiSigners: Signer[] = [], + confirmOptions?: ConfirmOptions, + programId = TOKEN_2022_PROGRAM_ID +): Promise { + const [authorityPublicKey, signers] = getSigners(authority, multiSigners); + + const transaction = new Transaction().add( + createUpdateTransferHookInstruction(mint, authorityPublicKey, transferHookProgramId, signers, programId) + ); + + return await sendAndConfirmTransaction(connection, transaction, [payer, ...signers], confirmOptions); +} diff --git a/token/js/src/extensions/transferHook/index.ts b/token/js/src/extensions/transferHook/index.ts new file mode 100644 index 00000000000..5e28fd6b10a --- /dev/null +++ b/token/js/src/extensions/transferHook/index.ts @@ -0,0 +1,3 @@ +export * from './actions.js'; +export * from './instructions.js'; +export * from './state.js'; diff --git a/token/js/src/extensions/transferHook/instructions.ts b/token/js/src/extensions/transferHook/instructions.ts new file mode 100644 index 00000000000..56d71921288 --- /dev/null +++ b/token/js/src/extensions/transferHook/instructions.ts @@ -0,0 +1,114 @@ +import { struct, u8 } from '@solana/buffer-layout'; +import type { PublicKey, Signer } from '@solana/web3.js'; +import { TransactionInstruction } from '@solana/web3.js'; +import { programSupportsExtensions, TOKEN_2022_PROGRAM_ID } from '../../constants.js'; +import { TokenUnsupportedInstructionError } from '../../errors.js'; +import { addSigners } from '../../instructions/internal.js'; +import { TokenInstruction } from '../../instructions/types.js'; +import { publicKey } from '@solana/buffer-layout-utils'; + +export enum TransferHookInstruction { + Initialize = 0, + Update = 1, +} + +/** Deserialized instruction for the initiation of an transfer hook */ +export interface InitializeTransferHookInstructionData { + instruction: TokenInstruction.TransferHookExtension; + transferHookInstruction: TransferHookInstruction.Initialize; + authority: PublicKey; + transferHookProgramId: PublicKey; +} + +/** The struct that represents the instruction data as it is read by the program */ +export const initializeTransferHookInstructionData = struct([ + u8('instruction'), + u8('transferHookInstruction'), + publicKey('authority'), + publicKey('transferHookProgramId'), +]); + +/** + * Construct an InitializeTransferHook instruction + * + * @param mint Token mint account + * @param authority Transfer hook authority account + * @param transferHookProgramId Transfer hook program account + * @param programId SPL Token program account + * + * @return Instruction to add to a transaction + */ +export function createInitializeTransferHookInstruction( + mint: PublicKey, + authority: PublicKey, + transferHookProgramId: PublicKey, + programId: PublicKey +): TransactionInstruction { + if (!programSupportsExtensions(programId)) { + throw new TokenUnsupportedInstructionError(); + } + const keys = [{ pubkey: mint, isSigner: false, isWritable: true }]; + + const data = Buffer.alloc(initializeTransferHookInstructionData.span); + initializeTransferHookInstructionData.encode( + { + instruction: TokenInstruction.TransferHookExtension, + transferHookInstruction: TransferHookInstruction.Initialize, + authority, + transferHookProgramId, + }, + data + ); + + return new TransactionInstruction({ keys, programId, data }); +} + +/** Deserialized instruction for the initiation of an transfer hook */ +export interface UpdateTransferHookInstructionData { + instruction: TokenInstruction.TransferHookExtension; + transferHookInstruction: TransferHookInstruction.Update; + transferHookProgramId: PublicKey; +} + +/** The struct that represents the instruction data as it is read by the program */ +export const updateTransferHookInstructionData = struct([ + u8('instruction'), + u8('transferHookInstruction'), + publicKey('transferHookProgramId'), +]); + +/** + * Construct an UpdateTransferHook instruction + * + * @param mint Mint to update + * @param authority The mint's transfer hook authority + * @param transferHookProgramId The new transfer hook program account + * @param signers The signer account(s) for a multisig + * @param tokenProgramId SPL Token program account + * + * @return Instruction to add to a transaction + */ +export function createUpdateTransferHookInstruction( + mint: PublicKey, + authority: PublicKey, + transferHookProgramId: PublicKey, + multiSigners: (Signer | PublicKey)[] = [], + programId = TOKEN_2022_PROGRAM_ID +): TransactionInstruction { + if (!programSupportsExtensions(programId)) { + throw new TokenUnsupportedInstructionError(); + } + + const keys = addSigners([{ pubkey: mint, isSigner: false, isWritable: true }], authority, multiSigners); + const data = Buffer.alloc(updateTransferHookInstructionData.span); + updateTransferHookInstructionData.encode( + { + instruction: TokenInstruction.TransferHookExtension, + transferHookInstruction: TransferHookInstruction.Update, + transferHookProgramId, + }, + data + ); + + return new TransactionInstruction({ keys, programId, data }); +} diff --git a/token/js/src/extensions/transferHook/state.ts b/token/js/src/extensions/transferHook/state.ts new file mode 100644 index 00000000000..4f7952c9e49 --- /dev/null +++ b/token/js/src/extensions/transferHook/state.ts @@ -0,0 +1,51 @@ +import { struct } from '@solana/buffer-layout'; +import type { Mint } from '../../state/mint.js'; +import { ExtensionType, getExtensionData } from '../extensionType.js'; +import type { PublicKey } from '@solana/web3.js'; +import { bool, publicKey } from '@solana/buffer-layout-utils'; +import type { Account } from '../../state/account.js'; + +/** TransferHook as stored by the program */ +export interface TransferHook { + /** The transfer hook update authrority */ + authority: PublicKey; + /** The transfer hook program account */ + programId: PublicKey; +} + +/** Buffer layout for de/serializing a transfer hook extension */ +export const TransferHookLayout = struct([publicKey('authority'), publicKey('programId')]); + +export const TRANSFER_HOOK_SIZE = TransferHookLayout.span; + +export function getTransferHook(mint: Mint): TransferHook | null { + const extensionData = getExtensionData(ExtensionType.TransferHook, mint.tlvData); + if (extensionData !== null) { + return TransferHookLayout.decode(extensionData); + } else { + return null; + } +} + +/** TransferHookAccount as stored by the program */ +export interface TransferHookAccount { + /** + * Whether or not this account is currently tranferring tokens + * True during the transfer hook cpi, otherwise false + */ + transferring: boolean; +} + +/** Buffer layout for de/serializing a transfer hook account extension */ +export const TransferHookAccountLayout = struct([bool('transferring')]); + +export const TRANSFER_HOOK_ACCOUNT_SIZE = TransferHookAccountLayout.span; + +export function getTransferHookAccount(account: Account): TransferHookAccount | null { + const extensionData = getExtensionData(ExtensionType.TransferHookAccount, account.tlvData); + if (extensionData !== null) { + return TransferHookAccountLayout.decode(extensionData); + } else { + return null; + } +} diff --git a/token/js/src/instructions/types.ts b/token/js/src/instructions/types.ts index eb62fe8fe69..4448cbf3469 100644 --- a/token/js/src/instructions/types.ts +++ b/token/js/src/instructions/types.ts @@ -36,4 +36,5 @@ export enum TokenInstruction { InterestBearingMintExtension = 33, CpiGuardExtension = 34, InitializePermanentDelegate = 35, + TransferHookExtension = 36, } diff --git a/token/js/test/e2e-2022/transferHook.test.ts b/token/js/test/e2e-2022/transferHook.test.ts new file mode 100644 index 00000000000..f88511d1910 --- /dev/null +++ b/token/js/test/e2e-2022/transferHook.test.ts @@ -0,0 +1,80 @@ +import chai, { expect } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +chai.use(chaiAsPromised); + +import type { Connection, PublicKey, Signer } from '@solana/web3.js'; +import { sendAndConfirmTransaction, Keypair, SystemProgram, Transaction } from '@solana/web3.js'; +import { + createInitializeMintInstruction, + getMint, + getMintLen, + ExtensionType, + createInitializeTransferHookInstruction, + getTransferHook, + updateTransferHook, +} from '../../src'; +import { TEST_PROGRAM_ID, newAccountWithLamports, getConnection } from '../common'; + +const TEST_TOKEN_DECIMALS = 2; +const EXTENSIONS = [ExtensionType.TransferHook]; +describe('transferHook', () => { + let connection: Connection; + let payer: Signer; + let mint: PublicKey; + let transferHookProgramId: PublicKey; + let newTransferHookProgramId: PublicKey; + before(async () => { + connection = await getConnection(); + payer = await newAccountWithLamports(connection, 1000000000); + transferHookProgramId = Keypair.generate().publicKey; + newTransferHookProgramId = Keypair.generate().publicKey; + }); + beforeEach(async () => { + const mintKeypair = Keypair.generate(); + mint = mintKeypair.publicKey; + const mintLen = getMintLen(EXTENSIONS); + const lamports = await connection.getMinimumBalanceForRentExemption(mintLen); + + const transaction = new Transaction().add( + SystemProgram.createAccount({ + fromPubkey: payer.publicKey, + newAccountPubkey: mint, + space: mintLen, + lamports, + programId: TEST_PROGRAM_ID, + }), + createInitializeTransferHookInstruction(mint, payer.publicKey, transferHookProgramId, TEST_PROGRAM_ID), + createInitializeMintInstruction(mint, TEST_TOKEN_DECIMALS, payer.publicKey, null, TEST_PROGRAM_ID) + ); + + await sendAndConfirmTransaction(connection, transaction, [payer, mintKeypair], undefined); + }); + it('is initialized', async () => { + const mintInfo = await getMint(connection, mint, undefined, TEST_PROGRAM_ID); + const transferHook = getTransferHook(mintInfo); + expect(transferHook).to.not.be.null; + if (transferHook !== null) { + expect(transferHook.authority.toString()).to.eql(payer.publicKey.toString()); + expect(transferHook.programId.toString()).to.eql(transferHookProgramId.toString()); + } + }); + it('can be updated', async () => { + await updateTransferHook( + connection, + payer, + mint, + newTransferHookProgramId, + payer.publicKey, + [], + undefined, + TEST_PROGRAM_ID + ); + const mintInfo = await getMint(connection, mint, undefined, TEST_PROGRAM_ID); + const transferHook = getTransferHook(mintInfo); + expect(transferHook).to.not.be.null; + if (transferHook !== null) { + expect(transferHook.authority.toString()).to.eql(payer.publicKey.toString()); + expect(transferHook.programId.toString()).to.eql(newTransferHookProgramId.toString()); + } + }); +}); diff --git a/token/js/test/unit/programId.test.ts b/token/js/test/unit/programId.test.ts index b192792a2ce..e9a435b85a9 100644 --- a/token/js/test/unit/programId.test.ts +++ b/token/js/test/unit/programId.test.ts @@ -16,6 +16,7 @@ import { TokenUnsupportedInstructionError, createInitializePermanentDelegateInstruction, createEnableCpiGuardInstruction, + createInitializeTransferHookInstruction, } from '../../src'; chai.use(chaiAsPromised); @@ -24,6 +25,7 @@ describe('unsupported extensions in spl-token', () => { const account = new PublicKey('7o36UsWR1JQLpZ9PE2gn9L4SQ69CNNiWAXd4Jt7rqz9Z'); const authority = new PublicKey('7o36UsWR1JQLpZ9PE2gn9L4SQ69CNNiWAXd4Jt7rqz9Z'); const payer = new PublicKey('7o36UsWR1JQLpZ9PE2gn9L4SQ69CNNiWAXd4Jt7rqz9Z'); + const transferHookProgramId = new PublicKey('7o36UsWR1JQLpZ9PE2gn9L4SQ69CNNiWAXd4Jt7rqz9Z'); it('initializeMintCloseAuthority', () => { expect(function () { createInitializeMintCloseAuthorityInstruction(mint, null, TOKEN_PROGRAM_ID); @@ -64,6 +66,14 @@ describe('unsupported extensions in spl-token', () => { createCreateNativeMintInstruction(payer, NATIVE_MINT_2022, TOKEN_2022_PROGRAM_ID); }).to.not.throw(TokenUnsupportedInstructionError); }); + it('transferHook', () => { + expect(function () { + createInitializeTransferHookInstruction(mint, authority, transferHookProgramId, TOKEN_PROGRAM_ID); + }).to.throw(TokenUnsupportedInstructionError); + expect(function () { + createInitializeTransferHookInstruction(mint, authority, transferHookProgramId, TOKEN_2022_PROGRAM_ID); + }).to.not.throw(TokenUnsupportedInstructionError); + }); it('nonTransferableMint', () => { expect(function () { createInitializeNonTransferableMintInstruction(mint, TOKEN_PROGRAM_ID);