Patch Thursday — All About Account Abstraction

ChainLight
ChainLight Blog & Research
17 min readAug 31, 2023

In this study, we introduce “Account Abstraction,” which enhances usability and lowers the barriers for Web3 users that utilize the decentralized ecosystem of the Ethereum network.

Before we start,

Interacting with the Ethereum network requires a thorough understanding and experience with blockchain, which includes downloading the Web3 wallet, buying a certain amount of ETH from exchanges, and safely managing the private key that controls the ownership of the wallet.

Account Abstraction is a concept that can address these issues and has been getting attention in the Ethereum ecosystem recently. It dilutes the border between EOA(Externally Owned Account), managed by the private key, and CA, which is a smart contract. It abstracts* the account to perform both the roles of EOA and CA.

*Abstraction in programming refers to a method of improving the complexity of a program. “Abstracting” indicates simplifying the program’s operation without letting users acknowledge the exact implementation. In the same context, account abstraction refers to the simplification of various complex actions involving Ethereum accounts, such as transaction submission and wallet management.

Utilizing Account Abstraction benefits users with improved user experience and lowered entry barriers through the enhancements, including:

  • Interacting with Web3 services through emails
  • Convenience of signing
  • Gas fee sponsorship

Various forms of Account Abstraction, including EIP-2938, have been proposed in the Ethereum ecosystem. However, each of them needed critical updates on the Ethereum consensus, halting the introduction of Account Abstraction. In this study, we will provide an in-depth analysis of ERC-4337, the officially accepted Account Abstraction proposal on Ethereum, which has been spotlighted by introducing decentralized account abstraction without the change in the consensus of Ethereum.

Components of ERC-4337

The figure describes how the transactions submitted through ERC-4337 are sent to the Ethereum network. The user submits a transaction-like object named UserOperation to a separated mempool, which is called Alternative Mempool. Submitted UserOp is selected by Bundler. Bundler selects multiple UserOps from Alternative Mempool, ties them into a bundle, and submits it to the network. During this step, the gas fee for the submission is paid by the Bundler since the Bundler is the actual subject who submits the transaction. However, it can get the fee repaid by ETH prepaid into the Entrypoint or the contract called Paymaster, which pays users’ gas fees on their behalf.

In the following section, more detailed explanations of each component that constitutes ERC-4337 will be provided.

1. UserOperation

In simple terms, a UserOperation can be explained as an “abstracted transaction.”

According to the EIP-4337, UserOperation is a structure that describes a transaction to be sent on behalf of a user.

In addition to the data that the Ethereum transaction should contain, UserOperation carries information that needs to be conveyed to various components involved in ERC-4337, such as the Bundler or the Paymaster.

UserOperation(UserOp from now on) has some fields in common with the transactions, like sender and nonce, but includes other fields that the original transactions do not own, such as initCode, callData, verificationGasLimit, and paymasterAndData.

struct UserOperation {
address sender;
uint256 nonce;
bytes initCode;
bytes callData;
uint256 callGasLimit;
uint256 verificationGasLimit;
uint256 preVerificationGas;
uint256 maxFeePerGas;
uint256 maxPriorityFeePerGas;
bytes paymasterAndData;
bytes signature;
}

2. Bundler

Bundler is an entity that submits the transaction to the Ethereum network on behalf of the users. It generates a bundle transaction by tying up the UserOps that are submitted in Alternative Mempool. When the submitted bundle transaction is properly executed, the Bundler receives the fee from the user or the Paymaster.

However, this *reimbursement can only be done when UserOp is successfully executed.

*“Reimbursement” is the concept of receiving a refund of the fees the Bundler consumed during the transaction submission process.

3. Entrypoint

Entrypoint plays the role of verifying the validity of bundle transactions that the Bundler submits and executing them in the on-chain environment. Entrypoints proceed with UserOp’s verification phase and execution phase. Following the pattern illustrated in steps 2 to 3 of the diagram, the execution phase takes place after the verification phase of all UserOps has been completed. Each phase of UserOp will be explained in the later section, “Workflow of ERC-4337”.

4. Paymaster

Paymaster is a contract that pays gas fees on behalf of the user. This contract interacts with the Entrypoint and passes the intention of reimbursement and also the fees to the Bundler.

Workflow of ERC-4337

