From 11863e63ba5f765786a9199e9a679f76b83a30fc Mon Sep 17 00:00:00 2001 From: leantOnSol Date: Wed, 19 Feb 2025 04:16:52 +0100 Subject: [PATCH 1/2] t22 helpers (getTransferHook + getTransferHookExtraAccounts) --- common-helpers/package.json | 3 +- common-helpers/src/constants.ts | 3 + common-helpers/src/index.ts | 9 +- common-helpers/src/t22/index.ts | 1 + common-helpers/src/t22/transferHook.ts | 369 +++++++++++++++++++++++++ pnpm-lock.yaml | 12 + 6 files changed, 392 insertions(+), 5 deletions(-) create mode 100644 common-helpers/src/constants.ts create mode 100644 common-helpers/src/t22/index.ts create mode 100644 common-helpers/src/t22/transferHook.ts diff --git a/common-helpers/package.json b/common-helpers/package.json index 0adae5e..cdcad32 100644 --- a/common-helpers/package.json +++ b/common-helpers/package.json @@ -1,6 +1,6 @@ { "name": "@tensor-foundation/common-helpers", - "version": "1.0.0-beta.1", + "version": "1.0.0-beta.2", "description": "Common helper functions for Tensor SDKs", "sideEffects": false, "module": "./dist/src/index.mjs", @@ -38,6 +38,7 @@ "license": "Apache-2.0", "dependencies": { "@solana/web3.js": "2.0.0", + "@solana-program/token-2022": "0.3.4", "@types/bn.js": "^5.1.5", "bn.js": "^5.2.1", "js-sha3": "^0.9.3" diff --git a/common-helpers/src/constants.ts b/common-helpers/src/constants.ts new file mode 100644 index 0000000..61793bb --- /dev/null +++ b/common-helpers/src/constants.ts @@ -0,0 +1,3 @@ +import { address } from "@solana/web3.js"; + +export const DEFAULT_ADDRESS = address("11111111111111111111111111111111"); diff --git a/common-helpers/src/index.ts b/common-helpers/src/index.ts index 92cd775..7bdefef 100644 --- a/common-helpers/src/index.ts +++ b/common-helpers/src/index.ts @@ -1,4 +1,5 @@ -export * from './compression'; -export * from './DAS'; -export * from './metadata'; -export * from './transactions'; +export * from "./compression"; +export * from "./DAS"; +export * from "./metadata"; +export * from "./t22"; +export * from "./transactions"; diff --git a/common-helpers/src/t22/index.ts b/common-helpers/src/t22/index.ts new file mode 100644 index 0000000..c1d90f9 --- /dev/null +++ b/common-helpers/src/t22/index.ts @@ -0,0 +1 @@ +export * from "./transferHook"; diff --git a/common-helpers/src/t22/transferHook.ts b/common-helpers/src/t22/transferHook.ts new file mode 100644 index 0000000..82007b8 --- /dev/null +++ b/common-helpers/src/t22/transferHook.ts @@ -0,0 +1,369 @@ +import { + AccountRole, + Address, + Decoder, + GetAccountInfoApi, + IAccountMeta, + IInstruction, + ProgramDerivedAddress, + ReadonlyUint8Array, + Rpc, + address, + getAddressDecoder, + getAddressEncoder, + getArrayDecoder, + getBooleanDecoder, + getProgramDerivedAddress, + getStructDecoder, + getU32Decoder, + getU64Decoder, + getU8Decoder, + getUtf8Encoder, + isSome, +} from "@solana/web3.js"; +import { getMintDecoder, Mint } from "@solana-program/token-2022"; +import { DEFAULT_ADDRESS } from "../constants"; + +export type TransferHook = { + program: Address; + remainingAccounts?: IAccountMeta[] | null; +} | null; + +export async function getTransferHook({ + mint, + rpc, +}: { + mint: Address; + rpc: Rpc; +}): Promise { + const mintAccData = await rpc.getAccountInfo(mint, { encoding: "base64" }).send(); + if (!mintAccData.value?.data) return null; + const mintAcc = getMintDecoder().decode(Buffer.from(mintAccData.value?.data[0], "base64")); + // transfer hook + const transferHook = isSome(mintAcc.extensions) + ? mintAcc.extensions.value.find((e) => e.__kind === "TransferHook") + : undefined; + if (!transferHook) return null; + // remaining accounts from metadata + const metadataAccountAddress = isSome(mintAcc.extensions) + ? mintAcc.extensions.value.find((e) => e.__kind === "MetadataPointer")?.metadataAddress + : undefined; + let metadataAccount: Mint | undefined; + if (metadataAccountAddress && isSome(metadataAccountAddress)) { + if (metadataAccountAddress.value === mint) { + metadataAccount = mintAcc; + } else { + const metadataAccountData = await rpc + .getAccountInfo(metadataAccountAddress.value, { encoding: "base64" }) + .send(); + if (!metadataAccountData.value?.data) return null; + metadataAccount = getMintDecoder().decode( + Buffer.from(metadataAccountData.value?.data[0], "base64") + ); + } + } + const additionalMetadata = + metadataAccount && isSome(metadataAccount.extensions) + ? metadataAccount.extensions.value.find((e) => e.__kind === "TokenMetadata") + ?.additionalMetadata + : undefined; + const remainingAccounts = additionalMetadata + ? Object.keys(additionalMetadata).reduce((acc, cur) => { + if (!cur.startsWith("_ro_")) return acc; + const pk = cur.replace("_ro_", ""); + return [ + ...acc, + { + address: address(pk), + role: AccountRole.WRITABLE, + }, + ]; + }, []) + : null; + return { + program: transferHook?.programId, + remainingAccounts, + }; +} + +export async function getTransferHookExtraAccounts({ + mint, + rpc, + transferHookProgramId, + instruction, +}: { + mint: Address; + rpc: Rpc; + transferHookProgramId: Address; + instruction: IInstruction; +}): Promise { + const [extraAccountMetaAddress] = await getExtraAccountMetaAddress(mint, transferHookProgramId); + + const extraMetas: IAccountMeta[] = [ + { + address: transferHookProgramId, + role: AccountRole.READONLY, + }, + ]; + + const extraAccountMetaData = await rpc + .getAccountInfo(extraAccountMetaAddress, { encoding: "base64" }) + .send(); + if (!extraAccountMetaData.value?.data) return extraMetas; + + const extraAccountMetas = getExtraAccountMetas( + Buffer.from(extraAccountMetaData.value?.data[0], "base64") + ); + for (const extraAccountMeta of extraAccountMetas) { + extraMetas.push( + await resolveExtraAccountMeta( + rpc, + extraAccountMeta, + instruction.accounts?.map((a) => ({ address: a.address, role: a.role })) || [], + instruction.data, + instruction.programAddress + ) + ); + } + extraMetas.push({ + address: extraAccountMetaAddress, + role: AccountRole.READONLY, + }); + + return extraMetas; +} + +async function getExtraAccountMetaAddress( + mint: Address, + programId: Address +): Promise { + return await getProgramDerivedAddress({ + programAddress: programId, + seeds: [getUtf8Encoder().encode("extra-account-metas"), getAddressEncoder().encode(mint)], + }); +} + +// translated from https://github.com/solana-labs/solana-program-library/blob/0ab6ed7869679c0f5e2a72068e7a4e0591076d1f/token/js/src/extensions/transferHook/state.ts +export async function resolveExtraAccountMeta( + rpc: Rpc, + extraAccountMeta: ExtraAccountMeta, + previousMetas: IAccountMeta[], + data: IInstruction["data"] = new Uint8Array(), + transferHookProgramId: IInstruction["programAddress"] +): Promise { + if (extraAccountMeta.discriminator === 0) { + return { + address: extraAccountMeta.addressConfig, + role: (Number(extraAccountMeta.isSigner) << 1) | Number(extraAccountMeta.isWritable), + }; + } + + let programId: Address = DEFAULT_ADDRESS; + + if (extraAccountMeta.discriminator === 1) { + programId = transferHookProgramId; + } else { + const accountIndex = extraAccountMeta.discriminator - (1 << 7); + if (previousMetas.length <= Number(accountIndex)) { + throw new Error("TokenTransferHookAccountNotFound"); + } + programId = previousMetas[Number(accountIndex)].address; + } + + const seeds = await unpackSeeds( + new Uint8Array(getAddressEncoder().encode(extraAccountMeta.addressConfig)), + previousMetas, + Buffer.from(data), + rpc + ); + const pubkey = await getProgramDerivedAddress({ + programAddress: programId, + seeds: seeds, + }); + + return { + address: pubkey[0], + role: (Number(extraAccountMeta.isSigner) << 1) | Number(extraAccountMeta.isWritable), + }; +} + +export function getExtraAccountMetas(accountData: ReadonlyUint8Array): ExtraAccountMeta[] { + const extraAccountMetaAccountData = getExtraAccountMetaAccountDataDecoder().decode( + new Uint8Array(accountData) + ); + const extraAccountsList = extraAccountMetaAccountData.extraAccountsList; + return extraAccountsList.accounts.slice(0, extraAccountsList.count); +} + +export type ExtraAccountMetaAccountData = { + instructionDiscriminator: bigint; //u64 + length: number; //u32 + extraAccountsList: ExtraAccountMetaList; +}; + +export type ExtraAccountMetaList = { + count: number; //u32 + accounts: ExtraAccountMeta[]; +}; + +function getExtraAccountMetaDecoder(): Decoder { + return getStructDecoder([ + ["discriminator", getU8Decoder()], + ["addressConfig", getAddressDecoder()], + ["isSigner", getBooleanDecoder()], + ["isWritable", getBooleanDecoder()], + ]); +} + +function getExtraAccountMetaListDecoder(): Decoder { + return getStructDecoder([ + ["count", getU32Decoder()], + ["accounts", getArrayDecoder(getExtraAccountMetaDecoder(), { size: "remainder" })], + ]); +} + +export interface ExtraAccountMeta { + discriminator: number; + addressConfig: Address; + isSigner: boolean; + isWritable: boolean; +} + +function getExtraAccountMetaAccountDataDecoder(): Decoder { + return getStructDecoder([ + ["instructionDiscriminator", getU64Decoder()], + ["length", getU32Decoder()], + ["extraAccountsList", getExtraAccountMetaListDecoder()], + ]); +} + +// translated (mostly yoinked) from https://github.com/solana-labs/solana-program-library/blob/0ab6ed7869679c0f5e2a72068e7a4e0591076d1f/token/js/src/extensions/transferHook/seeds.ts +interface Seed { + data: Buffer; + packedLength: number; +} + +const DISCRIMINATOR_SPAN = 1; +const LITERAL_LENGTH_SPAN = 1; +const INSTRUCTION_ARG_OFFSET_SPAN = 1; +const INSTRUCTION_ARG_LENGTH_SPAN = 1; +const ACCOUNT_KEY_INDEX_SPAN = 1; +const ACCOUNT_DATA_ACCOUNT_INDEX_SPAN = 1; +const ACCOUNT_DATA_OFFSET_SPAN = 1; +const ACCOUNT_DATA_LENGTH_SPAN = 1; + +function unpackSeedLiteral(seeds: Uint8Array): Seed { + if (seeds.length < 1) { + throw new Error("TokenTransferHookInvalidSeed"); + } + const [length, ...rest] = seeds; + if (rest.length < length) { + throw new Error("TokenTransferHookInvalidSeed"); + } + return { + data: Buffer.from(rest.slice(0, length)), + packedLength: DISCRIMINATOR_SPAN + LITERAL_LENGTH_SPAN + length, + }; +} + +function unpackSeedInstructionArg(seeds: Uint8Array, instructionData: Buffer): Seed { + if (seeds.length < 2) { + throw new Error("TokenTransferHookInvalidSeed"); + } + const [index, length] = seeds; + if (instructionData.length < length + index) { + throw new Error("TokenTransferHookInvalidSeed"); + } + return { + data: instructionData.subarray(index, index + length), + packedLength: DISCRIMINATOR_SPAN + INSTRUCTION_ARG_OFFSET_SPAN + INSTRUCTION_ARG_LENGTH_SPAN, + }; +} + +function unpackSeedAccountKey(seeds: Uint8Array, previousMetas: IAccountMeta[]): Seed { + if (seeds.length < 1) { + throw new Error("TokenTransferHookInvalidSeed"); + } + const [index] = seeds; + if (previousMetas.length <= index) { + throw new Error("TokenTransferHookInvalidSeed"); + } + return { + data: Buffer.from(getAddressEncoder().encode(previousMetas[index].address)), + packedLength: DISCRIMINATOR_SPAN + ACCOUNT_KEY_INDEX_SPAN, + }; +} + +async function unpackSeedAccountData( + seeds: Uint8Array, + previousMetas: IAccountMeta[], + rpc: Rpc +): Promise { + if (seeds.length < 3) { + throw new Error("TokenTransferHookInvalidSeed"); + } + const [accountIndex, dataIndex, length] = seeds; + if (previousMetas.length <= accountIndex) { + throw new Error("TokenTransferHookInvalidSeed"); + } + const accountInfo = await rpc + .getAccountInfo(previousMetas[accountIndex].address, { encoding: "base64" }) + .send(); + if (accountInfo == null || !accountInfo.value?.data) { + throw new Error("TokenTransferHookAccountDataNotFound"); + } + if (accountInfo.value?.data?.length < dataIndex + length) { + throw new Error("TokenTransferHookInvalidSeed"); + } + return { + data: Buffer.from(accountInfo.value?.data[0]).subarray(dataIndex, dataIndex + length), + packedLength: + DISCRIMINATOR_SPAN + + ACCOUNT_DATA_ACCOUNT_INDEX_SPAN + + ACCOUNT_DATA_OFFSET_SPAN + + ACCOUNT_DATA_LENGTH_SPAN, + }; +} + +async function unpackFirstSeed( + seeds: Uint8Array, + previousMetas: IAccountMeta[], + instructionData: Buffer, + rpc: Rpc +): Promise { + const [discriminator, ...rest] = seeds; + const remaining = new Uint8Array(rest); + switch (discriminator) { + case 0: + return null; + case 1: + return unpackSeedLiteral(remaining); + case 2: + return unpackSeedInstructionArg(remaining, instructionData); + case 3: + return unpackSeedAccountKey(remaining, previousMetas); + case 4: + return unpackSeedAccountData(remaining, previousMetas, rpc); + default: + throw new Error("TokenTransferHookInvalidSeed"); + } +} + +async function unpackSeeds( + seeds: Uint8Array, + previousMetas: IAccountMeta[], + instructionData: Buffer, + rpc: Rpc +): Promise { + const unpackedSeeds: Buffer[] = []; + let i = 0; + while (i < 32) { + const seed = await unpackFirstSeed(seeds.slice(i), previousMetas, instructionData, rpc); + if (seed == null) { + break; + } + unpackedSeeds.push(seed.data); + i += seed.packedLength; + } + return unpackedSeeds; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de658eb..458c48d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,6 +10,9 @@ importers: common-helpers: dependencies: + '@solana-program/token-2022': + specifier: 0.3.4 + version: 0.3.4(@solana/web3.js@2.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.6.3)(ws@8.18.0(bufferutil@4.0.8)(utf-8-validate@5.0.10))) '@solana/web3.js': specifier: 2.0.0 version: 2.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.6.3)(ws@8.18.0(bufferutil@4.0.8)(utf-8-validate@5.0.10)) @@ -1300,6 +1303,11 @@ packages: peerDependencies: '@solana/web3.js': 2.0.0-rc.4 + '@solana-program/token-2022@0.3.4': + resolution: {integrity: sha512-URHA91F9sDibbL6RbuhnKHWGeAONCDcCmHq8tMtpVOhse9/WKp0JOvdLSiGuRkKZqLHo74xF8otmgPVchgVZXQ==} + peerDependencies: + '@solana/web3.js': ^2.0.0 + '@solana-program/token@0.4.0': resolution: {integrity: sha512-RNj2ge5bQzXKozI2u2HIbw6zvDsn/S1yHwoPC+SHSESOSzP7FDxlnaOMoAgyw++vXoQjXLsbNcyuKVsqiDgXUw==} peerDependencies: @@ -5418,6 +5426,10 @@ snapshots: dependencies: '@solana/web3.js': 2.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.6.3)(ws@8.18.0(bufferutil@4.0.8)(utf-8-validate@5.0.10)) + '@solana-program/token-2022@0.3.4(@solana/web3.js@2.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.6.3)(ws@8.18.0(bufferutil@4.0.8)(utf-8-validate@5.0.10)))': + dependencies: + '@solana/web3.js': 2.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.6.3)(ws@8.18.0(bufferutil@4.0.8)(utf-8-validate@5.0.10)) + '@solana-program/token@0.4.0(@solana/web3.js@2.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.6.3)(ws@8.18.0(bufferutil@4.0.8)(utf-8-validate@5.0.10)))': dependencies: '@solana/web3.js': 2.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.6.3)(ws@8.18.0(bufferutil@4.0.8)(utf-8-validate@5.0.10)) From d7ad5e6cd0bb3e35244e198d87af516fb89a71ea Mon Sep 17 00:00:00 2001 From: leantOnSol Date: Wed, 19 Feb 2025 04:18:57 +0100 Subject: [PATCH 2/2] bump v --- common-helpers/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common-helpers/package.json b/common-helpers/package.json index cdcad32..edf828d 100644 --- a/common-helpers/package.json +++ b/common-helpers/package.json @@ -1,6 +1,6 @@ { "name": "@tensor-foundation/common-helpers", - "version": "1.0.0-beta.2", + "version": "1.0.0-beta.3", "description": "Common helper functions for Tensor SDKs", "sideEffects": false, "module": "./dist/src/index.mjs",