UNPKG

@chorus-one/ethereum

Version:

All-in-one toolkit for building staking dApps on Ethereum network

778 lines (703 loc) 28.3 kB
import type { Signer } from '@chorus-one/signer' import { secp256k1 } from '@noble/curves/secp256k1' import { Chain, createWalletClient, encodeFunctionData, formatEther, Hex, http, keccak256, parseEther, serializeTransaction } from 'viem' import { StakewiseConnector } from './lib/connector' import { NativeStakingConnector } from './lib/nativeStakingConnector' import { buildStakeTx, buildUnstakeTx, buildWithdrawTx, buildMintTx, buildBurnTx, buildValidatorExitTx, getVault, getStake, getMaxUnstake, getRewardsHistory, getTxHistory, getUnstakeQueue, getMint, getMaxMint, getMintHealth } from './lib/methods' import { Networks } from './lib/types/networks' import { Transaction } from './lib/types/transaction' import { EthereumTxStatus } from './lib/types/txStatus' import { BatchDetailsDepositData, BatchDetailsResponse, CreateBatchRequest, CreateBatchResponse, ListBatchesResponse } from './lib/types/nativeStaking' import { depositAbi } from './lib/contracts/depositContractAbi' import { toHexString } from './lib/utils/toHexString' import { getNetworkConfig } from './lib/utils/getNetworkConfig' /** * This class provides the functionality to stake, unstake, and withdraw for Ethereum network. * * It also provides the ability to retrieve staking information and rewards for an account. */ export class EthereumStaker { private connector: StakewiseConnector private nativeStakingConnector?: NativeStakingConnector private network: Networks private rpcUrl?: string /** * 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`. * * @returns Returns an array containing the derived address. */ static getAddressDerivationFn = () => async (publicKey: Uint8Array): Promise<Array<string>> => { const point = secp256k1.Point.fromHex(publicKey) const pkUncompressed = point.toBytes(false) const hash = keccak256(pkUncompressed.subarray(1)) const ethAddress = hash.slice(-40) return [ethAddress] } /** * Creates an EthereumStaker instance. * * @param params - Initialization configuration * @param params.network - The network to connect to * @param params.rpcUrl - (Optional) The URL of the RPC endpoint. If not provided, the public RPC URL for the network will be used. * @param params.nativeStakingApiToken - (Optional) API token for native staking operations. Required for native staking methods. * * @returns An instance of EthereumStaker. */ constructor (params: { network: Networks; rpcUrl?: string; nativeStakingApiToken?: string }) { this.network = params.network this.rpcUrl = params.rpcUrl this.connector = new StakewiseConnector(this.network, this.rpcUrl) if (params.nativeStakingApiToken) { this.nativeStakingConnector = new NativeStakingConnector(this.network, params.nativeStakingApiToken, this.rpcUrl) } } /** * Initializes the EthereumStaker instance and connects to the Ethereum network. * * @returns A promise which resolves once the EthereumStaker instance has been initialized. */ async init (): Promise<void> {} /** * Builds a staking transaction. * * @param params - Parameters for building the transaction * @param params.delegatorAddress - The delegator (wallet) address to stake from * @param params.validatorAddress - The validator (vault) address to stake with * @param params.amount - The amount to stake, specified in `ETH`. E.g. "1" - 1 ETH * @param params.referrer - (Optional) The address of the referrer. This is used to track the origin of transactions, * providing insights into which sources or campaigns are driving activity. This can be useful for analytics and * optimizing user acquisition strategies * * @returns Returns a promise that resolves to an Ethereum staking transaction. */ async buildStakeTx (params: { delegatorAddress: Hex validatorAddress: Hex amount: string // ETH assets referrer?: Hex }): Promise<{ tx: Transaction }> { const tx = await buildStakeTx({ connector: this.connector, userAccount: params.delegatorAddress, vault: params.validatorAddress, amount: this.parseEther(params.amount), referrer: params.referrer }) return { tx } } /** * Builds an unstaking transaction. * * The unstake transaction effectively moves the user's assets into an unstake queue where they remain until they * become eligible for withdrawal. This queue is a safeguard mechanism that ensures the liquidity and stability of * the vault by managing the flow of assets. To check the status of these assets, use the `getUnstakeQueue` * method. * * @param params - Parameters for building the transaction * @param params.delegatorAddress - The delegator (wallet) address that is unstaking * @param params.validatorAddress - The validator (vault) address to unstake from * @param params.amount - The amount to unstake, specified in `ETH`. E.g. "1" - 1 ETH * * @returns Returns a promise that resolves to an Ethereum unstaking transaction. */ async buildUnstakeTx (params: { delegatorAddress: Hex validatorAddress: Hex amount: string // ETH assets }): Promise<{ tx: Transaction }> { const tx = await buildUnstakeTx({ connector: this.connector, userAccount: params.delegatorAddress, vault: params.validatorAddress, amount: this.parseEther(params.amount) }) return { tx } } /** * Builds a withdrawal transaction. * * This method is the final step in the unstaking process. Once assets in the unstake queue have reached a * withdrawable state (as determined by the `getUnstakeQueue` method), the `buildWithdrawTx` method prepares the * transaction data necessary for transferring these assets back into the user's wallet. * * * @param params - Parameters for building the transaction * @param params.delegatorAddress - The delegator (wallet) address * @param params.validatorAddress - The validator (vault) address to withdraw from * @param params.positionTickets - (Optional) An array of position tickets to withdraw. If not provided, all withdrawable * assets will be withdrawn. (see `getUnstakeQueue`) * * @returns Returns a promise that resolves to an Ethereum withdrawal transaction. */ async buildWithdrawTx (params: { delegatorAddress: Hex validatorAddress: Hex positionTickets?: string[] }): Promise<{ tx: Transaction }> { const tx = await buildWithdrawTx({ connector: this.connector, userAccount: params.delegatorAddress, vault: params.validatorAddress, positionTickets: params.positionTickets }) return { tx } } /** * Builds a mint transaction. * * @param params - Parameters for building the transaction * @param params.delegatorAddress - The delegator (wallet) address * @param params.validatorAddress - The validator (vault) address to mint shares for * @param params.amount - The amount to mint, specified in `osETH`. E.g. "1" - 1 osETH * @param params.referrer - (Optional) The address of the referrer. This is used to track the origin of transactions, * providing insights into which sources or campaigns are driving activity. This can be useful for analytics and * optimizing user acquisition strategies * * @returns Returns a promise that resolves to an Ethereum mint transaction. */ async buildMintTx (params: { delegatorAddress: Hex validatorAddress: Hex amount: string // osETH shares referrer?: Hex }): Promise<{ tx: Transaction }> { const tx = await buildMintTx({ connector: this.connector, userAccount: params.delegatorAddress, vault: params.validatorAddress, amount: this.parseEther(params.amount), referrer: params.referrer }) return { tx } } /** * Builds a burn transaction. * * @param params - Parameters for building the transaction * @param params.delegatorAddress - The delegator (wallet) address * @param params.validatorAddress - The validator (vault) address to burn shares from * @param params.amount - The amount to burn, specified in `osETH`. E.g. "1" - 1 osETH * * @returns Returns a promise that resolves to an Ethereum burn transaction. */ async buildBurnTx (params: { delegatorAddress: Hex validatorAddress: Hex amount: string // osETH shares }): Promise<{ tx: Transaction }> { const tx = await buildBurnTx({ connector: this.connector, userAccount: params.delegatorAddress, vault: params.validatorAddress, amount: this.parseEther(params.amount) }) return { tx } } /** * Retrieves the staking information for a specified vault, including TVL, APY, description, logo. * * @param params - Parameters for the request * @param params.validatorAddress - The validator (vault) address * * @returns Returns a promise that resolves to the staking information for the specified vault. */ async getVault ({ validatorAddress }: { validatorAddress: Hex }) { const vault = await getVault({ connector: this.connector, vault: validatorAddress }) return { vault } } /** * Retrieves the staking information for a specified delegator. * * The staking information includes the current balance and the maximum amount that can be unstaked. * * @param params - Parameters for the request * @param params.delegatorAddress - The delegator (wallet) address * @param params.validatorAddress - The validator (vault) address to gather staking information from * * @returns Returns a promise that resolves to the staking information for the delegator. */ async getStake (params: { delegatorAddress: Hex; validatorAddress: Hex }) { const stake = await getStake({ connector: this.connector, userAccount: params.delegatorAddress, vaultAddress: params.validatorAddress }) const maxUnstake = await getMaxUnstake({ connector: this.connector, userAccount: params.delegatorAddress, vault: params.validatorAddress }) return { balance: formatEther(stake.assets), maxUnstake: formatEther(maxUnstake) } } /** * Retrieves the rewards history for a specified delegator. * * @param params - Parameters for the request * @param params.delegatorAddress - The delegator (wallet) address * @param params.validatorAddress - The validator (vault) address to gather rewards data from * @param params.startTime - The start time of the rewards data to retrieve, specified in milliseconds * @param params.endTime - The end time of the rewards data to retrieve, specified in milliseconds * * @returns Returns a promise that resolves to the rewards data for the specified delegator. */ async getRewardsHistory (params: { startTime: number endTime: number delegatorAddress: Hex validatorAddress: Hex }) { const rewards = await getRewardsHistory({ connector: this.connector, from: params.startTime, to: params.endTime, vault: params.validatorAddress, userAccount: params.delegatorAddress }) return rewards.map((item) => ({ timestamp: item.timestamp, /** * @deprecated Use `totalRewards` instead. */ amount: formatEther(item.totalRewards), totalRewards: formatEther(item.totalRewards), dailyRewards: formatEther(item.dailyRewards) })) } /** * Retrieves the transaction history for a specified delegator. * * @param params - Parameters for the request * @param params.delegatorAddress - The delegator (wallet) address * @param params.validatorAddress - The validator (vault) address to gather transaction data from * * @returns Returns a promise that resolves to the transaction history for the specified delegator. */ async getTxHistory (params: { delegatorAddress: Hex; validatorAddress: Hex }) { const txHistory = await getTxHistory({ connector: this.connector, userAccount: params.delegatorAddress, vault: params.validatorAddress }) return txHistory.map((item) => ({ timestamp: item.when.getTime(), type: item.type, amount: formatEther(item.amount), txHash: item.hash.split('-')[0] })) } /** * Retrieves the unstake queue for a specified delegator. * * After initiating an unstake request using the `buildUnstakeTx` method, assets are placed into an unstake * queue. * * The `getUnstakeQueue` method allows users to query the queue to check the current state of their unstake requests, * including their positionTicket, the amount of assets that are withdrawable, and the total amount. * * To prepare the transaction for withdrawing these assets, use the `buildWithdrawTx` method. * * @param params - Parameters for the request * @param params.delegatorAddress - The delegator (wallet) address * @param params.validatorAddress - The validator (vault) address to gather the unstake queue from * * @returns Returns a promise that resolves to the unstake queue for the specified delegator. */ async getUnstakeQueue (params: { delegatorAddress: Hex; validatorAddress: Hex; isClaimed?: boolean }) { const queue = await getUnstakeQueue({ connector: this.connector, userAccount: params.delegatorAddress, vault: params.validatorAddress, isClaimed: params.isClaimed }) return queue .filter((item) => !item.wasWithdrawn) .map((item) => ({ positionTicket: item.positionTicket.toString(), exitQueueIndex: item.exitQueueIndex?.toString(), timestamp: item.timestamp, isWithdrawable: item.isWithdrawable, totalAmount: formatEther(item.totalAssets), withdrawableAmount: formatEther(item.exitedAssets), withdrawalTimestamp: item.withdrawalTimestamp })) } /** * Retrieves the mint information for a specified delegator. * * The mint information includes the current balance of minted `osETH` and the maximum amount of that can be minted. * * @param params - Parameters for the request * @param params.delegatorAddress - The delegator (wallet) address * @param params.validatorAddress - The validator (vault) address to gather mint data from * * @returns Returns a promise that resolves to the mint information */ async getMint (params: { delegatorAddress: Hex; validatorAddress: Hex }) { const mint = await getMint({ connector: this.connector, userAccount: params.delegatorAddress, vaultAddress: params.validatorAddress }) const maxMint = await getMaxMint({ connector: this.connector, userAccount: params.delegatorAddress, vault: params.validatorAddress }) return { balance: formatEther(mint.minted.shares), maxMint: formatEther(maxMint) } } /** * Retrieves the mint health for a specified stake and mint amount. * * Position health tracks the value of osETH minted by stakers relative to the value of their ETH stake in the vault. * Healthy positions have minted osETH that is well-collateralized by staked ETH. As the proportion of minted osETH * increases relative to staked ETH, position health deteriorates. * * Factors affecting position health include yield discrepancies (APY) between the vault and osETH, which can result * from: * - Differences in fee structures. * - Variations in attestation performance. * - The ratio of unbounded ETH to the vault's total value locked (TVL). * - Delays in validator activation on the Beacon Chain. * - Losses due to maximal extractable value (MEV) strategies. * * Risky positions may enter redemption processes, while positions deemed unhealthy are subject to liquidation. * * @param params - Parameters for the request * @param params.stakeAmount - The amount of ETH staked * @param params.mintAmount - The amount of osETH minted * @param params.validatorAddress - The validator (vault) address * * @returns Returns a promise that resolves to the mint health status('healthy' | 'risky' ) */ async getMintHealth (params: { stakeAmount: string; mintAmount: string; validatorAddress: Hex }) { const health = await getMintHealth({ connector: this.connector, mintedShares: this.parseEther(params.mintAmount), stakedAssets: this.parseEther(params.stakeAmount), vault: params.validatorAddress }) return { health } } /** * Creates a batch of validators for native Ethereum staking. * * This method creates a new batch of validators using the Chorus One Native Staking API. * Each validator requires 32 ETH to be deposited. The batch will generate deposit data * that can be used to deposit validators on the Ethereum network. * * @param params - Parameters for creating the validator batch * @param params.batchId - Unique identifier for the batch * @param params.withdrawalAddress - The withdrawal address that will control the staked funds.Must be 0x02 compounding credentials for compounding validators. * @param params.feeRecipientAddress - The address that will receive MEV rewards * @param params.numberOfValidators - Number of validators to create (each requires 32 ETH) * @param params.isCompounding - (Optional) Whether to create compounding validators (0x02 withdrawal credentials). Default is false. * @param params.depositGweiPerValidator - (Optional) The deposit in gwei per validator. Default is 32000000000 gwei i.e. 32ETH. * * @returns Returns a promise that resolves to the batch creation response. */ async createValidatorBatch (params: { batchId: string withdrawalAddress: Hex feeRecipientAddress: Hex numberOfValidators: number isCompounding?: boolean depositGweiPerValidator?: bigint }): Promise<CreateBatchResponse> { if (!this.nativeStakingConnector) { throw new Error('Native staking is not enabled. Please provide nativeStakingApiToken in constructor.') } const request: CreateBatchRequest = { batch_id: params.batchId, withdrawal_address: params.withdrawalAddress, fee_recipient: params.feeRecipientAddress, number_of_validators: params.numberOfValidators, network: this.nativeStakingConnector.network, is_compounding: params.isCompounding, deposit_gwei_per_validator: params.depositGweiPerValidator } return this.nativeStakingConnector.createBatch(request) } /** * Lists all validator batches for the authenticated tenant. * * This method retrieves all validator batches that have been created for the current tenant. * * @returns Returns a promise that resolves to an array of validator batches. */ async listValidatorBatches (): Promise<ListBatchesResponse> { if (!this.nativeStakingConnector) { throw new Error('Native staking is not enabled. Please provide nativeStakingApiToken in constructor.') } return this.nativeStakingConnector.listBatches() } /** * Gets the status of a validator batch. * * This method retrieves the current status of a validator batch, including the deposit data * for each validator when ready. * * @param params - Parameters for getting batch status * @param params.batchId - The batch identifier * * @returns Returns a promise that resolves to the batch information. */ async getValidatorBatchStatus (params: { batchId: string }): Promise<BatchDetailsResponse> { if (!this.nativeStakingConnector) { throw new Error('Native staking is not enabled. Please provide nativeStakingApiToken in constructor.') } return await this.nativeStakingConnector.getBatchDetails(params.batchId) } /** * Exports deposit data in the format required by the Ethereum Staking Launchpad. * * This method the deposit data for each validator in the batch, which can be used to deposit * validators with the oficial Ethereum Staking Launchpad or other depositing tools. * * @param params - Parameters for exporting deposit data * @param params.batchData - Pre-fetched batch of validators * * @returns Returns a promise that resolves to an array of deposit data objects. */ async exportDepositData ({ batchData }: { batchData: BatchDetailsResponse }): Promise<{ depositData: BatchDetailsDepositData[] }> { if (batchData.status !== 'ready') { return { depositData: [] } } const depositData = batchData.validators .filter((validator) => validator.status === 'created') .map((validator) => validator.deposit_data) return { depositData } } /** * Builds deposit transactions for native Ethereum staking. * * This method creates transactions for depositing validators to the Ethereum deposit contract. * Each validator requires exactly 32 ETH to be deposited along with the deposit data. * * @param params - Parameters for building deposit transactions * @param params.batchData - Pre-fetched batch of validators * * @returns Returns a promise that resolves to an array of deposit transactions. */ async buildDepositTx ({ batchData }: { batchData: BatchDetailsResponse }): Promise<{ transactions: Transaction[] }> { if (batchData.status !== 'ready') { return { transactions: [] } } const validatorsToDeposit = batchData.validators.filter((validator) => validator.status === 'created') if (validatorsToDeposit.length === 0) { throw new Error('No validators found that need to be deposited. All validators may have already been deposited.') } const config = getNetworkConfig(this.network) const transactions: Transaction[] = [] for (const validator of validatorsToDeposit) { const depositData = validator.deposit_data const depositFunctionData = this.encodeDepositFunction({ pubkey: toHexString(depositData.pubkey), withdrawalCredentials: toHexString(depositData.withdrawal_credentials), signature: toHexString(depositData.signature), depositDataRoot: toHexString(depositData.deposit_data_root) }) const transaction: Transaction = { to: config.depositContractAddress, value: parseEther('32'), // Each validator requires exactly 32 ETH data: depositFunctionData } transactions.push(transaction) } return { transactions } } /** * Encodes the deposit function call for the Ethereum deposit contract. */ private encodeDepositFunction (params: { pubkey: Hex withdrawalCredentials: Hex signature: Hex depositDataRoot: Hex }): Hex { return encodeFunctionData({ abi: depositAbi, functionName: 'deposit', args: [params.pubkey, params.withdrawalCredentials, params.signature, params.depositDataRoot] }) } /** * Builds a withdrawal request transaction for a validator based on EIP-7002. * * This method creates a transaction that triggers a full validator exit through * the execution layer withdrawal credentials (0x01) as specified in EIP-7002. * * @param params - Parameters for building the withdrawal transaction * @param params.validatorPubkey - The validator public key (48 bytes) * @param params.value - (Optional) The amount of ETH to send with the transaction. Default is 1 wei. * * @returns Returns a promise that resolves to a withdrawal transaction. */ async buildValidatorExitTx (params: { validatorPubkey: string; value?: bigint }): Promise<{ tx: Transaction }> { const config = getNetworkConfig(this.network) const tx = await buildValidatorExitTx({ config, validatorPubkey: params.validatorPubkey, value: params.value }) return { tx } } /** * Signs a transaction using the provided signer. * * @param params - Parameters for the signing process * @param params.signer - A signer instance. * @param params.signerAddress - The address of the signer * @param params.tx - The transaction to sign * @param params.baseFeeMultiplier - (Optional) The multiplier for fees, which is used to manage fee fluctuations, is applied to the base fee per gas from the latest block to determine the final `maxFeePerGas`. The default value is 1.2. * @param params.defaultPriorityFee - (Optional) This overrides the the `maxPriorityFeePerGas` estimated by the RPC. * * @returns A promise that resolves to an object containing the signed transaction. */ async sign (params: { signer: Signer signerAddress: Hex tx: Transaction baseFeeMultiplier?: number defaultPriorityFee?: string }): Promise<{ signedTx: Hex }> { const { signer, signerAddress, tx: tx, baseFeeMultiplier, defaultPriorityFee } = params const baseChain = this.connector.chain const baseFees = baseChain.fees ?? {} const fees = { ...baseFees, baseFeeMultiplier: baseFeeMultiplier ?? baseFees.baseFeeMultiplier, defaultPriorityFee: defaultPriorityFee === undefined ? baseFees.maxPriorityFeePerGas : this.parseEther(defaultPriorityFee) } const chain: Chain = { ...baseChain, fees } const client = createWalletClient({ chain, transport: http(), account: signerAddress }) const request = await client.prepareTransactionRequest({ chain: undefined, account: signerAddress, to: tx.to, value: tx.value, data: tx.data, // Pin tx type to avoid type conflict with serializeTransaction bellow type: 'eip1559' }) const message = keccak256(serializeTransaction(request)).slice(2) const data = { tx } const { sig } = await signer.sign(signerAddress.toLowerCase().slice(2), { message, data }, {}) const signature = { r: `0x${sig.r}` as const, s: `0x${sig.s}` as const, v: sig.v ? 28n : 27n, yParity: sig.v } const signedTx = serializeTransaction(request, signature) return { signedTx } } /** * Broadcasts a signed transaction to the network. * * @param params - Parameters for the broadcast process * @param params.signedTx - The signed transaction to broadcast * * @returns A promise that resolves to the final execution outcome of the broadcast transaction. */ async broadcast (params: { signedTx: Hex }): Promise<{ txHash: Hex }> { const { signedTx } = params const hash = await this.connector.eth.sendRawTransaction({ serializedTransaction: signedTx }) return { txHash: hash } } /** * Retrieves the status of a transaction using the transaction hash. * * @param params - Parameters for the transaction status request * @param params.txHash - The transaction hash to query * * @returns A promise that resolves to an object containing the transaction status. */ async getTxStatus (params: { txHash: Hex }): Promise<EthereumTxStatus> { const { txHash } = params try { const tx = await this.connector.eth.getTransactionReceipt({ hash: txHash }) if (tx.status === 'reverted') { return { status: 'failure', receipt: tx } } return { status: 'success', receipt: tx } } catch (e) { return { status: 'unknown', receipt: null } } } private parseEther (amount: string): bigint { if (typeof amount === 'bigint') throw new Error( 'Amount must be a string, denominated in ETH. e.g. "1.5" - 1.5 ETH. You can use `formatEther` to convert a `bigint` to a string' ) if (typeof amount !== 'string') throw new Error('Amount must be a string, denominated in ETH. e.g. "1.5" - 1.5 ETH.') if (amount === '') throw new Error('Amount cannot be empty') let result: bigint try { result = parseEther(amount) } catch (e) { throw new Error('Amount must be a valid number denominated in ETH. e.g. "1.5" - 1.5 ETH') } if (result <= 0n) throw new Error('Amount must be greater than 0') return result } }