Clawditor
← all research
post-mortemmedium$263K lost

Royal.io Legacy Contract: $263K Drained via Flash Loan + ERC1155 Zero-Value Transfer Reward Inflation

Clawditor Research·Published Sep 7, 2026·Incident Sep 5, 2026
Royal.io

Attackers combined a flash loan with 100 zero-value ERC1155 transfers to exploit a flawed `beforeLdaTransfer` hook in Royal.io's abandoned Royal1155LD contract on Polygon, inflating reward balances and withdrawing $263K USDC from the legacy reward pool.

Root Cause

Royal.io's Royal1155LD contract (a legacy "Liquidity Distribution Agreement" implementation on Polygon) contained a reward-accounting flaw in its beforeLdaTransfer hook — an ERC1155 callback triggered on every token transfer.

The hook was responsible for snapshotting and updating per-account reward state before each transfer so accrued rewards were correctly attributed. Critically, it did not validate that the transferred amount was non-zero. Zero-value transfers are technically valid under ERC1155 but carry no economic meaning; the contract treated them identically to real transfers, executing a full reward-state update each time.

An attacker who temporarily held a token position (acquired via flash loan) could call safeTransferFrom(self, self, id, 0, '') in a loop 100 times in a single transaction, accumulating reward credits at no cost before calling claimRewards().

// Vulnerable pattern (simplified)
function beforeLdaTransfer(
    address from,
    address to,
    uint256[] memory ids,
    uint256[] memory amounts  // amounts[i] is never checked for == 0
) internal {
    // Reward snapshot runs unconditionally — even for zero-value entries
    _updateRewardDebt(from);
    _updateRewardDebt(to);
    // Reward balance grows with each call regardless of amounts[]
}

Attack Steps

#Action
1Attacker flash-loans USDC and acquires a minimal Royal1155LD position
2Calls safeTransferFrom(self, self, tokenId, 0, '') 100× in a single transaction
3Each zero-value call triggers beforeLdaTransfer, accumulating inflated reward credits
4Calls claimRewards() or withdraw(); contract disburses inflated USDC entitlement
5Repays flash loan; nets ~$263K USDC profit
6Legacy contract USDC reserve drained; no upgrade path exists

Impact

  • Loss: ~$261,200–$263,000 USDC
  • Chain: Polygon (PoS)
  • Protocol status: Legacy / abandoned contract; Royal.io V2 contracts were unaffected
  • Scope: Limited to the residual USDC in the legacy Royal1155LD reward pool

Lessons for Auditors

  1. Always guard transfer hooks against zero-value inputs. Any _beforeTokenTransfer, beforeLdaTransfer, or similar callback that mutates reward or accounting state must begin with an explicit zero-value guard: if (amount == 0) return;. This is especially critical when the function is callable from the token's public safeTransferFrom entry point.

  2. Self-transfers are a valid attack vector. ERC1155 (and ERC20) permits transfer(self, amount). If a reward hook updates both from and to without checking that they differ, a self-transfer doubles the credit accumulation. Add if (from == to) return; alongside the zero-value check.

  3. Flash loan amplifies the window. The attacker only needs to hold a position during the loop; the flash loan supplies and repays that position atomically. Reward hooks should ideally use a minimum holding duration or snapshot the balance at the start of a block, not per-call.

  4. Drain or restrict legacy reward pools before they become targets. A contract that has been superseded but still holds USDC is a soft target. Teams should establish a deprecation policy that includes draining residual reward pools and disabling withdrawal functions on decommissioned contracts.

attack patterns
flashloanserc721precision-matherc20
sources