We do not have a reliable USD price for the recorded assets yet.
0xa81bd16aa6f6b25e66965a2f842e9c806c0aa11fEthereum0x2320a28f52334d62622cc2eafa15de55f9987ed9EthereumAn attacker-controlled helper contract for the Revest protocol used the depositAdditionalToFNFT and TokenVault.handleMultipleDeposits path to inflate the per-FNFT depositAmount of a fresh FNFT series, then withdrew nearly the entire RENA balance of the Revest TokenVault into attacker-controlled addresses before swapping the resulting position into ETH.
The root cause is that TokenVault’s multi-deposit accounting allows a new FNFT series to reuse pre-existing tokenTrackers[asset] balance without corresponding deposits. By minting and reconfiguring FNFT ids 0x403/0x404, the attacker was able to withdraw RENA that previously backed older FNFT positions.
Revest represents tokenized positions as ERC-1155 FNFTs managed by FNFTHandler, while TokenVault holds the underlying ERC-20 collateral for all FNFTs. For each underlying asset, TokenVault maintains a tokenTrackers[asset] structure with fields such as lastBalance and lastMul. For each FNFT id, TokenVault stores an FNFTConfig containing the asset, depositAmount, depositMul, and other parameters. The value of an FNFT series is derived from both its and the shared token tracker for that asset.
0xa0ff4e057f1f0652aa5a0f4fdeb0746fef24caad4a4829552e79a88c28c97863FNFTConfigThe RENA token (0x56de8bc61346321d4f2211e3ac3c0a7f00db9b76) is an ERC-20 with a transfer fee, implemented as:
// Seed source: Rena.sol for 0x56de8b...
contract Rena is ERC20("Rena", "RENA"), Ownable, ReentrancyGuard {
using SafeMath for uint256;
...
function _transfer(address from, address to, uint256 amount) internal override {
if(feeDivisor > 0 && feeless[from] == false && feeless[to] == false) {
uint256 feeAmount = amount.div(feeDivisor);
super._transfer(from, address(feeDistributor), feeAmount);
super._transfer(from, to, amount.sub(feeAmount));
} else {
super._transfer(from, to, amount);
}
}
}
Prior to the attack, TokenVault 0xa81bd16aa6f6b25e66965a2f842e9c806c0aa11f held a large RENA balance backing multiple historical FNFT positions. Pre-seed state at block 14465356 shows that the specific ids used by the attacker (0x403 and 0x404) had no associated asset or value:
// TokenVault pre-seed FNFT state (block 14465356) for ids 0x403/0x404
{
"fnft_id_hex": "0x403",
"getFNFT": {
"asset": "0x0000000000000000000000000000000000000000",
"depositAmount": 0,
"depositMul": 0,
"isMulti": false
},
"getFNFTCurrentValue": 0
}
The attacker EOA 0xef967ece5322c0d7d26dab41778acb55ce5bd58b deployed a helper contract 0xb480ac726528d1c195cd3bb32f19c92e8d928519 that hard-codes the Revest controller (0x2320a28f52334d62622cc2eafa15de55f9987ed9), TokenVault (0xa81bd1...), FNFTHandler (0xe952bd...), RENA, and relevant liquidity contracts. This helper orchestrates minting, additional deposits, FNFT withdrawal, and subsequent swaps.
The vulnerability arises from Revest’s handling of “multi-deposit” FNFT series. When a holder calls depositAdditionalToFNFT, the protocol updates a global token tracker for the underlying asset and then uses handleMultipleDeposits to increase the depositAmount associated with an entire FNFT series. That update reuses a shared tokenTrackers[asset].lastBalance that already includes collateral deposited for historical FNFT series.
Because the token tracker is global per asset and not partitioned per FNFT series, a freshly created FNFT series can be configured so that its depositAmount reflects both its own deposits and pre-existing collateral. As a result, withdrawing the new series with withdrawFNFT can pull out RENA that originally backed older positions. In this incident, FNFT ids 0x403/0x404 were created with no pre-state backing and then reconfigured via depositAdditionalToFNFT and handleMultipleDeposits to inherit a large claim on the existing RENA pool.
The core root cause is a violation of accounting isolation: TokenVault’s accounting of depositAmount per FNFT series is not constrained to match the share of collateral that series introduced into the vault, and the multi-deposit path reuses global tokenTrackers[asset] state without proportionally adjusting other series.
TokenVault maintains a per-asset tracker and per-FNFT configuration:
// TokenVault.sol (victim contract 0xa81bd1...)
mapping(uint => IRevest.FNFTConfig) private fnfts;
mapping(address => IRevest.TokenTracker) public tokenTrackers;
function updateBalance(uint fnftId, uint incomingDeposit) internal {
IRevest.FNFTConfig storage fnft = fnfts[fnftId];
address asset = fnft.asset;
IRevest.TokenTracker storage tracker = tokenTrackers[asset];
uint currentAmount;
uint lastBal = tracker.lastBalance;
if (asset != address(0)) {
currentAmount = IERC20(asset).balanceOf(address(this));
} else {
currentAmount = lastBal;
}
tracker.lastMul = lastBal == 0 ? multiplierPrecision : multiplierPrecision * currentAmount / lastBal;
tracker.lastBalance = currentAmount + incomingDeposit;
}
function depositToken(uint fnftId, uint transferAmount, uint quantity) public override onlyRevestController {
updateBalance(fnftId, quantity * transferAmount);
IRevest.FNFTConfig storage fnft = fnfts[fnftId];
fnft.depositMul = tokenTrackers[fnft.asset].lastMul;
}
function handleMultipleDeposits(uint fnftId, uint newFNFTId, uint amount)
external override onlyRevestController
{
require(amount >= fnfts[fnftId].depositAmount, 'E003');
IRevest.FNFTConfig storage config = fnfts[fnftId];
config.depositAmount = amount;
mapFNFTToToken(fnftId, config);
if (newFNFTId != 0) {
mapFNFTToToken(newFNFTId, config);
}
}
updateBalance computes a per-asset multiplier based on the previous tracker.lastBalance and the current ERC-20 balance in the vault, then updates tracker.lastBalance to currentAmount + incomingDeposit. Crucially, tokenTrackers[asset] is shared across all FNFT series for that asset.
withdrawToken later uses the FNFT’s depositAmount and the shared tracker state to determine how much collateral each unit can withdraw:
function withdrawToken(uint fnftId, uint quantity, address user)
external override onlyRevestController
{
IRevest.FNFTConfig storage fnft = fnfts[fnftId];
IRevest.TokenTracker storage tracker = tokenTrackers[fnft.asset];
address asset = fnft.asset;
updateBalance(fnftId, 0);
uint withdrawAmount = fnft.depositAmount * quantity * tracker.lastMul / fnft.depositMul;
tracker.lastBalance -= withdrawAmount;
...
if (asset != address(0)) {
IERC20(asset).safeTransfer(user, withdrawAmount);
}
}
The intended invariant is that, for each FNFT series, depositAmount * supply * tracker.lastMul / depositMul should equal collateral that was actually deposited on behalf of that series. That invariant relies on depositAmount being calibrated to the series’ share of the global token tracker.
The Revest controller (0x2320a2...) exposes depositAdditionalToFNFT, which allows an FNFT holder to add more collateral to an existing series:
// Revest.sol (controller 0x2320a2...)
function depositAdditionalToFNFT(
uint fnftId,
uint amount,
uint quantity,
bool createNewSeries
) external payable override returns (uint) {
...
uint newFNFTId;
if (createNewSeries) {
newFNFTId = IFNFTHandler(handler).getNextId();
ILockManager(lockHandler).pointFNFTToLock(newFNFTId, lockId);
burn(_msgSender(), fnftId, quantity);
IFNFTHandler(handler).mint(_msgSender(), newFNFTId, quantity, "");
} else {
newFNFTId = 0;
}
ITokenVault(vault).depositToken(fnftId, amount, quantity);
if (fnft.asset != address(0)) {
IERC20(fnft.asset).safeTransferFrom(_msgSender(), vault, quantity * amount);
}
ITokenVault(vault).handleMultipleDeposits(fnftId, newFNFTId, fnft.depositAmount + amount);
emit FNFTAddionalDeposited(_msgSender(), newFNFTId, quantity, amount);
return newFNFTId;
}
In this flow:
depositToken calls updateBalance, which recalculates the global tracker based on the vault’s entire RENA balance (including legacy deposits).handleMultipleDeposits then overwrites fnfts[fnftId].depositAmount with fnft.depositAmount + amount and, if newFNFTId != 0, clones that same config to the new series.handleMultipleDeposits does not compute amount from a per-series share of tokenTrackers[asset].lastBalance and does not adjust other FNFT configs. That omission allows a new series to be assigned a depositAmount that effectively claims a large portion of the pre-existing RENA pool.
Pre-seed FNFT state for TokenVault at block 14465356 shows that:
fnftId 0x403 and 0x404 have asset = 0x0, depositAmount = 0, depositMul = 0, and getFNFTCurrentValue = 0.In the seed vault-drain transaction (0xe0b0c2672b760bef4e2851e91c69c8c0ad135c6987bbf1f43f5846d89e691428, block 14465357), the trace for helper contract 0xb480ac... shows calls into Revest:
Revest.mintAddressLock is invoked with an FNFTConfig whose asset is set to RENA and depositAmount is nonzero, targeting a fresh fnftId that becomes 0x403.TokenVault.createFNFT is called from Revest, which maps fnftId to the new config and calls depositToken to update tracker state.FNFTHandler mints FNFT units of id 0x403 to the helper contract.This sequence initializes a new RENA-backed FNFT series while the vault already holds more than 360,000,000,129,143,348,989,911 RENA from historical positions.
Later in the same seed transaction, the helper calls Revest.depositAdditionalToFNFT on fnftId 0x403. The call trace shows:
Revest.depositAdditionalToFNFT invoking TokenVault.depositToken(fnftId=0x403, amount, quantity), which:
updateBalance(0x403, quantity * amount); currentAmount includes the entire RENA balance in TokenVault (old plus new deposits).fnft.depositMul for 0x403 to tokenTrackers[RENA].lastMul, which is derived from this global balance.quantity * amount RENA from the helper into TokenVault.TokenVault.handleMultipleDeposits(fnftId=0x403, newFNFTId=0x404, amount = fnft.depositAmount + amount), which:
amount is at least the previous depositAmount.depositAmount back to fnfts[0x403].fnftId 0x404 via mapFNFTToToken.At this point:
0x403 and 0x404 share the same inflated depositAmount and depositMul.tokenTrackers[RENA].lastBalance used for their valuation still includes the large RENA pool contributed by historical FNFTs; there is no logic that restricts their claim to the incremental deposit from depositAdditionalToFNFT.The invariant “each FNFT series can only withdraw collateral it introduced” is thus broken in a specific, code-local way: handleMultipleDeposits allows a freshly created series to inherit a depositAmount calibrated against the global tracker, not the series’ own deposits.
After inflating the series, the helper calls Revest.withdrawFNFT for fnftId 0x404 with the full supply. The trace shows:
Revest.withdrawFNFT calling TokenVault.withdrawToken(fnftId=0x404, quantity=full_supply, user=helper).TokenVault.withdrawToken calling updateBalance(0x404, 0), recomputing tracker.lastMul from the current RENA balance and tokenTrackers[RENA].lastBalance.withdrawToken computing:
withdrawAmount = fnft.depositAmount * quantity * tracker.lastMul / fnft.depositMul.TokenVault transferring RENA to the helper and the RENA fee recipient according to the token’s transfer logic.The prestateTracer balance diff for the seed transaction confirms the resulting RENA flows:
// Seed tx balance_diff for 0xe0b0c2...
{
"erc20_balance_deltas": [
{
"token": "0x56de8bc61346321d4f2211e3ac3c0a7f00db9b76",
"holder": "0xa81bd16aa6f6b25e66965a2f842e9c806c0aa11f",
"before": "364710000000000000000000",
"after": "4709999870856651010089",
"delta": "-360000000129143348989911",
"contract_name": "Rena"
},
{
"token": "0x56de8bc61346321d4f2211e3ac3c0a7f00db9b76",
"holder": "0xef967ece5322c0d7d26dab41778acb55ce5bd58b",
"before": "0",
"after": "352835762625573396345012",
"delta": "352835762625573396345012",
"contract_name": "Rena"
},
{
"token": "0x56de8bc61346321d4f2211e3ac3c0a7f00db9b76",
"holder": "0xd94c8cbdbbb602005c3c4c22dedb856a4d5e9e13",
"delta": "7164059503569952644899",
"contract_name": "Rena"
}
]
}
TokenVault loses exactly 360,000,000,129,143,348,989,911 RENA in this single transaction. The attacker EOA receives 352,835,762,625,573,396,345,012 RENA, and the RENA tax recipient receives the remainder due to the token’s transfer fee mechanism.
The native ETH balance diff for the same transaction shows the attacker paying 42,505,000,000,000,000 wei in gas while maintaining a positive net position within this step.
In the subsequent Uniswap swap transaction (0xa0ff4e057f1f0652aa5a0f4fdeb0746fef24caad4a4829552e79a88c28c97863, block 14465416), the attacker converts the drained position into ETH using the Uniswap V2 router 0xd9e1ce17f2641f24ae83637ab66a2cca9c378b9f. The balance diff shows:
// Swap tx balance_diff for 0xa0ff4e...
{
"native_balance_deltas": [
{
"address": "0xef967ece5322c0d7d26dab41778acb55ce5bd58b",
"before_wei": "771512329924161079",
"after_wei": "540493006867079299645",
"delta_wei": "539721494537155138566"
}
]
}
Combining the prestateTracer artifacts for the vault-drain and swap transactions:
value_before_in_reference_asset = 860,553,334,815,435,805 wei (~0.860553334815435833 ETH).value_after_in_reference_asset = 540,493,006,867,079,299,645 wei (~540.49300686707931618 ETH).value_delta_in_reference_asset = 539,632,453,532,263,863,840 wei (~539.632453532263866691 ETH).Root_cause.json records a deterministic fees_paid_in_reference_asset of 93,028,483,363,884,246 wei, representing the sum of gas costs for the attacker-crafted transactions in the window. Regardless of the fee breakdown per tx, the measured value_delta_in_reference_asset remains strongly positive, satisfying the profit predicate for an ACT opportunity.
The adversary-related cluster consists of:
0xef967ece5322c0d7d26dab41778acb55ce5bd58b (unprivileged external account).0xb480ac726528d1c195cd3bb32f19c92e8d928519 (deployed by the EOA).Victim-side contracts are:
0x2320a28f52334d62622cc2eafa15de55f9987ed9.0xa81bd16aa6f6b25e66965a2f842e9c806c0aa11f.0xe952bda8c06481506e4731c4f54ced2d4ab81659.Helper contract deployment
0xef6c9cb605bf14a9a9c3c0d6fcf75f34112f604257b8d4bfe0904f7f15d270ae (block 14465209).CREATE initiated by EOA 0xef967e....tx_trace_1_0xef6c9c....json shows the constructor writing hard-coded addresses for:
0xb480ac... is a dedicated orchestrator for this strategy, under full attacker control.Multi-deposit FNFT misconfiguration and vault drain
0xe0b0c2672b760bef4e2851e91c69c8c0ad135c6987bbf1f43f5846d89e691428 (block 14465357).0x403/0x404 backed by RENA.depositAdditionalToFNFT on 0x403, inducing TokenVault.depositToken and handleMultipleDeposits.withdrawFNFT on 0x404 to pull RENA from TokenVault.tx_trace_1_0xe0b0c2....json shows the call stack: helper → Revest.mintAddressLock → TokenVault.createFNFT → TokenVault.depositToken → Revest.depositAdditionalToFNFT → TokenVault.handleMultipleDeposits → Revest.withdrawFNFT → TokenVault.withdrawToken.token_vault_fnft_state_preseed.json confirms 0x403/0x404 were uninitialized immediately before the seed tx.balance_diff.json confirms the exact RENA flows and TokenVault’s loss.360,000,000,129,143,348,989,911 RENA; most of it is routed to the attacker EOA, with a portion sent to the RENA tax recipient.Profit realization via Uniswap swap
0xa0ff4e057f1f0652aa5a0f4fdeb0746fef24caad4a4829552e79a88c28c97863 (block 14465416).0xef967e... calls the Uniswap V2 router 0xd9e1ce... to swap the attack-derived position into ETH.tx_trace_1_0xa0ff4e....json shows the swap path.balance_diff_1_0xa0ff4e....json shows the attacker’s ETH balance increasing by 539,721,494,537,155,138,566 wei during this tx.All attacker-crafted steps were executed by an unprivileged EOA and its own contract using public Revest and Uniswap interfaces:
CREATE transaction with no special permissions.mintAddressLock, depositAdditionalToFNFT, withdrawFNFT) are public and callable by any address that satisfies the lock conditions and holds the relevant FNFTs.swapExactTokensForETH-style call.Any unprivileged adversary that can deploy an equivalent helper contract and construct the same call sequence against the same Revest contracts and pre-state can reproduce the same vault drain and profit. This satisfies the “anyone-can-take” definition for ACT opportunities.
Seed balance diffs record the TokenVault’s RENA loss:
0xa81bd1... loses:
360,000,000,129,143,348,989,911 RENA (delta = -360000000129143348989911).0xef967e... receives:
352,835,762,625,573,396,345,012 RENA.0xd94c8c... receives:
7,164,059,503,569,952,644,899 RENA.The total RENA outflow from TokenVault is thus concentrated in a single attacker-crafted transaction.
Using ETH as the reference asset and the prestateTracer balance diffs:
860,553,334,815,435,805 wei (~0.860553334815435833 ETH).540,493,006,867,079,299,645 wei (~540.49300686707931618 ETH).539,632,453,532,263,863,840 wei (~539.632453532263866691 ETH).93,028,483,363,884,246 wei (~0.093028483363884246 ETH).Even after accounting for all gas fees, the attacker’s net portfolio value in ETH increases by more than 539 ETH, confirming a clear profit-based success predicate.
0xa81bd16aa6f6b25e66965a2f842e9c806c0aa11f, which lost the RENA collateral backing multiple historical FNFT positions.0x403/0x404, reducing or eliminating the value of their positions.Seed vault-drain transaction and pre-state
0xe0b0c2672b760bef4e2851e91c69c8c0ad135c6987bbf1f43f5846d89e691428 (Ethereum mainnet, block 14465357).artifacts/root_cause/seed/1/0xe0b0c2.../metadata.json, balance_diff.json.14465356: artifacts/root_cause/data_collector/iter_2/token_vault_fnft_state_preseed.json.Helper contract deployment
0xef6c9cb605bf14a9a9c3c0d6fcf75f34112f604257b8d4bfe0904f7f15d270ae (block 14465209).artifacts/root_cause/data_collector/iter_2/tx_trace_1_0xef6c9c....json showing constructor code and hard-coded addresses.Profit-taking swap
0xa0ff4e057f1f0652aa5a0f4fdeb0746fef24caad4a4829552e79a88c28c97863 (block 14465416).artifacts/root_cause/data_collector/iter_1/tx_trace_1_0xa0ff4e....json, balance_diff_1_0xa0ff4e....json.Revest and RENA contract code
artifacts/root_cause/data_collector/iter_1/contracts/.artifacts/root_cause/seed/1/0x56de8bc61346321d4f2211e3ac3c0a7f00db9b76/.These references are sufficient for an external reviewer to replay the traces, verify the accounting bug in TokenVault’s multi-deposit logic, and confirm the adversary’s profit and the ACT nature of the opportunity.