From f5d2235fecd7ab1eb97c2f6e3b316599d2005811 Mon Sep 17 00:00:00 2001 From: Abhishek Agrawal Date: Fri, 17 Jul 2026 12:50:51 +0530 Subject: [PATCH] feat(sdk-coin-sol): read Token-2022 mint extensions from chain TICKET: CSHLD-1194 --- modules/sdk-coin-sol/src/lib/index.ts | 8 + .../sdk-coin-sol/src/lib/tokenExtensions.ts | 149 ++++++++++++++++++ .../sdk-coin-sol/test/unit/tokenExtensions.ts | 34 ++++ modules/statics/src/account.ts | 34 +++- modules/statics/src/index.ts | 2 +- modules/statics/test/unit/coins.ts | 6 +- 6 files changed, 224 insertions(+), 9 deletions(-) create mode 100644 modules/sdk-coin-sol/src/lib/tokenExtensions.ts create mode 100644 modules/sdk-coin-sol/test/unit/tokenExtensions.ts diff --git a/modules/sdk-coin-sol/src/lib/index.ts b/modules/sdk-coin-sol/src/lib/index.ts index 1ad02ffe29..0ca14ae42d 100644 --- a/modules/sdk-coin-sol/src/lib/index.ts +++ b/modules/sdk-coin-sol/src/lib/index.ts @@ -21,3 +21,11 @@ export { WalletInitializationBuilder } from './walletInitializationBuilder'; export { Interface, Utils }; export { MessageBuilderFactory } from './messages'; export { explainSolTransaction, ExplainTransactionWasmOptions } from './explainTransactionWasm'; +export { + MintExtensionReadResult, + assertExtensionCompatibility, + extensionTypeNames, + mapModeledExtensions, + parseMintExtensions, + readMintExtensions, +} from './tokenExtensions'; diff --git a/modules/sdk-coin-sol/src/lib/tokenExtensions.ts b/modules/sdk-coin-sol/src/lib/tokenExtensions.ts new file mode 100644 index 0000000000..eb1e1f1955 --- /dev/null +++ b/modules/sdk-coin-sol/src/lib/tokenExtensions.ts @@ -0,0 +1,149 @@ +import { SolTokenExtensions, SolTokenExtensionType } from '@bitgo/statics'; +import { + AccountState, + ExtensionType, + getExtensionData, + getExtensionTypes, + getInterestBearingMintConfigState, + getMint, + getPermanentDelegate, + getTransferFeeConfig, + getTransferHook, + type Mint, + TOKEN_2022_PROGRAM_ID, +} from '@solana/spl-token'; +import { Commitment, Connection, PublicKey } from '@solana/web3.js'; + +/** + * Result of reading a Token-2022 mint's extension data. + * + * `detectedTypeNames` lists EVERY extension present on the mint — including ones BitGo does + * not model — so callers can run the onboarding safety gate (`getUnsupportedSolTokenExtensions`). + * `extensions` is the typed, storable config for the extensions BitGo does model. + */ +export interface MintExtensionReadResult { + detectedTypeNames: string[]; + extensions: SolTokenExtensions; +} + +/** Map from `@solana/spl-token` ExtensionType names to BitGo's modeled extension types. */ +const EXTENSION_NAME_MAP: Readonly> = { + TransferFeeConfig: SolTokenExtensionType.TransferFee, + TransferHook: SolTokenExtensionType.TransferHook, + PermanentDelegate: SolTokenExtensionType.PermanentDelegate, + InterestBearingConfig: SolTokenExtensionType.InterestBearing, + DefaultAccountState: SolTokenExtensionType.DefaultAccountState, + ScaledUiAmountConfig: SolTokenExtensionType.ScaledUiAmount, +}; + +const CONFIDENTIAL_TRANSFER_NAMES = ['ConfidentialTransferMint', 'ConfidentialTransferFeeConfig']; + +/** Human-readable names of every extension type present on the mint. */ +export function extensionTypeNames(mintInfo: Mint): string[] { + if (mintInfo.tlvData.length === 0) { + return []; + } + return getExtensionTypes(mintInfo.tlvData).map((t) => ExtensionType[t] ?? `unknown(${t})`); +} + +/** The subset of detected extensions that BitGo models. Pure — unit-testable without chain data. */ +export function mapModeledExtensions(detectedTypeNames: readonly string[]): SolTokenExtensionType[] { + const modeled: SolTokenExtensionType[] = []; + for (const name of detectedTypeNames) { + const mapped = EXTENSION_NAME_MAP[name]; + if (mapped !== undefined && !modeled.includes(mapped)) { + modeled.push(mapped); + } + } + return modeled; +} + +/** + * Enforce the protocol-level incompatibility: Transfer Hook and Confidential + * Transfer cannot coexist on the same mint. Pure — unit-testable without chain data. + */ +export function assertExtensionCompatibility(detectedTypeNames: readonly string[]): void { + const hasHook = detectedTypeNames.includes('TransferHook'); + const hasConfidential = detectedTypeNames.some((n) => CONFIDENTIAL_TRANSFER_NAMES.includes(n)); + if (hasHook && hasConfidential) { + throw new Error('Mint declares both Transfer Hook and Confidential Transfer, which cannot coexist'); + } +} + +function toBase58(key: PublicKey | null): string | undefined { + return key ? key.toBase58() : undefined; +} + +/** + * Parse a decoded Token-2022 `Mint` into BitGo's extension model. Separated from the RPC call + * so it can be unit-tested against recorded mint fixtures. + */ +export function parseMintExtensions(mintInfo: Mint): MintExtensionReadResult { + const detectedTypeNames = extensionTypeNames(mintInfo); + assertExtensionCompatibility(detectedTypeNames); + + const extensions: SolTokenExtensions = { detected: mapModeledExtensions(detectedTypeNames) }; + const authorities: NonNullable = { + mintAuthority: toBase58(mintInfo.mintAuthority), + freezeAuthority: toBase58(mintInfo.freezeAuthority), + }; + + const feeConfig = getTransferFeeConfig(mintInfo); + if (feeConfig) { + extensions.transferFee = { + transferFeeBasisPoints: feeConfig.newerTransferFee.transferFeeBasisPoints, + maximumFee: feeConfig.newerTransferFee.maximumFee.toString(), + }; + authorities.transferFeeConfigAuthority = toBase58(feeConfig.transferFeeConfigAuthority); + authorities.withdrawWithheldAuthority = toBase58(feeConfig.withdrawWithheldAuthority); + } + + const hook = getTransferHook(mintInfo); + if (hook) { + extensions.transferHookProgramId = hook.programId.toBase58(); + } + + const delegate = getPermanentDelegate(mintInfo); + if (delegate?.delegate) { + authorities.permanentDelegate = delegate.delegate.toBase58(); + } + + const interest = getInterestBearingMintConfigState(mintInfo); + if (interest) { + extensions.interestBearing = { rateBasisPoints: interest.currentRate }; + authorities.rateAuthority = toBase58(interest.rateAuthority); + } + + // DefaultAccountState is a single byte (AccountState). Decoded generically to avoid depending + // on a version-specific typed getter. permissionlessThaw is NOT on-chain — resolved separately + // at onboarding; defaults false until confirmed. + const defaultStateData = getExtensionData(ExtensionType.DefaultAccountState, mintInfo.tlvData); + if (defaultStateData) { + extensions.defaultAccountState = { + frozen: defaultStateData[0] === AccountState.Frozen, + permissionlessThaw: false, + }; + } + + // NOTE: ScaledUiAmount config parsing (initialMultiplier) is intentionally deferred — the typed + // getter is not available in @solana/spl-token@0.4.9. The extension is still *detected* above + // (so the safety gate sees it); populating scaledUiAmount.initialMultiplier is a follow-up that + // bumps @solana/spl-token to a version exposing getScaledUiAmountConfig. See PR for 3.4. + + if (Object.values(authorities).some((v) => v !== undefined)) { + extensions.authorities = authorities; + } + + return { detectedTypeNames, extensions }; +} + +/** Fetch a Token-2022 mint and parse its extensions. Uses `finalized` commitment rules. */ +export async function readMintExtensions( + connection: Connection, + mint: string | PublicKey, + commitment: Commitment = 'finalized' +): Promise { + const mintPubkey = typeof mint === 'string' ? new PublicKey(mint) : mint; + const mintInfo = await getMint(connection, mintPubkey, commitment, TOKEN_2022_PROGRAM_ID); + return parseMintExtensions(mintInfo); +} diff --git a/modules/sdk-coin-sol/test/unit/tokenExtensions.ts b/modules/sdk-coin-sol/test/unit/tokenExtensions.ts new file mode 100644 index 0000000000..93d9b3df73 --- /dev/null +++ b/modules/sdk-coin-sol/test/unit/tokenExtensions.ts @@ -0,0 +1,34 @@ +import 'should'; +import { SolTokenExtensionType } from '@bitgo/statics'; +import { assertExtensionCompatibility, mapModeledExtensions } from '../../src/lib/tokenExtensions'; + +describe('Sol Token-2022 mint extension parsing', function () { + describe('mapModeledExtensions', function () { + it('maps spl-token extension names to modeled BitGo types', function () { + mapModeledExtensions(['TransferFeeConfig', 'PermanentDelegate']).should.eql([ + SolTokenExtensionType.TransferFee, + SolTokenExtensionType.PermanentDelegate, + ]); + }); + + it('ignores extensions BitGo does not model', function () { + mapModeledExtensions(['ImmutableOwner', 'MetadataPointer', 'TransferHook']).should.eql([ + SolTokenExtensionType.TransferHook, + ]); + }); + + it('deduplicates', function () { + mapModeledExtensions(['TransferFeeConfig', 'TransferFeeConfig']).should.eql([SolTokenExtensionType.TransferFee]); + }); + }); + + describe('assertExtensionCompatibility', function () { + it('throws when Transfer Hook and Confidential Transfer coexist', function () { + (() => assertExtensionCompatibility(['TransferHook', 'ConfidentialTransferMint'])).should.throw(/cannot coexist/); + }); + + it('allows Transfer Hook without Confidential Transfer', function () { + (() => assertExtensionCompatibility(['TransferHook', 'TransferFeeConfig'])).should.not.throw(); + }); + }); +}); diff --git a/modules/statics/src/account.ts b/modules/statics/src/account.ts index b91e5679ca..8dcc543af2 100644 --- a/modules/statics/src/account.ts +++ b/modules/statics/src/account.ts @@ -86,17 +86,41 @@ export interface SolTokenExtensions { defaultAccountState?: SolDefaultAccountStateConfig; } -/** Extensions BitGo can safely custody (the onboarding allowlist). */ -export const SUPPORTED_SOL_TOKEN_EXTENSIONS: ReadonlySet = new Set( - Object.values(SolTokenExtensionType) -); +/** + * spl-token ExtensionType names BitGo recognizes at onboarding. + * Two groups: extensions with custody handling (modeled as SolTokenExtensionType), and + * holding-only extensions that need no special handling but must still be *recognized* + * (else common mints carrying MetadataPointer/TokenMetadata would be wrongly rejected). + */ +export const SUPPORTED_SOL_TOKEN_EXTENSION_NAMES: ReadonlySet = new Set([ + // Custody-impacting (handled per PRD 3.1–3.7) + 'TransferFeeConfig', + 'TransferHook', + 'PermanentDelegate', + 'InterestBearingConfig', + 'ScaledUiAmountConfig', + 'DefaultAccountState', + // Recognized, no custody handling required + 'ImmutableOwner', + 'MintCloseAuthority', + 'MetadataPointer', + 'TokenMetadata', + 'MemoTransfer', + 'CpiGuard', + 'GroupPointer', + 'TokenGroup', + 'GroupMemberPointer', + 'TokenGroupMember', + 'NonTransferable', + 'NonTransferableAccount', +]); /** * Returns the detected extensions BitGo has no handling code for. * A non-empty result MUST hard-stop onboarding. */ export function getUnsupportedSolTokenExtensions(detected: readonly string[]): string[] { - return detected.filter((ext) => !SUPPORTED_SOL_TOKEN_EXTENSIONS.has(ext as SolTokenExtensionType)); + return detected.filter((name) => !SUPPORTED_SOL_TOKEN_EXTENSION_NAMES.has(name)); } export interface AccountConstructorOptions { diff --git a/modules/statics/src/index.ts b/modules/statics/src/index.ts index 862ee5ca00..3394ab8a39 100644 --- a/modules/statics/src/index.ts +++ b/modules/statics/src/index.ts @@ -46,7 +46,7 @@ export { SolDefaultAccountStateConfig, SolInterestBearingConfig, SolScaledUiAmountConfig, - SUPPORTED_SOL_TOKEN_EXTENSIONS, + SUPPORTED_SOL_TOKEN_EXTENSION_NAMES, getUnsupportedSolTokenExtensions, } from './account'; export { CoinMap } from './map'; diff --git a/modules/statics/test/unit/coins.ts b/modules/statics/test/unit/coins.ts index 5a097cc32a..e27a9d575a 100644 --- a/modules/statics/test/unit/coins.ts +++ b/modules/statics/test/unit/coins.ts @@ -1109,9 +1109,9 @@ describe('Token contract address field defaults', () => { }); it('flags extensions with no BitGo handling code (onboarding safety gate)', () => { - getUnsupportedSolTokenExtensions([SolTokenExtensionType.TransferFee]).should.eql([]); - getUnsupportedSolTokenExtensions(['confidentialTransfer', SolTokenExtensionType.TransferHook]).should.eql([ - 'confidentialTransfer', + getUnsupportedSolTokenExtensions(['TransferFeeConfig', 'MetadataPointer']).should.eql([]); + getUnsupportedSolTokenExtensions(['ConfidentialTransferMint', 'TransferHook']).should.eql([ + 'ConfidentialTransferMint', ]); });