diff --git a/modules/bitgo/src/v2/coinFactory.ts b/modules/bitgo/src/v2/coinFactory.ts index c249573309..81d17da7a5 100644 --- a/modules/bitgo/src/v2/coinFactory.ts +++ b/modules/bitgo/src/v2/coinFactory.ts @@ -6,6 +6,7 @@ import { AlgoToken } from '@bitgo/sdk-coin-algo'; import { Bcha, Tbcha } from '@bitgo/sdk-coin-bcha'; import { HbarToken } from '@bitgo/sdk-coin-hbar'; import { Near, TNear, Nep141Token } from '@bitgo/sdk-coin-near'; +import { TonToken } from '@bitgo/sdk-coin-ton'; import { SolToken } from '@bitgo/sdk-coin-sol'; import { TrxToken } from '@bitgo/sdk-coin-trx'; import { CoinFactory, CoinConstructor } from '@bitgo/sdk-core'; @@ -36,6 +37,7 @@ import { VetTokenConfig, TaoTokenConfig, PolyxTokenConfig, + TonTokenConfig, } from '@bitgo/statics'; import { Ada, @@ -532,6 +534,10 @@ export function registerCoinConstructors(coinFactory: CoinFactory, coinMap: Coin VetToken.createTokenConstructors().forEach(({ name, coinConstructor }) => coinFactory.register(name, coinConstructor) ); + + TonToken.createTokenConstructors([...tokens.bitcoin.ton.tokens, ...tokens.testnet.ton.tokens]).forEach( + ({ name, coinConstructor }) => coinFactory.register(name, coinConstructor) + ); } export function getCoinConstructor(coinName: string): CoinConstructor | undefined { @@ -983,6 +989,9 @@ export function getTokenConstructor(tokenConfig: TokenConfig): CoinConstructor | case 'zeta': case 'tzeta': return CosmosToken.createTokenConstructor(tokenConfig as CosmosTokenConfig); + case 'ton': + case 'tton': + return TonToken.createTokenConstructor(tokenConfig as TonTokenConfig); default: return undefined; } diff --git a/modules/sdk-coin-ton/src/index.ts b/modules/sdk-coin-ton/src/index.ts index 4586361389..0de4e88199 100644 --- a/modules/sdk-coin-ton/src/index.ts +++ b/modules/sdk-coin-ton/src/index.ts @@ -2,3 +2,4 @@ export * from './lib'; export * from './register'; export * from './ton'; export * from './tton'; +export * from './tonToken'; diff --git a/modules/sdk-coin-ton/src/register.ts b/modules/sdk-coin-ton/src/register.ts index 55505caf35..bc511fff8b 100644 --- a/modules/sdk-coin-ton/src/register.ts +++ b/modules/sdk-coin-ton/src/register.ts @@ -1,8 +1,15 @@ import { BitGoBase } from '@bitgo/sdk-core'; import { Ton } from './ton'; import { Tton } from './tton'; +import { TonToken } from './tonToken'; export const register = (sdk: BitGoBase): void => { sdk.register('ton', Ton.createInstance); sdk.register('tton', Tton.createInstance); + + // Register Jetton tokens + const tokens = TonToken.createTokenConstructors(); + tokens.forEach((token) => { + sdk.register(token.name, token.coinConstructor); + }); }; diff --git a/modules/sdk-coin-ton/src/tonToken.ts b/modules/sdk-coin-ton/src/tonToken.ts new file mode 100644 index 0000000000..661da2ea93 --- /dev/null +++ b/modules/sdk-coin-ton/src/tonToken.ts @@ -0,0 +1,105 @@ +import { BitGoBase, CoinConstructor, NamedCoinConstructor, VerifyTransactionOptions } from '@bitgo/sdk-core'; +import BigNumber from 'bignumber.js'; +import { coins, TonTokenConfig, NetworkType, tokens } from '@bitgo/statics'; + +import { Transaction } from './lib'; +import { Ton } from './ton'; + +export class TonToken extends Ton { + public readonly tokenConfig: TonTokenConfig; + + constructor(bitgo: BitGoBase, tokenConfig: TonTokenConfig) { + const staticsCoin = tokenConfig.network === NetworkType.MAINNET ? coins.get('ton') : coins.get('tton'); + super(bitgo, staticsCoin); + this.tokenConfig = tokenConfig; + } + + static createTokenConstructor(config: TonTokenConfig): CoinConstructor { + return (bitgo: BitGoBase) => new TonToken(bitgo, config); + } + + static createTokenConstructors( + tokenConfig: TonTokenConfig[] = [...tokens.bitcoin.ton.tokens, ...tokens.testnet.ton.tokens] + ): NamedCoinConstructor[] { + const tokensCtors: NamedCoinConstructor[] = []; + for (const token of tokenConfig) { + const tokenConstructor = TonToken.createTokenConstructor(token); + tokensCtors.push({ name: token.type, coinConstructor: tokenConstructor }); + } + return tokensCtors; + } + + get name(): string { + return this.tokenConfig.name; + } + + get coin(): string { + return this.tokenConfig.coin; + } + + get jettonMaster(): string { + return this.tokenConfig.jettonMaster; + } + + get decimalPlaces(): number { + return this.tokenConfig.decimalPlaces; + } + + getChain(): string { + return this.tokenConfig.type; + } + + getBaseChain(): string { + return this.coin; + } + + getFullName(): string { + return 'TON Token'; + } + + getBaseFactor(): number { + return Math.pow(10, this.tokenConfig.decimalPlaces); + } + + async verifyTransaction(params: VerifyTransactionOptions): Promise { + const { txPrebuild: txPrebuild, txParams: txParams } = params; + const rawTx = txPrebuild.txHex; + let totalAmount = new BigNumber(0); + if (!rawTx) { + throw new Error('missing required tx prebuild property txHex'); + } + const coinConfig = coins.get(this.getChain()); + const transaction = new Transaction(coinConfig); + transaction.fromRawTransaction(Buffer.from(rawTx, 'hex').toString('base64')); + const explainedTx = transaction.explainTransaction(); + if (txParams.recipients !== undefined) { + txParams.recipients.forEach((recipient) => { + if (recipient.tokenName && recipient.tokenName !== coinConfig.name) { + throw new Error('incorrect token name specified in recipients'); + } + recipient.tokenName = coinConfig.name; + }); + const filteredRecipients = txParams.recipients?.map((recipient) => ({ + address: recipient.address, + amount: recipient.amount, + tokenName: recipient.tokenName, + })); + const filteredOutputs = explainedTx.outputs.map((output) => ({ + address: output.address, + amount: output.amount, + tokenName: output.tokenName, + })); + const outputsMatch = JSON.stringify(filteredRecipients) === JSON.stringify(filteredOutputs); + if (!outputsMatch) { + throw new Error('Tx outputs does not match with expected txParams recipients'); + } + for (const recipient of txParams.recipients) { + totalAmount = totalAmount.plus(recipient.amount); + } + if (!totalAmount.isEqualTo(explainedTx.outputAmount)) { + throw new Error('Tx total amount does not match with expected total amount field'); + } + } + return true; + } +} diff --git a/modules/statics/src/account.ts b/modules/statics/src/account.ts index f3f482c167..8d9d3339c0 100644 --- a/modules/statics/src/account.ts +++ b/modules/statics/src/account.ts @@ -158,6 +158,10 @@ export interface Nep141TokenConstructorOptions extends AccountConstructorOptions storageDepositAmount: string; } +export interface TonTokenConstructorOptions extends AccountConstructorOptions { + jettonMaster: string; +} + export interface VetTokenConstructorOptions extends AccountConstructorOptions { contractAddress: string; gasTankToken?: string; @@ -635,6 +639,18 @@ export class Nep141Token extends AccountCoinToken { } } +export class TonToken extends AccountCoinToken { + public jettonMaster: string; + + constructor(options: TonTokenConstructorOptions) { + super({ + ...options, + }); + + this.jettonMaster = options.jettonMaster; + } +} + export class VetToken extends AccountCoinToken { public contractAddress: string; public gasTankToken?: string; @@ -3091,6 +3107,95 @@ export function sip10Token( * @param network? Optional token network. Defaults to the testnet Stacks network. * @param features? Features of this coin. Defaults to the DEFAULT_FEATURES defined in `AccountCoin` */ +/** + * Factory function for TON token instances. + * + * @param id uuid v4 + * @param name unique identifier of the token + * @param fullName Complete human-readable name of the token + * @param decimalPlaces Number of decimal places this token supports (divisibility exponent) + * @param jettonMaster Jetton master address of this token + * @param asset Asset which this coin represents. This is the same for both mainnet and testnet variants of a coin. + * @param features Features of this coin. Defaults to the DEFAULT_FEATURES defined in `AccountCoin` + * @param prefix Optional token prefix. Defaults to empty string + * @param suffix Optional token suffix. Defaults to token name. + * @param network Optional token network. Defaults to TON main network. + * @param primaryKeyCurve The elliptic curve for this chain/token + */ +export function tonToken( + id: string, + name: string, + fullName: string, + decimalPlaces: number, + jettonMaster: string, + asset: UnderlyingAsset, + features: CoinFeature[] = AccountCoin.DEFAULT_FEATURES, + prefix = '', + suffix: string = name.toUpperCase(), + network: AccountNetwork = Networks.main.ton, + primaryKeyCurve: KeyCurve = KeyCurve.Ed25519 +) { + return Object.freeze( + new TonToken({ + id, + name, + fullName, + network, + jettonMaster, + prefix, + suffix, + features, + decimalPlaces, + asset, + isToken: true, + primaryKeyCurve, + baseUnit: BaseUnit.TON, + }) + ); +} + +/** + * Factory function for testnet TON token instances. + * + * @param id uuid v4 + * @param name unique identifier of the token + * @param fullName Complete human-readable name of the token + * @param decimalPlaces Number of decimal places this token supports (divisibility exponent) + * @param jettonMaster Jetton master address of this token + * @param asset Asset which this coin represents. This is the same for both mainnet and testnet variants of a coin. + * @param features Features of this coin. Defaults to the DEFAULT_FEATURES defined in `AccountCoin` + * @param prefix Optional token prefix. Defaults to empty string + * @param suffix Optional token suffix. Defaults to token name. + * @param network Optional token network. Defaults to the testnet TON network. + */ +export function ttonToken( + id: string, + name: string, + fullName: string, + decimalPlaces: number, + jettonMaster: string, + asset: UnderlyingAsset, + features: CoinFeature[] = AccountCoin.DEFAULT_FEATURES, + prefix = '', + suffix: string = name.toUpperCase(), + network: AccountNetwork = Networks.test.ton, + primaryKeyCurve: KeyCurve = KeyCurve.Ed25519 +) { + return tonToken( + id, + name, + fullName, + decimalPlaces, + jettonMaster, + asset, + features, + prefix, + suffix, + network, + primaryKeyCurve + ); +} + export function tsip10Token( id: string, name: string, diff --git a/modules/statics/src/tokenConfig.ts b/modules/statics/src/tokenConfig.ts index 1cc51ecd11..09f6157246 100644 --- a/modules/statics/src/tokenConfig.ts +++ b/modules/statics/src/tokenConfig.ts @@ -15,6 +15,7 @@ import { Erc20Coin, Erc721Coin, HederaToken, + TonToken, Nep141Token, OpethERC20Token, PolygonERC20Token, @@ -126,6 +127,10 @@ export type Nep141TokenConfig = BaseNetworkConfig & { storageDepositAmount: string; }; +export type TonTokenConfig = BaseNetworkConfig & { + jettonMaster: string; +}; + export type VetTokenConfig = BaseNetworkConfig & { contractAddress: string; }; @@ -156,7 +161,8 @@ export type TokenConfig = | CosmosTokenConfig | VetTokenConfig | TaoTokenConfig - | PolyxTokenConfig; + | PolyxTokenConfig + | TonTokenConfig; export interface Tokens { bitcoin: { @@ -249,6 +255,9 @@ export interface Tokens { cosmos: { tokens: CosmosTokenConfig[]; }; + ton: { + tokens: TonTokenConfig[]; + }; }; testnet: { eth: { @@ -340,6 +349,9 @@ export interface Tokens { cosmos: { tokens: CosmosTokenConfig[]; }; + ton: { + tokens: TonTokenConfig[]; + }; }; } @@ -995,6 +1007,25 @@ function getCosmosTokenConfig(coin: CosmosChainToken): CosmosTokenConfig { }; } +function getTonTokenConfig(coin: TonToken): TonTokenConfig { + return { + type: coin.name, + coin: coin.network.type === NetworkType.MAINNET ? 'ton' : 'tton', + network: coin.network.type === NetworkType.MAINNET ? 'Mainnet' : 'Testnet', + name: coin.fullName, + jettonMaster: coin.jettonMaster, + decimalPlaces: coin.decimalPlaces, + }; +} + +const getFormattedTonTokens = (customCoinMap = coins) => + customCoinMap.reduce((acc: TonTokenConfig[], coin) => { + if (coin instanceof TonToken) { + acc.push(getTonTokenConfig(coin)); + } + return acc; + }, []); + export const getFormattedTokens = (coinMap = coins): Tokens => { const formattedAptNFTCollections = getFormattedAptNFTCollections(coinMap); return { @@ -1092,6 +1123,9 @@ export const getFormattedTokens = (coinMap = coins): Tokens => { cosmos: { tokens: getFormattedCosmosChainTokens(coinMap).filter((token) => token.network === 'Mainnet'), }, + ton: { + tokens: getFormattedTonTokens(coinMap).filter((token) => token.network === 'Mainnet'), + }, }, testnet: { eth: { @@ -1187,6 +1221,9 @@ export const getFormattedTokens = (coinMap = coins): Tokens => { cosmos: { tokens: getFormattedCosmosChainTokens(coinMap).filter((token) => token.network === 'Testnet'), }, + ton: { + tokens: getFormattedTonTokens(coinMap).filter((token) => token.network === 'Testnet'), + }, }, }; }; @@ -1292,6 +1329,8 @@ export function getFormattedTokenConfigForCoin(coin: Readonly): TokenC return getCosmosTokenConfig(coin); } else if (coin instanceof VetToken) { return getVetTokenConfig(coin); + } else if (coin instanceof TonToken) { + return getTonTokenConfig(coin); } return undefined; }