UNPKG

@chorus-one/ton

Version:

All-in-one tooling for building staking dApps on TON

527 lines (526 loc) 24.5 kB
"use strict"; 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;