Royal’s legacy Royalties system on Polygon was exploited on 2026-06-23 at 16:27:52 UTC in transaction 0x7a92106f145045b7a2bdce60a22109739f9b0cd0185bf16ff83fd1fac98cb42e. The attacker abused Royal1155LDA’s transfer hook/accounting path by submitting a batch transfer with 100 copies of the same tier-42 LDA ID and every transfer amount set to zero. The standard ERC1155 balance remained zero, but Royal’s custom tier ownership accounting recorded the receiver as holding 100 tier-42 LDAs, allowing the receiver to claim 100x a newly deposited USDC royalty. The transaction extracted 263,808.953900 USDC from the Royalties contract and retained approximately 261,162.926278 USDC after flash-swap repayment.

Root Cause

Vulnerable Contract

The primary vulnerable component is Royal1155LDA, proxied at 0x7c885c4bfd179fb59f1056fbea319d579a278075 with implementation 0xd5b297c08d890376b6cbdba6023a39ffbdf65c78. The exploited payout component is the Royalties proxy 0xfe16ee78828672e86cf8e42d8a5119ab79877ec7, with implementation 0x1e0598614d9168a657cb57bd038dfd71812c9074. Both implementations have verified Polygon source. The Royalties contract trusted Royal1155LDA.tierBalanceOf() as the source of pro-rata ownership, so a corrupted LDA tier balance directly became an inflated royalty entitlement.

Vulnerable Function

The vulnerable path is Royal1155LDA._beforeTokenTransfer(address,address,address,uint256[],uint256[],bytes), reached by safeBatchTransferFrom(address,address,uint256[],uint256[],bytes) selector 0x2eb2c2d6. The hook loops over ids but never checks amounts[i], so zero-amount ERC1155 transfers still invoke beforeLdaTransfer(), append the LDA ID to _OWNED_TOKENS_, increment _BALANCES_, and overwrite _OWNERS_. Royalties later uses claim(address,uint128[],address) selector 0xd4f877e1; inside _settleUcr(), it calls LDA.tierBalanceOf(tierId, account) and turns that corrupted custom balance into claimable USDC.

Vulnerable Code

function _beforeTokenTransfer(
    address operator,
    address from,
    address to,
    uint256[] memory ids,
    uint256[] memory amounts,
    bytes memory data
) internal override {
    for (uint256 i; i < ids.length; i++) {
        uint256 ldaId = ids[i];
        (uint128 tierId,,) = RoyalUtil.decomposeLDA_ID(ldaId);

        if (_ROYALTIES_CONTRACT_ != address(0)) {
            ILdaTransferHook(_ROYALTIES_CONTRACT_).beforeLdaTransfer(from, to, tierId);
        }

        if (from == to) {
            continue;
        }

        if (from != address(0)) {
            uint256 balance = _BALANCES_[tierId][from];
            if (_IS_OWNED_TOKENS_BACKFILL_COMPLETE_ || balance != 0) {
                // removal logic omitted
                _BALANCES_[tierId][from] = balance - 1;
            }
        }

        if (to != address(0)) {
            uint256 oldBalance = _BALANCES_[tierId][to];
            _OWNED_TOKENS_[tierId][to][oldBalance] = ldaId;
            _OWNED_TOKENS_INDEX_[ldaId] = oldBalance;
            _BALANCES_[tierId][to] = oldBalance + 1; // <-- VULNERABILITY: increments even when amounts[i] == 0
        }

        _OWNERS_[ldaId] = to; // <-- VULNERABILITY: ownership mirror diverges from ERC1155 balance on zero-amount transfer
    }

    super._beforeTokenTransfer(operator, from, to, ids, amounts, data);
}
function _claim(address claimer, uint128 tierId, address recipient) internal returns (uint256) {
    uint256 ucr = _settleUcr(tierId, claimer);
    uint256 uce = _settleUce(tierId, claimer);

    uint256 oldClaimed = _CLAIMED_[tierId][claimer];
    uint256 newClaimed = ucr - uce;
    uint256 claimable = newClaimed - oldClaimed;

    _CLAIMED_[tierId][claimer] = newClaimed;
    PAYMENT_ERC20.safeTransfer(recipient, claimable);
    return claimable;
}