This section describes the series of steps from transaction submission to execution within ERC-4337. Once a UserOp is submitted to the user’s Alternative Mempool, the Bundler undergoes a process of simulation of the submitted UserOps. The Bundler bundles the UserOps that successfully pass the simulation and submits it to the Entrypoint. The Entrypoint verifies the validity of the UserOps and executes them. Following this, depending on whether the Paymaster is specified or not, either the Paymaster or the Entrypoint goes through the process of reimbursing the Bundler with the fees.

Each step of the workflow can be described as follows:

  1. A User submits UserOp to Alternative Mempool
  2. Bundler simulates submitted UserOp
  3. Bundler submits the UserOps which passes the simulation to the Entrypoint, in the form of bundle transaction
  4. Entrypoint executes UserOp after verification
  5. Depending on the specification in UserOp, Paymaster or Entrypoint reimburses gas fees to the Bundler

The following sections provide detailed explanations of each of these processes.

1. UserOp Submission

The user fills the UserOp with the specifications of “How the transaction should be executed” and submits it to the Alternative Mempool.

2. Composal and Submission of Bundle Transaction

Bundler generates a bundle transaction by tying up the UserOps submitted in Alternative Mempool. The Bundler also performs the role of sending the created bundle transactions to the Entrypoints. To determine whether it can receive a refund of the consumed gas fee, an off-chain simulation is conducted for the UserOp. After the simulation, the Bundler submits the bundle transactions it created by invoking the functions of the Entrypoints.

For a comprehensive understanding of UserOp’s simulation, we will introduce each step of the simulation and the mechanism that prevents simulation-passed UserOp from failing on actual on-chain submission.

Following sections describe the Bundler’s composal of bundle transactions and simulation process within the submission.

Simulation of UserOp by Bundler

The execution of UserOp is separated into two phases: the Verification phase and the Execution phase. This is because performing all the simulations off-chain requires a considerable amount of computing resources. It would take a long time for the verification when plenty of invalid transactions are included in a bundle transaction.

Bundler’s duty is to confirm if UserOp successfully passes the verification phase since the failure in the execution phase does not affect the success of the actual transaction. Therefore, Bundler performs a quick test of whether the UserOp in the Alternative Mempool passes the verification phase on off-chain and generates a bundle transaction only with the passed UserOps.

Regardless of the success of the simulation, UserOp might fail in the on-chain execution. What would be the ERC-4337’s prevention for this case?

Preventing UserOp’s on-chain execution failure

Simulation might succeed but may fail in the actual on-chain execution. This results in financial losses for the Bundler. Therefore, maintaining consistency between simulation and actual on-chain submission outcomes is crucial. For instance, consider a UserOp in the verification stage that only passes if the timestamp value of the block is below 1000. During simulation, conditions like this might be met, but if the timestamp of the transaction when included in a block is higher than 1000, the transaction could fail on-chain.

To prevent such situations, EIP-4337 disallows access to any information that could introduce differences between simulation and actual execution results during the verification stage. Consequently, opcodes like block time, block number, and block hash cannot be used in the verification stage. Furthermore, the verification stage has constraints on *accessing only the storage associated with the sender’s address of the transaction. If multiple UserOps can access the same storage, the validity of other UserOps that reference and perform computations based on the same storage due to the results of one UserOp accessing the storage could be compromised. Compliance with such constraints must be ensured by the Bundler software (node) during simulation.

*https://eips.ethereum.org/EIPS/eip-4337#:~:text=Storage%20is%20enabled.-,Storage%20associated%20with%20an%20address,-We%20define%20storage

3. UserOp Execution by Entrypoint

For the submission of the bundle transactions, the Bundler calls handleOps() of the Entrypoint. Within handleOps(), the validity of the UserOp is confirmed, and Bundler receives fees from the account that submitted the UserOp. This means that even if the execution phase fails, the Bundler can still receive fees, as the account preemptively pays the fees during the verification phase. Below is the source code of the handleOps() function in the Entrypoint.

function handleOps(UserOperation[] calldata ops, address payable beneficiary) public nonReentrant {
uint256 opslen = ops.length;
UserOpInfo[] memory opInfos = new UserOpInfo[](opslen);
unchecked {
for (uint256 i = 0; i < opslen; i++) {
UserOpInfo memory opInfo = opInfos[i];
(uint256 validationData, uint256 pmValidationData) = _validatePrepayment(i, ops[i], opInfo);
_validateAccountAndPaymasterValidationData(i, validationData, pmValidationData, address(0));
}
uint256 collected = 0;
emit BeforeExecution();
for (uint256 i = 0; i < opslen; i++) {
collected += _executeUserOp(i, ops[i], opInfos[i]);
}
_compensate(beneficiary, collected);
}
}

