From f1998da76e8a84985cfe4e55e0ba9914d2c55255 Mon Sep 17 00:00:00 2001 From: Abhishek Agrawal Date: Thu, 16 Jul 2026 17:13:19 +0530 Subject: [PATCH] feat(statics): model Token-2022 extensions on SolCoin TICKET: CSHLD-1194 --- modules/statics/src/account.ts | 99 +++++++++++++++++++++++++++++- modules/statics/src/index.ts | 9 +++ modules/statics/test/unit/coins.ts | 39 +++++++++++- 3 files changed, 143 insertions(+), 4 deletions(-) diff --git a/modules/statics/src/account.ts b/modules/statics/src/account.ts index abbd8a1db2..b91e5679ca 100644 --- a/modules/statics/src/account.ts +++ b/modules/statics/src/account.ts @@ -20,6 +20,85 @@ export enum ProgramID { Token2022ProgramId = 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb', } +/** + * Token-2022 (SPL Token Extensions) that BitGo has explicit custody handling for. + * This set is the onboarding safety gate: a mint whose + * TLV data declares any extension NOT represented here must be rejected at onboarding + * and escalated to engineering — no soft pass. + */ +export enum SolTokenExtensionType { + TransferFee = 'transferFee', + TransferHook = 'transferHook', + PermanentDelegate = 'permanentDelegate', + InterestBearing = 'interestBearing', + ScaledUiAmount = 'scaledUiAmount', + DefaultAccountState = 'defaultAccountState', +} + +/** On-chain authority addresses relevant to Token-2022 extensions, captured at onboarding. */ +export interface SolTokenAuthorities { + freezeAuthority?: string; + mintAuthority?: string; + transferFeeConfigAuthority?: string; + withdrawWithheldAuthority?: string; + permanentDelegate?: string; + rateAuthority?: string; +} + +export interface SolTransferFeeConfig { + transferFeeBasisPoints: number; + /** Raw base units, stringified to avoid precision loss. */ + maximumFee: string; +} + +export interface SolDefaultAccountStateConfig { + /** True when the mint's Default Account State is Frozen. */ + frozen: boolean; + /** True when a permissionless thaw program (Token ACL etc.) is associated */ + permissionlessThaw: boolean; +} + +export interface SolInterestBearingConfig { + /** Annual interest rate in basis points. */ + rateBasisPoints: number; + /** 'continuous' (issuer mints frequently) or 'redeemOnMaturity' (accrued not transferable until settlement). */ + settlementModel?: 'continuous' | 'redeemOnMaturity'; +} +export interface SolScaledUiAmountConfig { + /** UiAmountMultiplier active at onboarding; time-series history is tracked off-chain from day one. */ + initialMultiplier: string; +} +/** + * Per-mint Token-2022 extension configuration detected at onboarding. + * Absent => classic SPL / no modeled extensions. + */ +export interface SolTokenExtensions { + /** Every extension detected on the mint's TLV data. Onboarding hard-stops if any is not a SolTokenExtensionType. */ + detected: SolTokenExtensionType[]; + authorities?: SolTokenAuthorities; + /** Resolved human-readable issuer name for notifications and transaction history. */ + issuerName?: string; + transferFee?: SolTransferFeeConfig; + /** Transfer Hook program id. The extra-account-meta list is resolved fresh at tx time — never stored. */ + transferHookProgramId?: string; + interestBearing?: SolInterestBearingConfig; + scaledUiAmount?: SolScaledUiAmountConfig; + defaultAccountState?: SolDefaultAccountStateConfig; +} + +/** Extensions BitGo can safely custody (the onboarding allowlist). */ +export const SUPPORTED_SOL_TOKEN_EXTENSIONS: ReadonlySet = new Set( + Object.values(SolTokenExtensionType) +); + +/** + * 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)); +} + export interface AccountConstructorOptions { id: string; fullName: string; @@ -128,6 +207,7 @@ export interface SolCoinConstructorOptions extends AccountConstructorOptions { tokenAddress: string; contractAddress: string; programId: string; + tokenExtensions?: SolTokenExtensions; } export interface XrpCoinConstructorOptions extends AccountConstructorOptions { @@ -453,6 +533,7 @@ export class SolCoin extends AccountCoinToken { public tokenAddress: string; public contractAddress: string; public programId: string; + public tokenExtensions?: SolTokenExtensions; constructor(options: SolCoinConstructorOptions) { super({ ...options, @@ -461,6 +542,12 @@ export class SolCoin extends AccountCoinToken { this.tokenAddress = options.contractAddress; this.contractAddress = options.contractAddress; this.programId = options.programId; + this.tokenExtensions = options.tokenExtensions; + } + + /** True if this token was onboarded with the given Token-2022 extension. */ + public hasExtension(type: SolTokenExtensionType): boolean { + return this.tokenExtensions?.detected.includes(type) ?? false; } } @@ -2086,7 +2173,8 @@ export function solToken( prefix = '', suffix: string = name.toUpperCase(), network: AccountNetwork = Networks.main.sol, - primaryKeyCurve: KeyCurve = KeyCurve.Ed25519 + primaryKeyCurve: KeyCurve = KeyCurve.Ed25519, + tokenExtensions?: SolTokenExtensions ) { return Object.freeze( new SolCoin({ @@ -2105,6 +2193,7 @@ export function solToken( isToken: true, primaryKeyCurve, baseUnit: BaseUnit.SOL, + tokenExtensions, }) ); } @@ -2135,7 +2224,9 @@ export function tsolToken( programId = ProgramID.TokenProgramId, prefix = '', suffix: string = name.toUpperCase(), - network: AccountNetwork = Networks.test.sol + network: AccountNetwork = Networks.test.sol, + primaryKeyCurve: KeyCurve = KeyCurve.Ed25519, + tokenExtensions?: SolTokenExtensions ) { return solToken( id, @@ -2149,7 +2240,9 @@ export function tsolToken( programId, prefix, suffix, - network + network, + primaryKeyCurve, + tokenExtensions ); } diff --git a/modules/statics/src/index.ts b/modules/statics/src/index.ts index c2beb7d678..862ee5ca00 100644 --- a/modules/statics/src/index.ts +++ b/modules/statics/src/index.ts @@ -39,6 +39,15 @@ export { JettonToken, CantonToken, Erc7984Coin, + SolTokenExtensionType, + SolTokenExtensions, + SolTokenAuthorities, + SolTransferFeeConfig, + SolDefaultAccountStateConfig, + SolInterestBearingConfig, + SolScaledUiAmountConfig, + SUPPORTED_SOL_TOKEN_EXTENSIONS, + getUnsupportedSolTokenExtensions, } from './account'; export { CoinMap } from './map'; export { diff --git a/modules/statics/test/unit/coins.ts b/modules/statics/test/unit/coins.ts index df28d67464..5a097cc32a 100644 --- a/modules/statics/test/unit/coins.ts +++ b/modules/statics/test/unit/coins.ts @@ -17,6 +17,7 @@ import { getFormattedTokenConfigForCoin, getFormattedTokens, HederaToken, + KeyCurve, Networks, NetworkType, registerNetwork, @@ -41,7 +42,15 @@ import { trimmedDynamicBaseChainConfig, } from './resources/amsTokenConfig'; import { EthLikeErc20Token } from '../../../sdk-coin-evm/src'; -import { ProgramID, taptNFTCollection, terc20 } from '../../src/account'; +import { + AccountCoin, + getUnsupportedSolTokenExtensions, + ProgramID, + solToken, + SolTokenExtensionType, + taptNFTCollection, + terc20, +} from '../../src/account'; import { allCoinsAndTokens } from '../../src/allCoinsAndTokens'; interface DuplicateCoinObject { @@ -1098,6 +1107,34 @@ describe('Token contract address field defaults', () => { validProgramIds.should.containEql((coin as SolCoin).programId); }); }); + + it('flags extensions with no BitGo handling code (onboarding safety gate)', () => { + getUnsupportedSolTokenExtensions([SolTokenExtensionType.TransferFee]).should.eql([]); + getUnsupportedSolTokenExtensions(['confidentialTransfer', SolTokenExtensionType.TransferHook]).should.eql([ + 'confidentialTransfer', + ]); + }); + + it('exposes detected extensions via SolCoin.hasExtension', () => { + const token = solToken( + 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d', + 'sol:testext', + 'Test Extension Token', + 6, + 'Ea5SjE2Y6yvCeW5dYTn7PYMuW5ikXkvbGdcmSnXeaLjS', + 'Ea5SjE2Y6yvCeW5dYTn7PYMuW5ikXkvbGdcmSnXeaLjS', + UnderlyingAsset.SOL, + [...AccountCoin.DEFAULT_FEATURES, CoinFeature.REQUIRES_RESERVE], + ProgramID.Token2022ProgramId, + '', + 'TESTEXT', + Networks.main.sol, + KeyCurve.Ed25519, + { detected: [SolTokenExtensionType.ScaledUiAmount], scaledUiAmount: { initialMultiplier: '1' } } + ); + token.hasExtension(SolTokenExtensionType.ScaledUiAmount).should.be.true(); + token.hasExtension(SolTokenExtensionType.TransferFee).should.be.false(); + }); }); describe('XRP tokens', function () { it('have `contractAddress` === `issuerAddress::currencyCode`', () => {