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: ✨ add jetton withdrawal support #103

Closed
wants to merge 1 commit into from
Closed
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 @@ -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