Validation of UserOp

UserOp’s validation starts with calling account.validateUserOp() in _validatePrepayment(). In this step, it’s important that the validation logic is designed and operated through each user’s preferences.

For instance, users can employ various signature algorithms beyond Ethereum’s ECDSA(Elliptic Curve Digital Signature Algorithm) method. However, as previously mentioned, there are constraints on opcodes and storage access in the verification phase to ensure consistency between simulation and actual on-chain execution outcomes. Furthermore, to prevent replay attacks across different Entrypoints or different chains, signatures must be dependent on the chainId as well as the address of the Entrypoint.

contract EntryPoint {
function getUserOpHash(UserOperation calldata userOp) public view returns (bytes32) {
return keccak256(abi.encode(userOp.hash(), address(this), block.chainid));
}
}

// ref: https://www.youtube.com/watch?v=edPJaUYWlhY&list=LL&index=1
contract Test {
function testSignature(UserOpration memory op, EntryPoint entryPoint) public {
bytes32 userOpHash = entryPoint.getUserOpHash(op);
bytes32 signHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", userOpHash));
(uint8 v, bytes32 r, bytes32 s) = vm.sign(alicePk, signHash);
op.signautre = abi.encodePacked(r, s, v);
}
}

The provided code serves as an example of how a user generates a signature for a UserOp. The user utilizes getUserOpHash() of the specified Entrypoint to create a signature dependent on the UserOp, the address of the Entrypoint, and the chainId.

As mentioned earlier, the account submitting the UserOp can use verification logic distinct from Ethereum’s ECDSA. By calling validateUserOp() of the Account contract from the Entrypoint, the account receives the UserOp and verifies the validity of the signature. If the signature is valid, the account deposits a certain amount of ETH, which is equivalent to missingAccountFunds, to the Entrypoint to pay the gas fees for the Bundler. It’s possible to deposit more ETH than missingAccountFunds, and the excess amount remains for use in the next UserOp. Account contracts must specify and register specific Entrypoints in a whitelist. Only Entrypoints in the whitelist are allowed to call validateUserOp().

function validateUserOp(
UserOperation calldata userOp,
bytes32 userOpHash,
uint256 missingAccountFunds
) external virtual override returns (uint256 validationData) {
_requireFromEntryPoint();
validationData = _validateSignature(userOp, userOpHash);
_validateNonce(userOp.nonce);
_payPrefund(missingAccountFunds);
}

Furthermore, the Entrypoint is responsible for invoking the actions of the Paymaster. When a separate Paymaster is specified for a UserOp, during the verification phase, instead of charging the user fees, the Entrypoint confirms the Paymaster’s willingness to pay fees and, if allowed, receives fees from the Paymaster and then tosses them to the Bundler.

4. Reimbursement of Fees and postOp Execution

When the transaction submitted by the Entrypoint is executed successfully, and the Paymaster is specified, the Paymaster pays the fee to the Bundler through a certain logic. Additionally, after the payment, it can perform predefined actions such as measuring the actually consumed gas fees. These actions, defined in advance, are referred to as postOps.

Paymaster can be implemented in various forms, and currently, two main scenarios exist.

1) On-chain Signature Verifier Paymaster

Visa has been exploring the option of using account abstractions to enable payment of Ethereum gas fees with Visa cards since 2022. We will explain the operational structure and process of Visa’s Paymaster, which was implemented and deployed on August 11th.

Source: https://usa.visa.com/solutions/crypto/paying-blockchain-gas-fees-with-card.html

Workflow

As the upper diagram illustrates, Visa’s implementation of the Paymaster operates as follows.

  1. Visa utilizes the user’s credit card to make off-chain payments equivalent to gas fees.
  2. Once the payment is successfully processed, Visa’s Paymaster web application returns the Paymaster’s signature to the user.
  3. The user submits the UserOp along with the Paymaster’s signature.
  4. During the verification phase of UserOp, Entrypoint confirms whether the Paymaster intends to cover the gas fees. In this step, the Entrypoint calls validatePaymasterUserOp() of the Paymaster.
  5. Visa’s Paymaster verifies the user’s signature on-chain and covers the gas fees. For this process, the Paymaster must pre-deposit a sufficient amount of ETH into the Entrypoint.

