Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(@fireblocks/recovery-utility): ✨ add Jetton support #105

Merged
merged 5 commits into from
Dec 18, 2024
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@ import {
getLogger,
sanatize,
useOfflineQuery,
getDerivationMapKey,
} from '@fireblocks/recovery-shared';
import { AssetConfig, getAssetConfig, isNativeAssetId } from '@fireblocks/asset-config';
import { LOGGER_NAME_RELAY } from '@fireblocks/recovery-shared/constants';
import { useWorkspace } from '../../../context/Workspace';
import { Derivation, AccountData } from '../../../lib/wallets';
import { LateInitConnectedWallet } from '../../../lib/wallets/LateInitConnectedWallet';
import { useSettings } from '../../../context/Settings';
import { Jetton } from '../../../lib/wallets/Jetton';

const logger = getLogger(LOGGER_NAME_RELAY);

Expand Down Expand Up @@ -149,7 +151,7 @@ export const CreateTransaction = ({ asset, inboundRelayParams, setSignTxResponse

const fromAddress = values.fromAddress ?? defaultValues.fromAddress;

const derivation = wallet?.derivations?.get(fromAddress);
const derivation = wallet?.derivations?.get(getDerivationMapKey(asset?.id, fromAddress));

// TODO: Show both original balance and adjusted balance in create tx UI

Expand All @@ -166,6 +168,11 @@ export const CreateTransaction = ({ asset, inboundRelayParams, setSignTxResponse
throw new Error(`No RPC Url for: ${derivation?.assetId}`);
}
if (rpcUrl !== null) derivation!.setRPCUrl(rpcUrl);
if (asset.address && asset.protocol === 'TON') {
(derivation as Jetton).setTokenAddress(asset.address);
(derivation as Jetton).setDecimals(asset.decimals);
}

return await derivation!.prepare?.(toAddress, values.memo);
},
onSuccess: (prepare: AccountData) => {
Expand Down Expand Up @@ -421,6 +428,10 @@ export const CreateTransaction = ({ asset, inboundRelayParams, setSignTxResponse
<Typography variant='body1' color={(theme) => theme.palette.error.main}>
Insufficient balance for transaction
</Typography>
) : prepareQuery.data?.insufficientBalanceForTokenTransfer === true && prepareQuery.data?.insufficientBalance !== true ? (
<Typography variant='body1' color={(theme) => theme.palette.error.main}>
Insufficient fee asset balance for token transaction
</Typography>
) : (
''
)}
Expand All @@ -430,7 +441,12 @@ export const CreateTransaction = ({ asset, inboundRelayParams, setSignTxResponse
type='submit'
disabled={
!prepareQuery.data?.balance ||
(prepareQuery.data && prepareQuery.data?.insufficientBalance !== undefined && prepareQuery.data.insufficientBalance)
(prepareQuery.data &&
prepareQuery.data?.insufficientBalance !== undefined &&
prepareQuery.data.insufficientBalance) ||
(prepareQuery.data &&
prepareQuery.data?.insufficientBalanceForTokenTransfer !== undefined &&
prepareQuery.data.insufficientBalanceForTokenTransfer)
Comment on lines +448 to +449
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
prepareQuery.data?.insufficientBalanceForTokenTransfer !== undefined &&
prepareQuery.data.insufficientBalanceForTokenTransfer)
prepareQuery.data.insufficientBalanceForTokenTransfer)

}
>
Prepare Transaction
Expand Down
5 changes: 4 additions & 1 deletion apps/recovery-relay/components/WithdrawModal/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
RelaySignTxResponseParams,
getLogger,
useWrappedState,
getDerivationMapKey,
} from '@fireblocks/recovery-shared';
import { getDerivableAssetConfig } from '@fireblocks/asset-config';
import { LOGGER_NAME_RELAY } from '@fireblocks/recovery-shared/constants';
Expand Down Expand Up @@ -127,7 +128,9 @@ export const WithdrawModal = () => {
.get(inboundRelayParams?.accountId)
?.wallets.get(inboundRelayParams?.signedTx.assetId);

const derivation = wallet?.derivations?.get(inboundRelayParams?.signedTx.from);
const derivation = wallet?.derivations?.get(
getDerivationMapKey(inboundRelayParams?.signedTx.assetId, inboundRelayParams?.signedTx.from),
);

const rpcUrl = getAssetURL(derivation?.assetId ?? '', RPCs);
if (rpcUrl === undefined) {
Expand Down
11 changes: 10 additions & 1 deletion apps/recovery-relay/context/Workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { getAssetConfig } from '@fireblocks/asset-config';
import packageJson from '../package.json';
import { WalletClasses, Derivation } from '../lib/wallets';
import { LOGGER_NAME_RELAY } from '@fireblocks/recovery-shared/constants';
import { isTransferableToken } from '@fireblocks/asset-config/util';

type DistributiveOmit<T, K extends keyof any> = T extends any ? Omit<T, K> : never;

Expand Down Expand Up @@ -61,7 +62,6 @@ const getInboundRelayWalletIds = (inboundRelayParams?: RelayRequestParams) => {
return null;
}

logger.info('Inbound Relay params', inboundRelayParams);
let ret;
switch (inboundRelayParams.action) {
case 'import':
Expand Down Expand Up @@ -116,6 +116,15 @@ export const WorkspaceProvider = ({ children }: Props) => {
app: 'relay',
relayBaseUrl: 'fireblocks-recovery:/',
deriveWallet: (input) => {
if (isTransferableToken(input.assetId)) {
if (input.assetId in WalletClasses) {
TomerHFB marked this conversation as resolved.
Show resolved Hide resolved
logger.debug(`Dervied wallet for a token: ${input.assetId}`);
return new WalletClasses[input.assetId as keyof typeof WalletClasses](input, 0);
} else {
throw new Error(`Unsupported token: ${input.assetId}`);
}
}

const nativeAssetId = (getAssetConfig(input.assetId)?.nativeAsset ?? input.assetId) as keyof typeof WalletClasses;

if (nativeAssetId in WalletClasses) {
Expand Down
6 changes: 6 additions & 0 deletions apps/recovery-relay/lib/defaultRPCs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,4 +168,10 @@ export const defaultRPCs: Record<
enabled: true,
allowedEmptyValue: false,
},
Jetton: {
url: 'https://toncenter.com/api/v2/jsonRPC',
name: 'The Open Network',
enabled: true,
allowedEmptyValue: false,
},
};
185 changes: 185 additions & 0 deletions apps/recovery-relay/lib/wallets/Jetton/index.ts
TomerHFB marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import { Ton as BaseTon } from '@fireblocks/wallet-derivation';
import { JettonMaster, TonClient, WalletContractV4 } from '@ton/ton';
import { Address, beginCell, Cell, toNano } from '@ton/core';
import { AccountData } from '../types';
import { defaultTonWalletV4R2code } from '../TON/tonParams';
import axios from 'axios';
import { LateInitConnectedWallet } from '../LateInitConnectedWallet';

export class Jetton extends BaseTon implements LateInitConnectedWallet {
public memo: string | undefined;
public tokenAddress: string | undefined;
public decimals: number | undefined;
public rpcURL: string | undefined;
private client: TonClient | undefined;

private tonWallet = WalletContractV4.create({ publicKey: Buffer.from(this.publicKey.replace('0x', ''), 'hex'), workchain: 0 });

public setTokenAddress(address: string) {
this.tokenAddress = address;
}

public setDecimals(decimals: number) {
this.decimals = decimals;
}

public updateDataEndpoint(memo?: string): void {
this.memo = memo;
}

public getLateInitLabel(): string {
return 'Ton wallet client';
}

public setRPCUrl(url: string): void {
this.rpcURL = url;
}

public async getBalance(): Promise<number> {
if (this.client) {
if (!this.tokenAddress) {
this.relayLogger.error('TON Jettons: Jetton token address unavailable');
throw new Error('TON Jettons: Jetton token address unavailable');
}

const contractAddress = await this.getContractAddress();
if (!contractAddress) {
this.relayLogger.error(`TON Jettons: wallet's contract address unavailable`);
throw new Error(`TON Jettons: wallet's contract address unavailable`);
}

await new Promise((resolve) => setTimeout(resolve, 2000));

const { stack } = await this.client.runMethod(contractAddress, 'get_wallet_data');
const normalizingFactor = 10 ** this.decimals!;

return stack.readNumber() / normalizingFactor;
} else {
this.relayLogger.error('TON Jettons: Client failed to initialize');
throw new Error('TON Jettons: Client failed to initialize');
}
}

public async broadcastTx(tx: string): Promise<string> {
try {
// init the TonClient
this.init();

// parse the tx back to Ton Cell
const body = Cell.fromBoc(Buffer.from(tx, 'base64'))[0];
const pubKey = Buffer.from(this.publicKey.replace('0x', ''), 'hex');
const externalMessage = beginCell()
.storeUint(0b10, 2)
.storeUint(0, 2)
.storeAddress(Address.parse(this.address))
.storeCoins(0);

const seqno = await this.getSeqno();
if (seqno === 0) {
// for the fist transaction we initialize a state init struct which consists of init struct and code
externalMessage
.storeBit(1) // We have State Init
.storeBit(1) // We store State Init as a reference
.storeRef(await this.createStateInit(pubKey)); // Store State Init as a reference
} else {
externalMessage.storeBit(0); // We don't have state init
}
const finalExternalMessage = externalMessage.storeBit(1).storeRef(body).endCell();

if (this.client) {
// broadcast Tx and calc TxHash
await new Promise((resolve) => setTimeout(resolve, 2000));
await this.client.sendFile(finalExternalMessage.toBoc());
const txHash = finalExternalMessage.hash().toString('hex');
this.relayLogger.debug(`Jetton: Tx broadcasted: ${txHash}`);
return txHash;
} else {
throw new Error('Jetton: Client failed to initialize');
}
} catch (e) {
this.relayLogger.error(`Jetton: Error broadcasting tx: ${e}`);
if (axios.isAxiosError(e)) {
this.relayLogger.error(`Axios error: ${e.message}\n${e.response?.data}`);
}
throw e;
}
}

public async prepare(): Promise<AccountData> {
// init the TonClient
this.init();

const jettonBalance = await this.getBalance();
const contract = this.client!.open(this.tonWallet);
const tonBalance = await contract.getBalance();

// fee for token tx is hardcoded to 0.1 TON
const feeRate = Number(toNano(0.1));
await new Promise((resolve) => setTimeout(resolve, 2000));

// get seqno of the wallet, set it as exrtaParams
const seqno = await this.getSeqno();

// get the contract address of the wallet
await new Promise((resolve) => setTimeout(resolve, 2000));
const contractAddress = await this.getContractAddress();

// set extraParams
const extraParams = new Map<string, any>();
extraParams.set('seqno', seqno);
extraParams.set('contract-address', contractAddress?.toString({ bounceable: true, testOnly: false }));
extraParams.set('decimals', this.decimals);

const preperedData = {
balance: jettonBalance,
memo: this.memo,
feeRate,
extraParams,
insufficientBalance: jettonBalance <= 0,
insufficientBalanceForTokenTransfer: tonBalance < feeRate,
} as AccountData;

return preperedData;
}

private init() {
this.client = new TonClient({
endpoint: this.rpcURL!,
});
}

private async getContractAddress(): Promise<Address | undefined> {
const jettonMasterAddress = Address.parse(this.tokenAddress!);
const walletAddress = Address.parse(this.address);
const jettonMaster = this?.client?.open(JettonMaster.create(jettonMasterAddress));
return await jettonMaster?.getWalletAddress(walletAddress);
}

private async getSeqno() {
await new Promise((resolve) => setTimeout(resolve, 2000));
return await this.client!.open(this.tonWallet).getSeqno();
}

private async createStateInit(pubKey: Buffer): Promise<Cell> {
// the initial data cell our contract will hold. Wallet V4 has an extra value for plugins in the end
const dataCell = beginCell()
.storeUint(await this.getSeqno(), 32) // Seqno 0 for the first tx
.storeUint(698983191, 32) // Subwallet ID -> https://docs.ton.org/v3/guidelines/smart-contracts/howto/wallet#subwallet-ids
.storeBuffer(pubKey)
.storeBit(0) // only for Wallet V4
.endCell();

// we take a boiler place already made WalletV4R2 code
const codeCell = Cell.fromBoc(Buffer.from(defaultTonWalletV4R2code, 'base64'))[0];
const stateInit = beginCell()
.storeBit(0) // No split_depth
.storeBit(0) // No special
.storeBit(1) // We have code
.storeRef(codeCell)
.storeBit(1) // We have data
.storeRef(dataCell)
.storeBit(0) // No library
.endCell();
return stateInit;
}
}
7 changes: 3 additions & 4 deletions apps/recovery-relay/lib/wallets/TON/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Ton as BaseTon } from '@fireblocks/wallet-derivation';
import { TonClient, WalletContractV4 } from '@ton/ton';
import { beginCell, Cell, fromNano } from '@ton/core';
import { beginCell, Cell, fromNano, toNano } from '@ton/core';
import { AccountData } from '../types';
import { defaultTonWalletV4R2code } from './tonParams';
import axios from 'axios';
Expand Down Expand Up @@ -35,7 +35,6 @@ export class Ton extends BaseTon implements LateInitConnectedWallet {

public async getBalance(): Promise<number> {
if (this.client) {
await new Promise((resolve) => setTimeout(resolve, 2000));
const contract = this.client.open(this.tonWallet);
return Number(fromNano(await contract.getBalance()));
} else {
Expand Down Expand Up @@ -91,7 +90,7 @@ export class Ton extends BaseTon implements LateInitConnectedWallet {
const balance = await this.getBalance();

// fee for regular tx is hardcoded to 0.02 TON
const feeRate = 0.02;
const feeRate = Number(toNano(0.02));
await new Promise((resolve) => setTimeout(resolve, 2000));

// get seqno of the wallet, set it as exrtaParams
Expand All @@ -104,7 +103,7 @@ export class Ton extends BaseTon implements LateInitConnectedWallet {
memo: this.memo,
feeRate,
extraParams,
insufficientBalance: balance < 0.005,
insufficientBalance: Number(toNano(balance)) - feeRate < Number(toNano(0.005)), // 0.005 is minimum amount for transfer
} as AccountData;

return preperedData;
Expand Down
16 changes: 16 additions & 0 deletions apps/recovery-relay/lib/wallets/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { getAllJettons } from '@fireblocks/asset-config';
import { Cardano } from './ADA';
import { Cosmos } from './ATOM';
import { Bitcoin, BitcoinCash, BitcoinSV, DASH, DogeCoin, LiteCoin, ZCash } from './BTCBased';
Expand Down Expand Up @@ -37,8 +38,22 @@ import { Algorand } from './ALGO';
import { Celestia } from './CELESTIA';
import { CoreDAO } from './EVM/CORE_COREDAO';
import { Ton } from './TON';
import { Jetton } from './Jetton';
export { ConnectedWallet } from './ConnectedWallet';

const fillJettons = () => {
const jettonsList = getAllJettons();
const jettons = jettonsList.reduce(
(prev, curr) => ({
...prev,
[curr]: Jetton,
}),
{},
) as any;
Object.keys(jettons).forEach((key) => (jettons[key] === undefined ? delete jettons[key] : {}));
return jettons;
};

export const WalletClasses = {
ALGO: Algorand,
ALGO_TEST: Algorand,
Expand Down Expand Up @@ -121,6 +136,7 @@ export const WalletClasses = {
CELESTIA_TEST: Celestia,
TON: Ton,
TON_TEST: Ton,
...fillJettons(),
} as const;

type WalletClass = (typeof WalletClasses)[keyof typeof WalletClasses];
Expand Down
1 change: 1 addition & 0 deletions apps/recovery-relay/lib/wallets/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export type AccountData = {
extraParams?: Map<string, any>;
endpoint?: string;
insufficientBalance?: boolean;
insufficientBalanceForTokenTransfer?: boolean;
};

export type TxPayload = {
Expand Down
Loading