diff --git a/contracts/AgglayerBridge.sol b/contracts/AgglayerBridge.sol index 7e5490fb0..7858f81b3 100644 --- a/contracts/AgglayerBridge.sol +++ b/contracts/AgglayerBridge.sol @@ -601,6 +601,30 @@ contract AgglayerBridge is amount ); + _transferAssets( + originNetwork, + originTokenAddress, + destinationAddress, + amount, + metadata + ); + } + + /** + * @notice Internal function to transfer assets to destination address + * @param originNetwork Origin network + * @param originTokenAddress Origin token address, + * @param destinationAddress Address destination + * @param amount Amount of tokens + * @param metadata Abi encoded metadata if any, empty otherwise + */ + function _transferAssets( + uint32 originNetwork, + address originTokenAddress, + address destinationAddress, + uint256 amount, + bytes calldata metadata + ) internal virtual { // Transfer funds if ( originTokenAddress == address(0) && @@ -980,7 +1004,7 @@ contract AgglayerBridge is function isClaimed( uint32 leafIndex, uint32 sourceBridgeNetwork - ) external view virtual returns (bool) { + ) public view virtual returns (bool) { uint256 globalIndex; // For consistency with the previous set nullifiers diff --git a/contracts/interfaces/IAgglayerBridgeL2.sol b/contracts/interfaces/IAgglayerBridgeL2.sol index bb7004a9f..afc1d4ac2 100644 --- a/contracts/interfaces/IAgglayerBridgeL2.sol +++ b/contracts/interfaces/IAgglayerBridgeL2.sol @@ -145,6 +145,17 @@ interface IAgglayerBridgeL2 is IAgglayerBridge { */ error OnlyDeployer(); + /** + * @dev Thrown when the caller is not phantom claim manager + */ + error OnlyPhantomClaimManager(); + + /** + * @dev Thrown when attempting to set a phantom claim for a globalIndex that already maps to a different leaf + * and the override flag is not set to true + */ + error PhantomGlobalIndexInvalid(); + function initialize( uint32 _networkID, address _gasTokenAddress, diff --git a/contracts/sovereignChains/AgglayerBridgeL2.sol b/contracts/sovereignChains/AgglayerBridgeL2.sol index ed4d93874..4c6b92b1e 100644 --- a/contracts/sovereignChains/AgglayerBridgeL2.sol +++ b/contracts/sovereignChains/AgglayerBridgeL2.sol @@ -63,14 +63,34 @@ contract AgglayerBridgeL2 is AgglayerBridge, IAgglayerBridgeL2 { // Emergency bridge unpauser address: can unpause the bridge, both bridges and claims address public emergencyBridgeUnpauser; - // This account will be able to accept the emergencyBridgeUnpauser role + // This account will be able to accept the emergencyBridgeUnpauser role address public pendingEmergencyBridgeUnpauser; + /** + * @notice Mapping to track phantom claims that have been executed + * @dev Maps the leaf value hash to the number of phantom claims executed for that leaf + * When a phantom claim is made, this counter is incremented + * When a regular claim is made, if a phantom claim exists, the counter is decremented + * and no actual token transfer occurs (as tokens were already transferred via phantom claim) + */ + mapping(bytes32 leafValue => uint256 phantomClaimCount) + public phantomClaimMap; + + /** + * @notice Mapping to track which leaf value corresponds to each phantom claimed global index + * @dev Maps globalIndex to the leaf value that was phantom claimed + * This main a protection for the message sender to not be able to claim the same globalIndex with different leaves (unless override is enabled) + * This prevents the same globalIndex from being used for different leaves (unless override is enabled) + * Ensures consistency between phantom claims and actual claims + */ + mapping(uint256 globalIndex => bytes32 leafValue) + public phantomGlobalIndexToLeaf; + /** * @dev This empty reserved space is put in place to allow future versions to add new * variables without shifting down storage in the inheritance chain. */ - uint256[48] private __gap; + uint256[46] private __gap; /** * @dev Emitted when a bridge manager is updated @@ -248,6 +268,29 @@ contract AgglayerBridgeL2 is AgglayerBridge, IAgglayerBridgeL2 { bytes metadata ); + /** + * @dev Emitted when a phantom claim is executed + * @notice This event indicates that assets have been transferred before the actual claim proof + * @param globalIndex The global index of the claim + * @param leafType Type of the leaf (0 for asset, 1 for message) + * @param originNetwork Network ID where the tokens originated + * @param originAddress Address of the origin token + * @param destinationNetwork Network ID of the destination (this network) + * @param destinationAddress Address receiving the tokens + * @param amount Amount of tokens transferred + * @param metadata Additional metadata for the claim (token info for wrapped tokens) + */ + event PhantomClaim( + uint256 globalIndex, + uint8 leafType, + uint32 originNetwork, + address originAddress, + uint32 destinationNetwork, + address destinationAddress, + uint256 amount, + bytes metadata + ); + /** * Disable initializers on the implementation following the best practices * @dev the deployer is set to the contract creator and will be the only allowed to initialize the contract in a 2 steps process @@ -425,6 +468,21 @@ contract AgglayerBridgeL2 is AgglayerBridge, IAgglayerBridgeL2 { _; } + /** + * @dev Modifier to check that the caller is the phantom claim manager + * The phantom claim manager is the globalExitRootUpdater from the global exit root manager + * This role is authorized to execute phantom claims on behalf of users + */ + modifier onlyPhantomClaimManager() { + // Only allowed to be called by PhantomClaimManager + if ( + IAgglayerGERL2(address(globalExitRootManager)) + .globalExitRootUpdater() != msg.sender + ) { + revert OnlyPhantomClaimManager(); + } + _; + } /** * @notice Remap multiple wrapped tokens to a new sovereign token address * @dev This function is a "multi/batch call" to `setSovereignTokenAddress` @@ -1128,7 +1186,7 @@ contract AgglayerBridgeL2 is AgglayerBridge, IAgglayerBridgeL2 { function isClaimed( uint32 leafIndex, uint32 sourceBridgeNetwork - ) external view override returns (bool) { + ) public view override returns (bool) { uint256 globalIndex = uint256(leafIndex) + uint256(sourceBridgeNetwork) * _MAX_LEAFS_PER_NETWORK; @@ -1179,6 +1237,111 @@ contract AgglayerBridgeL2 is AgglayerBridge, IAgglayerBridgeL2 { _deactivateEmergencyState(); } + /** + * @notice Function to execute a phantom claim - transfers assets before the actual claim proof is submitted + * @dev This function allows the phantom claim manager to pre-execute asset transfers for claims + * that are expected to be claimed later. When the actual claim is made, no transfer occurs + * as the phantom claim counter is decremented instead. + * @dev Note that phantom claims do not store the sourceBridgeNetwork, therefore could happen + * that a phantom claim that was made for a "mainnet" is consolidated with a claim from a rollup. + * @dev Security considerations: + * - Only callable by the phantom claim manager (globalExitRootUpdater) + * - Protected by nonReentrant modifier to prevent reentrancy attacks + * - Only executable when not in emergency state + * - Validates that the global index hasn't been claimed yet + * @param globalIndex Global index is defined as: + * | 191 bits | 1 bit | 32 bits | 32 bits | + * | 0 | mainnetFlag | rollupIndex | localRootIndex | + * @param originNetwork Origin network + * @param originTokenAddress Origin token address + * @param destinationNetwork Network destination (must be this networkID) + * @param destinationAddress Address destination + * @param amount Amount of tokens to claim + * @param metadata Abi encoded metadata if any, empty otherwise + * @param overridePhantomGlobalIndex If true, allows overriding an existing globalIndex->leaf mapping + * If false, reverts with PhantomGlobalIndexInvalid if globalIndex already maps to a different leaf + */ + function phantomClaimAsset( + uint256 globalIndex, + uint32 originNetwork, + address originTokenAddress, + uint32 destinationNetwork, + address destinationAddress, + uint256 amount, + bytes calldata metadata, + bool overridePhantomGlobalIndex + ) public ifNotEmergencyState nonReentrant onlyPhantomClaimManager { + // Destination network must be this networkID + if (destinationNetwork != networkID) { + revert DestinationNetworkInvalid(); + } + + // Validate and decode global index + ( + uint32 leafIndex, + , + uint32 sourceBridgeNetwork + ) = _validateAndDecodeGlobalIndex(globalIndex); + + // Ensure global index hasn't been claimed yet to prevent front-running attacks + // If already claimed, the phantom claim would be useless as the tokens were already transferred + require( + isClaimed(leafIndex, sourceBridgeNetwork) == false, + AlreadyClaimed() + ); + + // Calculate the leaf value for this claim + bytes32 leafValue = getLeafValue( + _LEAF_TYPE_ASSET, + originNetwork, + originTokenAddress, + destinationNetwork, + destinationAddress, + amount, + keccak256(metadata) + ); + + // Validate and set the globalIndex to leaf mapping + // This ensures consistency - a globalIndex should always map to the same leaf + bytes32 currentPhantomleaf = phantomGlobalIndexToLeaf[globalIndex]; + if ( + currentPhantomleaf == bytes32(0) || // First time setting this globalIndex + (overridePhantomGlobalIndex == true && + currentPhantomleaf != leafValue) // Override allowed and leaf is different + ) { + // Set or override the mapping + phantomGlobalIndexToLeaf[globalIndex] = leafValue; + } else { + // GlobalIndex already maps to a different leaf and override not allowed + revert PhantomGlobalIndexInvalid(); + } + + // Increment phantom claim counter for this leaf value + phantomClaimMap[leafValue]++; + + // Execute the asset transfer immediately + // When the actual claim is made later, this transfer will be skipped + _transferAssets( + originNetwork, + originTokenAddress, + destinationAddress, + amount, + metadata + ); + + // Emit event for tracking phantom claims + emit PhantomClaim( + globalIndex, + _LEAF_TYPE_ASSET, + originNetwork, + originTokenAddress, + destinationNetwork, + destinationAddress, + amount, + metadata + ); + } + /** * @notice Override claimAsset to emit additional DetailedClaimEvent for rollup gas efficiency * @dev This function extends the parent claimAsset functionality by emitting an additional event @@ -1214,24 +1377,69 @@ contract AgglayerBridgeL2 is AgglayerBridge, IAgglayerBridgeL2 { address destinationAddress, uint256 amount, bytes calldata metadata - ) public override(IAgglayerBridge, AgglayerBridge) { - // Call parent implementation with all inherited security modifiers: - // - ifNotEmergencyState: Only allows claims when emergency state is inactive - // - nonReentrant: Prevents reentrancy attacks during token operations - super.claimAsset( + ) + public + override(IAgglayerBridge, AgglayerBridge) + ifNotEmergencyState + nonReentrant + { + // Destination network must be this networkID + if (destinationNetwork != networkID) { + revert DestinationNetworkInvalid(); + } + + // Verify leaf exist and it does not have been claimed + _verifyLeafBridge( smtProofLocalExitRoot, smtProofRollupExitRoot, globalIndex, mainnetExitRoot, rollupExitRoot, + _LEAF_TYPE_ASSET, originNetwork, originTokenAddress, destinationNetwork, destinationAddress, amount, - metadata + keccak256(metadata) + ); + + emit ClaimEvent( + globalIndex, + originNetwork, + originTokenAddress, + destinationAddress, + amount + ); + + // Calculate the leaf value to check if there is a phantom claim to consume + bytes32 leafValue = getLeafValue( + _LEAF_TYPE_ASSET, + originNetwork, + originTokenAddress, + destinationNetwork, + destinationAddress, + amount, + keccak256(metadata) ); + // Check if a phantom claim exists for this leaf + // If yes, decrement the counter and skip the asset transfer (already done in phantom claim) + // If no, proceed with the normal asset transfer + if (phantomClaimMap[leafValue] > 0) { + // Consume one phantom claim by decrementing the counter + phantomClaimMap[leafValue]--; + } else { + // No phantom claim exists, proceed with normal claim process and transfer assets + _transferAssets( + originNetwork, + originTokenAddress, + destinationAddress, + amount, + metadata + ); + } + emit DetailedClaimEvent( smtProofLocalExitRoot, smtProofRollupExitRoot, diff --git a/test/contractsv2/PhantomClaim.test.ts b/test/contractsv2/PhantomClaim.test.ts new file mode 100644 index 000000000..f8416f090 --- /dev/null +++ b/test/contractsv2/PhantomClaim.test.ts @@ -0,0 +1,893 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ +/* eslint-disable prefer-destructuring */ +/* eslint-disable no-restricted-syntax */ +import { expect } from 'chai'; +import { ethers, upgrades } from 'hardhat'; +import { MTBridge, mtBridgeUtils } from '@0xpolygonhermez/zkevm-commonjs'; +import { ERC20PermitMock, AgglayerGERL2, AgglayerBridgeL2, TokenWrapped } from '../../typechain-types'; +import { computeWrappedTokenProxyAddress } from './helpers/helpers-sovereign-bridge'; + +const MerkleTreeBridge = MTBridge; +const { verifyMerkleProof, getLeafValue } = mtBridgeUtils; + +function calculateGlobalExitRoot(mainnetExitRoot: any, rollupExitRoot: any) { + return ethers.solidityPackedKeccak256(['bytes32', 'bytes32'], [mainnetExitRoot, rollupExitRoot]); +} + +// eslint-disable-next-line @typescript-eslint/naming-convention +const _GLOBAL_INDEX_MAINNET_FLAG = 2n ** 64n; + +function computeGlobalIndex(indexLocal: any, indexRollup: any, isMainnet: boolean) { + if (isMainnet === true) { + return BigInt(indexLocal) + _GLOBAL_INDEX_MAINNET_FLAG; + } + return BigInt(indexLocal) + BigInt(indexRollup) * 2n ** 32n; +} + +describe('Phantom Claim Functionality Tests', () => { + upgrades.silenceWarnings(); + + let sovereignChainBridgeContract: AgglayerBridgeL2; + let polTokenContract: ERC20PermitMock; + let sovereignChainGlobalExitRootContract: AgglayerGERL2; + + let deployer: any; + let rollupManager: any; + let bridgeManager: any; + let acc1: any; + let acc2: any; + let emergencyBridgePauser: any; + let globalExitRootRemover: any; + let phantomClaimManager: any; + let proxiedTokensManager: any; + + const tokenName = 'Matic Token'; + const tokenSymbol = 'MATIC'; + const decimals = 18; + const tokenInitialBalance = ethers.parseEther('20000000'); + const metadataToken = ethers.AbiCoder.defaultAbiCoder().encode( + ['string', 'string', 'uint8'], + [tokenName, tokenSymbol, decimals], + ); + const networkIDMainnet = 0; + const networkIDRollup = 1; + const networkIDRollup2 = 2; + + const LEAF_TYPE_ASSET = 0; + + beforeEach('Deploy contracts', async () => { + // load signers + [ + deployer, + rollupManager, + acc1, + acc2, + bridgeManager, + emergencyBridgePauser, + phantomClaimManager, + proxiedTokensManager, + ] = await ethers.getSigners(); + globalExitRootRemover = deployer; + + // Set trusted sequencer as coinbase for sovereign chains + await ethers.provider.send('hardhat_setCoinbase', [deployer.address]); + + // deploy AgglayerBridgeL2 + const BridgeL2SovereignChainFactory = await ethers.getContractFactory('AgglayerBridgeL2'); + sovereignChainBridgeContract = (await upgrades.deployProxy(BridgeL2SovereignChainFactory, [], { + initializer: false, + unsafeAllow: ['constructor', 'missing-initializer', 'missing-initializer-call'], + })) as unknown as AgglayerBridgeL2; + + // deploy global exit root manager + const GlobalExitRootManagerL2SovereignChainFactory = await ethers.getContractFactory('AgglayerGERL2'); + sovereignChainGlobalExitRootContract = (await upgrades.deployProxy( + GlobalExitRootManagerL2SovereignChainFactory, + [], + { + initializer: false, + constructorArgs: [sovereignChainBridgeContract.target], + unsafeAllow: ['constructor', 'missing-initializer', 'state-variable-immutable'], + }, + )) as unknown as AgglayerGERL2; + + await sovereignChainGlobalExitRootContract.initialize(deployer.address, globalExitRootRemover.address); + + // Initialize bridge + await sovereignChainBridgeContract.initialize( + networkIDRollup, + ethers.ZeroAddress, + 0, + sovereignChainGlobalExitRootContract.target, + rollupManager.address, + '0x', + bridgeManager.address, + ethers.ZeroAddress, + false, + emergencyBridgePauser.address, + emergencyBridgePauser.address, + proxiedTokensManager.address, + ); + + // deploy ERC20 token + const maticTokenFactory = await ethers.getContractFactory('ERC20PermitMock'); + polTokenContract = await maticTokenFactory.deploy( + tokenName, + tokenSymbol, + deployer.address, + tokenInitialBalance, + ); + }); + + describe('Phantom Claim Basic Functionality', () => { + it('Should allow phantom claim manager to execute phantom claim', async () => { + const depositCount = await sovereignChainBridgeContract.depositCount(); + const originNetwork = networkIDMainnet; + const tokenAddress = polTokenContract.target; + const amount = ethers.parseEther('10'); + const destinationNetwork = networkIDRollup; + const destinationAddress = acc1.address; + const metadata = metadataToken; + + // Transfer tokens to bridge + await polTokenContract.transfer(sovereignChainBridgeContract.target, amount); + + // Compute merkle tree for the claim + const merkleTree = new MerkleTreeBridge(32); + const leafValue = getLeafValue( + LEAF_TYPE_ASSET, + originNetwork, + tokenAddress, + destinationNetwork, + destinationAddress, + amount, + ethers.keccak256(metadata), + ); + merkleTree.add(leafValue); + const rootLocalExitRoot = merkleTree.getRoot(); + + // Compute global index + const indexLocal = 0; + const indexRollup = 0; + const globalIndex = computeGlobalIndex(indexLocal, indexRollup, false); + + // Check initial phantom claim count + const leafHash = ethers.solidityPackedKeccak256( + ['uint8', 'uint32', 'address', 'uint32', 'address', 'uint256', 'bytes32'], + [ + LEAF_TYPE_ASSET, + originNetwork, + tokenAddress, + destinationNetwork, + destinationAddress, + amount, + ethers.keccak256(metadata), + ], + ); + expect(await sovereignChainBridgeContract.phantomClaimMap(leafHash)).to.equal(0); + + // Check initial balance + const initialBalance = await polTokenContract.balanceOf(destinationAddress); + + // Execute phantom claim as phantom claim manager (deployer in this test setup) + // overridePhantomGlobalIndex = false (don't override existing mappings) + const tx = await sovereignChainBridgeContract.connect(deployer).phantomClaimAsset( + globalIndex, + originNetwork, + tokenAddress, + destinationNetwork, + destinationAddress, + amount, + metadata, + false, // overridePhantomGlobalIndex + ); + + // Verify PhantomClaim event emitted + await expect(tx) + .to.emit(sovereignChainBridgeContract, 'PhantomClaim') + .withArgs( + globalIndex, + LEAF_TYPE_ASSET, + originNetwork, + tokenAddress, + destinationNetwork, + destinationAddress, + amount, + metadata, + ); + + // Check phantom claim count incremented + expect(await sovereignChainBridgeContract.phantomClaimMap(leafHash)).to.equal(1); + + // Check phantom global index to leaf mapping is set correctly + // leafValue was already computed above for the merkle tree + expect(await sovereignChainBridgeContract.phantomGlobalIndexToLeaf(globalIndex)).to.equal(leafValue); + + // Compute wrapped token proxy address (same as done in other tests) + const wrappedTokenAddress = await computeWrappedTokenProxyAddress( + originNetwork, + tokenAddress as string, + sovereignChainBridgeContract, + false, + ); + const tokenWrappedFactory = await ethers.getContractFactory('TokenWrapped'); + const wrappedToken = tokenWrappedFactory.attach(wrappedTokenAddress) as TokenWrapped; + + // Check balance of destination address on wrapped token contract (should have received tokens) + expect(await wrappedToken.balanceOf(destinationAddress)).to.equal(amount); + + // Also, check that the balance of the underlying (original) token did not increase + expect(await polTokenContract.balanceOf(destinationAddress)).to.equal(initialBalance); + }); + + it('Should revert if non-phantom claim manager tries to execute phantom claim', async () => { + const originNetwork = networkIDMainnet; + const tokenAddress = polTokenContract.target; + const amount = ethers.parseEther('10'); + const destinationNetwork = networkIDRollup; + const destinationAddress = acc1.address; + const metadata = metadataToken; + const globalIndex = 0; + + // acc1 is not the phantom claim manager (deployer is) + // Should revert with OnlyPhantomClaimManager error + await expect( + sovereignChainBridgeContract.connect(acc1).phantomClaimAsset( + globalIndex, + originNetwork, + tokenAddress, + destinationNetwork, + destinationAddress, + amount, + metadata, + false, // overridePhantomGlobalIndex + ), + ).to.be.revertedWithCustomError(sovereignChainBridgeContract, 'OnlyPhantomClaimManager'); + }); + + it('Should revert phantom claim for wrong destination network', async () => { + const originNetwork = networkIDMainnet; + const tokenAddress = polTokenContract.target; + const amount = ethers.parseEther('10'); + const wrongDestinationNetwork = networkIDRollup2; // Wrong network + const destinationAddress = acc1.address; + const metadata = metadataToken; + const globalIndex = 0; + + await expect( + sovereignChainBridgeContract.connect(deployer).phantomClaimAsset( + globalIndex, + originNetwork, + tokenAddress, + wrongDestinationNetwork, + destinationAddress, + amount, + metadata, + false, // overridePhantomGlobalIndex + ), + ).to.be.revertedWithCustomError(sovereignChainBridgeContract, 'DestinationNetworkInvalid'); + }); + + it('Should revert phantom claim during emergency state', async () => { + // Activate emergency state + await sovereignChainBridgeContract.connect(emergencyBridgePauser).activateEmergencyState(); + + const originNetwork = networkIDMainnet; + const tokenAddress = polTokenContract.target; + const amount = ethers.parseEther('10'); + const destinationNetwork = networkIDRollup; + const destinationAddress = acc1.address; + const metadata = metadataToken; + const globalIndex = 0; + + // Should revert with OnlyNotEmergencyState error + await expect( + sovereignChainBridgeContract.connect(deployer).phantomClaimAsset( + globalIndex, + originNetwork, + tokenAddress, + destinationNetwork, + destinationAddress, + amount, + metadata, + false, // overridePhantomGlobalIndex + ), + ).to.be.revertedWithCustomError(sovereignChainBridgeContract, 'OnlyNotEmergencyState'); + }); + }); + + describe('Phantom Claim Consumption', () => { + it('Should consume phantom claim when regular claim is made', async () => { + const depositCount = await sovereignChainBridgeContract.depositCount(); + const originNetwork = networkIDMainnet; + const tokenAddress = polTokenContract.target; + const amount = ethers.parseEther('10'); + const destinationNetwork = networkIDRollup; + const destinationAddress = acc1.address; + const metadata = metadataToken; + + // Transfer tokens to bridge (only once, for phantom claim) + await polTokenContract.transfer(sovereignChainBridgeContract.target, amount); + + // Compute merkle tree for the claim + const merkleTree = new MerkleTreeBridge(32); + const leafValue = getLeafValue( + LEAF_TYPE_ASSET, + originNetwork, + tokenAddress, + destinationNetwork, + destinationAddress, + amount, + ethers.keccak256(metadata), + ); + merkleTree.add(leafValue); + const rootLocalExitRoot = merkleTree.getRoot(); + + // Add to rollup exit root + const rollupMerkleTree = new MerkleTreeBridge(32); + rollupMerkleTree.add(rootLocalExitRoot); + const rootRollupExitRoot = rollupMerkleTree.getRoot(); + + // Update global exit root + // impersonate bridge addres to connect and updates eexit root + await ethers.provider.send('hardhat_impersonateAccount', [sovereignChainBridgeContract.target]); + await ethers.provider.send('hardhat_setBalance', [ + sovereignChainBridgeContract.target, + ethers.toQuantity(ethers.parseEther('10')), + ]); + const bridgeMock = await ethers.getSigner(sovereignChainBridgeContract.target as any); + await sovereignChainGlobalExitRootContract.connect(bridgeMock).updateExitRoot(rootRollupExitRoot); + + // Insert global exit root for claim verification + const mainnetExitRoot = ethers.ZeroHash; + const computedGlobalExitRoot = calculateGlobalExitRoot(mainnetExitRoot, rootRollupExitRoot); + await sovereignChainGlobalExitRootContract.insertGlobalExitRoot(computedGlobalExitRoot); + + // Compute global index + const indexLocal = 0; + const indexRollup = 0; + const globalIndex = computeGlobalIndex(indexLocal, indexRollup, false); + + // Get proofs + const proofLocalExitRoot = merkleTree.getProofTreeByIndex(0); + const proofRollupExitRoot = rollupMerkleTree.getProofTreeByIndex(0); + + const rollupExitRoot = rootRollupExitRoot; + + // Execute phantom claim first (simulate early liquidity provision) + await sovereignChainBridgeContract.connect(deployer).phantomClaimAsset( + globalIndex, + originNetwork, + tokenAddress, + destinationNetwork, + destinationAddress, + amount, + metadata, + false, // overridePhantomGlobalIndex + ); + + // Check phantom claim count + const leafHash = ethers.solidityPackedKeccak256( + ['uint8', 'uint32', 'address', 'uint32', 'address', 'uint256', 'bytes32'], + [ + LEAF_TYPE_ASSET, + originNetwork, + tokenAddress, + destinationNetwork, + destinationAddress, + amount, + ethers.keccak256(metadata), + ], + ); + expect(await sovereignChainBridgeContract.phantomClaimMap(leafHash)).to.equal(1); + + // Compute wrapped token address + const wrappedTokenAddress = await computeWrappedTokenProxyAddress( + originNetwork, + tokenAddress as string, + sovereignChainBridgeContract, + false, + ); + const tokenWrappedFactory = await ethers.getContractFactory('TokenWrapped'); + const wrappedToken = tokenWrappedFactory.attach(wrappedTokenAddress) as TokenWrapped; + + // Check balance after phantom claim + const balanceAfterPhantom = await wrappedToken.balanceOf(destinationAddress); + + // Now execute regular claim - should consume phantom claim + await sovereignChainBridgeContract.claimAsset( + proofLocalExitRoot, + proofRollupExitRoot, + globalIndex, + mainnetExitRoot, + rollupExitRoot, + originNetwork, + tokenAddress, + destinationNetwork, + destinationAddress, + amount, + metadata, + ); + + // Check phantom claim count decremented + expect(await sovereignChainBridgeContract.phantomClaimMap(leafHash)).to.equal(0); + + // Check no additional tokens transferred (phantom claim already transferred) + const balanceAfterClaim = await wrappedToken.balanceOf(destinationAddress); + expect(balanceAfterClaim).to.equal(balanceAfterPhantom); + + // Verify claim is marked as claimed + expect(await sovereignChainBridgeContract.isClaimed(indexLocal, destinationNetwork)).to.equal(true); + }); + + it('Should transfer tokens on regular claim if no phantom claim exists', async () => { + const depositCount = await sovereignChainBridgeContract.depositCount(); + const originNetwork = networkIDMainnet; + const tokenAddress = polTokenContract.target; + const amount = ethers.parseEther('10'); + const destinationNetwork = networkIDRollup; + const destinationAddress = acc1.address; + const metadata = metadataToken; + + // Transfer tokens to bridge + await polTokenContract.transfer(sovereignChainBridgeContract.target, amount); + + // Compute merkle tree for the claim + const merkleTree = new MerkleTreeBridge(32); + const leafValue = getLeafValue( + LEAF_TYPE_ASSET, + originNetwork, + tokenAddress, + destinationNetwork, + destinationAddress, + amount, + ethers.keccak256(metadata), + ); + merkleTree.add(leafValue); + const rootLocalExitRoot = merkleTree.getRoot(); + + // Add to rollup exit root + const rollupMerkleTree = new MerkleTreeBridge(32); + rollupMerkleTree.add(rootLocalExitRoot); + const rootRollupExitRoot = rollupMerkleTree.getRoot(); + + // Update global exit root - use bridge impersonation + await ethers.provider.send('hardhat_impersonateAccount', [sovereignChainBridgeContract.target]); + await ethers.provider.send('hardhat_setBalance', [ + sovereignChainBridgeContract.target, + ethers.toQuantity(ethers.parseEther('10')), + ]); + const bridgeMock = await ethers.getSigner(sovereignChainBridgeContract.target as any); + await sovereignChainGlobalExitRootContract.connect(bridgeMock).updateExitRoot(rootRollupExitRoot); + + // Insert global exit root for claim verification + const mainnetExitRoot = ethers.ZeroHash; + const computedGlobalExitRoot = calculateGlobalExitRoot(mainnetExitRoot, rootRollupExitRoot); + await sovereignChainGlobalExitRootContract.insertGlobalExitRoot(computedGlobalExitRoot); + + // Compute global index + const indexLocal = 0; + const indexRollup = 0; + const globalIndex = computeGlobalIndex(indexLocal, indexRollup, false); + + // Get proofs + const proofLocalExitRoot = merkleTree.getProofTreeByIndex(0); + const proofRollupExitRoot = rollupMerkleTree.getProofTreeByIndex(0); + + const rollupExitRoot = rootRollupExitRoot; + + // Execute regular claim WITHOUT phantom claim (this will create the wrapped token) + await sovereignChainBridgeContract.claimAsset( + proofLocalExitRoot, + proofRollupExitRoot, + globalIndex, + mainnetExitRoot, + rollupExitRoot, + originNetwork, + tokenAddress, + destinationNetwork, + destinationAddress, + amount, + metadata, + ); + + // Now get the wrapped token (created by the claim) + const wrappedTokenAddress = await computeWrappedTokenProxyAddress( + originNetwork, + tokenAddress as string, + sovereignChainBridgeContract, + false, + ); + const tokenWrappedFactory = await ethers.getContractFactory('TokenWrapped'); + const wrappedToken = tokenWrappedFactory.attach(wrappedTokenAddress) as TokenWrapped; + + // Check tokens were transferred (wrapped tokens minted) + const finalBalance = await wrappedToken.balanceOf(destinationAddress); + expect(finalBalance).to.equal(amount); + }); + }); + + describe('Phantom Claim Edge Cases', () => { + it('Should handle multiple phantom claims for different leaves', async () => { + const originNetwork = networkIDMainnet; + const tokenAddress = polTokenContract.target; + const amount1 = ethers.parseEther('10'); + const amount2 = ethers.parseEther('20'); + const destinationNetwork = networkIDRollup; + const destinationAddress1 = acc1.address; + const destinationAddress2 = acc2.address; + const metadata = metadataToken; + + // Transfer tokens to bridge + await polTokenContract.transfer(sovereignChainBridgeContract.target, amount1 + amount2); + + // Compute global indexes + const globalIndex1 = computeGlobalIndex(0, 0, false); + const globalIndex2 = computeGlobalIndex(1, 0, false); + + // Execute first phantom claim for different leaf + await sovereignChainBridgeContract.connect(deployer).phantomClaimAsset( + globalIndex1, + originNetwork, + tokenAddress, + destinationNetwork, + destinationAddress1, + amount1, + metadata, + false, // overridePhantomGlobalIndex + ); + + // Execute second phantom claim for different leaf + await sovereignChainBridgeContract.connect(deployer).phantomClaimAsset( + globalIndex2, + originNetwork, + tokenAddress, + destinationNetwork, + destinationAddress2, + amount2, + metadata, + false, // overridePhantomGlobalIndex + ); + + // Check both phantom claims recorded + const leafHash1 = ethers.solidityPackedKeccak256( + ['uint8', 'uint32', 'address', 'uint32', 'address', 'uint256', 'bytes32'], + [ + LEAF_TYPE_ASSET, + originNetwork, + tokenAddress, + destinationNetwork, + destinationAddress1, + amount1, + ethers.keccak256(metadata), + ], + ); + const leafHash2 = ethers.solidityPackedKeccak256( + ['uint8', 'uint32', 'address', 'uint32', 'address', 'uint256', 'bytes32'], + [ + LEAF_TYPE_ASSET, + originNetwork, + tokenAddress, + destinationNetwork, + destinationAddress2, + amount2, + ethers.keccak256(metadata), + ], + ); + + expect(await sovereignChainBridgeContract.phantomClaimMap(leafHash1)).to.equal(1); + expect(await sovereignChainBridgeContract.phantomClaimMap(leafHash2)).to.equal(1); + + // Compute wrapped token address + const wrappedTokenAddress = await computeWrappedTokenProxyAddress( + originNetwork, + tokenAddress as string, + sovereignChainBridgeContract, + false, + ); + const tokenWrappedFactory = await ethers.getContractFactory('TokenWrapped'); + const wrappedToken = tokenWrappedFactory.attach(wrappedTokenAddress) as TokenWrapped; + + // Check wrapped tokens transferred to both addresses + expect(await wrappedToken.balanceOf(destinationAddress1)).to.equal(amount1); + expect(await wrappedToken.balanceOf(destinationAddress2)).to.equal(amount2); + }); + + it('Should allow multiple phantom claims for same leaf (counter increments)', async () => { + const originNetwork = networkIDMainnet; + const tokenAddress = polTokenContract.target; + const amount = ethers.parseEther('10'); + const destinationNetwork = networkIDRollup; + const destinationAddress = acc1.address; + const metadata = metadataToken; + const globalIndex = computeGlobalIndex(0, 0, false); + + // Transfer tokens to bridge (3x amount for 3 phantom claims) + await polTokenContract.transfer(sovereignChainBridgeContract.target, amount * 3n); + + const leafHash = ethers.solidityPackedKeccak256( + ['uint8', 'uint32', 'address', 'uint32', 'address', 'uint256', 'bytes32'], + [ + LEAF_TYPE_ASSET, + originNetwork, + tokenAddress, + destinationNetwork, + destinationAddress, + amount, + ethers.keccak256(metadata), + ], + ); + + // Execute first phantom claim for the same leaf (counter should increment) + await sovereignChainBridgeContract.connect(deployer).phantomClaimAsset( + globalIndex, + originNetwork, + tokenAddress, + destinationNetwork, + destinationAddress, + amount, + metadata, + false, // overridePhantomGlobalIndex + ); + // Verify phantom claim counter incremented to 1 + expect(await sovereignChainBridgeContract.phantomClaimMap(leafHash)).to.equal(1); + }); + + it('Should revert when trying to set same globalIndex with different leaf without override', async () => { + const originNetwork = networkIDMainnet; + const tokenAddress = polTokenContract.target; + const amount1 = ethers.parseEther('10'); + const amount2 = ethers.parseEther('20'); // Different amount = different leaf + const destinationNetwork = networkIDRollup; + const destinationAddress = acc1.address; + const metadata = metadataToken; + const globalIndex = computeGlobalIndex(0, 0, false); + + // Transfer tokens to bridge + await polTokenContract.transfer(sovereignChainBridgeContract.target, amount1 + amount2); + + // Execute first phantom claim + await sovereignChainBridgeContract.connect(deployer).phantomClaimAsset( + globalIndex, + originNetwork, + tokenAddress, + destinationNetwork, + destinationAddress, + amount1, + metadata, + false, // overridePhantomGlobalIndex = false + ); + + // Try to execute second phantom claim with same globalIndex but different amount (different leaf) + // Should revert with PhantomGlobalIndexInvalid error + await expect( + sovereignChainBridgeContract.connect(deployer).phantomClaimAsset( + globalIndex, // Same global index + originNetwork, + tokenAddress, + destinationNetwork, + destinationAddress, + amount2, // Different amount = different leaf + metadata, + false, // overridePhantomGlobalIndex = false (no override allowed) + ), + ).to.be.revertedWithCustomError(sovereignChainBridgeContract, 'PhantomGlobalIndexInvalid'); + }); + + it('Should allow override of globalIndex to leaf mapping when override flag is true', async () => { + const originNetwork = networkIDMainnet; + const tokenAddress = polTokenContract.target; + const amount1 = ethers.parseEther('10'); + const amount2 = ethers.parseEther('20'); // Different amount = different leaf + const destinationNetwork = networkIDRollup; + const destinationAddress = acc1.address; + const metadata = metadataToken; + const globalIndex = computeGlobalIndex(0, 0, false); + + // Transfer tokens to bridge + await polTokenContract.transfer(sovereignChainBridgeContract.target, amount1 + amount2); + + // Execute first phantom claim + await sovereignChainBridgeContract.connect(deployer).phantomClaimAsset( + globalIndex, + originNetwork, + tokenAddress, + destinationNetwork, + destinationAddress, + amount1, + metadata, + false, // overridePhantomGlobalIndex = false + ); + + // Check first mapping is set + const leafValue1 = getLeafValue( + LEAF_TYPE_ASSET, + originNetwork, + tokenAddress, + destinationNetwork, + destinationAddress, + amount1, + ethers.keccak256(metadata), + ); + expect(await sovereignChainBridgeContract.phantomGlobalIndexToLeaf(globalIndex)).to.equal(leafValue1); + + // Execute second phantom claim with same globalIndex but different amount (different leaf) + // This time with override = true, should succeed + await sovereignChainBridgeContract.connect(deployer).phantomClaimAsset( + globalIndex, // Same global index + originNetwork, + tokenAddress, + destinationNetwork, + destinationAddress, + amount2, // Different amount = different leaf + metadata, + true, // overridePhantomGlobalIndex = true (override allowed) + ); + + // Check mapping was overridden to new leaf + const leafValue2 = getLeafValue( + LEAF_TYPE_ASSET, + originNetwork, + tokenAddress, + destinationNetwork, + destinationAddress, + amount2, + ethers.keccak256(metadata), + ); + expect(await sovereignChainBridgeContract.phantomGlobalIndexToLeaf(globalIndex)).to.equal(leafValue2); + }); + + it('Should handle native ETH phantom claims', async () => { + const originNetwork = networkIDMainnet; + const tokenAddress = ethers.ZeroAddress; // Native ETH + const amount = ethers.parseEther('10'); + const destinationNetwork = networkIDRollup; + const destinationAddress = acc1.address; + const metadata = '0x'; // ETH doesn't need metadata + const globalIndex = computeGlobalIndex(0, 0, false); + + // Fund bridge with ETH using hardhat special RPC + await ethers.provider.send('hardhat_setBalance', [ + sovereignChainBridgeContract.target as string, + ethers.toQuantity(amount), + ]); + + // Check initial balance + const initialBalance = await ethers.provider.getBalance(destinationAddress); + + // Execute phantom claim for native ETH + const tx = await sovereignChainBridgeContract.connect(deployer).phantomClaimAsset( + globalIndex, + originNetwork, + tokenAddress, + destinationNetwork, + destinationAddress, + amount, + metadata, + false, // overridePhantomGlobalIndex + ); + + // Verify PhantomClaim event emitted + await expect(tx) + .to.emit(sovereignChainBridgeContract, 'PhantomClaim') + .withArgs( + globalIndex, + LEAF_TYPE_ASSET, + originNetwork, + tokenAddress, + destinationNetwork, + destinationAddress, + amount, + metadata, + ); + + // Check ETH transferred to destination address + const finalBalance = await ethers.provider.getBalance(destinationAddress); + expect(finalBalance - initialBalance).to.equal(amount); + + // Check phantom claim count incremented + const leafHash = ethers.solidityPackedKeccak256( + ['uint8', 'uint32', 'address', 'uint32', 'address', 'uint256', 'bytes32'], + [ + LEAF_TYPE_ASSET, + originNetwork, + tokenAddress, + destinationNetwork, + destinationAddress, + amount, + ethers.keccak256(metadata), + ], + ); + expect(await sovereignChainBridgeContract.phantomClaimMap(leafHash)).to.equal(1); + }); + }); + + describe('Security Tests', () => { + it('Should prevent reentrancy during phantom claim', async () => { + // This test would require a malicious contract that attempts reentrancy + // The nonReentrant modifier should prevent this + // TODO: Implement with a malicious contract if needed + }); + + it('Should prevent claims during emergency state', async () => { + const originNetwork = networkIDMainnet; + const tokenAddress = polTokenContract.target; + const amount = ethers.parseEther('10'); + const destinationNetwork = networkIDRollup; + const destinationAddress = acc1.address; + const metadata = metadataToken; + + // Transfer tokens and setup merkle tree + await polTokenContract.transfer(sovereignChainBridgeContract.target, amount); + + const merkleTree = new MerkleTreeBridge(32); + const leafValue = getLeafValue( + LEAF_TYPE_ASSET, + originNetwork, + tokenAddress, + destinationNetwork, + destinationAddress, + amount, + ethers.keccak256(metadata), + ); + merkleTree.add(leafValue); + const rootLocalExitRoot = merkleTree.getRoot(); + + const rollupMerkleTree = new MerkleTreeBridge(32); + rollupMerkleTree.add(rootLocalExitRoot); + const rootRollupExitRoot = rollupMerkleTree.getRoot(); + + // Update global exit root - use bridge impersonation + await ethers.provider.send('hardhat_impersonateAccount', [sovereignChainBridgeContract.target]); + await ethers.provider.send('hardhat_setBalance', [ + sovereignChainBridgeContract.target, + ethers.toQuantity(ethers.parseEther('10')), + ]); + const bridgeMock = await ethers.getSigner(sovereignChainBridgeContract.target as any); + await sovereignChainGlobalExitRootContract.connect(bridgeMock).updateExitRoot(rootRollupExitRoot); + + // Insert global exit root for claim verification + const mainnetExitRoot = ethers.ZeroHash; + const computedGlobalExitRoot = calculateGlobalExitRoot(mainnetExitRoot, rootRollupExitRoot); + await sovereignChainGlobalExitRootContract.insertGlobalExitRoot(computedGlobalExitRoot); + + const globalIndex = computeGlobalIndex(0, 0, false); + + // Execute phantom claim before activating emergency state + await sovereignChainBridgeContract.connect(deployer).phantomClaimAsset( + globalIndex, + originNetwork, + tokenAddress, + destinationNetwork, + destinationAddress, + amount, + metadata, + false, // overridePhantomGlobalIndex + ); + + // Activate emergency state to test claim prevention + await sovereignChainBridgeContract.connect(emergencyBridgePauser).activateEmergencyState(); + + // Try to claim during emergency - should fail + const proofLocalExitRoot = merkleTree.getProofTreeByIndex(0); + const proofRollupExitRoot = rollupMerkleTree.getProofTreeByIndex(0); + const rollupExitRoot = rootRollupExitRoot; + + await expect( + sovereignChainBridgeContract.claimAsset( + proofLocalExitRoot, + proofRollupExitRoot, + globalIndex, + mainnetExitRoot, + rollupExitRoot, + originNetwork, + tokenAddress, + destinationNetwork, + destinationAddress, + amount, + metadata, + ), + ).to.be.revertedWithCustomError(sovereignChainBridgeContract, 'OnlyNotEmergencyState'); + }); + }); +});