function _settleUcr(uint128 tierId, address account) internal returns (uint256) {
    uint256 ldaBalance = LDA.tierBalanceOf(tierId, account); // <-- VULNERABILITY: trusts corrupted custom LDA balance
    uint256 ldaSupply = _TIERS_[tierId].supply;
    uint256 proRataOwnership = PRO_RATA_BASE * ldaBalance / ldaSupply;
    uint256 ucrDiff = tcrDiff * proRataOwnership / PRO_RATA_BASE;
    // record update omitted
}

Why It’s Vulnerable

Expected behavior: A zero-amount ERC1155 transfer should not change ownership, custom enumeration, tier balances, royalty entitlements, or mirror ownership state. If custom ownership accounting is maintained outside the ERC1155 _balances mapping, that custom accounting must be updated consistently with the actual transferred amount.

Actual behavior: Royal1155LDA._beforeTokenTransfer() processes every ids[i] entry as if one LDA moved, regardless of amounts[i]. In the underlying ERC1155 transfer, a zero amount passes the standard fromBalance >= amount check and leaves _balances[id][from] and _balances[id][to] unchanged. In Royal’s custom hook, however, each zero-amount entry still increments _BALANCES_[tierId][to], adds an _OWNED_TOKENS_ entry, and writes _OWNERS_[ldaId] = to.

This gap lets an attacker manufacture royalty voting/ownership weight without owning any ERC1155 unit. In this transaction, the attacker used 100 zero-amount entries of the same tier-42 LDA ID. The receiver’s standard balanceOf(receiver, id) remained 0, but tierBalanceOf(42, receiver) became 100. Since the Royalties tier-42 supply was 1, a later USDC deposit was treated as if the receiver owned 100 / 1 = 100x of the tier, producing a 100x royalty claim.

Attack Execution

High-Level Flow

  1. The attacker EOA 0xbd829aa63311bb1e3c0ea58a7193364de670bd56 called attack contract 0x7fd7be7bc8a26bd6a98b10683912c604af8bca52, which routed execution through helper 0x11ca9155aedfeb6772df5ea42ff714db7fba6adb.
  2. The helper flash-swapped 2,638.089539 USDC from Uniswap V2 pair 0x6e7a5fafcec6bb1e78bae2a1f0b612012bf14827.
  3. During the flash-swap callback, the helper called Royal1155LDA.safeBatchTransferFrom() with 100 copies of LDA ID 14291859410679415465461733512134265305394 and 100 zero amounts, moving no ERC1155 balance but inflating receiver 0xbab48f6f6c7d10ca3f73a23e21ef052af460f684’s tier-42 custom balance to 100.
  4. The helper approved and deposited the borrowed 2,638.089539 USDC into the Royalties contract for tier 42, creating deposit ID 9.
  5. The inflated receiver contract called Royalties.claim() for tier 42, causing the Royalties contract to transfer 263,808.953900 USDC to the helper.
  6. The helper repaid 2,646.027622 USDC to the flash-swap pair and retained approximately 261,162.926278 USDC in the transaction flow.

Detailed Call Trace

The root call used selector 0x22b858b0 on attack contract 0x7fd7be7bc8a26bd6a98b10683912c604af8bca52, which called helper 0x11ca9155aedfeb6772df5ea42ff714db7fba6adb with selector 0x54a830ad. The helper called the Uniswap V2 pair swap(uint256,uint256,address,bytes) (0x022c0d9f) and received 2,638.089539 USDC from pair 0x6e7a5fafcec6bb1e78bae2a1f0b612012bf14827.

Inside uniswapV2Call(address,uint256,uint256,bytes) (0x10d1e85c), the helper invoked safeBatchTransferFrom() on the Royal1155LDA proxy. The calldata decoded to from = 0x11ca9155aedfeb6772df5ea42ff714db7fba6adb, to = 0xbab48f6f6c7d10ca3f73a23e21ef052af460f684, ids.length = 100, one unique ID 14291859410679415465461733512134265305394, amounts.length = 100, and one unique amount 0. The ID decomposes to tier 42 and low token component 424242.

For each of the 100 batch entries, the LDA hook called Royalties.beforeLdaTransfer(address,address,uint128) (0xf3319033). The Royalties hook settled UCR for the sender and receiver by calling LDA.tierBalanceOf(uint128,address) (0xde8b9955). The receiver’s observed tier-42 custom balance climbed from 0 through 99 during the loop and became 100 after the batch, even though the batch transferred zero total ERC1155 amount.

