Skip to content

Commit e74729c

Browse files
Merge pull request #9275 from BitGo/CSHLD-1194
feat(statics): model Token-2022 extensions on SolCoin
2 parents f0410aa + f1998da commit e74729c

3 files changed

Lines changed: 143 additions & 4 deletions

File tree

modules/statics/src/account.ts

Lines changed: 96 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,85 @@ export enum ProgramID {
2020
Token2022ProgramId = 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb',
2121
}
2222

23+
/**
24+
* Token-2022 (SPL Token Extensions) that BitGo has explicit custody handling for.
25+
* This set is the onboarding safety gate: a mint whose
26+
* TLV data declares any extension NOT represented here must be rejected at onboarding
27+
* and escalated to engineering — no soft pass.
28+
*/
29+
export enum SolTokenExtensionType {
30+
TransferFee = 'transferFee',
31+
TransferHook = 'transferHook',
32+
PermanentDelegate = 'permanentDelegate',
33+
InterestBearing = 'interestBearing',
34+
ScaledUiAmount = 'scaledUiAmount',
35+
DefaultAccountState = 'defaultAccountState',
36+
}
37+
38+
/** On-chain authority addresses relevant to Token-2022 extensions, captured at onboarding. */
39+
export interface SolTokenAuthorities {
40+
freezeAuthority?: string;
41+
mintAuthority?: string;
42+
transferFeeConfigAuthority?: string;
43+
withdrawWithheldAuthority?: string;
44+
permanentDelegate?: string;
45+
rateAuthority?: string;
46+
}
47+
48+
export interface SolTransferFeeConfig {
49+
transferFeeBasisPoints: number;
50+
/** Raw base units, stringified to avoid precision loss. */
51+
maximumFee: string;
52+
}
53+
54+
export interface SolDefaultAccountStateConfig {
55+
/** True when the mint's Default Account State is Frozen. */
56+
frozen: boolean;
57+
/** True when a permissionless thaw program (Token ACL etc.) is associated */
58+
permissionlessThaw: boolean;
59+
}
60+
61+
export interface SolInterestBearingConfig {
62+
/** Annual interest rate in basis points. */
63+
rateBasisPoints: number;
64+
/** 'continuous' (issuer mints frequently) or 'redeemOnMaturity' (accrued not transferable until settlement). */
65+
settlementModel?: 'continuous' | 'redeemOnMaturity';
66+
}
67+
export interface SolScaledUiAmountConfig {
68+
/** UiAmountMultiplier active at onboarding; time-series history is tracked off-chain from day one. */
69+
initialMultiplier: string;
70+
}
71+
/**
72+
* Per-mint Token-2022 extension configuration detected at onboarding.
73+
* Absent => classic SPL / no modeled extensions.
74+
*/
75+
export interface SolTokenExtensions {
76+
/** Every extension detected on the mint's TLV data. Onboarding hard-stops if any is not a SolTokenExtensionType. */
77+
detected: SolTokenExtensionType[];
78+
authorities?: SolTokenAuthorities;
79+
/** Resolved human-readable issuer name for notifications and transaction history. */
80+
issuerName?: string;
81+
transferFee?: SolTransferFeeConfig;
82+
/** Transfer Hook program id. The extra-account-meta list is resolved fresh at tx time — never stored. */
83+
transferHookProgramId?: string;
84+
interestBearing?: SolInterestBearingConfig;
85+
scaledUiAmount?: SolScaledUiAmountConfig;
86+
defaultAccountState?: SolDefaultAccountStateConfig;
87+
}
88+
89+
/** Extensions BitGo can safely custody (the onboarding allowlist). */
90+
export const SUPPORTED_SOL_TOKEN_EXTENSIONS: ReadonlySet<SolTokenExtensionType> = new Set(
91+
Object.values(SolTokenExtensionType)
92+
);
93+
94+
/**
95+
* Returns the detected extensions BitGo has no handling code for.
96+
* A non-empty result MUST hard-stop onboarding.
97+
*/
98+
export function getUnsupportedSolTokenExtensions(detected: readonly string[]): string[] {
99+
return detected.filter((ext) => !SUPPORTED_SOL_TOKEN_EXTENSIONS.has(ext as SolTokenExtensionType));
100+
}
101+
23102
export interface AccountConstructorOptions {
24103
id: string;
25104
fullName: string;
@@ -128,6 +207,7 @@ export interface SolCoinConstructorOptions extends AccountConstructorOptions {
128207
tokenAddress: string;
129208
contractAddress: string;
130209
programId: string;
210+
tokenExtensions?: SolTokenExtensions;
131211
}
132212

133213
export interface XrpCoinConstructorOptions extends AccountConstructorOptions {
@@ -453,6 +533,7 @@ export class SolCoin extends AccountCoinToken {
453533
public tokenAddress: string;
454534
public contractAddress: string;
455535
public programId: string;
536+
public tokenExtensions?: SolTokenExtensions;
456537
constructor(options: SolCoinConstructorOptions) {
457538
super({
458539
...options,
@@ -461,6 +542,12 @@ export class SolCoin extends AccountCoinToken {
461542
this.tokenAddress = options.contractAddress;
462543
this.contractAddress = options.contractAddress;
463544
this.programId = options.programId;
545+
this.tokenExtensions = options.tokenExtensions;
546+
}
547+
548+
/** True if this token was onboarded with the given Token-2022 extension. */
549+
public hasExtension(type: SolTokenExtensionType): boolean {
550+
return this.tokenExtensions?.detected.includes(type) ?? false;
464551
}
465552
}
466553

@@ -2086,7 +2173,8 @@ export function solToken(
20862173
prefix = '',
20872174
suffix: string = name.toUpperCase(),
20882175
network: AccountNetwork = Networks.main.sol,
2089-
primaryKeyCurve: KeyCurve = KeyCurve.Ed25519
2176+
primaryKeyCurve: KeyCurve = KeyCurve.Ed25519,
2177+
tokenExtensions?: SolTokenExtensions
20902178
) {
20912179
return Object.freeze(
20922180
new SolCoin({
@@ -2105,6 +2193,7 @@ export function solToken(
21052193
isToken: true,
21062194
primaryKeyCurve,
21072195
baseUnit: BaseUnit.SOL,
2196+
tokenExtensions,
21082197
})
21092198
);
21102199
}
@@ -2135,7 +2224,9 @@ export function tsolToken(
21352224
programId = ProgramID.TokenProgramId,
21362225
prefix = '',
21372226
suffix: string = name.toUpperCase(),
2138-
network: AccountNetwork = Networks.test.sol
2227+
network: AccountNetwork = Networks.test.sol,
2228+
primaryKeyCurve: KeyCurve = KeyCurve.Ed25519,
2229+
tokenExtensions?: SolTokenExtensions
21392230
) {
21402231
return solToken(
21412232
id,
@@ -2149,7 +2240,9 @@ export function tsolToken(
21492240
programId,
21502241
prefix,
21512242
suffix,
2152-
network
2243+
network,
2244+
primaryKeyCurve,
2245+
tokenExtensions
21532246
);
21542247
}
21552248

modules/statics/src/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,15 @@ export {
3939
JettonToken,
4040
CantonToken,
4141
Erc7984Coin,
42+
SolTokenExtensionType,
43+
SolTokenExtensions,
44+
SolTokenAuthorities,
45+
SolTransferFeeConfig,
46+
SolDefaultAccountStateConfig,
47+
SolInterestBearingConfig,
48+
SolScaledUiAmountConfig,
49+
SUPPORTED_SOL_TOKEN_EXTENSIONS,
50+
getUnsupportedSolTokenExtensions,
4251
} from './account';
4352
export { CoinMap } from './map';
4453
export {

modules/statics/test/unit/coins.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
getFormattedTokenConfigForCoin,
1818
getFormattedTokens,
1919
HederaToken,
20+
KeyCurve,
2021
Networks,
2122
NetworkType,
2223
registerNetwork,
@@ -41,7 +42,15 @@ import {
4142
trimmedDynamicBaseChainConfig,
4243
} from './resources/amsTokenConfig';
4344
import { EthLikeErc20Token } from '../../../sdk-coin-evm/src';
44-
import { ProgramID, taptNFTCollection, terc20 } from '../../src/account';
45+
import {
46+
AccountCoin,
47+
getUnsupportedSolTokenExtensions,
48+
ProgramID,
49+
solToken,
50+
SolTokenExtensionType,
51+
taptNFTCollection,
52+
terc20,
53+
} from '../../src/account';
4554
import { allCoinsAndTokens } from '../../src/allCoinsAndTokens';
4655

4756
interface DuplicateCoinObject {
@@ -1098,6 +1107,34 @@ describe('Token contract address field defaults', () => {
10981107
validProgramIds.should.containEql((coin as SolCoin).programId);
10991108
});
11001109
});
1110+
1111+
it('flags extensions with no BitGo handling code (onboarding safety gate)', () => {
1112+
getUnsupportedSolTokenExtensions([SolTokenExtensionType.TransferFee]).should.eql([]);
1113+
getUnsupportedSolTokenExtensions(['confidentialTransfer', SolTokenExtensionType.TransferHook]).should.eql([
1114+
'confidentialTransfer',
1115+
]);
1116+
});
1117+
1118+
it('exposes detected extensions via SolCoin.hasExtension', () => {
1119+
const token = solToken(
1120+
'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d',
1121+
'sol:testext',
1122+
'Test Extension Token',
1123+
6,
1124+
'Ea5SjE2Y6yvCeW5dYTn7PYMuW5ikXkvbGdcmSnXeaLjS',
1125+
'Ea5SjE2Y6yvCeW5dYTn7PYMuW5ikXkvbGdcmSnXeaLjS',
1126+
UnderlyingAsset.SOL,
1127+
[...AccountCoin.DEFAULT_FEATURES, CoinFeature.REQUIRES_RESERVE],
1128+
ProgramID.Token2022ProgramId,
1129+
'',
1130+
'TESTEXT',
1131+
Networks.main.sol,
1132+
KeyCurve.Ed25519,
1133+
{ detected: [SolTokenExtensionType.ScaledUiAmount], scaledUiAmount: { initialMultiplier: '1' } }
1134+
);
1135+
token.hasExtension(SolTokenExtensionType.ScaledUiAmount).should.be.true();
1136+
token.hasExtension(SolTokenExtensionType.TransferFee).should.be.false();
1137+
});
11011138
});
11021139
describe('XRP tokens', function () {
11031140
it('have `contractAddress` === `issuerAddress::currencyCode`', () => {

0 commit comments

Comments
 (0)