Assessment reports>d3-doma>Critical findings>Bridging can be used to deliberately block detokenization
Category: Business Logic

Bridging can be used to deliberately block detokenization

Critical Impact
Critical Severity
High Likelihood

Description

When a registrar initiates detokenization via DomaRecordRegistrarFacet::complianceDetokenize, DomaRecordRegistrarFacet::registrarDelete, and DomaRecordRegistrarFacet::registrarDetokenize, the token holder may bridge the token to a remote chain before the detokenization message from the Doma chain arrives at the ProxyDomaRecord contract.

Since the call to ProxyDomaRecord::bridge() by the token holder will burn the token, the cross-chain call to ProxyDomaRecord::detokenize() from the DomaRecordRegistrarFacet functions listed above will revert. This will mean that the detokenization flow cannot complete, thus completely preventing the token from being detokenized.

Impact

The user can continue to block detokenization requests by constantly bridging the token between chains as soon as they notice a cross-chain detokenization occurring. This prevents the domain from ever being tokenized, even after ownership is transferred off-chain.

This issue also affects expired domains, where detokenization may be blocked in the same way.

Proof of Concept

Add the following test to test/ProxyDomaRecord.test.ts and run it using npx hardhat test --audit grep:

describe('audit', () => {
  const TARGET_CHAIN_ID = 'eip155:1';
  
  beforeEach(async () => {
    // Add the target chain ID to the supported list
    await proxyDomaRecord.connect(owner).addSupportedTargetChain(TARGET_CHAIN_ID);
  });

  it("audit - detokenization is blocked after burn from bridge", async () => {
    const { tokenId } = await mintOwnershipTokenForUser();

    // Assume that there is a detokenization request inbound from Doma chain to this chain.
    // The user notices this and decides to bridge their token away before the request arrives.
    await proxyDomaRecord.connect(user).bridge(tokenId, false, TARGET_CHAIN_ID, user.address);

    // Now after the bridging transaction, the detokenization request comes through
    const tx = proxyDomaRecord
      .connect(crossChainReceiver)
      .detokenize(BigInt(tokenId).toString(), false, user.address, TEST_CORRELATION_ID);
    
    // This detokenize request should ideally still succeed, and cross-chain logic must be
    // implemented to handle detokenizing the token after it arrives on the remote chain
    // that the user bridged to.
    // However, this transaction will revert, forcing detokenization to not complete.
    await expect(tx).to.not.be.reverted;
  });
});

It expects the detokenization to still succeed (see our recommendation below for an example of how to implement that logic here), but it will revert instead, preventing detokenization.

Recommendations

We recommend implementing logic that prevents bridging of tokens if a detokenization request is in progress. This is quite difficult to implement since it needs to account for the fact that any such logic could be circumvented if the user can front-run such preventions and bridge the token away first anyway.

One way to solve this would be to ensure that the ProxyDomaRecord::detokenize() function succeeds even if the token has been burned through the bridge() function. Then, on the Doma chain, the DomaRecordProxyFacet::bridge() function can check to see if the token ID being bridged is currently being detokenized. If so, it should not complete the bridging process.

Finally, once the DomaRecordProxyFacet::completeDetokenization() function is called, the token details will be deleted from the DomaRecord contract's state, and everything will be working as intended.

Remediation

This issue has been acknowledged by D3, and a fix was implemented in commit 6c680667.

Zellic © 2025Back to top ↑