Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions modules/sdk-coin-sol/src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
149 changes: 149 additions & 0 deletions modules/sdk-coin-sol/src/lib/tokenExtensions.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, SolTokenExtensionType>> = {
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<SolTokenExtensions['authorities']> = {
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<MintExtensionReadResult> {
const mintPubkey = typeof mint === 'string' ? new PublicKey(mint) : mint;
const mintInfo = await getMint(connection, mintPubkey, commitment, TOKEN_2022_PROGRAM_ID);
return parseMintExtensions(mintInfo);
}
34 changes: 34 additions & 0 deletions modules/sdk-coin-sol/test/unit/tokenExtensions.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
34 changes: 29 additions & 5 deletions modules/statics/src/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,17 +86,41 @@ export interface SolTokenExtensions {
defaultAccountState?: SolDefaultAccountStateConfig;
}

/** Extensions BitGo can safely custody (the onboarding allowlist). */
export const SUPPORTED_SOL_TOKEN_EXTENSIONS: ReadonlySet<SolTokenExtensionType> = 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<string> = 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 {
Expand Down
2 changes: 1 addition & 1 deletion modules/statics/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export {
SolDefaultAccountStateConfig,
SolInterestBearingConfig,
SolScaledUiAmountConfig,
SUPPORTED_SOL_TOKEN_EXTENSIONS,
SUPPORTED_SOL_TOKEN_EXTENSION_NAMES,
getUnsupportedSolTokenExtensions,
} from './account';
export { CoinMap } from './map';
Expand Down
6 changes: 3 additions & 3 deletions modules/statics/test/unit/coins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
]);
});

Expand Down
Loading