Skip to content

Commit

Permalink
feat: ✨ add jetton withdrawal support
Browse files Browse the repository at this point in the history
  • Loading branch information
TomerHFB committed Dec 12, 2024
1 parent dbaf168 commit 0640ccb
Show file tree
Hide file tree
Showing 23 changed files with 517 additions and 31 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ 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 +150,7 @@ export const CreateTransaction = ({ asset, inboundRelayParams, setSignTxResponse

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

const derivation = wallet?.derivations?.get(fromAddress);
const derivation = wallet?.derivations?.get(`${asset?.id}-${fromAddress}`); // fix for token support

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

Expand All @@ -166,6 +167,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 +427,10 @@ export const CreateTransaction = ({ asset, inboundRelayParams, setSignTxResponse
<Typography variant='body1' color={(theme) => theme.palette.error.main}>
Insufficient balance for transaction
</Typography>
) : prepareQuery.data?.insufficientFeeBalance === 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 +440,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?.insufficientFeeBalance !== undefined &&
prepareQuery.data.insufficientFeeBalance)
}
>
Prepare Transaction
Expand Down
4 changes: 3 additions & 1 deletion apps/recovery-relay/components/WithdrawModal/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,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(
`${inboundRelayParams?.signedTx.assetId}-${inboundRelayParams?.signedTx.from}`,
); // fix for token support

const rpcUrl = getAssetURL(derivation?.assetId ?? '', RPCs);
if (rpcUrl === undefined) {
Expand Down
10 changes: 9 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,14 @@ export const WorkspaceProvider = ({ children }: Props) => {
app: 'relay',
relayBaseUrl: 'fireblocks-recovery:/',
deriveWallet: (input) => {
if (isTransferableToken(input.assetId)) {
if (input.assetId in WalletClasses) {
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,
},
};
186 changes: 186 additions & 0 deletions apps/recovery-relay/lib/wallets/Jetton/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import { Ton as BaseTon } from '@fireblocks/wallet-derivation';
import { JettonMaster, TonClient, WalletContractV4 } from '@ton/ton';
import { Address, beginCell, Cell, fromNano, 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 setTokenAddress(address: string) {
this.tokenAddress = address;
}

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

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

public getLateInitLabel(): string {
throw new Error('Method not implemented.');
}

public rpcURL: string | undefined;

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

private client: TonClient | undefined;

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

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

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);
}

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,
insufficientFeeBalance: tonBalance < feeRate,
} as AccountData;

return preperedData;
}
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
4 changes: 4 additions & 0 deletions apps/recovery-relay/lib/wallets/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ 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';

export const WalletClasses = {
Expand Down Expand Up @@ -121,6 +122,9 @@ export const WalletClasses = {
CELESTIA_TEST: Celestia,
TON: Ton,
TON_TEST: Ton,
USDT_TON: Jetton,
NOTCOIN_TON: Jetton,
DOGS_TON: Jetton,
} 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;
insufficientFeeBalance?: boolean;
};

export type TxPayload = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,9 @@ export const SignTransaction = ({ txId, account, asset, inboundRelayParams }: Pr

const { to, amount, misc } = unsignedTx;

const derivation = account.wallets.get(asset.id)?.derivations.get(unsignedTx.from);
const derivation = account.wallets
.get(asset.id)
?.derivations.get(`${inboundRelayParams?.unsignedTx.assetId}-${inboundRelayParams?.unsignedTx.from}`);

if (!derivation) {
throw new Error('Derivation not found');
Expand Down
10 changes: 9 additions & 1 deletion apps/recovery-utility/renderer/context/Workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { handleRelayUrl } from '../lib/ipc/handleRelayUrl';
import { SigningWallet } from '../lib/wallets/SigningWallet';
import { useSettings } from './Settings';
import { LOGGER_NAME_UTILITY } 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 @@ -65,7 +66,14 @@ export const WorkspaceProvider = ({ children }: Props) => {
app: 'utility',
relayBaseUrl,
deriveWallet: (input) => {
const nativeAssetId = (getAssetConfig(input.assetId)?.nativeAsset ?? input.assetId) as keyof typeof WalletClasses;
let transferableToken = false;
const config = getAssetConfig(input.assetId);
if (config?.address && isTransferableToken(input.assetId)) {
transferableToken = true;
}
const nativeAssetId = (
transferableToken ? input.assetId : config?.nativeAsset ?? input.assetId
) as keyof typeof WalletClasses;

logger.info('Deriving native asset', nativeAssetId);

Expand Down
Loading

0 comments on commit 0640ccb

Please sign in to comment.