The workflow of validatePaymasterUserOp() can be found in Visa’s Paymaster contract, which is *deployed on the Ethereum Goerli testnet.

*https://goerli.etherscan.io/address/0x810a1797ffe00936c7da5723e474fe23cecdd6e9#code

function validatePaymasterUserOp(UserOperation calldata userOp, bytes32 userOpHash, uint256 maxCost) external override returns (bytes memory context, uint256 validationData) {
_requireFromEntryPoint();
return _validatePaymasterUserOp(userOp, userOpHash, maxCost);
}

function _validatePaymasterUserOp(UserOperation calldata userOp, bytes32 /*userOpHash*/, uint256 requiredPreFund) internal override returns (bytes memory context, uint256 validationData) {
(requiredPreFund);
(uint48 validUntil, uint48 validAfter, bytes calldata signature) = parsePaymasterAndData(userOp.paymasterAndData);

// ECDSA library supports both 64 and 65-byte long signatures.
// we only "require" it here so that the revert reason on invalid signature will be of "VerifyingPaymaster", and not "ECDSA"
require(signature.length == 64 || signature.length == 65, "VerifyingPaymaster: invalid signature length in paymasterAndData");
bytes32 hash = ECDSA.toEthSignedMessageHash(getHash(userOp, validUntil, validAfter));
senderNonce[userOp.getSender()]++;

// don't revert on signature failure: return SIG_VALIDATION_FAILED
if (verifyingSigner != ECDSA.recover(hash, signature)) {
return ("",_packValidationData(true,validUntil,validAfter));
}

// no need for other on-chain validation: entire UserOp should have been checked
// by the external service prior to signing it.
return ("",_packValidationData(false,validUntil,validAfter));
}

Paymaster calls only the predetermined Entrypoints through _requireFromEntryPoint(). The following steps include the function calls and signature verification procedure of _requireFromEntryPoint().

  1. After parsing paymasterAndData, the signer of the data is recovered using the ECDSA algorithm, and the signer’s registration with the Paymaster is then verified.
  2. When the signature is valid, _validatePaymasterUserOp() returns context and validationData. As context contains no value here, Paymaster’s postOp procedure is omitted.
  3. If aggregator is false (or 0), it means that the validation succeeded.

FYI: Entrypoints don’t face constraints on using specific opcodes, such as block.timestamp. Such constraints are only imposed during the verification phase when call depth is greater than 1. Thus, these limitations are enforced solely for the account, paymaster, and account factory.

validationData is a compressed entity of aggregator, validUntil, and validAfter, which signifies whether signature verification was successful.

validUntil and validAfter indicate the time range when the signature is valid. Validation is performed by comparing these values with the current block timestamp in the Entrypoint.

In this scenario, the crucial consideration is how much compensation the Paymaster will receive from users as a reimbursement for their gas fee sponsorship.

This concern arises due to the timing discrepancy between payment and execution in scenarios like the Visa Paymaster. Since payment is made before the UserOp execution, the exact gas consumption of the UserOp during execution remains to be determined.

To address this, the Paymaster must first calculate the maximum gas fees that could be incurred by UserOp, and initiate the off-chain payment based on this calculation.

Paymaster pays the calculated prepayment amount of ETH and returns the unused portion of the *prefund, accounting for the actual gas fees consumed, after the execution phase of UserOp.

*Prefund refers to the prepayment for the successful execution of the UserOp verification phase. Paymaster should deposit the prefund more than the gas fee that will be used on-chain. The payment for prefund is performed with _getRequiredPrefund().

Hence, one simple approach is to make the Paymaster ensure the off-chain payment only to cover the prefund. In other words, using the gas-related values assigned to UserOp, the Paymaster can calculate the prefund without executing UserOp. Considering the base fee of the block at the time of actual UserOp execution is the responsibility of the Bundler. Therefore, Bundler needs to monitor in each block whether the base fee of the current block exceeds the user-specified maximum gas fee, ensuring that the prefund made by the Paymaster remains sufficient.

function _getRequiredPrefund(
MemoryUserOp memory mUserOp
) internal pure returns (uint256 requiredPrefund) {
unchecked {
// When using a Paymaster, the verificationGasLimit is used also to as a limit for the postOp call.
// Our security model might call postOp eventually twice.
uint256 mul = mUserOp.paymaster != address(0) ? 3 : 1;
uint256 requiredGas = mUserOp.callGasLimit +
mUserOp.verificationGasLimit *
mul +
mUserOp.preVerificationGas;
requiredPrefund = requiredGas * mUserOp.maxFeePerGas;
}
}

2) Token Paymaster

In this scenario, the Paymaster receives ERC-20 tokens equivalent to fees from the account.

The process can be briefly described as follows:

  1. The user creates a UserOp that includes a Paymaster, which allows USDC payments and submits it to the Alternative Mempool.
  2. handleOps() of the Entrypoint is called by Bundler and checks if the Paymaster’s ETH deposit is sufficient. If it is, validatePaymasterUserOp() of the Paymaster is called to check whether the Paymaster is willing to pay the gas fees on behalf of the user.
  3. Paymaster verifies if the user’s account has approved enough USDC for the Paymaster, and if so, it covers the gas fees. The actual transfer of USDC tokens happens in the postOp() after the execution of UserOp.
  4. In the Entrypoint’s _executeUserOp(), the main execution of UserOp takes place. If the main execution concludes successfully, the Paymaster’s postOp() is triggered.
  5. Paymaster’s postOp() calls a function that receives the calculated amount of USDC equivalent to the actual gas fees used by the user.

In this scenario, it’s vital that the Paymaster consistently receives USDC tokens from the account submitting the UserOp. Consider an attack where, during step 4, the UserOp cancels the USDC approval for the Paymaster or transfers all USDC to a different address, thereby evading payment of ERC-20 tokens to the Paymaster. If such actions during the execution phase of the UserOp impact the Paymaster’s postOp resulting in failure, the UserOp’s main execution phase is skipped, and only the postOp is re-executed. The detailed process can be further found through the provided code.

function _executeUserOp(
uint256 opIndex,
UserOperation calldata userOp,
UserOpInfo memory opInfo
) private returns (uint256 collected) {

try this.innerHandleOp(userOp.callData, opInfo, context) returns (
uint256 _actualGasCost
) {
collected = _actualGasCost;
} catch {

uint256 actualGas = preGas - gasleft() + opInfo.preOpGas;
collected = _handlePostOp(
opIndex,
IPaymaster.PostOpMode.postOpReverted,
opInfo,
context,
actualGas
);
}
}

Security consideration

Utilizing Account Abstraction produces various security considerations beyond the component-specific aspects mentioned earlier. Here are some additional factors to consider.

Bundler

  • Collisions Due to Previous Transactions: When bundle transactions get included in the block, prior transactions in the same block might affect them regardless of the intention of attack(e.g., front-running, sandwich attack), potentially causing UserOps to fail. To mitigate this, Bundlers need to position UserOp bundles as the first transaction in a block. This requires Bundlers to either be block builders or leverage block-building infrastructure like MEV-Boost.
  • Layer 2 Network Challenges: In Layer 2 networks like Arbitrum and Optimism, a single sequencer generates blocks, making it difficult for Bundlers to ensure UserOp bundles are placed as the first transaction. If a bundled transaction fails due to conflicts with other transactions, the Bundler incurs financial losses, diminishing their incentive. To address this, a separate RPC endpoint has been proposed to ensure bundle transaction success based on specific conditions like account values, storage slots, block numbers, and timestamps.
  • Gas Fee Prediction Issues: If the measured gas cost from simulations is lower than the actual on-chain UserOp execution cost, transactions might fail due to insufficient gas fees. In such cases, Bundlers can not receive gas fee refunds, leading to losses.
  • Reputation System: If Paymasters intentionally revert postOps, transactions are reverted, and Bundlers can’t receive gas fees. To mitigate this issue, off-chain reputation systems have been suggested for Paymasters, Factories, and Aggregators to maintain trust and discourage malicious behavior.

