Shortly after midnight UTC on 28 October 2026, an attacker drained 1,013,353.61 USDC from a TranchePool on Avalanche C-Chain that SlowMist reports is associated with Valinor Digital, Oatfi and Fence Finance. The pool lost its entire USDC balance. There was no oracle manipulation and no reentrancy. The bug was a piece of credit accounting: when the calendar turned to a new UTC day, the pool treated a scheduled principal repayment as if it had actually been paid, even though no USDC arrived. That single bookkeeping step gave the attacker a fresh line of draw capacity, and the attacker used it three seconds after midnight.
This post walks through what the chain shows, why the root cause is so easy to miss in a review, and what teams building tokenized credit or vault products should test for.
The attack used two transactions from the same externally owned account, 0x6ff1c3ff...2b1246, both calling a helper contract at 0xf09dcf4e...a57805 that the attacker had deployed about fifteen minutes earlier.
Transaction 1: the setup (27 October, 23:56:41 UTC). The attacker’s contract took a flash loan of 1,013,353.611686 USDC through Aave v3. That figure is exactly the pool’s USDC balance. We read the receipt from a public Avalanche RPC node: the full amount went from the attacker contract into the TranchePool at 0xe25cb545...47621a, the same amount came straight back out of the pool, and 1,013,860.288492 USDC was returned to Aave, a premium of roughly 506.68 USDC. After this transaction, the pool still held all of its USDC. Nothing had been stolen yet.
Transaction 2: the drain (28 October, 00:00:03 UTC). Three seconds into the new UTC day, the attacker’s contract called the pool’s draw path again. This time the pool sent 1,013,353.611686 USDC of its own funds to the attacker contract, which forwarded it to the attacker’s wallet. The pool’s balance went to zero.
Within minutes the attacker moved the funds off Avalanche. Two calls to Circle’s CCTP depositForBurnWithHook at 00:03:41 and 00:05:22 UTC burned about 250,000 USDC and 763,375 USDC with destination domain 5, which is Solana in CCTP’s domain numbering.
Blockaid was among the first to flag the incident and initially described it as a flash loan used to mint inflated tranche shares. SlowMist’s follow-up analysis pointed to the settlement logic instead, and an independent fork replay published by Sentinelleai supports that reading. CertiK Alert summarised it the same way: a scheduled accounting update reopened draw capacity that had already been used.
According to SlowMist, the vulnerable pool tracks two numbers for a tranche: funded principal and outstanding principal. The draw function (selector 0x8e27c105) lets a borrower take out up to the difference:
available = fundedPrincipal - outstandingPrincipal
That check is reasonable on its own. Outstanding principal goes up when money is drawn and should only go down when money is repaid.
The flaw was in the settlement step that runs at the UTC day rollover. When a new day began, the pool processed the scheduled principal payment for that date and subtracted it from outstanding principal without confirming that any USDC had actually been received. On paper the loan had been paid down. On-chain, nothing had moved.
Put the two pieces together:
0x6ae13b00, which is 00:00:00 UTC on 28 October. The attacker scheduled a repayment for the very next second.The flash loan only made the setup capital-free. The theft itself was the rollover.
Sentinelleai forked Avalanche just before midnight and replayed the second transaction’s exact calldata with different block timestamps. At 23:59:59 UTC the draw reverted. At 00:00:00 UTC it succeeded and emptied the pool. With the same state and the same input, the only thing that changed the outcome was the block time. That is the signature of a time-dependent accounting bug, not a share-pricing bug.
One caveat matters here: the pool’s source code is not verified on the public explorers. The analyses above, including ours, rest on calldata, events, token transfers, balances and fork replays rather than on reading the original Solidity.
The code below is simplified and illustrative. It is not the pool’s real source, which is unverified. It shows the shape of the mistake.
// SIMPLIFIED, ILLUSTRATIVE ONLY - not the victim contract's code
contract TranchePoolSketch {
IERC20 public immutable usdc;
uint256 public fundedPrincipal;
uint256 public outstandingPrincipal;
struct Scheduled { uint64 dueAt; uint256 amount; bool settled; }
Scheduled[] public schedule;
function draw(uint256 amount) external {
_rollover();
require(amount <= fundedPrincipal - outstandingPrincipal, "no capacity");
outstandingPrincipal += amount;
usdc.transfer(msg.sender, amount);
}
// BUG: runs when a new UTC day starts and treats "due" as "paid".
function _rollover() internal {
for (uint256 i; i < schedule.length; i++) {
Scheduled storage s = schedule[i];
if (!s.settled && block.timestamp >= s.dueAt) {
s.settled = true;
outstandingPrincipal -= s.amount; // no USDC was received
}
}
}
}
A safer design separates “an amount is due” from “an amount has arrived.” Outstanding principal should only decrease inside a repayment function that pulls the tokens and measures the balance change:
// SIMPLIFIED, ILLUSTRATIVE ONLY
function repay(uint256 amount) external {
uint256 before = usdc.balanceOf(address(this));
usdc.transferFrom(msg.sender, address(this), amount);
uint256 received = usdc.balanceOf(address(this)) - before;
outstandingPrincipal -= received;
}
Scheduled payments that come due without funds should move the position into a “late” or “default” state, which reduces capacity rather than restoring it.
Real-world credit brings real-world conventions on-chain: due dates, day counts, payment waterfalls, senior and junior tranches. Each convention adds a place where state changes without money moving. In traditional finance, a back office reconciles the ledger against the bank account. On-chain, the contract is both the ledger and the account, so any line that updates the ledger without checking the token balance becomes an attack surface.
Three things made this one easy to overlook:
We saw a related theme in the X100 governance flash loan exploit, where a flash loan made an attack free rather than being the vulnerability itself. And just a day earlier, the Avalanche Safe stale approval incident showed how an assumption made at one moment can be cashed in later by whoever arrives first.
1. Write down the money invariant and enforce it. For any lending or credit pool the core rule is short: draw capacity can never exceed assets the contract has actually received. Encode it as an assertion or an invariant test, not just a comment.
2. Never reduce debt on a schedule. Outstanding principal should decrease only when tokens arrive, measured by balance deltas. A payment that comes due without funds is a missed payment, and it should tighten the position.
3. Fuzz across time boundaries. Property tests should warp across the UTC day rollover, test due dates equal to block.timestamp, test due dates set to the next second, and test due dates supplied by the caller. Foundry’s vm.warp makes this cheap.
4. Treat user-chosen dates as adversarial input. If a borrower can set the schedule, assume they will pick the value that makes your accounting worst. Enforce minimum intervals or require repayments to be prefunded.
5. Make draws idempotent. Replaying identical calldata should not succeed twice unless the state genuinely justifies it. Per-draw nonces or explicit drawdown records make double draws visible.
6. Verify your source code. An unverified contract slows down every responder during an incident and hides the logic from independent reviewers before one. Our smart contract security checklist covers this alongside the testing items above.
At 03:16:23 UTC on 28 October, an address sent the attacker’s wallet an on-chain message on behalf of Fence. The message offers a 10% recovery bounty in exchange for returning 90% of the verified affected funds and lists recovery@fence.finance as a contact. As of this writing we have not seen a confirmed return of funds or a full statement on depositor losses.
0xe25cb545bdd47a8ec2d08001cb5661b00d47621a0x6ff1c3ffa622e1062168f3497a275be9cc2b12460xf09dcf4ede2a88bd316ff355dfc333a281a578050x19ab145e...9769899fd0x3f6b2329...1de058c770x0e974743...92c2218b0x51bb85e1...ee3b9f3970xaf19a8a6...b2f4894The pool did exactly what its ledger told it to do. The ledger was wrong for one second, at a boundary nobody tested, on a date the attacker chose. When on-chain credit borrows TradFi conventions, every scheduled state change needs the same question: did the money actually arrive?
Building a lending, RWA or tranche-based protocol? Contact Panda Security for a review of your accounting invariants and time-dependent logic, or see our audit pricing.
How unlimited approvals to an address that was empty on Avalanche let anyone deploy a sweeper there and pull $7.45M from a 2-of-7 Safe multisig.
A MoveVM script-caching flaw let one account credit itself unbacked WETH, USDC and AUSD on Initia, and three bridge reserves paid out about $2.7M in real assets.
A zero-quorum DAO vote gave an attacker Yapeswap's feeToSetter role, and a fake Yearn registry then routed every pair's farmed reserves into attacker vaults.
Flash-loaned X tokens bought two of three X100 Senate seats, and one transaction later the Freezer treasury sold 400,000 X for about 36 USDT on Ethereum.