Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 38 additions & 23 deletions examples/nft/contracts/Connected.sol
Original file line number Diff line number Diff line change
Expand Up @@ -4,90 +4,104 @@ pragma solidity 0.8.26;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Burnable.sol";
import "@openzeppelin/contracts/access/Ownable2Step.sol";
import "@zetachain/protocol-contracts/contracts/evm/GatewayEVM.sol";
import {RevertContext} from "@zetachain/protocol-contracts/contracts/Revert.sol";

contract Connected is ERC721, ERC721Enumerable, ERC721URIStorage, Ownable {
import "./shared/Events.sol";

contract Connected is
ERC721,
ERC721Enumerable,
ERC721URIStorage,
Ownable2Step,
Events
{
GatewayEVM public immutable gateway;
uint256 private _nextTokenId;
address public counterparty;

error InvalidAddress();
error Unauthorized();

function setCounterparty(address contractAddress) external onlyOwner {
if (contractAddress == address(0)) revert InvalidAddress();
counterparty = contractAddress;
emit SetCounterparty(contractAddress);
}

modifier onlyGateway() {
require(msg.sender == address(gateway), "Caller is not the gateway");
if (msg.sender != address(gateway)) revert Unauthorized();
_;
}

constructor(
address payable gatewayAddress,
address initialOwner
) ERC721("MyToken", "MTK") Ownable(initialOwner) {
address owner,
string memory name,
string memory symbol
) ERC721(name, symbol) Ownable(owner) {
if (gatewayAddress == address(0) || owner == address(0))
revert InvalidAddress();
gateway = GatewayEVM(gatewayAddress);
}

function safeMint(address to, string memory uri) public onlyOwner {
uint256 hash = uint256(
keccak256(
abi.encodePacked(address(this), block.number, _nextTokenId++)
)
);
if (to == address(0)) revert InvalidAddress();

uint256 tokenId = hash & 0x00FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF;
uint256 tokenId = _nextTokenId++;

_safeMint(to, tokenId);
_setTokenURI(tokenId, uri);
emit TokenMinted(to, tokenId, uri);
}

function transferCrossChain(
uint256 tokenId,
address receiver,
address destination
) external payable {
if (receiver == address(0)) revert InvalidAddress();

string memory uri = tokenURI(tokenId);
_burn(tokenId);
bytes memory encodedData = abi.encode(
tokenId,
receiver,
uri,
destination
);
bytes memory message = abi.encode(tokenId, receiver, uri, destination);

RevertOptions memory revertOptions = RevertOptions(
address(this),
true,
address(0),
encodedData,
message,
0
);

if (destination == address(0)) {
gateway.call(counterparty, encodedData, revertOptions);
gateway.call(counterparty, message, revertOptions);
} else {
gateway.depositAndCall{value: msg.value}(
counterparty,
encodedData,
message,
revertOptions
);
}

emit TokenTransfer(tokenId, receiver, destination, uri);
}

function onCall(
MessageContext calldata messageContext,
MessageContext calldata context,
bytes calldata message
) external payable onlyGateway returns (bytes4) {
if (messageContext.sender != counterparty) revert("Unauthorized");
if (context.sender != counterparty) revert Unauthorized();

(uint256 tokenId, address receiver, string memory uri) = abi.decode(
message,
(uint256, address, string)
);

_safeMint(receiver, tokenId);
_setTokenURI(tokenId, uri);
emit TokenTransferReceived(tokenId, receiver, uri);
return "";
}

Expand All @@ -99,6 +113,7 @@ contract Connected is ERC721, ERC721Enumerable, ERC721URIStorage, Ownable {

_safeMint(sender, tokenId);
_setTokenURI(tokenId, uri);
emit TokenTransferReverted(tokenId, sender, uri);
}

receive() external payable {}
Expand Down
44 changes: 30 additions & 14 deletions examples/nft/contracts/Universal.sol
Original file line number Diff line number Diff line change
Expand Up @@ -4,45 +4,55 @@ pragma solidity ^0.8.26;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Burnable.sol";
import "@openzeppelin/contracts/access/Ownable2Step.sol";
import {RevertContext, RevertOptions} from "@zetachain/protocol-contracts/contracts/Revert.sol";
import "@zetachain/protocol-contracts/contracts/zevm/interfaces/UniversalContract.sol";
import "@zetachain/protocol-contracts/contracts/zevm/interfaces/IGatewayZEVM.sol";
import "@zetachain/protocol-contracts/contracts/zevm/GatewayZEVM.sol";
import {SwapHelperLib} from "@zetachain/toolkit/contracts/SwapHelperLib.sol";
import {SystemContract} from "@zetachain/toolkit/contracts/SystemContract.sol";
import "./shared/Events.sol";

contract Universal is
ERC721,
ERC721Enumerable,
ERC721URIStorage,
Ownable,
UniversalContract
Ownable2Step,
UniversalContract,
Events
{
GatewayZEVM public immutable gateway;
SystemContract public immutable systemContract =
SystemContract(0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9);
uint256 private _nextTokenId;
bool public isUniversal = true;
uint256 public gasLimit = 700000;
uint256 public gasLimit;

error TransferFailed();
error Unauthorized();
error InvalidAddress();
error InvalidGasLimit();

mapping(address => bytes) public counterparty;

event CounterpartySet(address indexed zrc20, bytes indexed contractAddress);

modifier onlyGateway() {
require(msg.sender == address(gateway), "Caller is not the gateway");
if (msg.sender != address(gateway)) revert Unauthorized();
_;
}

constructor(
address payable gatewayAddress,
address initialOwner
) ERC721("MyToken", "MTK") Ownable(initialOwner) {
address owner,
string memory name,
string memory symbol,
uint256 gas
) ERC721(name, symbol) Ownable(owner) {
if (gatewayAddress == address(0) || owner == address(0))
revert InvalidAddress();
if (gas == 0) revert InvalidGasLimit();
gateway = GatewayZEVM(gatewayAddress);
gasLimit = gas;
}

function setCounterparty(
Expand All @@ -58,6 +68,7 @@ contract Universal is
address receiver,
address destination
) public {
if (receiver == address(0)) revert InvalidAddress();
string memory uri = tokenURI(tokenId);
_burn(tokenId);

Expand All @@ -68,25 +79,27 @@ contract Universal is
!IZRC20(destination).transferFrom(msg.sender, address(this), gasFee)
) revert TransferFailed();
IZRC20(destination).approve(address(gateway), gasFee);
bytes memory encodedData = abi.encode(tokenId, receiver, uri);
bytes memory message = abi.encode(tokenId, receiver, uri);

CallOptions memory callOptions = CallOptions(gasLimit, false);

RevertOptions memory revertOptions = RevertOptions(
address(this),
true,
address(0),
encodedData,
message,
gasLimit
);

gateway.call(
counterparty[destination],
destination,
encodedData,
message,
callOptions,
revertOptions
);

emit TokenTransfer(tokenId, receiver, destination, uri);
}

function safeMint(address to, string memory uri) public onlyOwner {
Expand Down Expand Up @@ -121,9 +134,10 @@ contract Universal is
if (destination == address(0)) {
_safeMint(sender, tokenId);
_setTokenURI(tokenId, uri);
emit TokenTransferReceived(tokenId, sender, uri);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codebase verification

Reentrancy protection is recommended for external calls

The onCall function in Universal.sol contains external calls through gateway.call after state modifications (_safeMint, _setTokenURI). While the function is protected by onlyGateway modifier, implementing the following safeguards is recommended:

  • Add OpenZeppelin's ReentrancyGuard and use nonReentrant modifier
  • Alternatively, strictly follow checks-effects-interactions pattern by:
    • Moving the TokenTransferReceived event emission after all state changes
    • Ensuring gateway.call is the last operation in all execution paths

Example implementation:

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

contract Universal is ..., ReentrancyGuard {
    function onCall(...) external override onlyGateway nonReentrant {
        // existing implementation
    }
}
🔗 Analysis chain

Consider reentrancy protection for external calls.

The function makes external calls to swapExactTokensForTokens and gateway.call. Consider implementing a reentrancy guard or ensuring proper checks-effects-interactions pattern.

Also applies to: 140-140, 156-156, 159-159

🏁 Scripts executed

The following scripts were executed for the analysis:

Script:

#!/bin/bash
# Check for ReentrancyGuard usage in the codebase
ast-grep --pattern 'contract $_ is $$$, ReentrancyGuard, $$$ {'

Length of output: 65


Script:

#!/bin/bash
# Let's check for ReentrancyGuard imports and usage with different patterns
rg -i "reentrancy|ReentrancyGuard" -A 3

# Check the specific onCall function implementation
ast-grep --pattern 'function onCall($$$) {
  $$$
}'

# Check for nonReentrant modifier usage
ast-grep --pattern 'nonReentrant'

# Look for the gateway.call pattern to understand the external call context
rg "gateway.*call" -A 3

Length of output: 1514


Script:

#!/bin/bash
# Let's examine the full context of the onCall function to understand the control flow
rg "function onCall" -A 20

# Check for existing modifiers on the contract and function
ast-grep --pattern 'function onCall($$$) $_ {
  $$$
}'

# Look for existing checks-effects-interactions pattern
rg "require\(.*\)" -A 2 examples/nft/contracts/Universal.sol

Length of output: 7522

} else {
(, uint256 gasFee) = IZRC20(destination).withdrawGasFeeWithGasLimit(
700000
gasLimit
);

SwapHelperLib.swapExactTokensForTokens(
Expand All @@ -139,9 +153,10 @@ contract Universal is
counterparty[destination],
destination,
abi.encode(tokenId, sender, uri),
CallOptions(700000, false),
CallOptions(gasLimit, false),
RevertOptions(address(0), false, address(0), "", 0)
);
emit TokenTransferToDestination(tokenId, sender, destination, uri);
}
}

