@chorus-one/ton
Version:
All-in-one tooling for building staking dApps on TON
527 lines (526 loc) • 24.5 kB
JavaScript
;
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.getDefaultGas = exports.getRandomQueryId = exports.defaultValidUntil = exports.TonBaseStaker = void 0;
const axios_1 = __importStar(require("axios"));
const ton_1 = require("@ton/ton");
const crypto_1 = require("@ton/crypto");
const crypto_primitives_1 = require("@ton/crypto-primitives");
const TonClient_1 = require("./TonClient");
const tx_1 = require("./tx");
/**
* This class provides the functionality to stake assets on the Ton network.
*
* It also provides the ability to retrieve staking information and rewards for a delegator.
*/
class TonBaseStaker {
/** @ignore */
networkConfig;
/** @ignore */
addressDerivationConfig;
/** @ignore */
client;
/**
* This **static** method is used to derive an address from a public key.
*
* It can be used for signer initialization, e.g. `FireblocksSigner` or `LocalSigner`.
*
* @param params - Parameters for the address derivation
* @param params.addressDerivationConfig - TON address derivation configuration
*
* @returns Returns a single address derived from the public key
*/
static getAddressDerivationFn = (params) => async (publicKey, _derivationPath) => {
const { walletContractVersion, workchain, bounceable, testOnly, urlSafe } = params?.addressDerivationConfig ?? defaultAddressDerivationConfig();
// NOTE: different wallet versions may result in different addresses
const wallet = getWalletContract(walletContractVersion, workchain, Buffer.from(publicKey));
return [wallet.address.toString({ bounceable, urlSafe, testOnly })];
};
/**
* This **static** method is used to convert BIP39 mnemonic to seed. In TON
* network the seed is used as a private key.
*
* It can be used for signer initialization, e.g. `FireblocksSigner` or `LocalSigner`.
*
* @param params.addressDerivationConfig - TON address derivation configuration
*
* @returns Returns a seed derived from the mnemonic
*/
static getMnemonicToSeedFn = (params) => async (mnemonic, password) => {
const { isBIP39 } = params?.addressDerivationConfig ?? defaultAddressDerivationConfig();
// the logic is based on the following implementation:
// https://github.com/xssnick/tonutils-go/blob/619c2aa1f6b992997bf322f8f9bfc4ae036a5181/ton/wallet/seed.go#L82
if (isBIP39) {
const pass = password ?? '';
return await (0, crypto_primitives_1.pbkdf2_sha512)(mnemonic, 'mnemonic' + pass, 2048, 64);
}
const seed = await (0, crypto_1.mnemonicToSeed)(mnemonic.split(' '), 'TON default seed', password);
return seed.slice(0, 32);
};
/**
* This **static** method is used to convert a seed to a keypair. Note that
* TON network doesn't use BIP44 HD Path for address derivation.
*
* It can be used for signer initialization, e.g. `FireblocksSigner` or `LocalSigner`.
*
* @param params.addressDerivationConfig - TON address derivation configuration
*
* @returns Returns a public and private keypair derived from the seed
*/
static getSeedToKeypairFn = (params) => async (seed, hdPath) => {
const { isBIP39 } = params?.addressDerivationConfig ?? defaultAddressDerivationConfig();
// the logic is based on the following implementation:
// https://github.com/xssnick/tonutils-go/blob/619c2aa1f6b992997bf322f8f9bfc4ae036a5181/ton/wallet/seed.go#L82
let newSeed = Buffer.from(seed);
if (isBIP39) {
const path = hdPath
? hdPath
.replace('m/', '')
.split('/')
.map((x) => parseInt(x))
: [];
newSeed = await (0, crypto_1.deriveEd25519Path)(newSeed, path);
}
const keypair = (0, crypto_1.keyPairFromSeed)(newSeed);
return {
publicKey: keypair.publicKey,
privateKey: keypair.secretKey.slice(0, 32)
};
};
/**
* This creates a new TonStaker instance.
*
* @param params - Initialization parameters
* @param params.rpcUrl - RPC URL (e.g. https://toncenter.com/api/v2/jsonRPC)
* @param params.allowSeamlessWalletDeployment - (Optional) If enabled, the wallet contract is deployed automatically when needed
* @param params.allowTransferToInactiveAccount - (Optional) Allow token transfers to inactive accounts
* @param params.minimumExistentialBalance - (Optional) The amount of TON to keep in the wallet
* @param params.addressDerivationConfig - (Optional) TON address derivation configuration
*
* @returns An instance of TonStaker.
*/
constructor(params) {
const networkConfig = {
...params,
allowSeamlessWalletDeployment: params.allowSeamlessWalletDeployment ?? false,
allowTransferToInactiveAccount: params.allowTransferToInactiveAccount ?? false,
minimumExistentialBalance: params.minimumExistentialBalance ?? '5',
addressDerivationConfig: params.addressDerivationConfig ?? defaultAddressDerivationConfig()
};
this.networkConfig = networkConfig;
const cfg = networkConfig.addressDerivationConfig;
this.networkConfig.addressDerivationConfig = cfg;
this.addressDerivationConfig = cfg;
}
/**
* Initializes the TonStaker instance and connects to the blockchain.
*
* @returns A promise which resolves once the TonStaker instance has been initialized.
*/
async init() {
const rateLimitRetryAdapter = async (config) => {
const maxRetries = 10;
const retryDelay = 1000;
let retries = 0;
const defaultAdapter = axios_1.default.getAdapter(axios_1.default.defaults.adapter);
if (!defaultAdapter) {
throw new Error('Axios default adapter is not available');
}
while (retries <= maxRetries) {
try {
// Send the request using the default adapter
return await defaultAdapter({
...config,
headers: axios_1.AxiosHeaders.from({
...(config.headers || {}),
'Content-Type': 'application/json'
})
});
}
catch (err) {
const error = err;
const status = error.response?.status;
// If rate limit hit (429), wait and retry
if (status === 429 && retries < maxRetries) {
retries += 1;
console.log(`Rate limit hit, try ${retries}/${maxRetries}`);
await new Promise((resolve) => setTimeout(resolve, retryDelay));
}
else {
throw error;
}
}
}
// Should never reach this point
throw new Error(`Rate limit exceeded after ${maxRetries} retries.`);
};
this.client = new TonClient_1.TonClient({
endpoint: this.networkConfig.rpcUrl,
httpAdapter: rateLimitRetryAdapter
});
}
/** @ignore */
async buildTransferTx(params) {
const client = this.getClient();
const { destinationAddress, amount, validUntil, memo } = params;
// ensure the address is for the right network
this.checkIfAddressTestnetFlagMatches(destinationAddress);
// To transfer tokens to inactive account (without wallet contract deployed)
// one should send transaction with non-bounce mode. Source:
// https://docs.ton.org/develop/dapps/asset-processing/#wallet-deployment
//
// To minimize the risk of losing tokens, we should check if the destination
// address has an active wallet contract deployed. If the amount of tokens
// is small then we allow the unbouncable mode transfer. Otherwise the code
// bails out.
//
// This is based on the recommendation from the TON SDK:
// https://docs.ton.org/develop/smart-contracts/guidelines/non-bouncable-messages
let bounceable = true;
const parsedAddress = ton_1.Address.parse(destinationAddress);
const state = await client.getContractState(parsedAddress);
if (state.state !== 'active') {
if ((0, ton_1.toNano)(amount) > (0, ton_1.toNano)('5') && !this.networkConfig.allowTransferToInactiveAccount) {
throw new Error(`contract at ${destinationAddress} is not active: ${state.state} and the amount is too large (>5) to send in non-bounceable mode`);
}
bounceable = false;
}
const tx = {
validUntil: defaultValidUntil(validUntil),
messages: [
{
address: destinationAddress,
bounceable: bounceable,
amount: (0, ton_1.toNano)(amount),
payload: memo ?? ''
}
]
};
return { tx };
}
/**
* Builds a wallet deployment transaction
*
* @param params - Parameters for building the transaction
* @param params.address - The address to deploy the wallet contract to
* @param params.validUntil - (Optional) The Unix timestamp when the transaction expires
*
* @returns Returns a promise that resolves to a TON wallet deployment transaction.
*/
async buildDeployWalletTx(params) {
const client = this.getClient();
const { address, validUntil } = params;
// ensure the address is for the right network
this.checkIfAddressTestnetFlagMatches(address);
const isDeployed = await client.isContractDeployed(ton_1.Address.parse(address));
if (isDeployed) {
throw new Error('wallet contract is already deployed');
}
// To deploy a wallet contract we need to setup a non-bounceable message
// with empty payload and non-empty stateInit
// * bounceable - is set to false at the external message stage
// * stateInit - is set at the `sign` method
//
// reference: https://docs.ton.org/develop/smart-contracts/guidelines/non-bouncable-messages
const tx = {
validUntil: defaultValidUntil(validUntil)
};
return { tx };
}
/** @ignore */
async getBalance(params) {
const client = this.getClient();
const { address } = params;
// ensure the address is for the right network
this.checkIfAddressTestnetFlagMatches(address);
const amount = await client.getBalance(ton_1.Address.parse(address));
return { amount: (0, ton_1.fromNano)(amount) };
}
/**
* Signs a transaction using the provided signer.
*
* @param params - Parameters for the signing process
* @param params.signer - Signer instance
* @param params.signerAddress - The address of the signer
* @param params.tx - The transaction to sign
*
* @returns A promise that resolves to an object containing the signed transaction.
*/
async sign(params) {
const client = this.getClient();
const { signer, signerAddress, tx } = params;
// ensure the address is for the right network
this.checkIfAddressTestnetFlagMatches(signerAddress);
const pk = await signer.getPublicKey(signerAddress);
const wallet = getWalletContract(this.addressDerivationConfig.walletContractVersion, this.addressDerivationConfig.workchain, Buffer.from(pk));
let internalMsgs = [];
const msgs = tx.messages;
if (msgs !== undefined && msgs.length > 0) {
msgs.map((msg) => {
if (msg.payload === undefined) {
throw new Error('missing payload');
}
// ensure the address is for the right network
this.checkIfAddressTestnetFlagMatches(msg.address);
// ensure the balance is above the minimum existential balance
this.checkMinimumExistentialBalance(signerAddress, (0, ton_1.fromNano)(msg.amount));
// TON TEP-0002 defines how the flags within the address should be handled
// reference: https://github.com/ton-blockchain/TEPs/blob/master/text/0002-address.md#wallets-applications
//
// The Chorus TON SDK is not strictly a wallet application, so we follow most (but not all) of the rules.
// Specifically, we force the bounce flag wherever possible (for safety), but don't enforce user to specify
// the bounceable source address. This is because a user may use fireblocks signer where the address is in non-bounceable format.
internalMsgs.push((0, ton_1.internal)({
value: msg.amount,
bounce: msg.bounceable,
to: msg.address,
body: msg.payload,
init: msg.stateInit
}));
});
}
else {
internalMsgs = [];
}
const contract = client.open(wallet);
const seqno = await contract.getSeqno();
// safety check for createWalletTransferV4
if (this.addressDerivationConfig.walletContractVersion !== 4) {
throw new Error('unsupported wallet contract version');
}
const txArgs = {
seqno,
// As explained here: https://docs.ton.org/develop/smart-contracts/messages#message-modes
// IGNORE_ERRORS ignores only selected errors, such as insufficient funds etc
//
// The lack of that flag in case of insufficient funds would result in the internal mesasge being executed
// "in a loop" until the transaction expires, effectively draining the account (by incurring fees).
//
// To confirm this is a 'recommended practice' here are a few references from other wallets:
// * tonweb - https://github.com/toncenter/tonweb/blob/76dfd0701714c0a316aee503c2962840acaf74ef/src/contract/wallet/WalletContract.js#L184
// * tonkeeper - https://github.com/tonkeeper/wallet/blob/7452e11f8c6313f5a1f60bbc93e1b6a5e858470e/packages/mobile/src/blockchain/wallet.ts#L401
//
// The unfortunate consequence of the transaction error being ignored, the TX status is a success. This can be misleading to end user.
sendMode: ton_1.SendMode.PAY_GAS_SEPARATELY + ton_1.SendMode.IGNORE_ERRORS,
walletId: wallet.walletId,
messages: internalMsgs,
timeout: tx.validUntil
};
const preparedTx = (0, tx_1.createWalletTransferV4)(txArgs);
const signingData = {
tx,
txArgs,
txCell: preparedTx
};
// sign transaction via signer
const signedTx = await (0, tx_1.sign)(signerAddress, signer, signingData);
// decide whether to deploy wallet contract along with the transaction
const isContractDeployed = await client.isContractDeployed(ton_1.Address.parse(signerAddress));
let shouldDeployWallet = false;
if (!isContractDeployed) {
// if contract is missing and there is no messages, it must be the init transaction
if (internalMsgs.length === 0) {
shouldDeployWallet = true;
}
else if (this.networkConfig.allowSeamlessWalletDeployment) {
shouldDeployWallet = true;
}
else {
throw new Error('wallet contract is not deployed and seamless wallet deployment is disabled');
}
}
return {
tx: signedTx,
address: signerAddress,
txHash: signedTx.hash().toString('hex'),
stateInit: shouldDeployWallet ? wallet.init : undefined
};
}
/**
* This method is used to broadcast a signed transaction to the TON network.
*
* @param params - Parameters for the broadcast
* @param params.signedTx - The signed transaction to be broadcasted
*
* @returns Returns a promise that resolves to the response of the transaction that was broadcast to the network.
*/
async broadcast(params) {
const client = this.getClient();
const { signedTx } = params;
const external = await (0, tx_1.externalMessage)(client, ton_1.Address.parse(signedTx.address), signedTx.tx, signedTx.stateInit);
await client.sendFile(external.toBoc());
return external.hash().toString('hex');
}
/**
* Retrieves the status of a transaction using the transaction hash.
*
* This method is intended to check for transactions made recently (within limit) and not for historical transactions.
*
* @param params - Parameters for the transaction status request
* @param params.address - The account address to query
* @param params.txHash - The transaction hash to query
* @param params.limit - (Optional) The maximum number of transactions to fetch
*
* @returns A promise that resolves to an object containing the transaction status.
*/
async getTxStatus(params) {
const transaction = await this.getTransactionByHash(params);
return this.matchTransactionStatus(transaction);
}
/** @ignore */
matchTransactionStatus(transaction) {
if (transaction === undefined) {
return { status: 'unknown', receipt: null };
}
if (transaction.description.type === 'generic') {
const description = transaction.description;
if (description.aborted) {
return { status: 'failure', receipt: transaction, reason: 'aborted' };
}
if (description.computePhase.type === 'vm') {
const compute = description.computePhase;
if (description.actionPhase && description.actionPhase?.resultCode === 50 && compute.exitCode === 0) {
return { status: 'failure', receipt: transaction, reason: 'out_of_storage' };
}
if (compute.exitCode !== 0 || compute.success === false) {
return { status: 'failure', receipt: transaction, reason: 'compute_phase' };
}
}
if (description.actionPhase) {
const action = description.actionPhase;
if (action.success === false || action.valid === false) {
return { status: 'failure', receipt: transaction, reason: 'action_phase' };
}
}
// the transaction bounced if this is present (so likely it bounced due to error in the contract)
if (description.bouncePhase) {
return { status: 'failure', receipt: transaction, reason: 'bounce_phase' };
}
}
// at this point we can assume the transaction was successful
return { status: 'success', receipt: transaction };
}
/** @ignore */
async getTransactionByHash(params) {
const client = this.getClient();
const { address, txHash, limit } = params;
const transactions = await client.getTransactions(ton_1.Address.parse(address), { limit: limit ?? 10 });
const transaction = transactions.find((tx) => {
// Check tx hash
if (tx.hash().toString('hex') === txHash)
return true;
// Check inMessage tx hash(that is the one we get from broadcast method)
if (tx.inMessage && (0, ton_1.beginCell)().store((0, ton_1.storeMessage)(tx.inMessage)).endCell().hash().toString('hex') === txHash)
return true;
return false;
});
return transaction;
}
/** @ignore */
getClient() {
if (!this.client) {
throw new Error('TonStaker not initialized. Did you forget to call init()?');
}
return this.client;
}
/** @ignore */
checkIfAddressTestnetFlagMatches(address) {
const addr = ton_1.Address.parseFriendly(address);
if (addr.isTestOnly !== this.addressDerivationConfig.testOnly) {
if (addr.isTestOnly) {
throw new Error(`address ${address} is a testnet address but the configuration is for mainnet`);
}
else {
throw new Error(`address ${address} is a mainnet address but the configuration is for testnet`);
}
}
}
/** @ignore */
async checkMinimumExistentialBalance(address, amount) {
const balance = await this.getBalance({ address });
const minBalance = this.networkConfig.minimumExistentialBalance;
if ((0, ton_1.toNano)(balance.amount) - (0, ton_1.toNano)(amount) < (0, ton_1.toNano)(minBalance)) {
throw new Error(`sending ${amount} would result in balance below the minimum existential balance of ${minBalance} for address ${address}`);
}
}
// NOTE: this method is used only by Nominator and SingleNominator stakers, not by Pool
/** @ignore */
async getNominatorContractPoolData(contractAddress) {
const client = this.getClient();
const response = await client.runMethod(ton_1.Address.parse(contractAddress), 'get_pool_data', []);
// reference: https://github.com/ton-blockchain/nominator-pool/blob/main/func/pool.fc#L198
if (response.stack.remaining !== 17) {
throw new Error('invalid get_pool_data response, expected 17 fields got ' + response.stack.remaining);
}
const skipN = (n) => {
for (let i = 0; i < n; i++) {
response.stack.skip();
}
};
skipN(1);
const nominators_count = response.stack.readNumber(); // index: 1
skipN(4);
const max_nominators_count = response.stack.readNumber(); // index: 6
skipN(1);
const min_nominator_stake = response.stack.readBigNumber(); // index: 8
return {
nominators_count,
max_nominators_count,
min_nominator_stake
};
}
}
exports.TonBaseStaker = TonBaseStaker;
function defaultAddressDerivationConfig() {
return {
walletContractVersion: 4,
workchain: 0,
bounceable: false,
testOnly: false,
urlSafe: true,
isBIP39: false
};
}
// scaffold for future wallet releases
function getWalletContract(version, workchain, publicKey, walletId) {
switch (version) {
case 4:
return ton_1.WalletContractV4.create({ workchain, publicKey, walletId });
default:
throw new Error('unsupported wallet contract version');
}
}
function defaultValidUntil(validUntil) {
return validUntil ?? Math.floor(Date.now() / 1000) + 180; // 3 minutes
}
exports.defaultValidUntil = defaultValidUntil;
function getRandomQueryId() {
return Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
}
exports.getRandomQueryId = getRandomQueryId;
function getDefaultGas() {
return 100000;
}
exports.getDefaultGas = getDefaultGas;