@ledgerhq/coin-ton
Version:
339 lines (302 loc) • 9.65 kB
text/typescript
import { decodeAccountId } from "@ledgerhq/coin-framework/account/index";
import {
Builder,
SendMode,
Address as TonAddress,
WalletContractV4,
comment,
internal,
toNano,
} from "@ton/ton";
import BigNumber from "bignumber.js";
import { estimateFee } from "./bridge/bridgeHelpers/api";
import { getCoinConfig } from "./config";
import {
JettonOpCode,
MAX_COMMENT_BYTES,
TOKEN_TRANSFER_FORWARD_AMOUNT,
TOKEN_TRANSFER_MAX_FEE,
TOKEN_TRANSFER_QUERY_ID,
WORKCHAIN,
} from "./constants";
import {
KnownJetton,
TonAccount,
TonCell,
TonComment,
TonPayloadJettonTransfer,
TonSubAccount,
TonTransaction,
Transaction,
} from "./types";
/**
* Checks if the given recipient address is valid.
*/
export const isAddressValid = (recipient: string) => {
try {
return Boolean(
(TonAddress.isRaw(recipient) || TonAddress.isFriendly(recipient)) &&
TonAddress.parse(recipient),
);
} catch {
return false;
}
};
/**
* Compares two addresses to check if they are equal.
*/
export const addressesAreEqual = (addr1: string, addr2: string) => {
try {
return (
isAddressValid(addr1) &&
isAddressValid(addr2) &&
TonAddress.parse(addr1).equals(TonAddress.parse(addr2))
);
} catch {
return false;
}
};
/**
* Returns the known jetton ID and workchain for a given token address.
* Returns null if the token is not found in the known jettons list.
*/
function getKnownJettonId(tokenAddress: string, knownJettons: KnownJetton[]) {
const index = knownJettons.findIndex(jetton => jetton.masterAddress.toString() === tokenAddress);
return index > -1 ? { jettonId: index, workchain: WORKCHAIN } : null;
}
/**
* Finds a sub-account by its ID in a TON account.
* Returns undefined if no matching sub-account is found.
*/
export function findSubAccountById(account: TonAccount, id: string): TonSubAccount | undefined {
return account.subAccounts?.find(a => a.id === id) as TonSubAccount | undefined;
}
/**
* Builds a TonTransaction object based on the given transaction details.
*/
export function buildTonTransaction(
transaction: Transaction,
seqno: number,
account: TonAccount,
): TonTransaction {
const {
subAccountId,
useAllAmount,
amount,
comment: commentTx,
recipient,
payload,
} = transaction;
let recipientParsed = recipient;
// if recipient is not valid calculate fees with empty address
// we handle invalid addresses in account bridge
try {
TonAddress.parse(recipientParsed);
} catch {
recipientParsed = new TonAddress(0, Buffer.alloc(32)).toRawString();
}
// if there is a sub account, the transaction is a token transfer
const subAccount = findSubAccountById(account, subAccountId ?? "");
if (subAccount && !subAccount.jettonWallet) {
throw new Error("[ton] jetton wallet not found");
}
const finalAmount = subAccount
? toNano(TOKEN_TRANSFER_MAX_FEE) // for commission fees, excess will be returned
: useAllAmount
? BigInt(0)
: BigInt(amount.toFixed());
const to = subAccount?.jettonWallet ?? recipientParsed;
const tonTransaction: TonTransaction = {
to: TonAddress.parse(to),
seqno,
amount: finalAmount,
bounce: TonAddress.isFriendly(to) ? TonAddress.parseFriendly(to).isBounceable : true,
timeout: getTransferExpirationTime(),
sendMode:
useAllAmount && !subAccount
? SendMode.CARRY_ALL_REMAINING_BALANCE
: SendMode.IGNORE_ERRORS + SendMode.PAY_GAS_SEPARATELY,
payload,
};
if (commentTx.text.length) {
tonTransaction.payload = { type: "comment", text: commentTx.text };
}
if (subAccount) {
const forwardPayload = commentTx.text.length ? comment(commentTx.text) : null;
const currencyConfig = getCoinConfig();
const knownJettons = currencyConfig.infra.KNOWN_JETTONS;
tonTransaction.payload = {
type: "jetton-transfer",
queryId: BigInt(TOKEN_TRANSFER_QUERY_ID),
amount: BigInt(amount.toFixed()),
destination: TonAddress.parse(recipientParsed),
responseDestination: TonAddress.parse(account.freshAddress),
customPayload: null,
forwardAmount: BigInt(TOKEN_TRANSFER_FORWARD_AMOUNT),
forwardPayload,
knownJetton: knownJettons
? getKnownJettonId(subAccount?.token.contractAddress, knownJettons)
: null,
};
}
return tonTransaction;
}
/**
* Validates if the given comment is valid.
*/
export const commentIsValid = (msg: TonComment) =>
!msg.isEncrypted && msg.text.length <= MAX_COMMENT_BYTES && /^[\x20-\x7F]*$/.test(msg.text);
/**
* Gets the transfer expiration time.
*/
export const getTransferExpirationTime = () => Math.floor(Date.now() / 1000 + 60);
/**
* Estimates the fees for a Ton transaction.
*/
export const getTonEstimatedFees = async (
account: TonAccount,
needsInit: boolean,
tx: TonTransaction,
) => {
const { xpubOrAddress: pubKey } = decodeAccountId(account.id);
if (pubKey.length !== 64) throw Error("[ton] pubKey can't be found");
// build body depending the payload type
let body: TonCell | undefined;
let isJetton: boolean = false;
if (tx.payload) {
switch (tx.payload.type) {
case "comment":
body = comment(tx.payload.text);
break;
case "jetton-transfer":
body = buildTokenTransferBody(tx.payload);
isJetton = true;
break;
}
}
const contract = WalletContractV4.create({ workchain: 0, publicKey: Buffer.from(pubKey, "hex") });
const transfer = contract.createTransfer({
seqno: tx.seqno,
secretKey: Buffer.alloc(64), // secretKey set to 0, signature is not verified
messages: [
internal({
bounce: tx.bounce,
to: tx.to,
value: tx.amount,
body,
}),
],
sendMode: tx.sendMode,
});
const initCode = needsInit ? contract.init.code.toBoc().toString("base64") : undefined;
const initData = needsInit ? contract.init.data.toBoc().toString("base64") : undefined;
const fee = await estimateFee(
account.freshAddress,
transfer.toBoc().toString("base64"),
initCode,
initData,
);
return isJetton
? BigNumber(toNano(TOKEN_TRANSFER_MAX_FEE).toString())
: BigNumber(fee.fwd_fee + fee.gas_fee + fee.in_fwd_fee + fee.storage_fee);
};
/**
* Converts a Ledger path string to an array of numbers.length.
*/
export const getLedgerTonPath = (path: string): number[] => {
const numPath: number[] = [];
if (!path) throw Error("[ton] Path is empty");
if (path.startsWith("m/")) path = path.slice(2);
const pathEntries = path.split("/");
if (pathEntries.length !== 6) throw Error(`[ton] Path length is not right ${path}`);
for (const entry of pathEntries) {
if (!entry.endsWith("'")) throw Error(`[ton] Path entry is not hardened ${path}`);
const num = parseInt(entry.slice(0, entry.length - 1));
if (!Number.isInteger(num) || num < 0 || num >= 0x80000000)
throw Error(`[ton] Path entry is not right ${path}`);
numPath.push(num);
}
return numPath;
};
/**
* Builds the body of a token transfer transaction.
*/
function buildTokenTransferBody(params: TonPayloadJettonTransfer): TonCell {
const { queryId, amount, destination, responseDestination, forwardAmount } = params;
let forwardPayload = params.forwardPayload;
let builder = new Builder()
.storeUint(JettonOpCode.Transfer, 32)
.storeUint(queryId ?? generateQueryId(), 64)
.storeCoins(amount)
.storeAddress(destination)
.storeAddress(responseDestination)
.storeBit(false)
.storeCoins(forwardAmount ?? BigInt(0));
if (forwardPayload instanceof Uint8Array) {
forwardPayload = packBytesAsSnake(forwardPayload);
}
if (!forwardPayload) {
builder.storeBit(false);
} else if (typeof forwardPayload === "string") {
builder = builder.storeBit(false).storeUint(0, 32).storeBuffer(Buffer.from(forwardPayload));
} else if (forwardPayload instanceof Uint8Array) {
builder = builder.storeBit(false).storeBuffer(Buffer.from(forwardPayload));
} else {
builder = builder.storeBit(true).storeRef(forwardPayload);
}
return builder.endCell();
}
/**
* Generates a random BigInt of the specified byte length.
*/
function bigintRandom(bytes: number) {
let value = BigInt(0);
for (const randomNumber of randomBytes(bytes)) {
const randomBigInt = BigInt(randomNumber);
value = (value << BigInt(8)) + randomBigInt;
}
return value;
}
/**
* Generates a random byte array of the specified size.
*/
function randomBytes(size: number) {
return self.crypto.getRandomValues(new Uint8Array(size));
}
/**
* Generates a random query ID.
*/
function generateQueryId() {
return bigintRandom(8);
}
/**
* Packs a byte array into a TonCell using a snake-like structure.
*/
function packBytesAsSnake(bytes: Uint8Array): TonCell {
return packBytesAsSnakeCell(bytes);
}
/**
* Packs a byte array into a TonCell using a snake-like structure.
*/
function packBytesAsSnakeCell(bytes: Uint8Array): TonCell {
const buffer = Buffer.from(bytes);
const mainBuilder = new Builder();
let prevBuilder: Builder | undefined;
let currentBuilder = mainBuilder;
for (const [i, byte] of buffer.entries()) {
if (currentBuilder.availableBits < 8) {
prevBuilder?.storeRef(currentBuilder);
prevBuilder = currentBuilder;
currentBuilder = new Builder();
}
currentBuilder = currentBuilder.storeUint(byte, 8);
if (i === buffer.length - 1) {
prevBuilder?.storeRef(currentBuilder);
}
}
return mainBuilder.asCell();
}
export enum BotScenario {
DEFAULT = "default",
TOKEN_TRANSFER = "token-transfer",
}