UNPKG

@chorus-one/ton

Version:

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

631 lines (543 loc) 22.9 kB
import axios, { AxiosAdapter, AxiosError, AxiosHeaders, AxiosResponse, InternalAxiosRequestConfig } from 'axios' import type { Signer } from '@chorus-one/signer' import type { TonNetworkConfig, TonSigningData, AddressDerivationConfig, UnsignedTx, SignedTx, TonTxStatus, PoolData } from './types' import { toNano, fromNano, WalletContractV4, internal, MessageRelaxed, SendMode, Address, TransactionDescriptionGeneric, beginCell, storeMessage, Transaction } from '@ton/ton' import { mnemonicToSeed, deriveEd25519Path, keyPairFromSeed } from '@ton/crypto' import { pbkdf2_sha512 } from '@ton/crypto-primitives' import { TonClient } from './TonClient' import { createWalletTransferV4, externalMessage, sign } from './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. */ export class TonBaseStaker { /** @ignore */ protected readonly networkConfig: Required<TonNetworkConfig> /** @ignore */ protected readonly addressDerivationConfig: AddressDerivationConfig /** @ignore */ protected client?: TonClient /** * 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?: { addressDerivationConfig: AddressDerivationConfig | undefined }) => async (publicKey: Uint8Array, _derivationPath: string): Promise<Array<string>> => { 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?: { addressDerivationConfig: AddressDerivationConfig | undefined }) => async (mnemonic: string, password?: string): Promise<Uint8Array> => { 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 pbkdf2_sha512(mnemonic, 'mnemonic' + pass, 2048, 64) } const seed = await 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?: { addressDerivationConfig: AddressDerivationConfig | undefined }) => async (seed: Uint8Array, hdPath?: string): Promise<{ publicKey: Uint8Array; privateKey: Uint8Array }> => { 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 deriveEd25519Path(newSeed, path) } const keypair = 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: { rpcUrl: string allowSeamlessWalletDeployment?: boolean allowTransferToInactiveAccount?: boolean minimumExistentialBalance?: string addressDerivationConfig?: AddressDerivationConfig }) { 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 (): Promise<void> { const rateLimitRetryAdapter: AxiosAdapter = async (config: InternalAxiosRequestConfig): Promise<AxiosResponse> => { const maxRetries = 10 const retryDelay = 1000 let retries = 0 const defaultAdapter = axios.getAdapter(axios.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: AxiosHeaders.from({ ...(config.headers || {}), 'Content-Type': 'application/json' }) }) } catch (err) { const error = err as AxiosError 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({ endpoint: this.networkConfig.rpcUrl, httpAdapter: rateLimitRetryAdapter }) } /** @ignore */ async buildTransferTx (params: { destinationAddress: string amount: string validUntil?: number memo?: string }): Promise<{ tx: UnsignedTx }> { 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 = Address.parse(destinationAddress) const state = await client.getContractState(parsedAddress) if (state.state !== 'active') { if (toNano(amount) > 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: 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: { address: string; validUntil?: number }): Promise<{ tx: UnsignedTx }> { const client = this.getClient() const { address, validUntil } = params // ensure the address is for the right network this.checkIfAddressTestnetFlagMatches(address) const isDeployed = await client.isContractDeployed(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: { address: string }): Promise<{ amount: string }> { const client = this.getClient() const { address } = params // ensure the address is for the right network this.checkIfAddressTestnetFlagMatches(address) const amount = await client.getBalance(Address.parse(address)) return { amount: 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: { signer: Signer; signerAddress: string; tx: UnsignedTx }): Promise<SignedTx> { 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: MessageRelaxed[] = [] 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, 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( 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: number = 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: SendMode.PAY_GAS_SEPARATELY + SendMode.IGNORE_ERRORS, walletId: wallet.walletId, messages: internalMsgs, timeout: tx.validUntil } const preparedTx = createWalletTransferV4(txArgs) const signingData: TonSigningData = { tx, txArgs, txCell: preparedTx } // sign transaction via signer const signedTx = await sign(signerAddress, signer, signingData) // decide whether to deploy wallet contract along with the transaction const isContractDeployed = await client.isContractDeployed(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: { signedTx: SignedTx }): Promise<string> { const client = this.getClient() const { signedTx } = params const external = await externalMessage(client, 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: { address: string; txHash: string; limit?: number }): Promise<TonTxStatus> { const transaction = await this.getTransactionByHash(params) return this.matchTransactionStatus(transaction) } /** @ignore */ protected matchTransactionStatus (transaction: Transaction | undefined): TonTxStatus { if (transaction === undefined) { return { status: 'unknown', receipt: null } } if (transaction.description.type === 'generic') { const description = transaction.description as TransactionDescriptionGeneric 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 */ protected async getTransactionByHash (params: { address: string txHash: string limit?: number }): Promise<Transaction | undefined> { const client = this.getClient() const { address, txHash, limit } = params const transactions = await client.getTransactions(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 && beginCell().store(storeMessage(tx.inMessage)).endCell().hash().toString('hex') === txHash) return true return false }) return transaction } /** @ignore */ protected getClient (): TonClient { if (!this.client) { throw new Error('TonStaker not initialized. Did you forget to call init()?') } return this.client } /** @ignore */ protected checkIfAddressTestnetFlagMatches (address: string): void { const addr = 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 */ protected async checkMinimumExistentialBalance (address: string, amount: string): Promise<void> { const balance = await this.getBalance({ address }) const minBalance = this.networkConfig.minimumExistentialBalance if (toNano(balance.amount) - toNano(amount) < 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 */ protected async getNominatorContractPoolData (contractAddress: string): Promise<PoolData> { const client = this.getClient() const response = await client.runMethod(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: number) => { 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 } } } function defaultAddressDerivationConfig (): AddressDerivationConfig { return { walletContractVersion: 4, workchain: 0, bounceable: false, testOnly: false, urlSafe: true, isBIP39: false } } // scaffold for future wallet releases function getWalletContract (version: number, workchain: number, publicKey: Buffer, walletId?: number): WalletContractV4 { switch (version) { case 4: return WalletContractV4.create({ workchain, publicKey, walletId }) default: throw new Error('unsupported wallet contract version') } } export function defaultValidUntil (validUntil?: number): number { return validUntil ?? Math.floor(Date.now() / 1000) + 180 // 3 minutes } export function getRandomQueryId () { return Math.floor(Math.random() * Number.MAX_SAFE_INTEGER) } export function getDefaultGas () { return 100000 }