The helper then approved USDC to the Royalties proxy and called deposit(address,uint128,uint256) (0x60fb0f59) with depositor 0x11ca9155aedfeb6772df5ea42ff714db7fba6adb, tier 42, and amount 2,638.089539 USDC. The Royalties contract pulled that USDC from the helper and recorded a new tier-42 cumulative reward.

Finally, receiver contract 0xbab48f6f6c7d10ca3f73a23e21ef052af460f684 called claim(address,uint128[],address) (0xd4f877e1) on the Royalties proxy with claimer = 0xbab48f6f6c7d10ca3f73a23e21ef052af460f684, tierIds = [42], and recipient 0x11ca9155aedfeb6772df5ea42ff714db7fba6adb. In _settleUcr(), Royalties read tierBalanceOf(42, claimer) = 100; with tier supply 1, the deposited USDC was multiplied by 100. The claim returned and transferred 263,808.953900 USDC from Royalties to the helper, after which the helper repaid 2,646.027622 USDC to the pair.

Financial Impact

The Royalties proxy transferred 263,808.953900 USDC to the attacker-controlled helper. The helper had first deposited 2,638.089539 USDC, so gross extraction over the attacker-funded deposit was 261,170.864361 USDC. After repaying 2,646.027622 USDC to the Uniswap V2 pair, the in-transaction helper net was approximately 261,162.926278 USDC.

The Royalties contract’s USDC balance dropped from 261,170.864398 USDC before the transaction to 0.000037 USDC after the transaction. This independently confirms a roughly $261.2K drain from the Royalties contract. Subsequent balances checked after the transaction show no USDC left at the helper, attack contract, or attacker EOA, so the final off-transaction cash-out path is outside this single transaction’s immediate trace.

Evidence

  • Transaction: 0x7a92106f145045b7a2bdce60a22109739f9b0cd0185bf16ff83fd1fac98cb42e on Polygon, block 89018051, status success.
  • Time: 2026-06-23 16:27:52 UTC.
  • Attacker EOA: 0xbd829aa63311bb1e3c0ea58a7193364de670bd56.
  • Attack contract: 0x7fd7be7bc8a26bd6a98b10683912c604af8bca52.
  • Vulnerable LDA proxy and implementation: 0x7c885c4bfd179fb59f1056fbea319d579a278075 and 0xd5b297c08d890376b6cbdba6023a39ffbdf65c78.
  • Exploited Royalties proxy and implementation: 0xfe16ee78828672e86cf8e42d8a5119ab79877ec7 and 0x1e0598614d9168a657cb57bd038dfd71812c9074.
  • Key on-chain fact: safeBatchTransferFrom() carried 100 copies of the same tier-42 ID with all amounts equal to zero, but post-transaction tierBalanceOf(42, 0xbab48f6f6c7d10ca3f73a23e21ef052af460f684) was 100 while balanceOf(0xbab48f6f6c7d10ca3f73a23e21ef052af460f684, 14291859410679415465461733512134265305394) was 0.
  • Key on-chain fact: Royalties.claim() transferred 263,808.953900 USDC to 0x11ca9155aedfeb6772df5ea42ff714db7fba6adb after a same-transaction 2,638.089539 USDC deposit, confirming the 100x payout effect.

Remediation

Update Royal1155LDA’s transfer hook so custom ownership accounting is amount-aware. For this ERC1155 design, the safest fix is to skip all Royal custom ownership, tier-balance, _OWNERS_, and royalty hook updates when amounts[i] == 0, and to require each LDA transfer amount to be exactly 1 when the ID represents a non-fungible LDA. The hook should also reject duplicate IDs in a batch when the token is meant to be unique, or aggregate amounts by ID before applying ownership updates.

Add invariant tests that compare tierBalanceOf(tier, account) and getOwnedTokens(tier, account) against the canonical ERC1155 _balances for every mint, single transfer, batch transfer, self-transfer, zero-amount transfer, and duplicate-ID batch transfer. Royalties should avoid relying on a mutable mirror balance unless the mirror is proven equivalent to canonical ERC1155 ownership; alternatively, it should query canonical balances over validated token IDs or snapshot ownership through an audited, amount-aware transfer hook. Existing corrupted tier balances and _OWNERS_ entries should be repaired before reopening claims or deposits.