ERC20 vault shares normally represent a transferable claim on the assets held by a vault.
CarthaVault tracked that claim in two different places:
The ERC20 balance recorded who held the shares
A separate internal
Positionrecorded who was allowed to redeem them
Those records were aligned when a user deposited. They stopped being aligned after the lock expired because the shares could be transferred while the Position remained bound to the original owner.
The recipient received real vault shares but no corresponding redemption record.
Before the original position was deleted, Bob could still return the shares to Alice and restore the original ownership arrangement. The permanent failure occurred when the original owner kept only a dust balance and completed a release. CarthaVault burned only that dust, deleted the entire original Position, and left the recipient holding nearly all outstanding shares with no supported redemption path.
The finding was validated as High during the 0xMarkets Audit Contest. It was reported by 48 researchers, so my share of the reward was $0.19.

One economic claim was represented by two records
A CarthaVault deposit creates both ERC20 shares and an internal Position.
The position stores the deposited amount, the share amount associated with that deposit, the lock period, the cooldown, and an existence flag.
The positions are stored in a mapping:
mapping(bytes32 => Position) positions;
During depositAndLock, the key is derived from the depositor and the pool identifier:
bytes32 lockId =
lockIdOf(msg.sender, poolId_);
$.positions[lockId] = Position({
amount: amount,
shares: shares,
lockDays: lockDays,
start: currentTime,
maxLockDays: lockDays,
lastEpoch:
uint64(
block.timestamp /
EPOCH_DURATION
),
cooldownEnds:
currentTime +
uint64($.cooldownDuration),
exists: true
});
Source: CarthaVault.sol
Immediately after the deposit, the records agree:
ERC20 share owner
Alice
Position owner
Alice
The vulnerability appears when only the token balance moves.
Transfers moved shares but not positions
CarthaVault overrides the ERC20 _update hook.
A transfer is blocked while the sender's position is still locked. Once the lock has expired, the same transfer is allowed:
function _update(
address from,
address to,
uint256 value
) internal override {
if (
from != address(0) &&
to != address(0)
) {
CarthaVaultStorage storage $ =
_getCarthaVaultStorage();
bytes32 lockId =
lockIdOf(from, $.poolId);
Position storage position =
$.positions[lockId];
if (position.exists) {
bool locked =
block.timestamp <
position.start +
uint256(position.lockDays) *
1 days;
if (locked) {
revert PositionLocked();
}
}
}
super._update(from, to, value);
}
Source: CarthaVault.sol
The hook decides whether the ERC20 transfer may proceed.
It does not move, split, or recreate the internal Position.
After Alice transfers shares to Bob, the state becomes:
ERC20 share owner
Bob
Position owner
Alice
Bob owns the token balance, but the contract still stores the redemption metadata under Alice.
Why the recipient cannot redeem
Both normal redemption paths derive the position key from msg.sender.
The direct release path begins with:
bytes32 lockId =
lockIdOf(msg.sender, poolId_);
Position storage position =
$.positions[lockId];
if (!position.exists) {
revert PositionNotFound();
}
The queued withdrawal request uses the same owner-bound lookup.
Bob can therefore hold a positive balanceOf, while both supported user paths fail because no position exists under Bob:
Bob calls release
→ PositionNotFound
Bob calls requestRelease
→ PositionNotFound
At this point, the transferred shares are unusable by Bob, but the original Alice position still exists. Returning the shares to Alice could still restore the original ownership arrangement.
The next operation destroys that remaining recovery possibility.
A dust release deleted the complete position
The direct release function first determines whether the requested asset amount represents the complete position:
bool isFullRelease =
amount == position.amount;
uint256 shares;
if (isFullRelease) {
shares = position.shares;
} else {
shares =
position.shares *
amount /
position.amount;
}
For a full release, shares initially equals the complete share amount stored in the position.
The function then caps that number to the caller's current ERC20 balance:
uint256 userBalance =
balanceOf(msg.sender);
if (shares > userBalance) {
shares = userBalance;
}
if (shares == userBalance) {
isFullRelease = true;
}
After Alice transfers almost all shares, her current balance is only dust.
The cap correctly prevents the contract from burning shares Alice no longer owns. The problem is that the function does not cancel the full-release state after reducing the burn amount.
In the demonstrated scenario:
Shares recorded in Alice's Position
200,000
Shares still held by Alice
1
Shares actually burned
1
Release classification
Full
The contract then deletes the entire position:
if (isFullRelease) {
delete $.positions[lockId];
$.pendingWithdrawal[lockId] =
false;
$.totalPositions--;
}
It also subtracts the complete original position amount from totalLockedAmount:
uint256 releasedAmount =
isFullRelease
? position.amount
: amount;
$.totalLockedAmount -=
releasedAmount;
Source: CarthaVault.sol
The resulting state is internally inconsistent:
Alice's complete Position
Deleted
Alice's dust share
Burned
Bob's transferred shares
Still outstanding
Bob's Position
Missing
The token supply still represents assets, but the metadata required to redeem most of that supply no longer exists.
The proof measured a 199,999 USDC freeze
The proof began with a 200,000 USDC deposit.
CarthaVault minted 200,000 whole vault shares, represented on chain with 18 decimals.
After time advanced beyond the lock and cooldown, Alice transferred 199,999 shares to Bob and retained one share.
Before Alice's release:
Alice Position amount
200,000 USDC
Alice current shares
1
Bob current shares
199,999
Alice then requested release of the complete 200,000 USDC position.
CarthaVault burned her one remaining share and paid her one USDC. Despite that dust-sized burn and payout, it deleted the entire position and reduced totalLockedAmount from 200,000 USDC to zero.
The final state was:
Alice payout
1 USDC
Bob shares
199,999
Total supply
199,999 shares
Assets remaining in the vault
199,999 USDC
Bob Position
Missing
Bob release
Reverted
Bob requestRelease
Reverted
The assets were not stolen or destroyed.
They remained in CarthaVault and continued economically backing Bob's shares. The loss was access to the backing: Bob could not reach those assets through either supported user redemption path.
These values were reproduced by the end-to-end proof of concept.
A new position did not recover the old shares
The proof also tested whether Bob could repair the mismatch by depositing again.
Bob created a new position and received additional shares. After that position became releasable, he redeemed it normally.
Only the shares associated with the new position were removed.
The original 199,999 transferred shares remained in Bob's balance after the new position was deleted.
This ruled out an accidental recovery path. Creating a later position did not attach the orphaned shares to new redemption metadata.
The queued withdrawal path reproduced the same orphaning
processQueuedWithdrawals contained the same destructive pattern.
It loaded the original owner's position and capped the share amount to the owner's current balance:
uint256 shares =
position.shares;
uint256 actualShares =
balanceOf(owners[i]);
if (shares > actualShares) {
shares = actualShares;
}
It calculated the payout from that reduced share amount, but still deleted the complete position and removed the full recorded amount from totalLockedAmount:
uint256 totalAmount =
position.amount;
delete $.positions[lockId];
$.totalPositions--;
$.totalLockedAmount -=
totalAmount;
Source: CarthaVault.sol
The proof reproduced the complete sequence through the queue:
Alice transferred almost all shares to Bob
Alice requested release
The keeper processed Alice's withdrawal
Only Alice's dust shares were burned
Alice's full position was deleted
Bob retained the transferred shares
Bob still had no position
Bob's
releaseandrequestReleasecalls reverted
The issue therefore affected both direct release and keeper-processed withdrawal.
Why this was not merely self-inflicted loss
A recipient voluntarily accepting shares does not make the behavior correct.
CarthaVault deliberately exposed an ERC20 token and allowed ordinary transfers after the lock expired. A transferable vault share should have one of two properties:
Its redemption rights follow the token
Transfers are blocked while those rights remain bound to another account
CarthaVault did neither.
The transfer succeeded without moving the position, and the original owner could later delete the only position associated with the transferred shares.
This matters beyond a transfer between two personal wallets. The same mismatch could affect treasury movements, smart contract integrations, secondary transfers, or any workflow that treats the ERC20 balance as the vault claim.
How the proof isolated the root cause
The test used the real CarthaVault implementation and the real production entry points:
depositAndLockERC20
transferreleaserequestReleaseprocessQueuedWithdrawals
It did not use direct share minting, direct share burning, direct position deletion, storage modification, a custom harness, or mocks of the affected Cartha logic.
The proof also included a clean control.
Without transferring shares, the same 200,000 USDC position redeemed normally:
The Position was deleted
All associated shares were burned
200,000 USDC was returned
Total supply became zero
TVL became zero
The complete test result was:
1 passing
The control demonstrated that normal release worked and that the permanent freeze was introduced specifically by separating the share owner from the position owner.
Why the finding was High
The proof did not show direct theft, insolvency, or a vault-wide freeze, so Critical was not supported.
The impact was still more serious than an accounting display issue.
Bob held 199,999 shares backed by 199,999 USDC. The original position had been deleted, both normal redemption paths reverted, a later position did not recover the shares, and the queued withdrawal route reproduced the same permanent orphaning.
Triage confirmed the finding as High for permanent freezing of user funds.
The distinction is important:
The vault still holds the assets
≠
The share holder can recover the assets
Custody remained with the contract, but access through the protocol's supported paths was permanently lost.
Recommended remediation
The safest simple correction is to prevent ordinary share transfers while the sender has an active position.
Conceptually:
function _update(
address from,
address to,
uint256 value
) internal override {
if (
from != address(0) &&
to != address(0)
) {
CarthaVaultStorage storage $ =
_getCarthaVaultStorage();
bytes32 lockId =
lockIdOf(from, $.poolId);
Position storage position =
$.positions[lockId];
if (position.exists) {
revert PositionBoundShares();
}
}
super._update(from, to, value);
}
A design that preserves transferability would be more complex. It would need to move or split every piece of state required for redemption, including:
Position amount
Position shares
Lock and cooldown data
Pending withdrawal state
Position counters
totalLockedAmountattribution
The release paths also need an independent invariant:
A full Position may be deleted only when the complete share amount recorded in that Position is actually burned.
If the caller no longer holds the full position share amount, a full release should revert or be converted into a correctly accounted partial release. The same rule must be applied to processQueuedWithdrawals.
Any already orphaned shares would also require an explicit migration or recovery mechanism because preventing future transfers would not reconstruct positions that have already been deleted.
Broader audit lessons
Transferable balances cannot depend on immovable owner metadata
When redemption depends on metadata keyed to one address, moving only the ERC20 balance separates ownership from authority.
Every transfer must preserve the state required to exercise the economic claim.
balanceOf is not proof of redeemability
A holder can own shares that remain in totalSupply and are fully backed while still having no executable withdrawal route.
Vault reviews should test what the recipient can do after receiving shares, not only whether the balances changed.
Defensive caps can create destructive control flow
Capping the burn to the caller's current balance looked safe in isolation.
The bug appeared because the reduced amount was followed by logic that still deleted the complete position.
Whenever a value is clamped, every later branch and accounting update that depends on it must be reviewed.
Partial ownership changes require proportional state changes
A partial token transfer cannot safely leave all redemption metadata with the sender unless the shares are intentionally nontransferable.
Position-based vaults must explicitly define whether positions move, split, merge, or block transfers.
Conclusion
CarthaVault allowed ERC20 shares to move after lock expiry while keeping redemption rights inside a separate Position bound to the original owner.
Bob received 199,999 shares but no position. Both release and requestRelease reverted with PositionNotFound.
Alice then released the original position while holding only one share. CarthaVault burned that one share, paid one USDC, deleted the complete 200,000 USDC position, and reduced totalLockedAmount to zero.
The final state was unambiguous:
199,999 USDC remained in CarthaVault
Bob held 199,999 outstanding shares
Bob had no Position
Bob could not use release
Bob could not use requestRelease
A new Position did not recover the old shares
The engineering invariant is simple:
Vault shares must not move unless every piece of state required to redeem them moves with them.
Top comments (0)