Expand All @@ -153,6 +168,7 @@ contract Universal is

_safeMint(sender, tokenId);
_setTokenURI(tokenId, uri);
emit TokenTransferReverted(tokenId, sender, uri);
}

// The following functions are overrides required by Solidity.
Expand Down
30 changes: 30 additions & 0 deletions examples/nft/contracts/shared/Events.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;

contract Events {
event SetCounterparty(address indexed newCounterparty);
event TokenMinted(address indexed to, uint256 indexed tokenId, string uri);
event TokenTransfer(
uint256 indexed tokenId,
address indexed receiver,
address indexed destination,
string uri
);
event TokenTransferReceived(
uint256 indexed tokenId,
address indexed receiver,
string uri
);
event TokenTransferReverted(
uint256 indexed tokenId,
address indexed sender,
string uri
);
event CounterpartySet(address indexed zrc20, bytes indexed contractAddress);
event TokenTransferToDestination(
uint256 indexed tokenId,
address indexed sender,
address indexed destination,
string uri
);
}
26 changes: 12 additions & 14 deletions examples/nft/scripts/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,16 @@

set -e

if [ "$1" = "localnet" ]; then
npx hardhat localnet --exit-on-error & sleep 10
fi
if [ "$1" = "localnet" ]; then npx hardhat localnet --exit-on-error & sleep 10; fi

