Bridging can be used to deliberately block detokenization
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↗.