As of 14 Oct 2026, BetFin has 4 audit reports by 1 security firm on Trustblock; the latest is by CertiK, dated 10 Dec 2024. Those reports list no open critical findings.
Record last updated . It holds audits and findings, contracts and incidents up to Oct 2026. Statuses are the security firms’ own. Trustblock does not review code.
Newest first
| Audit | Security firm | Date | Scope | Critical findings | Report |
|---|---|---|---|---|---|
| Liro - contract | Security firm: CertiK | Date: | Scope: Files named in the report | 1 critical finding, fixed | Open · skynet.certik.com |
| Betfin Stones Contracts | Security firm: CertiK | Date: | Scope: Files named in the report | No open critical findings | Open · skynet.certik.com |
| Betfin Lucky Round Contracts | Security firm: CertiK | Date: | Scope: Files named in the report | 1 critical finding, fixed | Open · skynet.certik.com |
| Betfin Core Contracts | Security firm: CertiK | Date: | Scope: Files named in the report | 1 critical finding, fixed | Open · skynet.certik.com |
Statuses are the security firm’s own, from each report.
| Severity | Open | Acknowledged | Fixed |
|---|---|---|---|
| Critical | 0 | 0 | 2 |
| High | 1 | 1 | 3 |
| Medium | 0 | 0 | 2 |
| Low | 5 | 1 | 21 |
The `refundSingleBet` function in the `LiveRoulette` contract suffers from an vulnerability due to the lack of validation on the `_bet` parameter. This allows an attacker to create a malicious bet (`LiroBet`) and exploit the refund mechanism to steal funds from the `LiveRoulette` contract, effectively draining the contract's assets. ```solidity function refundSingleBet(address _bet) external { // Approve the bet amount token.approve(address(singlePlayerTable), LiroBet(_bet).getAmount()); // Refund the bet singlePlayerTable.refund(_bet); } ``` The function `refundSingleBet` does not validate whether the `_bet` address represents, for example, a **legitimate bet** created by the `SinglePlayerTable` contract. The current implementation simply approves the corresponding amount of BET tokens for the `_bet` contract and calls the `refund` function on the `singlePlayerTable` contract. As a result, an attacker can drain the entire balance of BET tokens from the `LiveRoulette` contract by exploiting the refund mechanism.
Open the reportThe `placeBet()` function in `LuckeyRound` can be directly called within `LuckeyRound` without going through the `placeBet()` function in `partner`. This allows any user to place bets without transferring token and to select any length of `Offset`. Additionally, this could result in a situation where there are not enough tokens in the contract to transfer to the winner, causing the chainlink VRF `rawFulfillRandomWords()` call to fail. Consequently, all bets for that round will be locked in the contract.
Open the reportThe use of nested loops (including the loops in `getBitMapPayout`), numerous contract calls, and extensive memory allocation(including the memory allocation in `getBitMapPayout`) in `getPossibleWin` results in substantial gas consumption: `Table.sol` ```solidity=46 function getPossibleWin(Library.Bet[] memory _bitmaps) public view returns (uint256, uint256) { uint256 maxPossible = 0; uint256 winNumber = 42; uint256 count = _bitmaps.length; for (uint256 i = 0; i <= 36; i++) { uint256 possible = 0; for (uint256 k = 0; k < count; k++) { uint256 amount = _bitmaps[k].amount; uint256 bitmap = _bitmaps[k].bitmap; (uint256 payout,,) = getBitMapPayout(bitmap); if (bitmap & (2 ** i) > 0) { possible += amount * payout + amount; } if (possible > maxPossible) { maxPossible = possible; winNumber = i; } } } return (maxPossible, winNumber); } ``` In the `MultiplayerTable`, excessive gas consumption can cause `placeBet()` to reach the block gas limit, preventing users from placing bets even when the number of bets is far below `MAX_BETS`. Furthermore, since `getBitMapPayout()` does not revert when an invalid bitMap is passed: `Table.sol` ```solidity=108 function getBitMapPayout(uint256 bitmap) public view returns (uint256, uint256, uint256) { // return invalid bitmap if (bitmap == 0) { return (0, 0, 0); } // check for straight 0,1,2,3...36 if (bitmap & (bitmap - 1) == 0) { return (35, limits["STRAIGHT"].min, limits["STRAIGHT"].max); } // check for corner if (isCorner(bitmap)) { return (8, limits["CORNER"].min, limits["CORNER"].max); } // check for row if (isRow(bitmap)) { return (11, limits["ROW"].min, limits["ROW"].max); } // check for split if (isSplit(bitmap)) { return (17, limits["SPLIT"].min, limits["SPLIT"].max); } // get limit string memory name = payouts[bitmap]; return (limits[name].payout, limits[name].min, limits[name].max); } ``` An attacker can attach multiple bets within a single `placeBet()` call, with each bet having an amount of 0 and an invalid `bitMap` value (e.g., 30, which comprises bets on 1, 2, 3, and 4) to reduce costs. This can quickly result in a DoS, preventing others from calling `placeBet()`. In such cases, if the official team does not actively call the `spin()` function to settle the game round, others would lack motivation to call `spin()`. The attacker could then exploit the `Users Can Front-Run to Revoke Unfavorable Game Results After Refund Waiting Period` vulnerability to frontrun after the refund waiting period, ensuring that each bet they place does not result in a disadvantageous outcome for them. From the chain perspective, on Ethereum, high gas costs could make the returns significantly lower than the transaction fees, discouraging users from participating in the game. However, on Polygon, the gas fee issue would be greatly alleviated.
Open the reportThe `spin()` function in `MultiplayerTable` lacks the `onlyLiro` modifier: `MultiPlayerTable.sol` ```solidity=84 function spin(uint256 _round) external returns (bytes memory) { // check if round is older than the current round require(_round < getCurrentRound(), "MP01"); // check if round status is 1 require(roundStatus[_round] == 1, "MP04"); // check if the bank is above the maximum limit require(liro.token().balanceOf(address(this)) >= roundPossibleWin[_round], "MP05"); // should not happen roundStatus[_round] = 2; return abi.encode(false, address(this), _round); } ``` Anyone can directly call `MultiplayerTable` `spin()` to change `roundStatus[_round]`, preventing others from using `LiveRoulette` `spin()` to send an event requesting randomness. This renders the round invalid, leaving users only able to request a refund after the refund waiting period.
Open the reportIn the `SinglePlayerTable` contract, there is a potential issue where unclaimed possible win tokens are not returned to the staking contract if the player does not win. This could result in tokens being locked in the `SinglePlayerTable` contract indefinitely, which should otherwise be returned to the staking contract for future use. In the `placeBet` function, a bet is placed, and the possible win amount is calculated. The contract then reserves the possible win tokens from the staking contract by transferring them to the `SinglePlayerTable` contract. This amount is held in the contract so that, if the player wins, the win can be paid out immediately. If the player wins, the contract does the following in the `result` function: 1. Transfers the win amount to the player. 2. Returns any remaining unclaimed amount (i.e., `rest`) from the possible win back to the staking contract. ```solidity=60 if (winAmount > 0) { // set the result bet.setResult(winAmount); // transfer the win amount liro.token().transfer(bet.getPlayer(), winAmount); // transfer rest to staking uint256 rest = betPossibleWin[_bet] - winAmount; if (rest > 0) { liro.token().transfer(address(liro.staking()), rest); } // transfer initial bet amount to staking liro.token().transferFrom(address(liro), address(liro.getStaking()), bet.getAmount()); } else { // set the result bet.setResult(0); } ``` If a player loses, the entire possible win amount remains locked in the `SinglePlayerTable` contract. Over time, as more players lose, this could accumulate a significant amount of tokens locked in the contract, which should ideally be returned to the staking contract for future use. Moreover, if the bet is lost, the bet tokens are not transferred to the staking contract and will remain locked in the `LiveRoulette` contract.
Open the reportIn the `LiveRoulette` contract, there is a refund mechanism that allows users to call a function to receive a refund if a round has not been settled for a prolonged period. However, this function is only applicable to the `MultiPlayerTable`: ```solidity=111 function refund(address _table, uint256 _round) external { // check if table exists require(tables[_table], "LR03"); token.approve(_table, MultiPlayerTable(_table).getRoundBank(_round)); // execute refund MultiPlayerTable(_table).refund(_round); } ``` The `singlePlayerTable` may also encounter cases where a game cannot be settled or is delayed for a prolonged period. For example, this could happen if the project team fails to deposit sufficient funds into Gelato's `1balance` contract, preventing the system from invoking the `fulfillRandomness` function in `LiveRoulette`. In this situation, the single player’s bet will be locked in the contract, unable to be withdrawn.
Open the reportBefore `core` calls `placeBet()` function, it will determine whether to charge a fee based on `getFeeType()` in the `Stones` contract. In core, when `getFeeType()==0`, the fee is charged and totalAmount will be deducted from the fee before being transferred to the `Stones` contract. When `getFeeType==1`, totalAmount is fully transferred to the `Stones` contract. In this contract, the result of the `getFeeType()` function is `1`, indicating no fee is charged. However, in the `executeResult()` function, `roundBank`(the total amount of tokens for a specified round) is deducted from the fee before calculating `winAmount` and `bonusAmount`. We would like to confirm with the team if this is the intended design. If so, the fee amount will be locked in the contract.
Open the reportIn `placeBet()` of `MultiplayerTable`, limit the `maxPossibleWin` for this round to no more than 5% of the tokens held in staking(`allowedToWin`): `MultiPlayerTable.sol` ```solidity=69 (uint256 maxPossibleWin,) = getPossibleWin(roundBitmaps[_round]); // check if the possibleWin is above the maximum limit // get allowed to win uint256 allowedToWin = liro.token().balanceOf(liro.getStaking()) * 5 / 100; // check if the possible win is allowed require(allowedToWin >= maxPossibleWin, "MP03"); // update round status if needed if (roundStatus[_round] == 0) { roundStatus[_round] = 1; } uint256 diff = maxPossibleWin - roundPossibleWin[_round]; roundPossibleWin[_round] = maxPossibleWin; ``` However, all bet funds prior to this `placeBet()` have already been transferred from the `staking` contract into the `MultiplayerTable` contract during previous calls: `LiveRoulette.sol` ```solidity=97 (address bet, uint256 diff) = table.placeBet(data); // reserve the amount from staking or send back if (diff > 0) { // reserve funds from staking staking.reserveFunds(uint256(diff)); // send the reserved funds to the table require(token.transfer(_table, uint256(diff)), "LR07"); } ``` Since the `allowedToWin` calculation does not account for this portion of funds, the funds of this game round in the `MultiplayerTable` contract are less than 5%.
Open the reportIn the `constructor`, the value of `_interval` will be assigned to the immutable state variable `interval` without any value validation. The value should be set to greater than or at least equal to a specified time interval (for example, one hour) to prevent the occurrence of invalid rounds. For instance, if the time is less than 12 seconds(one block interval), invalid rounds will certainly occur.
Open the reportThere should always be events emitted in the sensitive functions that are controlled by centralization roles.
Open the reportIn the `LiveRoulette` contract, the `placeBet` function does not perform a length check on the `_bitmaps` data. Firstly, if the `_bitmaps` array is excessively large, it will cause the entire transaction to revert. Secondly, although the `MultiplayerTable` contract enforces a limit on the total number of bets per round and ensure the total `possibleWin` does not exceed the cap (5% of the Staking contract's balance), a player can exploit this by placing bets on multiple positions in a single transaction (i.e., using an excessively long `_bitmaps` array). This could cause the `possibleWin` to reach the threshold prematurely, preventing other players from participating in each round.
Open the reportThe cited address input is missing a check that it is not `address(0)`. ```solidity constructor(address _staking, address _core, address __operator, address _admin) GelatoVRFConsumerBase() { created = block.timestamp; staking = StakingInterface(_staking); @> operator = __operator; core = CoreInterface(_core); token = Token(core.token()); singlePlayerTable = new SinglePlayerTable(address(this)); _grantRole(DEFAULT_ADMIN_ROLE, _admin); } ``` The `__operator` should be checked and is vital as it's used to `fulfillRandomness` function in Gelato VRF.
Open the reportIn the `LiveRoulette` contract, the admin role has the ability to call the `setLimit` function to change the betting parameters, which include the minimum bet amount, maximum bet amount, and payout ratios. This presents a potential centralization issue, particularly if the admin alters the payout ratios just before the generation of random numbers. Such changes can result in discrepancies between the expected and actual payouts for the users, and may even lead to errors. First, if the modified payout ratio is lower than the original, players might receive less than what they anticipated. Conversely, if the payout ratio is increased, it could allow winer get more rewards that expected and there could be a shortfall in funds when it comes time to distribute the winnings, leading to transaction reverts in the `_fulfillRandomness` function. If the Gelato VRF retries still face a lack of funds, the `LiroBet` might remain in a `finished` but unpaid state, making refunds impossible and effectively locking the bets within the contract.
Open the reportSince [Solidity Release 0.8.23](https://soliditylang.org/blog/2023/11/08/solidity-0.8.23-release-announcement), `MCOPY` opcode is introduced.[Solidity Release 0.8.25](https://soliditylang.org/blog/2024/03/14/solidity-0.8.25-release-announcement) finally makes use of `MCOPY` in the code generator, replacing the use of mload()/mstore() loops. However, this may not be compatible with all chains and L2s. As a result, the compatibility of the code could be affected.
Open the reportIn the Multiplayer table, if the current time has exceeded the `refund()` waiting period for a game round, i.e., `block.timestamp > (round + 1) * interval + 1 day`, and this round’s result has not yet been settled via `spin()`, users can front-run to revoke an unfavorable outcome after `spin()`. Specifically, after calling `spin()`, users can monitor the mempool for the `LiveRoulette` operator's transactions. If a `fulfillRandomness` transaction for the current game round appears in the mempool and indicates a potential loss for the user, the user can front-run this transaction by a `refund()` transaction. This allows he/she to reclaim their funds before the `fulfillRandomness` transaction executes, thereby negating the unfavorable result. By combining the `DoS Risk Due to High Gas Consumption` vulnerability, it becomes more feasible to create scenarios where a game round remains unsettled beyond the refund waiting period, allowing for more frequent attacks.
Open the reportThis [Checks-Effects-Interactions Pattern](https://docs.soliditylang.org/en/latest/security-considerations.html#use-the-checks-effects-interactions-pattern) is a best practice for writing secure smart contracts that involves performing all state changes before making any external function calls. ### External call(s) ```solidity=254 IERC20(token).safeTransfer(bet.getPlayer(), result); ``` ### State variables written after the call(s) ```solidity=255 betSettled[address(bet)] = true; distributedInRound[round] += result; ``` The `executeResult()` function is used to transfer tokens to winners, this pattern could help prevent a user from exploiting unintended flow patterns.
Open the reportSince the `Stones` contract is hard to compromise, so the owner of the `StonesBet` contract is protected, with state modifications in the `StonesBet` contract only occurring within specific functions of the `Stones` contract, the `bet.renounceOwnership()` function is not necessary to mitigate centralization risk in this case. In addition, since the access control and timelock mechanisms are not used in this contract, removing the relevant code can reduce the gas cost of the contract.
Open the reportExternal call `token.safetransfer()` is made inside a `for` loop. This may lead to a denial-of-service attack. If any of the calls fail, the entire loop will revert, causing all tokens deposited within this round to be locked in the contract. ```solidity function executeResult(uint256 round) public { ... for (uint256 i = 0; i < betsCount; i++) { // related calculations ... IERC20(address(staking.getToken())).safeTransfer( bet.getPlayer(), winAmount + bonusAmount ); } // get all other bets ... } ```
Open the reportThe smart contract contains one or more state changes that do not emit events to communicate the changes outside the blockchain, which can lead to difficulties in tracking or verifying these changes and may affect the contract's transparency and auditability. ```solidity=71 function setStatus(uint256 _status) public onlyOwner { status = _status; } ``` ```solidity=75 function setResult(uint256 _result) public onlyOwner { result = _result; } ```
Open the reportAddresses are not validated before assignment or external calls, potentially allowing the use of zero addresses and leading to unexpected behavior or vulnerabilities. For example, transferring tokens to a zero address can result in a permanent loss of those tokens. In the `Stones` contract, the following provided addresses in `constructor` function are not zero-checked before being used. ```solidity constructor( uint256 _subscriptionId, address _core, address _staking, ... address _admin ) VRFConsumerBaseV2Plus(_vrfCoordinator) { ... } ``` In the `StonesBet` contract, the following provided addresses in `constructor` function are not zero-checked before being used. ```solidity constructor( address _player, address _game, ... ) { ... } ```
Open the reportError codes `ST05` and `ST06` are repeated. Additionally, the `ST06` code is inconsistent with the corresponding code in the `constructor` function. The code in the `require` statement checks the status of the provided `_staking` instead of the transfer operation. ```solidity constructor( ... ) VRFConsumerBaseV2Plus(_vrfCoordinator) { ... require(core.isStaking(_staking), "ST06"); ... } ```
Open the reportImmutable state variables can be assigned during contract creation but will remain constant throughout the lifetime of a deployed contract. A big advantage of immutable variables is that reading them is significantly cheaper than reading from regular state variables since they will not be stored in storage. The `side` variable assigned in the constructor can be declared as `immutable`. ```solidity uint256 private side; constructor( ... uint256 _side ) { ... side = _side; } ```
Open the reportIn`fulfillRandomWords` function,`winnerOffset` will never be `lastOffset[round]`: ```solidity function fulfillRandomWords( uint256 requestId, uint256[] calldata randomWords ) internal override { uint256 round = requestRounds[requestId]; uint256 winnerOffset = (randomWords[0] % (lastOffset[round] - 1)) + 1; // exclude 0 roundWinners[round] = winnerOffset; executeResult(round); roundStatus[round] = 2; } ``` For example, if only A and B place bets in sequence, and each person bets 1000 ether. A's bet range (startOffset, endOffset) is `[1, 1000]`, and B's bet range is `[1001, 2000]`. At this time, `lastOffset[round] = 2000`. For the calculation: ```solidit uint256 winnerOffset = (randomWords[0] % (lastOffset[round] - 1)) + 1; // exclude 0 ``` `lastOffset[round] - 1 = 1999`,the range of `(randomWords[0] % (lastOffset[round] - 1))` is `[0,1998]`,the range of `winnerOffset` is `[1,1999]`,`winnerOffset` will never be `2000`. Therefore, the probability of A becoming the winner is $\frac{1000}{1999}$, and the probability of B becoming the winner is $\frac{999}{1999}$. They bet the same amount of tokens, but the probabilities are different, which is unfair to B.
Open the reportIn the executeResult function, a non-standard binary search code was used: ```solidity function executeResult(uint256 round) internal { uint256 winnerOffset = roundWinners[round]; LuckyRoundBet[] storage bets = roundBets[round]; // find using binary search uint256 low = 0; uint256 high = bets.length - 1; while (low <= high) { uint256 mid = (low + high) / 2; LuckyRoundBet bet = bets[mid]; uint256 start = bet.getStartOffset(); uint256 end = bet.getEndOffset(); if (start <= winnerOffset && end >= winnerOffset) { uint256 bank = roundBank[round]; // calculate bonus fee uint256 bonus = (bank * BONUS) / 100_00; // calculate reward uint reward = bank - ((bank * fee) / 100_00) - bonus; // transfer reward to player ERC20(token).transfer(bet.getPlayer(), reward); emit WinnerCalculated(round, winnerOffset, address(bet)); break; } else if (start < winnerOffset) { low = mid + 1; } else { high = mid - 1; } } } ``` Change `start < winnerOffset` to `end < winnerOffset` to ensure code readability.
Open the reportA reentrancy attack can occur when the contract creates a function that makes an external call to another untrusted contract before resolving any effects. If the attacker can control the untrusted contract, they can make a recursive call back to the original function, repeating interactions that would have otherwise not run after the external call resolved the effects. *This finding is considered minor because the state variable is only incremented or decremented. So, the effect of out-of-order increments may be unobservable after transaction. However, the reentrancy vulnerability may still cause other issues in the middle of transaction.* ### External call(s) ```solidity=175 requestCalculationInternal(round); ``` * This function call executes the following external call(s). * In `LuckyRound.requestCalculationInternal`, * `requestId = VRFCoordinatorV2_5(vrfCoordinator).requestRandomWords(VRFV2PlusClient.RandomWordsRequest({keyHash:keyHash,subId:subscriptionId,requestConfirmations:requestConfirmations,callbackGasLimit:callbackGasLimit,numWords:numWords,extraArgs:VRFV2PlusClient._argsToBytes(VRFV2PlusClient.ExtraArgsV1({nativePayment:false}))}))` ### State variables written after the call(s) ```solidity=177 betsPlayer[address(bet)] = player; ``` --- ### External call(s) ```solidity=193 uint256 requestId = VRFCoordinatorV2_5(vrfCoordinator) .requestRandomWords( VRFV2PlusClient.RandomWordsRequest({ keyHash: keyHash, subId: subscriptionId, requestConfirmations: requestConfirmations, callbackGasLimit: callbackGasLimit, numWords: numWords, extraArgs: VRFV2PlusClient._argsToBytes( VRFV2PlusClient.ExtraArgsV1({nativePayment: false}) ) }) ); ``` ### State variables written after the call(s) ```solidity=207 requestRounds[requestId] = round; ``` ```solidity=206 roundRequests[round] = requestId; ``` ```solidity=208 roundStatus[round] = 1; ``` --- ### External call(s) ```solidity=219 executeResult(round); ``` * This function call executes the following external call(s). * In `LuckyRound.executeResult`, * `ERC20(token).transfer(bet.getPlayer(),reward)` ### State variables written after the call(s) ```solidity=220 roundStatus[round] = 2; ```
Open the reportThe return values of the `transfer()` and `transferFrom()` calls in the smart contract are not checked. Some ERC-20 tokens' transfer functions return no values, while others return a bool value, they should be handled with care. If a function returns `false` instead of reverting upon failure, an unchecked failed transfer could be mistakenly considered successful in the contract. ```solidity=243 ERC20(token).transfer(bet.getPlayer(), reward); ``` --- ```solidity=286 ERC20(token).transfer(player, bonus); ```
Open the reportThe issue in the `LuckyRound` contract arises from the use of role-based access control, specifically related to the `SERVICE` role and its administration. The function `addService` is intended to allow the `TIMELOCK` role to grant the `SERVICE` role to a new account. The `addService` function uses the `grantRole` method to assign the `SERVICE` role to a provided address: ```solidity=290 function addService(address _service) external onlyRole(TIMELOCK) { grantRole(SERVICE, _service); } ``` However, for `grantRole` to succeed, the caller (in this case, `msg.sender` who must possess the `TIMELOCK` role) needs to be the admin of the `SERVICE` role. In the standard implementation of the `AccessControl` contract from OpenZeppelin, which `LuckyRound` inherits from, a role’s admin role has the authority to grant or revoke that role to other accounts. At present, the `LuckyRound` contract does not specify that the `TIMELOCK` role is the admin of the `SERVICE` role. Therefore, unless the `DEFAULT_ADMIN_ROLE` has been explicitly granted to the `TIMELOCK` role accounts or the role admin for `SERVICE` has been set to `TIMELOCK`, the `addService` function will fail when called by an account with only the `TIMELOCK` role.
Open the reportThe linked variables assigned in the constructor can be declared as `immutable`. Immutable state variables can be assigned during contract creation but will remain constant throughout the lifetime of a deployed contract. A big advantage of immutable variables is that reading them is significantly cheaper than reading from regular state variables since they will not be stored in storage.
Open the reportThe reports name a repository or files. Open a report to see its scope.
Is this your project? Show this record on your own website. The label states what the record holds, links to this page and updates by itself. It is free and needs no sign-in.
Automatic: the three strongest facts of this project’s own record (its last audit and two more), highlighted. They update with the record. Picking a fact turns Automatic off.
<head><script src="https://widget.trustblock.run/project-widget.min.js" defer></script></head><body><divdata-tb-project-widgetdata-tb-project-slug="betfin"></div></body>
Paste the code where the label should appear. It uses no API credit and updates by itself.
Something wrong on this page? A missing report, a wrong date, a finding that was fixed since. Tell us and we check it against its source.
Tell us