Particle Protocol - Invitational - adriro's results

Peer-to-peer interest earning and liquidity leveraging for NFTs.

General Information

Platform: Code4rena

Start Date: 30/05/2023

Pot Size: $24,170 USDC

Total HM: 10

Participants: 5

Period: 3 days

Judge: hansfriese

Total Solo HM: 2

Id: 244

League: ETH

Particle Protocol

Findings Distribution

Researcher Performance

Rank: 3/5

Findings: 4

Award: $0.00

QA:
grade-a
Gas:
grade-a

🌟 Selected for report: 3

🚀 Solo Findings: 0

Findings Information

🌟 Selected for report: rbserver

Also found by: adriro, bin2chen, d3e4, minhquanym

Labels

bug
3 (High Risk)
satisfactory
sponsor confirmed
upgraded by judge
duplicate-31

Awards

Data not available

External Links

Lines of code

https://github.com/code-423n4/2023-05-particle/blob/main/contracts/protocol/ParticleExchange.sol#L216 https://github.com/code-423n4/2023-05-particle/blob/main/contracts/protocol/ParticleExchange.sol#L744

Vulnerability details

Borrower can block being defaulted or auctioned

The borrower can potentially block the liquidation and auction processed by using a contract and reverting on ETH transfers.

Impact

When a loan is being liquidated or auctioned, any credit still available to the borrower (i.e. margin discounting the lender's interests) needs to be refunded. This is present in the withdrawEthWithInterest() function, that can be used to liquidate a position, and in the auctionBuyNft() function which is used to auction a loan.

https://github.com/code-423n4/2023-05-particle/blob/main/contracts/protocol/ParticleExchange.sol#L192-L223

192:     function withdrawEthWithInterest(Lien calldata lien, uint256 lienId) external override validateLien(lien, lienId) {
             ...
214:         // transfer PnL to borrower
215:         if (lien.credit > payableInterest) {
216:             payable(lien.borrower).transfer(lien.credit - payableInterest);
217:         }
             ...

https://github.com/code-423n4/2023-05-particle/blob/main/contracts/protocol/ParticleExchange.sol#L688-L748

688:     function auctionBuyNft(
689:         Lien calldata lien,
690:         uint256 lienId,
691:         uint256 tokenId,
692:         uint256 amount
693:     ) external override validateLien(lien, lienId) auctionLive(lien) {
             ...
741:         // pay PnL to borrower
742:         uint256 payback = lien.credit + lien.price - payableInterest - amount;
743:         if (payback > 0) {
744:             payable(lien.borrower).transfer(payback);
745:         }
             ...

As we can see in both of these cases, if the borrower still has some credit left, this is refunded by using the transfer() function. This is problematic, as the transfer() function reverts on failure. If the receiver is a contract, then this failure can be easily manipulated and forced.

This means that the borrower can use a contract to take the loan and arbitrarily decide to revert when the payback is being refunded, effectively blocking the liquidation and auction processes.

Proof of concept

The attacker can create a simple contract to interact with the Particle exchange. This contract has a receive() function that can be configured to revert.

function receive() external payable {
  if (_shouldRevert) {
    revert();
  }
}

The attacker then takes loans using this contract as an interface, and can block at will auctions or liquidations by setting _shouldRevert to true.

Recommendation

Similar to how interests are accrued for the lender, prefer a pull over push process to have the receiver pull ETH instead of pushing it in the middle of a transaction. The withdrawEthWithInterest() and auctionBuyNft() functions should accumulate amounts corresponding to the borrower in internal storage and then offer a function to withdraw these by separate.

Assessed type

DoS

#0 - c4-judge

2023-06-06T06:33:23Z

hansfriese marked the issue as satisfactory

#1 - c4-judge

2023-06-06T06:34:06Z

hansfriese changed the severity to 3 (High Risk)

#2 - c4-judge

2023-06-06T06:34:21Z

hansfriese marked the issue as primary issue

#3 - c4-judge

2023-06-06T07:33:58Z

hansfriese marked the issue as duplicate of #31

#4 - wukong-particle

2023-06-07T01:38:22Z

Judge is correct, indeed duplication

#5 - wukong-particle

2023-06-07T01:40:00Z

will update our contract following the pull based approach to aggregate gains to trader account level

#6 - c4-sponsor

2023-06-07T01:40:06Z

wukong-particle marked the issue as sponsor confirmed

#7 - wukong-particle

2023-06-12T23:05:32Z

will update our contract following the pull based approach to aggregate gains to trader account level

^ fixed with https://github.com/Particle-Platforms/particle-exchange-protocol/pull/13

Findings Information

🌟 Selected for report: adriro

Also found by: minhquanym, rbserver

Labels

bug
3 (High Risk)
primary issue
satisfactory
selected for report
sponsor acknowledged
upgraded by judge
H-02

Awards

Data not available

External Links

Lines of code

https://github.com/code-423n4/2023-05-particle/blob/main/contracts/protocol/ParticleExchange.sol#L212

Vulnerability details

Treasury fee is not collected in withdrawEthWithInterest()

The withdrawEthWithInterest() function fails to collect treasury fees from the lender interests.

Impact

The Particle exchange collects treasury fees from the lender's interests. These interests are accumulated in the interestAccrued mapping and are withdrawn using the _withdrawAccountInterest() function, which splits the portion that corresponds to the treasury.

https://github.com/code-423n4/2023-05-particle/blob/main/contracts/protocol/ParticleExchange.sol#L231-L246

231:     function _withdrawAccountInterest(address payable lender) internal {
232:         uint256 interest = interestAccrued[lender];
233:         if (interest == 0) return;
234: 
235:         interestAccrued[lender] = 0;
236: 
237:         if (_treasuryRate > 0) {
238:             uint256 treasuryInterest = MathUtils.calculateTreasuryProportion(interest, _treasuryRate);
239:             _treasury += treasuryInterest;
240:             interest -= treasuryInterest;
241:         }
242: 
243:         lender.transfer(interest);
244: 
245:         emit WithdrawAccountInterest(lender, interest);
246:     }

Lines 238-240 calculate treasury fees and accumulate them in the _treasury variable, which are later withdrawn by the owner using the withdrawTreasury() function.

However, these fees fail to be considered in the case of withdrawEthWithInterest():

https://github.com/code-423n4/2023-05-particle/blob/main/contracts/protocol/ParticleExchange.sol#L192-L223

192:     function withdrawEthWithInterest(Lien calldata lien, uint256 lienId) external override validateLien(lien, lienId) {
193:         if (msg.sender != lien.lender) {
194:             revert Errors.Unauthorized();
195:         }
196: 
197:         if (lien.loanStartTime == 0) {
198:             revert Errors.InactiveLoan();
199:         }
200: 
201:         uint256 payableInterest = _calculateCurrentPayableInterest(lien);
202: 
203:         // verify that the liquidation condition has met (borrower insolvent or auction concluded)
204:         if (payableInterest < lien.credit && !_auctionConcluded(lien.auctionStartTime)) {
205:             revert Errors.LiquidationHasNotReached();
206:         }
207: 
208:         // delete lien (delete first to prevent reentrancy)
209:         delete liens[lienId];
210: 
211:         // transfer ETH with interest back to lender
212:         payable(lien.lender).transfer(lien.price + payableInterest);
213: 
214:         // transfer PnL to borrower
215:         if (lien.credit > payableInterest) {
216:             payable(lien.borrower).transfer(lien.credit - payableInterest);
217:         }
218: 
219:         emit WithdrawETH(lienId);
220: 
221:         // withdraw interest from this account too
222:         _withdrawAccountInterest(payable(msg.sender));
223:     }

As we can see in the previous snippet of code, the interests are calculated in line 201 but that amount is then transferred, along with the lien price, back to the lender in full in line 212, without deducting any treasury fees.

Recommendation

The interest can be simply accumulated in the interestAccrued mapping, which are later withdrawn (correctly taking into account treasury fees) in the already present call to _withdrawAccountInterest().

  function withdrawEthWithInterest(Lien calldata lien, uint256 lienId) external override validateLien(lien, lienId) {
      if (msg.sender != lien.lender) {
          revert Errors.Unauthorized();
      }

      if (lien.loanStartTime == 0) {
          revert Errors.InactiveLoan();
      }

      uint256 payableInterest = _calculateCurrentPayableInterest(lien);

      // verify that the liquidation condition has met (borrower insolvent or auction concluded)
      if (payableInterest < lien.credit && !_auctionConcluded(lien.auctionStartTime)) {
          revert Errors.LiquidationHasNotReached();
      }

      // delete lien (delete first to prevent reentrancy)
      delete liens[lienId];
      
+     // accrue interest to lender
+     interestAccrued[lien.lender] += payableInterest;

@     // transfer ETH back to lender
@     payable(lien.lender).transfer(lien.price);

      // transfer PnL to borrower
      if (lien.credit > payableInterest) {
          payable(lien.borrower).transfer(lien.credit - payableInterest);
      }

      emit WithdrawETH(lienId);

      // withdraw interest from this account too
      _withdrawAccountInterest(payable(msg.sender));
  }

Assessed type

Other

#0 - c4-judge

2023-06-06T06:21:39Z

hansfriese marked the issue as primary issue

#1 - c4-judge

2023-06-06T06:22:26Z

hansfriese marked the issue as satisfactory

#2 - c4-sponsor

2023-06-06T19:50:25Z

wukong-particle marked the issue as sponsor acknowledged

#3 - wukong-particle

2023-06-06T19:52:04Z

We will likely fix the issue in another way. We will modify withdrawNftWithInterest and withdrawEthWithInterest into withdrawNft and withdrawEth, i.e., move the interest withdraw into the single account level interest withdraw function (similar to the suggestion made in https://github.com/code-423n4/2023-05-particle-findings/issues/31)

#4 - c4-judge

2023-06-07T13:54:39Z

hansfriese marked the issue as selected for report

#5 - hansfriese

2023-06-07T17:22:04Z

After discussion, I think that HIGH is the appropriate severity because this issue incurs loss for the protocol.

#6 - c4-judge

2023-06-07T17:22:14Z

hansfriese changed the severity to 3 (High Risk)

#7 - wukong-particle

2023-06-08T06:05:25Z

Findings Information

🌟 Selected for report: d3e4

Also found by: adriro

Labels

2 (Med Risk)
satisfactory
duplicate-44

Awards

Data not available

External Links

Judge has assessed an item in Issue #28 as 2 risk. The relevant finding follows:

[L-9] Griefer can DoS lender NFT withdrawals

#0 - c4-judge

2023-06-08T08:16:51Z

hansfriese marked the issue as duplicate of #44

#1 - c4-judge

2023-06-08T08:17:53Z

hansfriese marked the issue as satisfactory

Findings Information

🌟 Selected for report: minhquanym

Also found by: adriro

Labels

bug
2 (Med Risk)
satisfactory
sponsor confirmed
duplicate-7

Awards

Data not available

External Links

Lines of code

https://github.com/code-423n4/2023-05-particle/blob/main/contracts/protocol/ParticleExchange.sol#L428

Vulnerability details

Unspent WETH is not considered in buyNftFromMarket()

Impact

In the buyNftFromMarket() function, the borrower buys an NFT in order to repay and close their loan. The purchase is executed in the internal function named _execBuyNftFromMarket().

https://github.com/code-423n4/2023-05-particle/blob/main/contracts/protocol/ParticleExchange.sol#L395-L431

395:     function _execBuyNftFromMarket(
396:         address collection,
397:         uint256 tokenId,
398:         uint256 amount,
399:         uint256 useToken,
400:         address marketplace,
401:         bytes calldata tradeData
402:     ) internal {
403:         if (!registeredMarketplaces[marketplace]) {
404:             revert Errors.UnregisteredMarketplace();
405:         }
406: 
407:         uint256 balanceBefore = address(this).balance;
408: 
409:         // execute raw order on registered marketplace
410:         bool success;
411:         if (useToken == 0) {
412:             // use ETH
413:             // solhint-disable-next-line avoid-low-level-calls
414:             (success, ) = marketplace.call{value: amount}(tradeData);
415:         } else if (useToken == 1) {
416:             // use WETH
417:             weth.deposit{value: amount}();
418:             weth.approve(marketplace, amount);
419:             // solhint-disable-next-line avoid-low-level-calls
420:             (success, ) = marketplace.call(tradeData);
421:         }
422: 
423:         if (!success) {
424:             revert Errors.MartketplaceFailedToTrade();
425:         }
426: 
427:         // verify that the declared NFT is acquired and the balance decrease is correct
428:         if (IERC721(collection).ownerOf(tokenId) != address(this) || balanceBefore - address(this).balance != amount) {
429:             revert Errors.InvalidNFTBuy();
430:         }
431:     }

As we can see in the previous snippet of code, there are mainly two paths: use ETH or use WETH, which mainly depends on the specifics of the selected marketplace.

The ETH path is straightforward, the specified amount is sent as callvalue to the marketplace. If the difference doesn't match after the execution, because some ETH was refunded or something went wrong, it would cause a revert (line 428).

However, the WETH side is a bit different. Line 417 wraps the specified amount as WETH which is then approved to the marketplace. Now, if the marketplace doesn't consume the full amount, then any unused amount will still be present in the contract as WETH, causing the check in line 428 to succeed. In fact, if the intention is to use WETH (useToken == 1), the check in line 428 will always succeed as the amount parameter is transferred to the WETH contract causing balanceBefore - address(this).balance == amount to always be true.

Proof of concept

Let's say Bob wants to purchase an NFT to close his loan and submits an amount of 5 ether. The selected marketplace uses WETH so 5 ether are wrapped as WETH. The NFT is listed as 4.5 ETH and has an associated fee of 10%. The marketplace then pulls 4.95 WETH from the exchange contract. This leaves 0.05 WETH in the contract that is deducted from Bob's payback, while the check succeeds as the difference in ETH is still 5 ether.

Recommendation

The implementation can unwrap any available WETH after the execution of the marketplace operation so that it can be considered in the calculation present in the amount validation.

  function _execBuyNftFromMarket(
      address collection,
      uint256 tokenId,
      uint256 amount,
      uint256 useToken,
      address marketplace,
      bytes calldata tradeData
  ) internal {
      if (!registeredMarketplaces[marketplace]) {
          revert Errors.UnregisteredMarketplace();
      }

      uint256 balanceBefore = address(this).balance;

      // execute raw order on registered marketplace
      bool success;
      if (useToken == 0) {
          // use ETH
          // solhint-disable-next-line avoid-low-level-calls
          (success, ) = marketplace.call{value: amount}(tradeData);
      } else if (useToken == 1) {
          // use WETH
          weth.deposit{value: amount}();
          weth.approve(marketplace, amount);
          // solhint-disable-next-line avoid-low-level-calls
          (success, ) = marketplace.call(tradeData);
      }

      if (!success) {
          revert Errors.MartketplaceFailedToTrade();
      }
      
+     uint256 wethBalance = weth.balanceOf(address(this));
+     if (wethBalance > 0) {
+         weth.withdraw(wethBalance);
+     }

      // verify that the declared NFT is acquired and the balance decrease is correct
      if (IERC721(collection).ownerOf(tokenId) != address(this) || balanceBefore - address(this).balance != amount) {
          revert Errors.InvalidNFTBuy();
      }
  }

Assessed type

ERC20

#0 - c4-judge

2023-06-06T06:56:21Z

hansfriese marked the issue as satisfactory

#1 - c4-judge

2023-06-06T07:00:38Z

hansfriese marked the issue as duplicate of #7

#2 - wukong-particle

2023-06-07T01:35:09Z

Agreed with the judge, indeed duplication.

#3 - c4-sponsor

2023-06-07T01:35:16Z

wukong-particle marked the issue as sponsor confirmed

#4 - wukong-particle

2023-06-12T23:01:50Z

Findings Information

🌟 Selected for report: adriro

Also found by: bin2chen, d3e4, minhquanym, rbserver

Labels

bug
grade-a
QA (Quality Assurance)
selected for report
sponsor acknowledged
Q-03

Awards

Data not available

External Links

Report

  • Non Critical Issues (3)
  • Low Issues (10)

Non Critical Issues

IssueInstances
NC-1Use named parameters for mapping type declarations3
NC-2Unneeded explicit return1
NC-3Use bool type for togglable parameter1

<a name="NC-1"></a>[NC-1] Use named parameters for mapping type declarations

Consider using named parameters in mappings (e.g. mapping(address account => uint256 balance)) to improve readability. This feature is present since Solidity 0.8.18

Instances (3):

File: contracts/protocol/ParticleExchange.sol

24:     mapping(uint256 => bytes32) public liens;

25:     mapping(address => uint256) public interestAccrued;

26:     mapping(address => bool) public registeredMarketplaces;

<a name="NC-2"></a>[NC-2] Unneeded explicit return

The explicit return can be omitted as the function is using named return variables.

Instances (1):

<a name="NC-3"></a>[NC-3] Use bool type for togglable parameter

Instances (1):

Low Issues

Issue
L-1Contract files should define a locked compiler version
L-2_withdrawAccountInterest() can be front-runned to increase the treasury rate
L-3Relax amount check strictness while operating with marketplaces
L-4The onERC721Received callback can be used to create fake liens
L-5Accidental loss of NFTs due to misuse of push mechanism
L-6auctionBuyNft() should use current auction price instead of amount parameter
L-7Use Ownable2Step instead of Ownable for access control
L-8Provide safer limits for treasury rate
L-9Griefer can DoS lender NFT withdrawals
L-10Marketplace calls are too permissive

<a name="L-1"></a>[L-1] Contract files should define a locked compiler version

Contracts should be deployed with the same compiler version and flags that they have been tested with thoroughly. Locking the pragma helps to ensure that contracts do not accidentally get deployed using, for example, an outdated compiler version that might introduce bugs that affect the contract system negatively.

Instances (1):

File: contracts/protocol/ParticleExchange.sol

2: pragma solidity ^0.8.17;

<a name="L-2"></a>[L-2] _withdrawAccountInterest() can be front-runned to increase the treasury rate

https://github.com/code-423n4/2023-05-particle/blob/main/contracts/protocol/ParticleExchange.sol#L238

A call to _withdrawAccountInterest() can be front-runned to increase the treasury rate and diminish (or nullify) the lender's portion of the loan interests.

function _withdrawAccountInterest(address payable lender) internal {
    uint256 interest = interestAccrued[lender];
    if (interest == 0) return;

    interestAccrued[lender] = 0;

    if (_treasuryRate > 0) {
        uint256 treasuryInterest = MathUtils.calculateTreasuryProportion(interest, _treasuryRate);
        _treasury += treasuryInterest;
        interest -= treasuryInterest;
    }

    lender.transfer(interest);

    emit WithdrawAccountInterest(lender, interest);
}

The treasuryInterest variable is calculated as a proportion of the interest amount which is then subtracted to calculate the lender's share.

<a name="L-3"></a>[L-3] Relax amount check strictness while operating with marketplaces

In both sellNftToMarket() and buyNftFromMarket() the given amount is validated using a strict equality check.

These conditions can be relaxed to account for extra fees, rounding or potential minimal differences when the transaction gets executed. For example, the sell operation can check if the difference in balance is greater or equal than the amount (i.e. it received at least amount) and the buy operation can check the the balance differences is lower or equal than the amount (i.e. it sent at most amount).

<a name="L-4"></a>[L-4] The onERC721Received callback can be used to create fake liens

https://github.com/code-423n4/2023-05-particle/blob/main/contracts/protocol/ParticleExchange.sol#L74

Since the onERC721Received callback is a public function it can be called by anyone to create fake liens. The from parameter is user supplied to the function, which means that anyone can create fake liens on behalf of an arbitrary lender.

<a name="L-5"></a>[L-5] Accidental loss of NFTs due to misuse of push mechanism

https://github.com/code-423n4/2023-05-particle/blob/main/contracts/protocol/ParticleExchange.sol#L74

An accidental loss of NFTs can happen if the sender doesn't use safeTransferFrom() or submits an incorrect payload in safeTransferFrom().

<a name="L-6"></a>[L-6] auctionBuyNft() should use current auction price instead of amount parameter

https://github.com/code-423n4/2023-05-particle/blob/main/contracts/protocol/ParticleExchange.sol#L688

The amount parameter in the auctionBuyNft() function should be used as a slippage check to ensure the caller gets at least an amount value from the action, but the effective value the offerer receives should be currentAuctionPrice (as this represents the maximum incentive the offerer gets while executing the action).

<a name="L-7"></a>[L-7] Use Ownable2Step instead of Ownable for access control

Use the Ownable2Step variant of the Ownable contract to better safeguard against accidental transfers of access control.

<a name="L-8"></a>[L-8] Provide safer limits for treasury rate

https://github.com/code-423n4/2023-05-particle/blob/main/contracts/protocol/ParticleExchange.sol#L800

The current implementation of setTreasuryRate() only limits the rate parameter to _BASIS_POINTS, which if maxed represents the 100% of the lender's earnings.

<a name="L-9"></a>[L-9] Griefer can DoS lender NFT withdrawals

https://github.com/code-423n4/2023-05-particle/blob/main/contracts/protocol/ParticleExchange.sol#L172

A malicious actor can front-runs calls to withdrawNftWithInterest() to prevent the lender from claiming the NFT and deleting the lien, by taking a loan of the lien and withdrawing the NFT, causing the transaction to withdrawNftWithInterest() to fail. The malicious borrower can then immediately repay the loan with zero or minimum interests.

<a name="L-10"></a>[L-10] Marketplace calls are too permissive

The sellNftToMarket() and buyNftFromMarket() functions present in the ParticleExchange contract are used to execute a sell or a buy operation in a registered marketplace.

Even though marketplaces are whitelisted, both of these functions take an arbitrary tradeData payload that is supplied by the caller. This argument is then used as the calldata to the marketplace functions.

An attacker could use this to essentially call any function in the registered marketplace.

The recommendation here is to also whitelist function selectors and validate the right calls are being made. For example, this could be implemented as a mapping(address => mapping(bytes4 => bool)) that indicates whether a particular function signature is enabled for the corresponding marketplace.

#0 - c4-judge

2023-06-06T10:47:20Z

hansfriese marked the issue as grade-a

#1 - c4-sponsor

2023-06-06T19:58:40Z

wukong-particle marked the issue as sponsor acknowledged

#2 - wukong-particle

2023-06-06T20:00:13Z

NC-1, Current compiler preference is ^0.8.17, so 0.8.17 hasn't yet supported this NC-2, Acknowledged, explicit return for readability NC-3, Used uint256 instead of bool for better gas

#3 - wukong-particle

2023-06-06T20:25:31Z

L-1, will likely use 0.8.19, this duplicates the slither findings in https://github.com/code-423n4/2023-05-particle/tree/main/slither#pragma L-2, IIUC, same as https://github.com/code-423n4/2023-05-particle-findings/issues/9 L-3, we impose strict wei-level check to ensure no leaky bucket. Extra fees should be included in the "amount" argument. L-4, understood, but this is a designed case where anyone can supply NFT using this push-based method via onERC721Received, the "arbitrary" lender specified by "from" is as if this "from" address calls "supplyNft" directly, should be a benign behavior L-5, understood, this push-based method should only be used by our front-end, we are not liable for the accidental loss via direct interaction with the contract L-6, acknowledged, will use this suggestion L-7, acknowledged, will consider using Ownable2Step L-8, acknowledged, will provide a safer check L-9, understood, this should be allowed because this is a pricy attack as circulating nft in real market has a bid-ask spread that the attacker needs to fill L-10, understood, will consider the function check, though the check before/after the arbitrary function call should strictly prevent all mis-use cases

#4 - hansfriese

2023-06-07T09:36:09Z

All findings are valid.

L10 N3

#5 - hansfriese

2023-06-07T09:43:48Z

#22, #23 were downgraded to LOW,


L12 N3

Marking the best

#6 - c4-judge

2023-06-07T09:43:54Z

hansfriese marked the issue as selected for report

#7 - wukong-particle

2023-06-07T20:44:11Z

Findings Information

🌟 Selected for report: adriro

Also found by: d3e4, minhquanym

Labels

bug
G (Gas Optimization)
grade-a
selected for report
sponsor acknowledged
G-02

Awards

Data not available

External Links

MathUtils library

ParticleExchange contract

#0 - c4-judge

2023-06-06T10:45:16Z

hansfriese marked the issue as grade-a

#1 - c4-sponsor

2023-06-06T20:31:30Z

wukong-particle marked the issue as sponsor acknowledged

#2 - wukong-particle

2023-06-06T20:32:44Z

If marketplaces are trusted entities then WETH approval can be done once for an infinite amount instead of approving a specific amount in each call to _execBuyNftFromMarket().

Understood, we only transfer declared amount to WETH to avoid potential attack.

Agreed with all other gas optimization suggestions, will consider incorporating all of them.

#3 - c4-judge

2023-06-07T12:43:06Z

hansfriese marked the issue as selected for report

#4 - wukong-particle

2023-06-08T17:04:03Z

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter