UNPKG

@myria/airdrop-js

Version:

Airdrop in L1 with claim based approach

540 lines (522 loc) 19.4 kB
/** * Set of single functions in our airdrop-js * to let consumers pick and use with their strategies * @module Transaction/Core */ // re-exports: forward export from thirdweb export { saveSnapshot, setMerkleRoot } from 'thirdweb/extensions/airdrop'; // External imports below this line import { BaseTransactionOptions, EstimateGasOptions, PreparedTransaction, SendTransactionOptions, ThirdwebClient, ThirdwebContract, estimateGas, estimateGasCost, eth_gasPrice, eth_getTransactionCount, eth_maxPriorityFeePerGas, getContract, getRpcClient, readContract, sendTransaction, waitForReceipt, } from 'thirdweb'; import { GenerateMerkleTreeInfoERC20Params, claimERC20, generateMerkleTreeInfoERC20, saveSnapshot, setMerkleRoot, } from 'thirdweb/extensions/airdrop'; import { approve } from 'thirdweb/extensions/erc20'; import { TransactionReceipt } from 'thirdweb/transaction'; // Internal imports below this line import { retry } from '../common/Retry'; import { Account, Address, CreateRpcClientOptions, ExtraGasOptions, GasFeeInfo, GenerateMerkleTreeInfo, RetryOptions, SupportingChain, WhiteListItem, } from '../type'; import { DEFAULT_EXTRA_GAS_PERCENTAGE, DEFAULT_EXTRA_ON_RETRY_PERCENTAGE, DEFAULT_EXTRA_PRIORITY_TIP_PERCENTAGE, DEFAULT_MAX_BLOCKS_WAIT_TIME, } from '../type/ConstType'; import { doSubmitTransaction, getThirdWebChain, logFunctionDuration, logFunctionTrackStartTime, } from './internalUse'; /** * Returns an RPC request that can be used to make JSON-RPC requests * * @param {ThirdwebClient} client - Thirdweb client. * @param {CreateRpcClientOptions} options - Options to create RpcClient. */ /* eslint-disable @typescript-eslint/explicit-function-return-type, @typescript-eslint/explicit-module-boundary-types */ export const getRpcClientByChain = ( client: ThirdwebClient, options: CreateRpcClientOptions, ) => { const { selectingChain, chain } = options; if (chain) { return getRpcClient({ client, chain, }); } return getRpcClient({ client, chain: getThirdWebChain(selectingChain), }); }; /** * This callback type is called `transactionCallback` and is displayed as a global symbol. * * @callback transactionCallback * @param {TransactionReceipt} result - The transaction receipt received from submitting. */ /** * Calculate gas fee info need to paid to submit a transaction * @see {@link https://ethereum.org/en/developers/docs/gas|Ethereum's gas} or @see {@link https://support.metamask.io/transactions-and-gas/gas-fees/user-guide-gas/|Metamask's gas} * @see {@link https://etherscan.io/gastracker|Gas Tracker} * * @param {EstimateGasOptions} options - The options for estimating gas. * @param {boolean} isLogResult - Whether to log the result or not. Default true. * @returns {Promise<GasFeeInfo>} Promise object represents the gas fee info to perform an on-chain transaction * @throws An error if the account is missed. */ export const getGasFeeInfo = async ( options: EstimateGasOptions, extraGasOptions: ExtraGasOptions = {}, isLogResult = true, ): Promise<GasFeeInfo> => { const { transaction, account } = options; if (!account) { throw Error('require the account as sender of the transaction'); } const { extraGasPercentage = DEFAULT_EXTRA_GAS_PERCENTAGE, extraMaxPriorityFeePerGasPercentage = DEFAULT_EXTRA_PRIORITY_TIP_PERCENTAGE, extraOnRetryPercentage = DEFAULT_EXTRA_ON_RETRY_PERCENTAGE, } = extraGasOptions; const { chain, client } = transaction; const rpcClient = getRpcClientByChain(client, { chain }); // Estimate gas use for the transaction // -> gas limit const gasLimit = await estimateGas({ transaction, account, }); // -> gas extra const extraGas = (gasLimit / BigInt(100)) * BigInt(extraGasPercentage + extraOnRetryPercentage); // Gas price per unit of gas // -> base fee const gasPrice = await eth_gasPrice(rpcClient); // -> priority & max fee const maxPriorityFeePerGas = await eth_maxPriorityFeePerGas(rpcClient); const extraMaxPriorityFeePerGas = (maxPriorityFeePerGas / BigInt(100)) * BigInt(extraMaxPriorityFeePerGasPercentage + extraOnRetryPercentage); const maxFeePerGas = gasPrice + maxPriorityFeePerGas + extraMaxPriorityFeePerGas; // Estimate Gas Cost to compare with real transaction const gasCost = await estimateGasCost({ transaction, account }); console.log( ` [getGasFeeInfo] gasCost.ether = ${gasCost.ether} - gasCost.wei = ${gasCost.wei}`, ); const gasFeeInfo = { gas: gasLimit, gasPrice, maxPriorityFeePerGas, maxFeePerGas, extraGas, }; if (isLogResult) { console.log( ` [getGasFeeInfo]: gasFeeInfo = ${JSON.stringify(gasFeeInfo)}`, ); } return gasFeeInfo; }; /** * Retrieves the transaction count (nonce) for a given Ethereum address. * * @see {@link https://ethereum.org/en/developers/docs/gas} for GAS AND FEES. * @see {@link https://etherscan.io/gastracker|Gas Tracker} * * @param {Address} address - The Ethereum address of Sender. * @param {ThirdwebClient} client - Thirdweb client. * @param {CreateRpcClientOptions} options - Options to create RpcClient. * @param {boolean} isLogResult - Whether to log the result or not. Default true. * @returns {Promise<number>} Promise object represents the next transaction nonce */ export const getNextNonce = async ( address: Address, client: ThirdwebClient, options: CreateRpcClientOptions, isLogResult = true, ): Promise<number> => { const startTime = logFunctionTrackStartTime(getNextNonce.name); const rpcClient = getRpcClientByChain(client, options); const transactionNonce = await eth_getTransactionCount(rpcClient, { address, }); if (isLogResult) { // TODO: replace with the logger library for better format or other targets console.log( ` [getNextNonce]>[response] transactionNonce = ${transactionNonce}`, ); } logFunctionDuration(getNextNonce.name, startTime); return transactionNonce; }; /** * Reads owner of a smart contract. * * @param {ThirdwebContract} contract - The Thirdweb contract. * @returns {Promise<string>} A promise that resolves with the result of the owner ethereum address. */ export const getOwnerOfContract = async ( contract: ThirdwebContract, ): Promise<string> => { return await readContract({ contract, // Pass a snippet of the ABI for the method you want to call. method: { type: 'function', name: 'owner', inputs: [], outputs: [ { type: 'address', name: '', internalType: 'address', }, ], stateMutability: 'view', }, params: [], }); }; /** * Creates a Thirdweb contract by combining the Thirdweb client and contract options. * * @param {Address} address - The ethereum smart contract address. * @param {ThirdwebClient} client - Thirdweb client. * @param {SupportingChain} selectingChain - The selecting chain. * @returns The Thirdweb contract. */ /* eslint-disable @typescript-eslint/explicit-function-return-type*/ export const getThirdwebContract = ( address: Address, client: ThirdwebClient, selectingChain: SupportingChain, ) => { return getContract({ client, chain: getThirdWebChain(selectingChain), address, }); }; /** * End-User connects wallet to trigger Claim from Client side * * @param {Address} tokenAddress - The token address to claim. * @param {Account} account - The Account represent as sender @see {@link https://ethereum.org/en/glossary/#account|Account's Ethereum}. * @param {ThirdwebContract} airdropContract - The airdrop Thirdweb contract. * @param {transactionCallback} callback - The callback that handles the post-submit state. * @param {boolean} isLogResult - Whether to log the result or not. Default true. * @returns {Promise<TransactionReceipt>} A promise that resolves to the confirmed transaction receipt. * @throws An error if the wallet is not connected. * @example * ```ts * import { claimAirdropToken } from "./index"; * * const transactionReceipt = await claimAirdropToken( * tokenAddress, * account, * airdropContract * ); * ``` */ export const claimAirdropToken = async ( tokenAddress: Address, account: Account, airdropContract: ThirdwebContract, callback?: (result: TransactionReceipt) => void, isLogResult = true, ): Promise<TransactionReceipt> => { const claimTransaction = claimERC20({ contract: airdropContract, tokenAddress, recipient: account.address, }); // Send the transaction return doSubmitTransaction( { transaction: claimTransaction, account }, callback, isLogResult, ); }; /** * Token contract owner approve airdrop contract address as spender with amount * * @param {Address} spender - The airdrop smart contract address as spender. * @param {number} amount - The total airdrop amount in ether format. * @param {Account} account - The Account represent as sender @see {@link https://ethereum.org/en/glossary/#account|Account's Ethereum}. * @param {ThirdwebContract} tokenContract - The token Thirdweb contract to airdrop * @param {transactionCallback} callback - The callback that handles the post-submit state. * @param {boolean} isLogResult - Whether to log the result or not. Default true. * @returns {Promise<TransactionReceipt>} A promise that resolves to the confirmed transaction receipt. * @throws An error if the amount <= 0. * @throws An error if the wallet is not connected. * @example * ```ts * import { approveAirdropAsSpender } from "./index"; * * const transactionReceipt = await approveAirdropAsSpender( * spender, * amount, * account, * tokenContract * ); * ``` */ export const approveAirdropAsSpender = async ( spender: Address, amount: number, account: Account, tokenContract: ThirdwebContract, callback?: (result: TransactionReceipt) => void, isLogResult = true, ): Promise<TransactionReceipt> => { if (amount <= 0) { throw Error('total airdrop amount must be greater than 0'); } const startTime = logFunctionTrackStartTime(approveAirdropAsSpender.name); const transaction = approve({ contract: tokenContract, spender, amount, }); // Send the transaction const result = await doSubmitTransaction( { transaction, account }, callback, isLogResult, ); logFunctionDuration(approveAirdropAsSpender.name, startTime); return result; }; /** * Generate merkle tree info for a whitelist * * @param {WhiteListItem[]} whitelist - The list of items is available for airdrop. * @param {ThirdwebContract} airdropContract - The Airdrop Thirdweb contract. * @param {Address} tokenAddress - The token address to claim. * @param {boolean} isLogResult - Whether to log the result or not. Default true. * @returns {Promise<GenerateMerkleTreeInfo>} A promise that resolves to the generated info. * @throws An error if the wallet is zero. * @example * ```ts * import { generateMerkleTreeForWhitelist } from "./index"; * * const generateMerkleTreeInfo = await generateMerkleTreeForWhitelist( * whitelist, * airdropContract, * tokenAddress, * tokenContract * ); * ``` */ export const generateMerkleTreeInfoERC20ForWhitelist = async ( whitelist: WhiteListItem[], airdropContract: ThirdwebContract, tokenAddress: Address, isLogResult = true, ): Promise<GenerateMerkleTreeInfo> => { const startTime = logFunctionTrackStartTime( generateMerkleTreeInfoERC20ForWhitelist.name, ); const params: BaseTransactionOptions<GenerateMerkleTreeInfoERC20Params> = { contract: airdropContract, snapshot: whitelist, tokenAddress, }; const generateMerkleTreeInfo = await generateMerkleTreeInfoERC20(params); if (isLogResult) { // TODO: replace with the logger library for better format or other targets console.log(' [generateMerkleTreeForWhitelist]'); console.log(` [request] params = ${JSON.stringify(params)}`); console.log( ` [response] result = ${JSON.stringify(generateMerkleTreeInfo)}`, ); } logFunctionDuration( generateMerkleTreeInfoERC20ForWhitelist.name, startTime, ); return generateMerkleTreeInfo; }; /** * Airdrop's owner saved merkleRoot to on-chain. * * @remarks * MUST execute saveSnapshotByOwner first. Order master * * @param {Account} account - The Account represent as sender @see {@link https://ethereum.org/en/glossary/#account|Account's Ethereum}. * @param {ThirdwebContract} airdropContract - The airdrop Thirdweb contract. * @param {Address} tokenAddress - The token address to claim. * @param {string} merkleRoot - The generated merkleRoot from whitelist @see {@link generateMerkleTreeInfoERC20ForWhitelist|Generate merkleRoot} * @param {RetryOptions} retryOptions - The configuration on retry * @param {ExtraGasOptions} extraGasOptions - The extra gas options bidding for your transaction to be included in the next block. * @returns {Promise<TransactionReceipt>} A promise that resolves to the confirmed transaction receipt. * @throws An error if the wallet is not connected. */ export const saveMerkleRootByOwner = async ( account: Account, airdropContract: ThirdwebContract, tokenAddress: string, merkleRoot: string, retryOptions: RetryOptions = {}, extraGasOptions: ExtraGasOptions = {}, ): Promise<TransactionReceipt> => { const startTime = logFunctionTrackStartTime(saveMerkleRootByOwner.name); const merkleRootTransaction = setMerkleRoot({ contract: airdropContract, token: `0x${tokenAddress.replace('0x', '')}`, tokenMerkleRoot: `0x${merkleRoot.replace('0x', '')}`, resetClaimStatus: true, }); const result = await retryPrepareAndSubmitRawTransaction( merkleRootTransaction, account, retryOptions, extraGasOptions, ); logFunctionDuration(saveMerkleRootByOwner.name, startTime); return result; }; /** * Airdrop's owner saved snapshotUri to on-chain. * * @param {Account} account - The Account represent as sender @see {@link https://ethereum.org/en/glossary/#account|Account's Ethereum}. * @param {ThirdwebContract} airdropContract - The airdrop Thirdweb contract. * @param {string} merkleRoot - The generated merkleRoot from whitelist @see {@link generateMerkleTreeInfoERC20ForWhitelist|Generate merkleRoot} * @param {string} snapshotUri - The generated snapshotUri from whitelist @see {@link generateMerkleTreeInfoERC20ForWhitelist|Generate snapshotUri} * @param {RetryOptions} retryOptions - The configuration on retry * @param {ExtraGasOptions} extraGasOptions - The extra gas options bidding for your transaction to be included in the next block. * @returns {Promise<TransactionReceipt>} A promise that resolves to the confirmed transaction receipt. * @throws An error if the wallet is not connected. */ export const saveSnapshotByOwner = async ( account: Account, airdropContract: ThirdwebContract, merkleRoot: string, snapshotUri: string, retryOptions: RetryOptions = {}, extraGasOptions: ExtraGasOptions = {}, ): Promise<TransactionReceipt> => { const startTime = logFunctionTrackStartTime(saveSnapshotByOwner.name); const saveSnapshotTransaction = saveSnapshot({ contract: airdropContract, merkleRoot, snapshotUri, }); const result = await retryPrepareAndSubmitRawTransaction( saveSnapshotTransaction, account, retryOptions, extraGasOptions, ); logFunctionDuration(saveSnapshotByOwner.name, startTime); return result; }; /** * Sends a transaction using the provided wallet. * @param {SendTransactionOptions} options - The options for sending the transaction. * @param {number} maxBlocksWaitTime - The maximum of blocks to wait for confirmation before considering success. * @returns {Promise<TransactionReceipt>} A promise that resolves to the confirmed transaction receipt. * @throws An error if the wallet is not connected. * @example * ```ts * import { sendAndConfirmTransaction } from "./index"; * * const transactionReceipt = await sendAndConfirmTransaction( * options, * maxBlocksWaitTime * ); * ``` */ export async function sendTransactionAndWaitForReceipt( options: SendTransactionOptions, maxBlocksWaitTime = DEFAULT_MAX_BLOCKS_WAIT_TIME, ): Promise<TransactionReceipt> { const submittedTx = await sendTransaction(options); return waitForReceipt({ ...submittedTx, maxBlocksWaitTime, }); } /** * Retry on preparing the gas fee, and nonce, and send a transaction using the provided wallet. * * @param {PreparedTransaction} transaction - The raw transaction to submit * @param {Account} account - The Account represent as sender @see {@link https://ethereum.org/en/glossary/#account|Account's Ethereum}. * @param {ExtraGasOptions} extraGasOptions - The extra gas options bidding for your transaction to be included in the next block. Otherwise, Use default our sdk {@link Type | Default Variables} * @param {RetryOptions} retryOptions - The configuration on retry * @returns {Promise<TransactionReceipt>} A promise that resolves to the confirmed transaction receipt. * @throws An error if the wallet is not connected. */ /* eslint-disable @typescript-eslint/no-explicit-any */ export async function retryPrepareAndSubmitRawTransaction( transaction: PreparedTransaction<any>, account: Account, retryOptions: RetryOptions = {}, extraGasOptions: ExtraGasOptions = {}, ): Promise<TransactionReceipt> { const { extraGasPercentage = DEFAULT_EXTRA_GAS_PERCENTAGE, extraMaxPriorityFeePerGasPercentage = DEFAULT_EXTRA_PRIORITY_TIP_PERCENTAGE, extraOnRetryPercentage = DEFAULT_EXTRA_ON_RETRY_PERCENTAGE, } = extraGasOptions; const { client, chain } = transaction; return retry<TransactionReceipt>(async (retryCount) => { // Get transaction nonce const nextNonce = await getNextNonce(account.address, client, { chain, }); // Calculate gas fee of a transaction const gasFeeInfo = await getGasFeeInfo( { transaction, account }, { extraGasPercentage, extraMaxPriorityFeePerGasPercentage, extraOnRetryPercentage: extraOnRetryPercentage * retryCount, }, ); const sendingSnapshotTransaction = { ...transaction, ...gasFeeInfo, nonce: nextNonce, }; return await sendTransactionAndWaitForReceipt({ transaction: sendingSnapshotTransaction, account: account, }); }, retryOptions); }