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 |
|---|---|
| 1 | Attacker flash-loans USDC and acquires a minimal Royal1155LD position |
| 2 | Calls safeTransferFrom(self, self, tokenId, 0, '') 100× in a single transaction |
| 3 | Each zero-value call triggers beforeLdaTransfer, accumulating inflated reward credits |
| 4 | Calls claimRewards() or withdraw(); contract disburses inflated USDC entitlement |
| 5 | Repays flash loan; nets ~$263K USDC profit |
| 6 | Legacy 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
-
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 publicsafeTransferFromentry point. -
Self-transfers are a valid attack vector. ERC1155 (and ERC20) permits
transfer(self, amount). If a reward hook updates bothfromandtowithout checking that they differ, a self-transfer doubles the credit accumulation. Addif (from == to) return;alongside the zero-value check. -
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.
-
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.