Revest TokenVault withdrawFNFT accounting flaw drains RENA vault reserves
Exploit Transactions
0xef6c9cb605bf14a9a9c3c0d6fcf75f34112f604257b8d4bfe0904f7f15d270ae0xe0b0c2672b760bef4e2851e91c69c8c0ad135c6987bbf1f43f5846d89e6914280xa0ff4e057f1f0652aa5a0f4fdeb0746fef24caad4a4829552e79a88c28c97863Victim Addresses
0xa81bd16aa6f6b25e66965a2f842e9c806c0aa11fEthereum0x2320a28f52334d62622cc2eafa15de55f9987ed9EthereumLoss Breakdown
Similar Incidents
OMPxContract bonding-curve loop exploit drains ETH reserves
34%NFTX Doodles collateral accounting flaw enables flash-loan ETH extraction
34%Pool16 lend/redeem accounting bug drains USDC without HOME backing
33%PLNTOKEN transferFrom burn hook drains WETH reserves
33%AirdropGrapesToken ApeCoin Claim via NFTX BAYC Vault
32%SilicaPools decimal-manipulation bug drains WBTC flashloan collateral
31%Root Cause Analysis
Revest TokenVault withdrawFNFT accounting flaw drains RENA vault reserves
1. Incident Overview TL;DR
An 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.
2. Key Background
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 FNFTConfig and the shared token tracker for that asset.
The 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.
3. Vulnerability Analysis & Root Cause Summary
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.
4. Detailed Root Cause Analysis
4.1 TokenVault accounting model
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.
4.2 Multi-deposit reconfiguration via Revest controller
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:
depositTokencallsupdateBalance, which recalculates the global tracker based on the vault’s entire RENA balance (including legacy deposits).handleMultipleDepositsthen overwritesfnfts[fnftId].depositAmountwithfnft.depositAmount + amountand, ifnewFNFTId != 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.
4.3 Pre-state and FNFT creation
Pre-seed FNFT state for TokenVault at block 14465356 shows that:
fnftId 0x403and0x404haveasset = 0x0,depositAmount = 0,depositMul = 0, andgetFNFTCurrentValue = 0.- They have no associated RENA or other assets at this point.
In the seed vault-drain transaction (0xe0b0c2672b760bef4e2851e91c69c8c0ad135c6987bbf1f43f5846d89e691428, block 14465357), the trace for helper contract 0xb480ac... shows calls into Revest:
Revest.mintAddressLockis invoked with anFNFTConfigwhoseassetis set to RENA anddepositAmountis nonzero, targeting a freshfnftIdthat becomes0x403.TokenVault.createFNFTis called from Revest, which mapsfnftIdto the new config and callsdepositTokento update tracker state.FNFTHandlermints FNFT units of id0x403to 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.
4.4 Multi-deposit misconfiguration of ids 0x403/0x404
Later in the same seed transaction, the helper calls Revest.depositAdditionalToFNFT on fnftId 0x403. The call trace shows:
Revest.depositAdditionalToFNFTinvokingTokenVault.depositToken(fnftId=0x403, amount, quantity), which:- Calls
updateBalance(0x403, quantity * amount);currentAmountincludes the entire RENA balance in TokenVault (old plus new deposits). - Sets
fnft.depositMulfor0x403totokenTrackers[RENA].lastMul, which is derived from this global balance.
- Calls
- Revest then transfers
quantity * amountRENA from the helper into TokenVault. - Revest calls
TokenVault.handleMultipleDeposits(fnftId=0x403, newFNFTId=0x404, amount = fnft.depositAmount + amount), which:- Requires that the new
amountis at least the previousdepositAmount. - Writes this increased
depositAmountback tofnfts[0x403]. - Clones the updated config to
fnftId 0x404viamapFNFTToToken.
- Requires that the new
At this point:
- Both
0x403and0x404share the same inflateddepositAmountanddepositMul. - The
tokenTrackers[RENA].lastBalanceused 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 fromdepositAdditionalToFNFT.
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.
4.5 Vault drain via withdrawFNFT and evidence from balance diffs
After inflating the series, the helper calls Revest.withdrawFNFT for fnftId 0x404 with the full supply. The trace shows:
Revest.withdrawFNFTcallingTokenVault.withdrawToken(fnftId=0x404, quantity=full_supply, user=helper).TokenVault.withdrawTokencallingupdateBalance(0x404, 0), recomputingtracker.lastMulfrom the current RENA balance andtokenTrackers[RENA].lastBalance.withdrawTokencomputing:withdrawAmount = fnft.depositAmount * quantity * tracker.lastMul / fnft.depositMul.
TokenVaulttransferring 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.
4.6 Profit realization and ACT success predicate
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:
- The attacker EOA’s ETH balance immediately before the vault-drain tx is:
value_before_in_reference_asset = 860,553,334,815,435,805 wei(~0.860553334815435833 ETH).
- Immediately after the swap tx, the balance is:
value_after_in_reference_asset = 540,493,006,867,079,299,645 wei(~540.49300686707931618 ETH).
- The net change over the attack window is:
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.
5. Adversary Flow Analysis
5.1 Adversary-related accounts
The adversary-related cluster consists of:
- EOA
0xef967ece5322c0d7d26dab41778acb55ce5bd58b(unprivileged external account). - Helper/orchestrator contract
0xb480ac726528d1c195cd3bb32f19c92e8d928519(deployed by the EOA).
Victim-side contracts are:
- Revest controller
0x2320a28f52334d62622cc2eafa15de55f9987ed9. - Revest TokenVault
0xa81bd16aa6f6b25e66965a2f842e9c806c0aa11f. - Revest FNFTHandler
0xe952bda8c06481506e4731c4f54ced2d4ab81659.
5.2 Lifecycle stages
-
Helper contract deployment
- Transaction:
0xef6c9cb605bf14a9a9c3c0d6fcf75f34112f604257b8d4bfe0904f7f15d270ae(block14465209). - Mechanism:
CREATEinitiated by EOA0xef967e.... - Evidence:
tx_trace_1_0xef6c9c....jsonshows the constructor writing hard-coded addresses for:- RENA, BLOCKS, Revest controller, FNFTHandler, TokenVault, and multiple liquidity contracts (including the Uniswap pair and routers).
- Outcome:
0xb480ac...is a dedicated orchestrator for this strategy, under full attacker control.
- Transaction:
-
Multi-deposit FNFT misconfiguration and vault drain
- Transaction:
0xe0b0c2672b760bef4e2851e91c69c8c0ad135c6987bbf1f43f5846d89e691428(block14465357). - Mechanism: helper calls into Revest controller to:
- Mint address-locked FNFT series
0x403/0x404backed by RENA. - Call
depositAdditionalToFNFTon0x403, inducingTokenVault.depositTokenandhandleMultipleDeposits. - Call
withdrawFNFTon0x404to pull RENA from TokenVault.
- Mint address-locked FNFT series
- Evidence:
tx_trace_1_0xe0b0c2....jsonshows the call stack: helper → Revest.mintAddressLock → TokenVault.createFNFT → TokenVault.depositToken → Revest.depositAdditionalToFNFT → TokenVault.handleMultipleDeposits → Revest.withdrawFNFT → TokenVault.withdrawToken.- Pre-seed
token_vault_fnft_state_preseed.jsonconfirms0x403/0x404were uninitialized immediately before the seed tx. - Seed
balance_diff.jsonconfirms the exact RENA flows and TokenVault’s loss.
- Outcome: TokenVault loses
360,000,000,129,143,348,989,911RENA; most of it is routed to the attacker EOA, with a portion sent to the RENA tax recipient.
- Transaction:
-
Profit realization via Uniswap swap
- Transaction:
0xa0ff4e057f1f0652aa5a0f4fdeb0746fef24caad4a4829552e79a88c28c97863(block14465416). - Mechanism: EOA
0xef967e...calls the Uniswap V2 router0xd9e1ce...to swap the attack-derived position into ETH. - Evidence:
tx_trace_1_0xa0ff4e....jsonshows the swap path.balance_diff_1_0xa0ff4e....jsonshows the attacker’s ETH balance increasing by539,721,494,537,155,138,566 weiduring this tx.
- Outcome: Attacker’s net ETH position increases from ~0.86 ETH before the vault-drain to ~540.49 ETH after the swap, net of gas fees.
- Transaction:
5.3 ACT properties
All attacker-crafted steps were executed by an unprivileged EOA and its own contract using public Revest and Uniswap interfaces:
- The helper deployment uses a standard
CREATEtransaction with no special permissions. - Revest functions (
mintAddressLock,depositAdditionalToFNFT,withdrawFNFT) are public and callable by any address that satisfies the lock conditions and holds the relevant FNFTs. - The Uniswap V2 swap is a standard
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.
6. Impact & Losses
6.1 Direct token loss
Seed balance diffs record the TokenVault’s RENA loss:
- TokenVault
0xa81bd1...loses:360,000,000,129,143,348,989,911RENA (delta = -360000000129143348989911).
- Attacker EOA
0xef967e...receives:352,835,762,625,573,396,345,012RENA.
- RENA tax recipient
0xd94c8c...receives:7,164,059,503,569,952,644,899RENA.
The total RENA outflow from TokenVault is thus concentrated in a single attacker-crafted transaction.
6.2 Economic impact in ETH terms
Using ETH as the reference asset and the prestateTracer balance diffs:
- Attacker EOA ETH before the sequence:
860,553,334,815,435,805 wei(~0.860553334815435833 ETH).
- Attacker EOA ETH after the swap:
540,493,006,867,079,299,645 wei(~540.49300686707931618 ETH).
- Net ETH gain:
539,632,453,532,263,863,840 wei(~539.632453532263866691 ETH).
- Recorded total gas fees paid during the attack window:
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.
6.3 Affected parties
- Primary victim contract: Revest TokenVault at
0xa81bd16aa6f6b25e66965a2f842e9c806c0aa11f, which lost the RENA collateral backing multiple historical FNFT positions. - Indirectly affected: holders of older FNFTs whose RENA backing was diverted to the attacker’s FNFT series
0x403/0x404, reducing or eliminating the value of their positions.
7. References
-
Seed vault-drain transaction and pre-state
- Seed tx:
0xe0b0c2672b760bef4e2851e91c69c8c0ad135c6987bbf1f43f5846d89e691428(Ethereum mainnet, block14465357). - Seed metadata and balance diffs:
artifacts/root_cause/seed/1/0xe0b0c2.../metadata.json,balance_diff.json. - Pre-seed FNFT state at block
14465356:artifacts/root_cause/data_collector/iter_2/token_vault_fnft_state_preseed.json.
- Seed tx:
-
Helper contract deployment
- Deployment tx:
0xef6c9cb605bf14a9a9c3c0d6fcf75f34112f604257b8d4bfe0904f7f15d270ae(block14465209). - Trace:
artifacts/root_cause/data_collector/iter_2/tx_trace_1_0xef6c9c....jsonshowing constructor code and hard-coded addresses.
- Deployment tx:
-
Profit-taking swap
- Swap tx:
0xa0ff4e057f1f0652aa5a0f4fdeb0746fef24caad4a4829552e79a88c28c97863(block14465416). - Trace and balance diff:
artifacts/root_cause/data_collector/iter_1/tx_trace_1_0xa0ff4e....json,balance_diff_1_0xa0ff4e....json.
- Swap tx:
-
Revest and RENA contract code
- Revest controller (0x2320a2...), TokenVault (0xa81bd1...), FNFTHandler (0xe952bd...): verified source bundles under
artifacts/root_cause/data_collector/iter_1/contracts/. - RENA token (0x56de8b...): cloned repository under
artifacts/root_cause/seed/1/0x56de8bc61346321d4f2211e3ac3c0a7f00db9b76/.
- Revest controller (0x2320a2...), TokenVault (0xa81bd1...), FNFTHandler (0xe952bd...): verified source bundles under
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.