-
Notifications
You must be signed in to change notification settings - Fork 79
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
3 changed files
with
847 additions
and
126 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,386 @@ | ||
import fs from "fs"; | ||
import { config } from "dotenv"; | ||
import path from "path"; | ||
import { EIP1559GasProvider } from "@consensys/linea-sdk"; | ||
import { | ||
isAddress, | ||
isHexString, | ||
JsonRpcProvider, | ||
parseUnits, | ||
TransactionRequest, | ||
TransactionResponse, | ||
Wallet, | ||
} from "ethers"; | ||
import { defaultAbiCoder } from "@ethersproject/abi"; | ||
import { hexConcat } from "@ethersproject/bytes"; | ||
|
||
config(); | ||
|
||
const processedBatchIds: number[] = []; | ||
|
||
// ********************************************************************************* | ||
// ********************************* CONFIGURATION ********************************* | ||
// ********************************************************************************* | ||
|
||
const DEFAULT_MAX_FEE_PER_GAS = parseUnits("100", "gwei").toString(); | ||
const DEFAULT_GAS_ESTIMATION_PERCENTILE = "10"; | ||
const DEFAULT_GAS_PRICE_CAP = parseUnits("5", "gwei").toString(); | ||
|
||
type Config = { | ||
inputFile: string; | ||
destinationAddress: string; | ||
providerUrl: string; | ||
signerPrivateKey: string; | ||
maxFeePerGas: number; | ||
gasEstimationPercentile: number; | ||
gasPriceCap: string; | ||
}; | ||
|
||
type Batch = { | ||
id: number; | ||
recipients: string[]; | ||
amount: number; | ||
}; | ||
|
||
enum BatchStatuses { | ||
Failed = "Failed", | ||
Success = "Success", | ||
Pending = "Pending", | ||
} | ||
|
||
type TrackingData = { | ||
recipients: string[]; | ||
tokenAmount: number; | ||
status: BatchStatuses; | ||
transactionHash?: string; | ||
error?: unknown; | ||
}; | ||
|
||
function isValidUrl(urlString: string): boolean { | ||
try { | ||
return Boolean(new URL(urlString)); | ||
} catch (e) { | ||
return false; | ||
} | ||
} | ||
|
||
function requireEnv(name: string): string { | ||
const envVariable = process.env[name]; | ||
if (!envVariable) { | ||
throw new Error(`Missing ${name} environment variable.`); | ||
} | ||
return envVariable; | ||
} | ||
|
||
function getConfig(): Config { | ||
const inputFile = requireEnv("INPUT_FILE"); | ||
const destinationAddress = requireEnv("DESTINATION_ADDRESS"); | ||
const providerUrl = requireEnv("PROVIDER_URL"); | ||
const signerPrivateKey = requireEnv("SIGNER_PRIVATE_KEY"); | ||
|
||
if (!isAddress(destinationAddress)) { | ||
throw new Error(`Destination address is not a valid Ethereum address.`); | ||
} | ||
|
||
if (!isValidUrl(providerUrl)) { | ||
throw new Error(`Invalid provider URL.`); | ||
} | ||
|
||
if (!isHexString(signerPrivateKey, 64)) { | ||
throw new Error(`Signer private key must be hexadecimal string of length 64`); | ||
} | ||
|
||
if (path.extname(inputFile) !== ".json") { | ||
throw new Error(`File ${inputFile} is not a JSON file.`); | ||
} | ||
|
||
if (!fs.existsSync(inputFile)) { | ||
throw new Error(`File ${inputFile} does not exist.`); | ||
} | ||
|
||
return { | ||
inputFile, | ||
destinationAddress, | ||
providerUrl, | ||
signerPrivateKey, | ||
maxFeePerGas: parseInt(process.env.MAX_FEE_PER_GAS ?? DEFAULT_MAX_FEE_PER_GAS), | ||
gasEstimationPercentile: parseInt(process.env.GAS_ESTIMATION_PERCENTILE ?? DEFAULT_GAS_ESTIMATION_PERCENTILE), | ||
gasPriceCap: process.env.GAS_PRICE_CAP ?? DEFAULT_GAS_PRICE_CAP, | ||
}; | ||
} | ||
|
||
// ********************************************************************************* | ||
// ********************************* UTILS FUNCTIONS ******************************* | ||
// ********************************************************************************* | ||
|
||
export const wait = (timeout: number) => new Promise((resolve) => setTimeout(resolve, timeout)); | ||
|
||
async function estimateTransactionGas(signer: Wallet, transaction: TransactionRequest): Promise<BigNumber> { | ||
try { | ||
return signer.estimateGas(transaction); | ||
} catch (error: unknown) { | ||
throw new Error(`GasEstimationError: ${JSON.stringify(error)}`); | ||
} | ||
} | ||
|
||
async function executeTransaction( | ||
signer: Wallet, | ||
transaction: TransactionRequest, | ||
batch: Batch, | ||
): Promise<{ transactionResponse: TransactionResponse; batch: Batch }> { | ||
try { | ||
return { | ||
transactionResponse: await signer.sendTransaction(transaction), | ||
batch, | ||
}; | ||
} catch (error: unknown) { | ||
throw new Error(`TransactionError: ${JSON.stringify(error)}`); | ||
} | ||
} | ||
|
||
function createTrackingFile(path: string): Map<number, TrackingData> { | ||
if (fs.existsSync(path)) { | ||
const mapAsArray = fs.readFileSync(path, "utf-8"); | ||
return new Map(JSON.parse(mapAsArray)); | ||
} | ||
|
||
fs.writeFileSync(path, JSON.stringify(Array.from(new Map<number, TrackingData>().entries()))); | ||
return new Map<number, TrackingData>(); | ||
} | ||
|
||
function updateTrackingFile(trackingData: Map<number, TrackingData>) { | ||
fs.writeFileSync("tracking.json", JSON.stringify(Array.from(trackingData.entries()), null, 2)); | ||
} | ||
|
||
async function processPendingBatches( | ||
provider: JsonRpcProvider, | ||
batches: Batch[], | ||
trackingData: Map<number, TrackingData>, | ||
): Promise<(Batch & { transactionHash?: string })[]> { | ||
const pendingBatches = batches | ||
.filter((batch) => trackingData.get(batch.id)?.status === BatchStatuses.Pending) | ||
.map((batch) => ({ | ||
...batch, | ||
transactionHash: trackingData.get(batch.id)?.transactionHash, | ||
})); | ||
|
||
const remainingPendingBatches: (Batch & { transactionHash?: string })[] = []; | ||
|
||
for (const { transactionHash, id, recipients, amount } of pendingBatches) { | ||
if (!transactionHash) { | ||
remainingPendingBatches.push({ id, recipients, amount }); | ||
continue; | ||
} | ||
|
||
const receipt = await provider.getTransactionReceipt(transactionHash); | ||
|
||
if (!receipt) { | ||
remainingPendingBatches.push({ id, recipients, amount, transactionHash }); | ||
continue; | ||
} | ||
|
||
if (receipt.status == 0) { | ||
// track failing batches | ||
trackingData.set(id, { | ||
recipients, | ||
tokenAmount: amount, | ||
status: BatchStatuses.Failed, | ||
transactionHash, | ||
}); | ||
|
||
console.log(`Transaction reverted. Hash: ${transactionHash}, batchId: ${id}`); | ||
updateTrackingFile(trackingData); | ||
|
||
// continue the batch loop | ||
continue; | ||
} | ||
// track succeded batches | ||
trackingData.set(id, { | ||
recipients, | ||
tokenAmount: amount, | ||
status: BatchStatuses.Success, | ||
transactionHash: transactionHash, | ||
}); | ||
|
||
updateTrackingFile(trackingData); | ||
console.log(`Transaction succeed. Hash: ${transactionHash}, batchId: ${id}`); | ||
} | ||
|
||
return remainingPendingBatches; | ||
} | ||
|
||
// ********************************************************************************* | ||
// ********************************* MAIN FUNCTION ********************************* | ||
// ********************************************************************************* | ||
|
||
async function main() { | ||
const { | ||
inputFile, | ||
destinationAddress, | ||
providerUrl, | ||
signerPrivateKey, | ||
maxFeePerGas, | ||
gasEstimationPercentile, | ||
gasPriceCap, | ||
} = getConfig(); | ||
|
||
const provider = new JsonRpcProvider(providerUrl); | ||
const { chainId } = await provider.getNetwork(); | ||
const eip1559GasProvider = new EIP1559GasProvider(provider, maxFeePerGas, gasEstimationPercentile); | ||
const signer = new Wallet(signerPrivateKey, provider); | ||
|
||
const trackingData = createTrackingFile("tracking.json"); | ||
|
||
const readFile = fs.readFileSync(inputFile, "utf-8"); | ||
const batches: Batch[] = JSON.parse(readFile); | ||
|
||
const filteredBatches = batches.filter( | ||
(batch) => trackingData.get(batch.id)?.status === BatchStatuses.Failed || !trackingData.has(batch.id), | ||
); | ||
|
||
console.log("Processing pending batches..."); | ||
const remainingPendingBatches = await processPendingBatches(provider, batches, trackingData); | ||
|
||
if (remainingPendingBatches.length !== 0) { | ||
console.warn(`The following batches are still pending: ${JSON.stringify(remainingPendingBatches, null, 2)}`); | ||
return; | ||
} | ||
|
||
let nonce = await provider.getTransactionCount(signer.address); | ||
|
||
const pendingTransactions = []; | ||
|
||
console.log(`Total number of batches to process: ${filteredBatches.length}.`); | ||
|
||
for (const [index, batch] of filteredBatches.entries()) { | ||
try { | ||
const encodedBatchMintCall = hexConcat([ | ||
"0x83b74baa", | ||
defaultAbiCoder.encode(["address[]", "uint256"], [batch.recipients, parseUnits(batch.amount.toString())]), | ||
]); | ||
|
||
const encodedExecuteTransactionWithRole = hexConcat([ | ||
"0x6928e74b", | ||
defaultAbiCoder.encode( | ||
["address", "uint256", "bytes", "uint8", "uint16", "bool"], | ||
[destinationAddress, 0, encodedBatchMintCall, 0, 1, true], | ||
), | ||
]); | ||
|
||
let fees = await eip1559GasProvider.get1559Fees(); | ||
|
||
while (fees.maxFeePerGas.gt(gasPriceCap)) { | ||
console.warn(`Max fee per gas (${fees.maxFeePerGas.toString()}) exceeds gas price cap (${gasPriceCap})`); | ||
|
||
const currentBlockNumber = await provider.getBlockNumber(); | ||
while ((await provider.getBlockNumber()) === currentBlockNumber) { | ||
console.warn(`Waiting for next block: ${currentBlockNumber}`); | ||
await wait(4_000); | ||
} | ||
|
||
fees = await eip1559GasProvider.get1559Fees(); | ||
} | ||
|
||
const transactionRequest: TransactionRequest = { | ||
to: zodiacRolesModifierAddress, | ||
value: 0, | ||
type: 2, | ||
data: encodedExecuteTransactionWithRole, | ||
chainId, | ||
maxFeePerGas: fees.maxFeePerGas, | ||
maxPriorityFeePerGas: fees.maxPriorityFeePerGas, | ||
nonce, | ||
}; | ||
|
||
const transactionGasLimit = await estimateTransactionGas(signer, transactionRequest); | ||
|
||
const transaction: TransactionRequest = { | ||
...transactionRequest, | ||
gasLimit: transactionGasLimit, | ||
}; | ||
|
||
const transactionInfo = await executeTransaction(signer, transaction, batch); | ||
pendingTransactions.push(transactionInfo); | ||
|
||
trackingData.set(batch.id, { | ||
recipients: batch.recipients, | ||
tokenAmount: batch.amount, | ||
status: BatchStatuses.Pending, | ||
transactionHash: transactionInfo.transactionResponse.hash, | ||
}); | ||
|
||
updateTrackingFile(trackingData); | ||
|
||
processedBatchIds.push(batch.id); | ||
|
||
console.log(`Batch with ID=${batch.id} sent.\n ${JSON.stringify(batch)}\n`); | ||
nonce = nonce + 1; | ||
} catch (error) { | ||
trackingData.set(batch.id, { | ||
recipients: batch.recipients, | ||
tokenAmount: batch.amount, | ||
status: BatchStatuses.Failed, | ||
error, | ||
}); | ||
updateTrackingFile(trackingData); | ||
console.error(`Batch with ID=${batch.id} failed.\n Stopping script execution.`); | ||
return; | ||
} | ||
|
||
if (index + (1 % 15) === 0) { | ||
console.log(`Pause the execution for 60 seconds...`); | ||
await wait(60_000); | ||
} | ||
} | ||
|
||
if (pendingTransactions.length !== 0) { | ||
console.log(`Waiting for all receipts...`); | ||
} | ||
|
||
const transactionsInfos = await Promise.all( | ||
pendingTransactions.map(async ({ transactionResponse, batch }) => { | ||
return { | ||
transactionReceipt: await transactionResponse.wait(), | ||
batch, | ||
}; | ||
}), | ||
); | ||
|
||
for (const { batch, transactionReceipt } of transactionsInfos) { | ||
if (transactionReceipt.status == 0) { | ||
trackingData.set(batch.id, { | ||
recipients: batch.recipients, | ||
tokenAmount: batch.amount, | ||
status: BatchStatuses.Failed, | ||
transactionHash: transactionReceipt.transactionHash, | ||
}); | ||
|
||
console.log(`Transaction reverted. Hash: ${transactionReceipt.transactionHash}, batchId: ${batch.id}`); | ||
updateTrackingFile(trackingData); | ||
continue; | ||
} | ||
|
||
trackingData.set(batch.id, { | ||
recipients: batch.recipients, | ||
tokenAmount: batch.amount, | ||
status: BatchStatuses.Success, | ||
transactionHash: transactionReceipt.transactionHash, | ||
}); | ||
|
||
updateTrackingFile(trackingData); | ||
console.log(`Transaction succeed. Hash: ${transactionReceipt.transactionHash}, batchId: ${batch.id}`); | ||
} | ||
} | ||
|
||
main() | ||
.then(() => process.exit(0)) | ||
.catch((error) => { | ||
console.error(error); | ||
process.exit(1); | ||
}); | ||
|
||
process.on("SIGINT", () => { | ||
console.log(`Processed batches: ${JSON.stringify(processedBatchIds, null, 2)}`); | ||
console.log("\nGracefully shutting down from SIGINT (Ctrl-C)"); | ||
process.exit(1); | ||
}); |
Oops, something went wrong.