function nft_balance() {
function balance() {
local ZETACHAIN=$(cast call "$CONTRACT_ZETACHAIN" "balanceOf(address)(uint256)" "$SENDER")
local ETHEREUM=$(cast call "$CONTRACT_ETHEREUM" "balanceOf(address)(uint256)" "$SENDER")
local BNB=$(cast call "$CONTRACT_BNB" "balanceOf(address)(uint256)" "$SENDER")
echo -e "\n🖼️ NFT Balance"
echo "---------------------------------------------"
echo "🟢 ZetaChain: $ZETACHAIN"
echo "🔵 EVM Chain: $ETHEREUM"
echo "🔵 Ethereum: $ETHEREUM"
echo "🟡 BNB Chain: $BNB"
echo "---------------------------------------------"
}
Expand All @@ -27,11 +25,11 @@ GATEWAY_ETHEREUM=$(jq -r '.addresses[] | select(.type=="gatewayEVM" and .chain==
GATEWAY_BNB=$(jq -r '.addresses[] | select(.type=="gatewayEVM" and .chain=="bnb") | .address' localnet.json)
SENDER=0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266

CONTRACT_ZETACHAIN=$(npx hardhat deploy --network localhost --json | jq -r '.contractAddress')
CONTRACT_ZETACHAIN=$(npx hardhat deploy --network localhost --json --gas-limit 1000000 | jq -r '.contractAddress')
echo -e "\n🚀 Deployed NFT contract on ZetaChain: $CONTRACT_ZETACHAIN"

CONTRACT_ETHEREUM=$(npx hardhat deploy --name Connected --json --network localhost --gateway "$GATEWAY_ETHEREUM" | jq -r '.contractAddress')
echo -e "🚀 Deployed NFT contract on EVM chain: $CONTRACT_ETHEREUM"
echo -e "🚀 Deployed NFT contract on Ethereum: $CONTRACT_ETHEREUM"

CONTRACT_BNB=$(npx hardhat deploy --name Connected --json --network localhost --gateway "$GATEWAY_BNB" | jq -r '.contractAddress')
echo -e "🚀 Deployed NFT contract on BNB chain: $CONTRACT_BNB"
Expand All @@ -45,30 +43,30 @@ npx hardhat universal-set-counterparty --network localhost --contract "$CONTRACT
npx hardhat universal-set-counterparty --network localhost --contract "$CONTRACT_ZETACHAIN" --counterparty "$CONTRACT_BNB" --zrc20 "$ZRC20_BNB" --json &>/dev/null

npx hardhat localnet-check
nft_balance
balance

NFT_ID=$(npx hardhat mint --network localhost --json --contract "$CONTRACT_ZETACHAIN" --token-uri https://example.com/nft/metadata/1 | jq -r '.tokenId')
echo -e "\nMinted NFT with ID: $NFT_ID on ZetaChain."

npx hardhat localnet-check
nft_balance
balance

echo -e "\nTransferring NFT: ZetaChain → Ethereum..."
npx hardhat transfer --network localhost --json --token-id "$NFT_ID" --from "$CONTRACT_ZETACHAIN" --to "$ZRC20_ETHEREUM"
npx hardhat transfer --network localhost --json --token-id "$NFT_ID" --from "$CONTRACT_ZETACHAIN" --to "$ZRC20_ETHEREUM"

npx hardhat localnet-check
nft_balance
balance

echo -e "\nTransferring NFT: Ethereum → BNB..."
npx hardhat transfer --network localhost --json --token-id "$NFT_ID" --from "$CONTRACT_ETHEREUM" --to "$ZRC20_BNB" --gas-amount 0.1

npx hardhat localnet-check
nft_balance
balance

echo -e "\nTransferring NFT: BNB → ZetaChain..."
npx hardhat transfer --network localhost --json --token-id "$NFT_ID" --from "$CONTRACT_BNB"

npx hardhat localnet-check
nft_balance
balance

npx hardhat localnet-stop
if [ "$1" = "localnet" ]; then npx hardhat localnet-stop; fi
Loading
Loading