Reproduced Exploit
RoyalRoyalties Exploit — Zero-Amount ERC-1155 Batch Transfer Inflates `tierBalanceOf` 100×
1. Royal1155LDA is an ERC-1155 that keeps a custom per-tier balance ledger (_BALANCES_[tierId][owner]) on top of standard ERC-1155 balances. The Royalties contract reads this ledger via tierBalanceOf to pay royalty deposits out pro-rata to the holders of a tier.
Loss
261,162.93 USDC — drained from the Royalties payout float on Polygon
Chain
Polygon
Category
Logic / State
Date
Jun 2026
Source & credit. Exploit reproduction, trace data, and analysis adapted from DeFiHackLabs by SunWeb3Sec — an open registry of reproduced on-chain exploits. Standalone Foundry PoC and full write-up: 2026-06-RoyalRoyalties_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/RoyalRoyalties_exp.sol.
Vulnerability classes: vuln/logic/missing-check · vuln/logic/wrong-condition
Reproduction: the PoC compiles & runs in an isolated Foundry project at this project folder. It forks Polygon at block 89,018,050 and drives the live
Royal1155LDA+Royaltiesproxies. Full verbose trace: output.txt. Verified vulnerable source (active implementations at the fork block): contracts_ldas_Royal1155LDA.sol and contracts_royalties_Royalties.sol.
Key info#
| Loss | 261,162.93 USDC — drained from the Royalties payout float on Polygon |
| Vulnerable contract | Royal1155LDA (logic impl) — 0xd5b297c08d890376b6cbdba6023a39ffbdf65c78, behind proxy 0x7c885c4bFd179fb59f1056FBea319D579A278075 |
| Co-vulnerable contract | Royalties (logic impl) — 0x1e0598614d9168a657cb57bd038dfd71812c9074, behind proxy 0xfE16Ee78828672e86cf8E42d8A5119AB79877EC7 |
| Victim float | Royalties proxy USDC balance — ~261,170.86 USDC of un-disbursed royalty deposits |
| Attacker EOA | 0xbd829aa63311bb1e3c0ea58a7193364de670bd56 |
| Attacker contract | 0x11ca9155aedfeb6772df5ea42ff714db7fba6adb |
| Attack tx | 0x7a92106f145045b7a2bdce60a22109739f9b0cd0185bf16ff83fd1fac98cb42e |
| Chain / block / date | Polygon (chainId 137) / fork 89,018,050 / June 2026 |
| Compiler | Royal1155LDA & Royalties logic: Solidity v0.8.4+commit.c7e474f2, optimizer enabled, 200 runs; both proxies: v0.8.2+commit.661d1103, optimizer enabled, 200 runs |
| Bug class | Per-tier balance accounting inflation via zero-amount ERC-1155 batch items + pro-rata royalty payout that trusts the inflated balance |
TL;DR#
-
Royal1155LDAis an ERC-1155 that keeps a custom per-tier balance ledger (_BALANCES_[tierId][owner]) on top of standard ERC-1155 balances. TheRoyaltiescontract reads this ledger viatierBalanceOfto pay royalty deposits out pro-rata to the holders of a tier. -
The transfer hook
_beforeTokenTransfer(contracts_ldas_Royal1155LDA.sol:744-830) iterates over theids[]array of a batch transfer and increments the receiver's per-tier balance for every entry — without ever inspectingamounts[i]. A batch item withamounts[i] == 0still bumps_BALANCES_[tierId][to]by one. -
The sender-side decrement is skipped in a specific state: when the
_OWNED_TOKENS_backfill is still in progress (!_IS_OWNED_TOKENS_BACKFILL_COMPLETE_) and the sender's tier balance is already zero, the "insufficient balance" branch is bypassed (:776-801). At the fork blockgetIsOwnedTokensBackfillComplete()returnsfalse(output.txt:49), so a zero-balance sender can send freely. -
The attacker therefore calls
safeBatchTransferFromwith 100 copies of the same tier-42 LDA id and 100 zero amounts (RoyalRoyalties_exp.sol:142-155). The hook fires 100 times and walks the receiver's_BALANCES_[42][receiver]from0to100(output.txt:1774), while the tier's real supply is1(output.txt:43). -
The attacker flash-borrows USDC, makes a royalty
deposit(…, tierId=42, amount=2,638.09 USDC)(output.txt:1900-1901), thenclaims through the inflated receiver.Royalties._getProRataOwnership(100, 1)(contracts_royalties_Royalties.sol:1278-1287) computes 10,000 % ownership, so the claim pays out 100 × the deposit = 263,808.95 USDC (output.txt:1929) — funded by other depositors' royalties already sitting in the contract. -
After repaying the flash swap, the attacker keeps 261,162.926278 USDC (output.txt:7, output.txt:1976) — almost the entire royalty float of the
Royaltiescontract.
Background — what RoyalRoyalties does#
Royal.io issues music-royalty NFTs called LDAs ("Limited Digital Assets") as
ERC-1155 tokens via Royal1155LDA
(source). An LDA
id packs three fields into a uint256
(RoyalUtil.sol comment):
[ tier_id (128 bits) | version (16 bits) | token_id (112 bits) ]. A tier
(e.g. GOLD/PLATINUM/DIAMOND of a song) groups many individual token ids; each
token id has supply 1.
Two custom ledgers matter for this bug:
_BALANCES_[tierId][owner]— the number of LDAs an address owns within a tier (:120-122), exposed viatierBalanceOf(:595-605)._OWNED_TOKENS_/_IS_OWNED_TOKENS_BACKFILL_COMPLETE_— an enumeration the team was migrating in viasetOwnedTokens; until the owner callscompleteOwnedTokensBackfill()the contract runs in a permissive "backfill-incomplete" mode (:252-290).
The companion Royalties contract
(source) accepts
ERC-20 (USDC) deposits for a tier and lets the tier's holders claim their
pro-rata share, where pro-rata ownership =
PRO_RATA_BASE × ldaBalance / ldaSupply
(:1278-1287).
ldaBalance is LDA.tierBalanceOf(tierId, account)
(:982) and
ldaSupply is the tier supply locked in at tier initialization
(:1002).
On-chain parameters at the fork block (read in the trace):
| Parameter | Value | Source |
|---|---|---|
getTierTotalSupply(42) (tier supply) | 1 | output.txt:43 |
getIsOwnedTokensBackfillComplete() | false | output.txt:49 |
USDC held by Royalties proxy (float) | 261,170,864,398 wei (~261,170.86 USDC) | output.txt:64 |
| USDC decimals | 6 | output.txt:38 |
PRO_RATA_BASE | 1e18 (= 100 %) | contracts_royalties_Royalties.sol:121 |
| Attacker USDC before | 0 | output.txt:6 |
The decisive combination: tier supply is 1, the backfill flag is false,
and the contract is sitting on ~261K USDC of other people's royalties. Inflate
one address's tierBalanceOf from 0 to 100 and the pro-rata math pays it 100×.
The vulnerable code#
1. The batch-transfer hook increments per-tier balance for every id — ignoring amounts[i]#
function _beforeTokenTransfer(
address operator,
address from,
address to,
uint256[] memory ids,
uint256[] memory amounts, // ← amounts is received...
bytes memory data
)
internal
virtual
override
{
// Iterate over all LDAs being transferred
for (uint256 i; i < ids.length; i++) {
uint256 ldaId = ids[i];
// Get the tier ID.
(uint128 tierId, ,) = RoyalUtil.decomposeLDA_ID(ldaId);
// Call the callback on the Royalties contract.
if (_ROYALTIES_CONTRACT_ != address(0)) {
ILdaTransferHook(_ROYALTIES_CONTRACT_).beforeLdaTransfer(from, to, tierId);
}
// ... from-side / to-side bookkeeping below, all keyed off ids[i] only ...
(contracts_ldas_Royal1155LDA.sol:744-768)
amounts is accepted but never read in the bookkeeping. The receiver-side
update unconditionally adds one per id:
// Token enters a wallet: update balance and add token to owner enumeration.
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; // ⚠️ +1 even when amounts[i] == 0
}
(contracts_ldas_Royal1155LDA.sol:803-810)
2. The sender-side "insufficient balance" check is bypassed during backfill for a zero-balance sender#
// Token leaves a wallet: update balance and remove token from owner enumeration.
if (from != address(0)) {
uint256 balance = _BALANCES_[tierId][from];
// Special case: If backfill is ongoing AND balance is zero, do not update.
if (_IS_OWNED_TOKENS_BACKFILL_COMPLETE_ || balance != 0) {
require(
balance != 0,
"ERC1155: insufficient balance for transfer"
);
// ... swap-and-pop removal, _BALANCES_[tierId][from] = balance - 1 ...
}
}
(contracts_ldas_Royal1155LDA.sol:776-801)
When _IS_OWNED_TOKENS_BACKFILL_COMPLETE_ == false and the sender's
balance == 0, the entire if body is skipped: no require, no decrement. The
sender pays nothing, yet the receiver block above still increments. The net
effect of one zero-amount batch item is: sender unchanged at 0, receiver +1.
Repeat 100 times in a single batch and the receiver reaches 100.
3. Royalties pays out pro-rata using the (inflated) tier balance vs. fixed supply#
uint256 ldaBalance = LDA.tierBalanceOf(tierId, account); // == 100 for the receiver
// ...
uint256 ldaSupply = _TIERS_[tierId].supply; // == 1
uint256 proRataOwnership = _getProRataOwnership(ldaBalance, ldaSupply);
ucrDiff = _tcrDiffToUcrDiff(tcrDiff, proRataOwnership);
(contracts_royalties_Royalties.sol:982-1005)
function _getProRataOwnership(uint256 ldaBalance, uint256 ldaSupply)
internal pure returns (uint256)
{
return PRO_RATA_BASE * ldaBalance / ldaSupply; // 1e18 * 100 / 1 = 100e18 = 10,000%
}
function _tcrDiffToUcrDiff(uint256 tcrDiff, uint256 proRataOwnership)
internal pure returns (uint256)
{
return tcrDiff * proRataOwnership / PRO_RATA_BASE; // tcrDiff * 100
}
(contracts_royalties_Royalties.sol:1278-1298)
There is no invariant that ldaBalance <= ldaSupply. With ldaBalance = 100 and ldaSupply = 1, the claimant is credited with 100× the deposit it
just made — the difference is paid out of pre-existing royalty deposits.
Root cause — why it was possible#
Two independent defects compose into the loss:
-
amounts[i]is never validated in the LDA accounting. A faithful ERC-1155 implementation would only update ownership whenamounts[i] > 0. Here the custom_BALANCES_ledger keys off the number of ids in the batch, not the quantity transferred. Because each LDA token id is conceptually "supply 1," the code assumed one id ⇒ one unit, and dropped the amount entirely. A zero-amount item is therefore free balance inflation. -
The "backfill incomplete" mode disables the sender-side balance check for zero-balance senders. This was a migration convenience (:779 comment), intended so the team could move tokens before
_OWNED_TOKENS_was fully populated. Its side effect is that an address holding zero of a tier can originate transfers of that tier without reverting — so the attacker does not even need to own a single LDA to be thefrom. -
RoyaltiestruststierBalanceOfwith no supply cap. The pro-rata formula divides by the immutable tier supply but never bounds the numerator by it. A balance that exceeds supply silently produces ownership > 100 %, and the claim pays out everyone else's deposits.
The two contracts were audited and reasoned about separately; the danger only appears at the seam — the LDA's loose batch accounting feeds the royalties' trusting pro-rata math. Neither contract on its own loses funds; combined, one zero-cost batch transfer turns into a 100× royalty claim.
Preconditions#
getIsOwnedTokensBackfillComplete() == falseso the zero-balance sender's transfer is not rejected. True at the fork block (output.txt:49); the PoC asserts it (RoyalRoyalties_exp.sol:88).- A tier with small supply that the
Royaltiescontract is funded for. Tier 42 has supply 1 (output.txt:43) and theRoyaltiesproxy holds ~261,170.86 USDC of deposits (output.txt:64). - The LDA contract is registered with the
Royaltiescontract sobeforeLdaTransferfires and settles UCR for the receiver (visible as the repeatedbeforeLdaTransfercallbacks at output.txt:80-81). - Working capital in USDC to make the seed deposit. Only ~2,638.09 USDC is needed, and it is recovered intra-transaction, so it is trivially flash-loanable — the PoC borrows it from the QuickSwap WMATIC/USDC pair via a V2 flash swap (RoyalRoyalties_exp.sol:106, output.txt:66) and repays it with the 0.3 % fee.
Attack walkthrough (with on-chain numbers from the trace)#
USDC has 6 decimals; raw wei figures are shown with human approximations in
parentheses. All figures come directly from output.txt.
| # | Step | Tier-42 supply | Receiver tierBalanceOf | Royalties USDC float | Attacker USDC | Effect |
|---|---|---|---|---|---|---|
| 0 | Initial — supply read (output.txt:43); backfill=false (output.txt:49); float read (output.txt:64) | 1 | 0 | 261,170,864,398 (~261,170.86) | 0 (output.txt:6) | Honest state. |
| 1 | Flash-borrow USDC from QuickSwap pair: swap(0, 2,638,089,539, …) (output.txt:66); 2,638,089,539 (~2,638.09) USDC sent to attacker (output.txt:67-69) | 1 | 0 | 261,170,864,398 | 2,638,089,539 (~2,638.09) | Working capital acquired in-tx. |
| 2 | Zero-amount batch inflation — safeBatchTransferFrom of 100 copies of LDA id 14,291,859,410,679,415,465,461,733,512,134,265,305,394 with 100 zero amounts (output.txt:78); hook walks receiver balance 0→…→100 (output.txt:91 first +1; output.txt:1774 99→100) | 1 | 100 (output.txt:1891) | 261,170,864,398 | 2,638,089,539 | Receiver appears to own 100 of a 1-supply tier. |
| 3 | Seed deposit — approve 2,638,089,539 (output.txt:1893) then Royalties.deposit(attacker, 42, 2,638,089,539) (output.txt:1900-1904) | 1 | 100 | 263,808,953,937 (~263,808.95) | 0 | Float grows by the deposit; deposit creates a claimable TCR. |
| 4 | 100× claim — claim(receiver, [42], attacker); tierBalanceOf reads 100 (output.txt:1927); pays 2,638,089,539 × 100 = 263,808,953,900 (~263,808.95) to attacker (output.txt:1929-1931) | 1 | 100 | ~37 wei (drained) | 263,808,953,900 (~263,808.95) | Royalty float emptied to the attacker. |
| 5 | Repay flash swap — transfer 2,646,027,622 (~2,646.03) USDC back to the pair (output.txt:1950-1952); Sync/Swap emitted (output.txt:1967-1968) | 1 | 100 | — | 261,162,926,278 (~261,162.93) | Principal + 0.3 % fee returned; profit retained. |
Final attacker USDC balance: 261,162,926,278 wei = 261,162.926278 USDC
(output.txt:1976, output.txt:1999, logged at
output.txt:7). The PoC asserts profit > 260,000e6
(RoyalRoyalties_exp.sol:95) and that
afterBalance - beforeBalance == profit
(RoyalRoyalties_exp.sol:94).
Profit / loss accounting (USDC, raw wei)#
| Item | Amount (wei) | ~Human |
|---|---|---|
| Flash-borrowed from QuickSwap pair (inflow) | 2,638,089,539 | ~2,638.09 |
| Royalty claim payout (inflow) | 263,808,953,900 | ~263,808.95 |
| Total inflow | 266,447,043,439 | ~266,447.04 |
Seed deposit to Royalties (outflow) | 2,638,089,539 | ~2,638.09 |
| Flash-swap repayment incl. 0.3 % fee (outflow) | 2,646,027,622 | ~2,646.03 |
| Total outflow | 5,284,117,161 | ~5,284.12 |
| Net profit (asserted in PoC) | 261,162,926,278 | ~261,162.93 |
The claim payout (263,808.95) is 100 × the seed deposit (2,638.09). Of that,
2,638.09 is the attacker's own re-deposited money; the remaining 261,170.86 −
~0.0000 = ~261,162.93 USDC is the pre-existing royalty float belonging to other
depositors. The seed deposit was deliberately sized to
royaltyFloat / 99 ≈ 2,638.09 USDC (RoyalRoyalties_exp.sol:157-163)
so the 100× payout is exactly funded by the existing float and the attacker
walks away with all of it.
Diagrams#
Sequence of the attack#
State evolution#
The flaw inside _beforeTokenTransfer#
Why the claim is theft: pro-rata ownership before vs. after inflation#
Why each magic number#
TIER_ID = 42(RoyalRoyalties_exp.sol:31) — a real Royal tier whose supply is1and which theRoyaltiescontract is funded for. A supply of 1 maximizes the pro-rata multiplier per inflated unit.ZERO_AMOUNT_TRANSFERS = 100(RoyalRoyalties_exp.sol:32) — the number of zero-amount batch items, which becomes the inflatedtierBalanceOfand hence the 100× claim multiplier. Confirmed in the trace as the receiver balance reaching 100 (output.txt:1774, output.txt:1927).TRACE_LDA_TOKEN_NUMBER = 424_242(RoyalRoyalties_exp.sol:33) — the token-id portion of the LDA id; combined with the tier via(tierId << 128) | tokenNumberit yields the on-chain id14,291,859,410,679,415,465,461,733,512,134,265,305,394(output.txt:78). Any token number works becauseamountsis0and the id is never required to exist;decomposeLDA_ID(id) >> 128still recovers tier42.- Seed deposit
royaltyFloat / (100 - 1) = 261,170,864,398 / 99 = 2,638,089,539wei (~2,638.09 USDC) (RoyalRoyalties_exp.sol:157-163, output.txt:64,66) — sized so the100×payout equalsroyaltyFloat + deposit, i.e. the claim is funded entirely by the pre-existing float plus the attacker's own deposit, draining the contract without over-requesting. - Repay
(borrowAmount × 1000) / 997 + 1 = 2,646,027,622wei (~2,646.03 USDC) (RoyalRoyalties_exp.sol:138, output.txt:1950) — the QuickSwap V2 0.3 % flash-swap fee formula,+1wei to cover integer rounding so the pair'skcheck passes.
Remediation#
- Validate
amounts[i]in the LDA bookkeeping. In_beforeTokenTransfer, skip all ownership/balance updates whenamounts[i] == 0(orrequire(amounts[i] == 1)given the one-per-id model). Balance must track quantity, not array length. - Remove the zero-balance sender bypass. The "backfill incomplete AND
balance == 0 ⇒ skip" branch
(:780)
lets a non-owner originate transfers. Always
require(balance >= amount)for a real (non-zero) transfer regardless of backfill state, and gate any migration convenience behind an owner-only path, not the public transfer entry point. - Cap pro-rata ownership by supply in
Royalties. Enforce the invarianttierBalanceOf(tier, account) <= tierSupply(andΣ balances <= supply) before paying out. At minimum, clamp_getProRataOwnershipso it can never exceedPRO_RATA_BASE(100 %). - Do not let one contract trust another's mutable accounting without bounds.
Royaltiesshould treattierBalanceOfas untrusted external input and reject values above supply, since the LDA ledger is mutated by permissionless transfers. - Pause claims until the backfill is complete. A half-migrated state
(
_IS_OWNED_TOKENS_BACKFILL_COMPLETE_ == false) should disable royaltydeposit/claimfor affected tiers, since the accounting is provably loose in that window.
How to reproduce#
The PoC runs offline against a pinned Polygon fork served from the bundled
anvil_state.json; createSelectFork points at a local anvil endpoint
(RoyalRoyalties_exp.sol:72). The shared
harness starts anvil from that state and runs the test:
_shared/run_poc.sh 2026-06-RoyalRoyalties_exp --mt testExploit -vvvvv
- Fork: Polygon block 89,018,050, replayed from local
anvil_state.json(no public RPC required).foundry.tomlsetsevm_version = 'cancun'. - The test is heavy (100 hook callbacks in one batch); the run takes a few minutes (the recorded trace finished in ~219 s).
- Result:
[PASS] testExploit()with the attacker's USDC balance rising from0.000000to261162.926278.
Expected tail (from output.txt:1-7 and output.txt:2002-2004):
Ran 1 test for test/RoyalRoyalties_exp.sol:ContractTest
[PASS] testExploit() (gas: 9523499)
Logs:
Attacker Before exploit USDC Balance: 0.000000
Attacker After exploit USDC Balance: 261162.926278
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 219.27s (215.88s CPU time)
Reference: TenArmor alert — https://x.com/TenArmorAlert/status/2069596801725002121 (RoyalRoyalties, Polygon, ~$261K USDC).
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2026-06-RoyalRoyalties_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
RoyalRoyalties_exp.sol. - Attack transaction: view on explorer.
Alerts & third-party analyses
- Original alert / thread: post on X.
- DeFiHackLabs incident explorer: search "RoyalRoyalties Exploit".
- Web3Sec X hacked database: search.
- Rekt leaderboard: search.
- Solodit incident search: search.
These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.