Logo
Overview
I Thought My Cross-Chain Vault Shares Vanished -Turns Out They Were Just in a Hurry to Bridge

I Thought My Cross-Chain Vault Shares Vanished -Turns Out They Were Just in a Hurry to Bridge

January 31, 2026
4 min read

I Thought My Cross-Chain Vault Shares Vanished -Turns Out They Were Just in a Hurry to Bridge

A 6-hour LayerZero + ERC-4626 debugging meltdown: I blamed the bridge, cursed the mocks, drank way too much coffee… only to realize my composer was doing its job perfectly.

I was building YieldZero — my little cross-chain vault dream powered by ERC-4626 + LayerZero OFTs.

The flow felt clean and obvious:

  1. User sends assets from Ethereum
  2. Arbitrum composer grabs them
  3. Vault mints shares straight to the composer
  4. Shares immediately fly back to the user on Ethereum

Test runs. Logs look pristine:

  • Assets land in the vault
  • totalSupply jumps exactly 1e18
  • Deposit event pops with the right receiver
  • No reverts. No weird gas. Nothing.

Then I check:

vault.balanceOf(composer)

Zero.

Not “close enough.” Not dust. Stone-cold, infuriating zero.

My brain went straight to DEFCON 1:

“Bridge ate my tokens. LayerZero bug. Mock endpoint lying. Time to open GitHub issues and cry.”

Six hours, four coffees, and one minor existential crisis later -staring at traces and OpenZeppelin source -the penny finally dropped:

The shares didn’t vanish.

They were minted perfectly… then immediately yeeted cross-chain.

That’s literally the whole design.

Here’s the messy war story, why my test lied to my face, and the one lesson worth tattooing on my arm.


The Moment Everything Felt Broken

Test output:

Vault totalAssets: 1000000000000000000
Composer share balance: 0 ← excuse me what
Total supply after deposit: 1000000000000000000 ← you literally just minted?!

And the dagger:

[FAIL: composer should have shares: 0 != 1000000000000000000]

Assets arrived. Supply increased. Event fired.

Composer? Ghosted.

I was ready to rage-quit Solidity forever.


What Was Actually Happening (The Real Flow)

Deep in VaultComposerSync lives this little monster:

function handleCompose(...) external payable {
// decode, checks, etc.
if (_oftIn == ASSET_OFT) {
_depositAndSend(_composeFrom, _amount, sendParam, tx.origin, msg.value);
} else {
_redeemAndSend(...);
}
}

And _depositAndSend is brutally honest:

function _depositAndSend(...) internal {
uint256 pre = IERC20(SHARE_ERC20).balanceOf(address(this));
// Mint shares → directly to ourselves (the composer)
shareAmount = VAULT.deposit(_assetAmount, address(this));
uint256 post = IERC20(SHARE_ERC20).balanceOf(address(this));
uint256 shares = post - pre;
_assertSlippage(shares, _sendParam.minAmountLD);
// Pack the freshly minted babies
_sendParam.amountLD = shares;
_sendParam.minAmountLD = 0;
// And… poof - off to destination chain
_send(SHARE_OFT, _sendParam, _refundAddress, _msgValue);
emit Deposited(...);
}

The killer line:

shareAmount = VAULT.deposit(_assetAmount, address(this));

Vault mints shares to the composer contract itself (address(this)).

Next breath → _send() ships them cross-chain.

Composer isn’t a vault.

It’s a temporary hot potato.

My test checked the potato after it was thrown.

Of course the balance was zero.


Why My Test Lied to My Face

This was the crime scene:

yieldZeroComposer_arb.lzCompose{value: ...}(...);
// ← right here the shares are already gone
assertEq(
vault_arb.balanceOf(address(yieldZeroComposer_arb)),
TOKENS_TO_SEND,
"composer should have shares"
);

Wrong timing = wrong conclusion.

Better checks:

  • Assert the user received shares on destination chain
  • Or (debug mode) peek at composer balance before _send runs

The Side Lesson I Still Needed: super vs Explicit

Even though this wasn’t a mint bug, my original override was still risky:

function deposit(...) public override onlyComposer ... {
shares = super.deposit(assets, receiver); // ← blind trust
emit Deposit(...);
}

super.deposit() delegates to the parent — which means every hook, fee skim, rebase, or redirect in the chain gets to run.

In cross-chain, yield, or composable vaults -that’s asking for trouble.

What I use now (safer, clearer):

function deposit(uint256 assets, address receiver)
public
override
onlyComposer
whenNotPaused
returns (uint256 shares)
{
shares = previewDeposit(assets);
if (shares == 0) revert("Zero shares");
SafeERC20.safeTransferFrom(
IERC20(asset()),
msg.sender,
address(this),
assets
);
_mint(receiver, shares);
emit Deposit(msg.sender, receiver, assets, shares);
return shares;
}

No hidden magic.

You see every step.

Auditors smile.


Quick Reality Check Table

MetricValueNotes
Vault assets1e18✅ Received
Total shares1e18✅ Minted
Composer balance0✅ Already bridged away
User on ETH1e18✅ Success - the real goal

Everything worked.

My mental model was just late to the party.

Quick Excalidraw sketch:

vault shares


Takeaways (Steal These)

  1. Custody is fleeting — middle contracts in synchronous flows hold tokens for ~1 tx.

  2. Timing kills tests- one line can move your tokens before you look.

  3. Avoid blind super calls in ERC-4626 — especially cross-chain.

  4. Log like crazy - before & after every meaningful action.

  5. Trace is truth- -vvv never gaslights you.


Ever checked a balance one line too late and thought the protocol was haunted?

Or fought a “disappearing token” bug that turned out to be intended behavior?

Drop your story in the comments — debugging horror stories are how we all get better.

Happy coding, explicit minting, and perfectly timed assertions. ☕🚀