From 9d49b04f3c12b2e82f71c0894627635184aabde7 Mon Sep 17 00:00:00 2001 From: Csongor Kiss Date: Tue, 9 Apr 2024 12:34:45 +0200 Subject: [PATCH] solana/sdk: support transfer hook --- solana/tests/example-native-token-transfer.ts | 108 +++++++++-- solana/ts/sdk/ntt.ts | 178 ++++++++++++++++-- 2 files changed, 253 insertions(+), 33 deletions(-) diff --git a/solana/tests/example-native-token-transfer.ts b/solana/tests/example-native-token-transfer.ts index 21613204b..95939cb8f 100644 --- a/solana/tests/example-native-token-transfer.ts +++ b/solana/tests/example-native-token-transfer.ts @@ -16,14 +16,17 @@ import { serializePayload, deserializePayload, } from "@wormhole-foundation/sdk-definitions"; -import { NttMessage, postVaa, NTT, nttMessageLayout } from "../ts/sdk"; +import { postVaa, NTT, nttMessageLayout } from "../ts/sdk"; +import { WormholeTransceiverMessage } from "../ts/sdk/nttLayout"; + import { - NativeTokenTransfer, - TransceiverMessage, - WormholeTransceiverMessage, - nativeTokenTransferLayout, - nttManagerMessageLayout, -} from "../ts/sdk/nttLayout"; + PublicKey, + SystemProgram, + Transaction, + sendAndConfirmTransaction, +} from "@solana/web3.js"; + +import { DummyTransferHook } from "../target/types/dummy_transfer_hook"; export const GUARDIAN_KEY = "cfb12303a19cde580bb4dd771639b0d26bc68353645571a8cff516ab2ee113a0"; @@ -48,25 +51,68 @@ describe("example-native-token-transfers", () => { const user = anchor.web3.Keypair.generate(); let tokenAccount: anchor.web3.PublicKey; - let mint: anchor.web3.PublicKey; + const mint = anchor.web3.Keypair.generate(); + + const dummyTransferHook = anchor.workspace + .DummyTransferHook as anchor.Program; + + const [extraAccountMetaListPDA] = PublicKey.findProgramAddressSync( + [Buffer.from("extra-account-metas"), mint.publicKey.toBuffer()], + dummyTransferHook.programId + ); + + it("Initialize mint", async () => { + const extensions = [spl.ExtensionType.TransferHook]; + const mintLen = spl.getMintLen(extensions); + const lamports = await connection.getMinimumBalanceForRentExemption( + mintLen + ); + + const transaction = new Transaction().add( + SystemProgram.createAccount({ + fromPubkey: payer.publicKey, + newAccountPubkey: mint.publicKey, + space: mintLen, + lamports, + programId: spl.TOKEN_2022_PROGRAM_ID, + }), + spl.createInitializeTransferHookInstruction( + mint.publicKey, + owner.publicKey, + dummyTransferHook.programId, + spl.TOKEN_2022_PROGRAM_ID + ), + spl.createInitializeMintInstruction( + mint.publicKey, + 9, + owner.publicKey, + null, + spl.TOKEN_2022_PROGRAM_ID + ) + ); - before(async () => { - // airdrop some tokens to payer - mint = await spl.createMint(connection, payer, owner.publicKey, null, 9); + await sendAndConfirmTransaction(connection, transaction, [payer, mint]); tokenAccount = await spl.createAssociatedTokenAccount( connection, payer, - mint, - user.publicKey + mint.publicKey, + user.publicKey, + undefined, + spl.TOKEN_2022_PROGRAM_ID, + spl.ASSOCIATED_TOKEN_PROGRAM_ID ); + await spl.mintTo( connection, payer, - mint, + mint.publicKey, tokenAccount, owner, - BigInt(10000000) + BigInt(10000000), + undefined, + undefined, + spl.TOKEN_2022_PROGRAM_ID ); }); @@ -75,22 +121,46 @@ describe("example-native-token-transfers", () => { expect(version).to.equal("1.0.0"); }); + it("Create ExtraAccountMetaList Account", async () => { + const initializeExtraAccountMetaListInstruction = + await dummyTransferHook.methods + .initializeExtraAccountMetaList() + .accountsStrict({ + payer: payer.publicKey, + mint: mint.publicKey, + extraAccountMetaList: extraAccountMetaListPDA, + tokenProgram: spl.TOKEN_2022_PROGRAM_ID, + associatedTokenProgram: spl.ASSOCIATED_TOKEN_PROGRAM_ID, + systemProgram: SystemProgram.programId, + }) + .instruction(); + + const transaction = new Transaction().add( + initializeExtraAccountMetaListInstruction + ); + + await sendAndConfirmTransaction(connection, transaction, [payer]); + }); + describe("Locking", () => { before(async () => { await spl.setAuthority( connection, payer, - mint, + mint.publicKey, owner, - 0, // mint - ntt.tokenAuthorityAddress() + spl.AuthorityType.MintTokens, + ntt.tokenAuthorityAddress(), + [], + undefined, + spl.TOKEN_2022_PROGRAM_ID ); await ntt.initialize({ payer, owner: payer, chain: "solana", - mint, + mint: mint.publicKey, outboundLimit: new BN(1000000), mode: "locking", }); diff --git a/solana/ts/sdk/ntt.ts b/solana/ts/sdk/ntt.ts index 81f38595c..5b0162e2e 100644 --- a/solana/ts/sdk/ntt.ts +++ b/solana/ts/sdk/ntt.ts @@ -20,6 +20,7 @@ import { sendAndConfirmTransaction, type TransactionSignature, type Connection, + SystemProgram, TransactionMessage, VersionedTransaction } from '@solana/web3.js' @@ -232,7 +233,7 @@ export class NTT { const tokenProgram = mintInfo.owner const ix = await this.program.methods .initialize({ chainId, limit: args.outboundLimit, mode }) - .accounts({ + .accountsStrict({ payer: args.payer.publicKey, deployer: args.owner.publicKey, programData: programDataAddress(this.program.programId), @@ -241,8 +242,10 @@ export class NTT { rateLimit: this.outboxRateLimitAccountAddress(), tokenProgram, tokenAuthority: this.tokenAuthorityAddress(), - custody: await this.custodyAccountAddress(args.mint), + custody: await this.custodyAccountAddress(args.mint, tokenProgram), bpfLoaderUpgradeableProgram: BPF_LOADER_UPGRADEABLE_PROGRAM_ID, + associatedTokenProgram: splToken.ASSOCIATED_TOKEN_PROGRAM_ID, + systemProgram: SystemProgram.programId, }).instruction(); return sendAndConfirmTransaction(this.program.provider.connection, new Transaction().add(ix), [args.payer, args.owner]); } @@ -298,7 +301,9 @@ export class NTT { args.from, this.sessionAuthorityAddress(args.fromAuthority.publicKey, transferArgs), args.fromAuthority.publicKey, - BigInt(args.amount.toString()) + BigInt(args.amount.toString()), + [], + config.tokenProgram ); const tx = new Transaction() tx.add(approveIx, transferIx, releaseIx) @@ -398,7 +403,7 @@ export class NTT { shouldQueue: args.shouldQueue } - return await this.program.methods + const transferIx = await this.program.methods .transferLock(transferArgs) .accounts({ common: { @@ -416,6 +421,39 @@ export class NTT { sessionAuthority: this.sessionAuthorityAddress(args.fromAuthority, transferArgs) }) .instruction() + + const mintInfo = await splToken.getMint( + this.program.provider.connection, + config.mint, + undefined, + config.tokenProgram + ) + const transferHook = splToken.getTransferHook(mintInfo) + + if (transferHook) { + const source = args.from + const mint = config.mint + const destination = await this.custodyAccountAddress(config) + const owner = this.sessionAuthorityAddress(args.fromAuthority, transferArgs) + await addExtraAccountMetasForExecute( + this.program.provider.connection, + transferIx, + transferHook.programId, + source, + mint, + destination, + owner, + // TODO(csongor): compute the amount that's passed into transfer. + // Leaving this 0 is fine unless the transfer hook accounts addresses + // depend on the amount (which is unlikely). + // If this turns out to be the case, the amount to put here is the + // untrimmed amount after removing dust. + 0, + ); + } + + return transferIx + } /** @@ -496,14 +534,15 @@ export class NTT { .releaseInboundMint({ revertOnDelay: args.revertOnDelay }) - .accounts({ + .accountsStrict({ common: { payer: args.payer, config: { config: this.configAccountAddress() }, inboxItem: this.inboxItemAccountAddress(args.chain, args.nttMessage), - recipient: getAssociatedTokenAddressSync(mint, recipientAddress), + recipient: getAssociatedTokenAddressSync(mint, recipientAddress, true, config.tokenProgram), mint, - tokenAuthority: this.tokenAuthorityAddress() + tokenAuthority: this.tokenAuthorityAddress(), + tokenProgram: config.tokenProgram } }) .instruction() @@ -551,22 +590,50 @@ export class NTT { const mint = await this.mintAccountAddress(config) - return await this.program.methods + const transferIx = await this.program.methods .releaseInboundUnlock({ revertOnDelay: args.revertOnDelay }) - .accounts({ + .accountsStrict({ common: { payer: args.payer, config: { config: this.configAccountAddress() }, inboxItem: this.inboxItemAccountAddress(args.chain, args.nttMessage), - recipient: getAssociatedTokenAddressSync(mint, recipientAddress), + recipient: getAssociatedTokenAddressSync(mint, recipientAddress, true, config.tokenProgram), mint, - tokenAuthority: this.tokenAuthorityAddress() + tokenAuthority: this.tokenAuthorityAddress(), + tokenProgram: config.tokenProgram }, custody: await this.custodyAccountAddress(config) }) .instruction() + + const mintInfo = await splToken.getMint(this.program.provider.connection, config.mint, undefined, config.tokenProgram) + const transferHook = splToken.getTransferHook(mintInfo) + + if (transferHook) { + const source = await this.custodyAccountAddress(config) + const mint = config.mint + const destination = getAssociatedTokenAddressSync(mint, recipientAddress, true, config.tokenProgram) + const owner = this.tokenAuthorityAddress() + await addExtraAccountMetasForExecute( + this.program.provider.connection, + transferIx, + transferHook.programId, + source, + mint, + destination, + owner, + // TODO(csongor): compute the amount that's passed into transfer. + // Leaving this 0 is fine unless the transfer hook accounts addresses + // depend on the amount (which is unlikely). + // If this turns out to be the case, the amount to put here is the + // untrimmed amount after removing dust. + 0, + ); + } + + return transferIx } async releaseInboundUnlock(args: { @@ -891,11 +958,11 @@ export class NTT { * (i.e. the program is initialised), the mint is derived from the config. * Otherwise, the mint must be provided. */ - async custodyAccountAddress(configOrMint: Config | PublicKey): Promise { + async custodyAccountAddress(configOrMint: Config | PublicKey, tokenProgram = splToken.TOKEN_PROGRAM_ID): Promise { if (configOrMint instanceof PublicKey) { - return associatedAddress({ mint: configOrMint, owner: this.tokenAuthorityAddress() }) + return splToken.getAssociatedTokenAddress(configOrMint, this.tokenAuthorityAddress(), true, tokenProgram) } else { - return associatedAddress({ mint: await this.mintAccountAddress(configOrMint), owner: this.tokenAuthorityAddress() }) + return splToken.getAssociatedTokenAddress(configOrMint.mint, this.tokenAuthorityAddress(), true, configOrMint.tokenProgram) } } } @@ -903,3 +970,86 @@ export class NTT { function exhaustive(_: never): A { throw new Error('Impossible') } + +/** + * TODO: this is copied from @solana/spl-token, because the most recent released + * version (0.4.3) is broken (does object equality instead of structural on the pubkey) + * + * this version fixes that error, looks like it's also fixed on main: + * https://github.com/solana-labs/solana-program-library/blob/ad4eb6914c5e4288ad845f29f0003cd3b16243e7/token/js/src/extensions/transferHook/instructions.ts#L208 + */ +async function addExtraAccountMetasForExecute( + connection: Connection, + instruction: TransactionInstruction, + programId: PublicKey, + source: PublicKey, + mint: PublicKey, + destination: PublicKey, + owner: PublicKey, + amount: number | bigint, + commitment?: Commitment +) { + const validateStatePubkey = splToken.getExtraAccountMetaAddress(mint, programId); + const validateStateAccount = await connection.getAccountInfo(validateStatePubkey, commitment); + if (validateStateAccount == null) { + return instruction; + } + const validateStateData = splToken.getExtraAccountMetas(validateStateAccount); + + // Check to make sure the provided keys are in the instruction + if (![source, mint, destination, owner].every((key) => instruction.keys.some((meta) => meta.pubkey.equals(key)))) { + throw new Error('Missing required account in instruction'); + } + + const executeInstruction = splToken.createExecuteInstruction( + programId, + source, + mint, + destination, + owner, + validateStatePubkey, + BigInt(amount) + ); + + for (const extraAccountMeta of validateStateData) { + executeInstruction.keys.push( + deEscalateAccountMeta( + await splToken.resolveExtraAccountMeta( + connection, + extraAccountMeta, + executeInstruction.keys, + executeInstruction.data, + executeInstruction.programId + ), + executeInstruction.keys + ) + ); + } + + // Add only the extra accounts resolved from the validation state + instruction.keys.push(...executeInstruction.keys.slice(5)); + + // Add the transfer hook program ID and the validation state account + instruction.keys.push({ pubkey: programId, isSigner: false, isWritable: false }); + instruction.keys.push({ pubkey: validateStatePubkey, isSigner: false, isWritable: false }); +} + +// TODO: delete (see above) +function deEscalateAccountMeta(accountMeta: AccountMeta, accountMetas: AccountMeta[]): AccountMeta { + const maybeHighestPrivileges = accountMetas + .filter((x) => x.pubkey.equals(accountMeta.pubkey)) + .reduce<{ isSigner: boolean; isWritable: boolean } | undefined>((acc, x) => { + if (!acc) return { isSigner: x.isSigner, isWritable: x.isWritable }; + return { isSigner: acc.isSigner || x.isSigner, isWritable: acc.isWritable || x.isWritable }; + }, undefined); + if (maybeHighestPrivileges) { + const { isSigner, isWritable } = maybeHighestPrivileges; + if (!isSigner && isSigner !== accountMeta.isSigner) { + accountMeta.isSigner = false; + } + if (!isWritable && isWritable !== accountMeta.isWritable) { + accountMeta.isWritable = false; + } + } + return accountMeta; +}