Paymaster

  • Oracle Price Manipulation Risk: For a Paymaster that receives ERC20 tokens from user accounts, it’s crucial to accurately retrieve the price of the ERC20 token from an oracle. For instance, if a Paymaster charges an additional 10% over the gas fees it covers, and the received ERC20 token price from the oracle is 10% larger than the actual price, the Paymaster will incur losses. Therefore, considerations about the oracle providing accurate values and when to update the oracle price are essential. If the oracle price is updated every time a postOp is executed, and there’s a significant difference between the updated price from the last postOp and the actual price, an off-chain monitoring tool might need to be employed to call the oracle price update function separately.

User

  • User’s ERC-20 Token Depletion During Execution: In the scenario of a token Paymaster, it’s essential to ensure that ERC-20 tokens intended for payment are not depleted during the execution process. For instance, if a UserOp involves paying ERC-20 tokens like USDC as gas fees to the Paymaster, executing a UserOp that transfers all USDC to another wallet would cause the postOp to fail. This failure would lead to a situation where the UserOp’s execution step is skipped, and only the postOp is re-invoked, allowing the Paymaster to take the USDC. This would result in the user paying only the gas fees without achieving their intended action. This scenario could repeat, causing users to spend gas fees without their transactions being processed. Consequently, handling such cases should be a priority within the account abstraction-supporting interface to prevent these issues.
  • Gas Limit Settings for the Entrypoint: Users set three gas limits when submitting a UserOp: PreVerificationGas, VerificationGasLimit, and CallGasLimit. PreVerificationGas refers to the gas fees consumed during the process of bundling multiple UserOps and sending them to the Entrypoint. Since PreVerificationGas is not pre-calculated, careful consideration is necessary when setting it. Setting it too high might lead to users paying excessive gas fees to the Bundler while setting it too low could cause the Bundler to be unable to cover the gas fees, resulting in transaction reverts and wasted fees.
  • Execution Logic: The execution logic beneath account abstraction is an area of ongoing discussion. Implementing new features would require cloning and rebuilding the account, and due to the nature of code reuse, vulnerabilities could lead to chaining problems. To address these concerns, a modular approach to account abstraction has been proposed. Rather than simply invoking external logic using delegatecall or call as part of a wallet, this approach aims to modularize the application logic itself and execute it within the wallet, thereby mitigating the vulnerabilities associated with the current execution model.
  • MEV by Bundlers: Due to the structure of account abstraction, Bundlers can potentially reorder the UserOps submitted by users. Therefore, considering the interplay between MEV and account abstraction is crucial to address potential issues.

Entrypoint

  • Revert Reason Bombing Vulnerability: When an external function call reverts the transaction, the Entrypoint receives the reason for the revert in the form of memory bytes. However, if the reason is excessively long, copying it within the Entrypoint consumes a significant amount of gas, potentially leading to forced transaction reverts. This attack takes place during the execution phase and can induce transaction failures due to the Entrypoint’s function call, not internal function calls. Consequently, predicting transaction reverts during the simulation is challenging. Although this vulnerability has been patched, unexpected transaction failures within Entrypoint’s function calls can still result in economic losses for the Bundler. Thus, when designing a separate Entrypoint, it’s crucial to be aware of these potential issues to avoid unintended financial loss.

Interaction Between Existing DeFi / NFT Contracts and Account

  • In ERC-4337, an account is treated as a contract address(CA) instead of EOA. This poses challenges when interacting with existing contracts since conventional DeFi or NFT contracts tend to reject interactions when msg.sender is a CA. Moreover, adapting existing DeFi and NFT contracts to support account abstraction by allowing interactions with contract msg.sender requires careful consideration of potential security vulnerabilities, such as replay attacks from account contracts.

Want to request an audit? 👉 https://chainlight.io/

✨ We are ChainLight!

ChainLight explores new and effective blockchain security technologies with rich practical experience and deep technical understanding. Our innovative security audits built upon such research proactively identify and eliminate various security risks and vulnerabilities in the Web3 ecosystem. To ensure continuous security even after the audit, we provide a digital asset risk management solution using on-chain data monitoring and automated vulnerability detection services.

ChainLight serves to guide and protect all users of decentralized services, lighting the way for a safer Web3 ecosystem.

  • Want to see more from the ChainLight team? 👉 Check out our Twitter account.

🌐 Website: chainlight.io | 📩 TG: @chainlight | 📧 chainlight@theori.io

--

--

ChainLight
ChainLight Blog & Research

Established in 2016, ChainLight's award-winning experts provide tailored security solutions to fortify your smart contract and help you thrive on the blockchain