DEV Community

Daniel
Daniel

Posted on • Edited on

How Transferred CarthaVault Shares Became Permanently Unredeemable

ERC20 vault shares normally represent a transferable claim on the assets held by a vault.

CarthaVault tracked that claim in two different places:

  1. The ERC20 balance recorded who held the shares

  2. A separate internal Position recorded 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;
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

Source: CarthaVault.sol

Immediately after the deposit, the records agree:

ERC20 share owner
Alice

Position owner
Alice
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The contract then deletes the entire position:

if (isFullRelease) {
    delete $.positions[lockId];
    $.pendingWithdrawal[lockId] =
        false;
    $.totalPositions--;
}
Enter fullscreen mode Exit fullscreen mode

It also subtracts the complete original position amount from totalLockedAmount:

uint256 releasedAmount =
    isFullRelease
        ? position.amount
        : amount;

$.totalLockedAmount -=
    releasedAmount;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

Source: CarthaVault.sol

The proof reproduced the complete sequence through the queue:

  1. Alice transferred almost all shares to Bob

  2. Alice requested release

  3. The keeper processed Alice's withdrawal

  4. Only Alice's dust shares were burned

  5. Alice's full position was deleted

  6. Bob retained the transferred shares

  7. Bob still had no position

  8. Bob's release and requestRelease calls 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:

  1. Its redemption rights follow the token

  2. 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:

  1. depositAndLock

  2. ERC20 transfer

  3. release

  4. requestRelease

  5. processQueuedWithdrawals

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
Enter fullscreen mode Exit fullscreen mode

The complete test result was:

1 passing
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

A design that preserves transferability would be more complex. It would need to move or split every piece of state required for redemption, including:

  1. Position amount

  2. Position shares

  3. Lock and cooldown data

  4. Pending withdrawal state

  5. Position counters

  6. totalLockedAmount attribution

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)