├── .env.example ├── .gitignore ├── LICENSE ├── README.md ├── contracts ├── SyscoinRelay.sol ├── SyscoinVaultManager.sol ├── interfaces │ ├── ISyscoinRelay.sol │ └── ISyscoinTransactionProcessor.sol ├── lib │ └── SyscoinMessageLibrary.sol └── test │ ├── MaliciousReentrant.sol │ ├── MockERC1155.sol │ ├── MockERC20.sol │ └── MockERC721.sol ├── deployments ├── localhost.json ├── syscoin.json └── tanenbaum.json ├── hardhat.config.ts ├── package-lock.json ├── package.json ├── scripts ├── deploy.ts └── verify.ts ├── test ├── MaliciousReentrant.ts ├── SyscoinRelay.ts └── SyscoinVaultManager.ts ├── tsconfig.json └── yarn.lock /.env.example: -------------------------------------------------------------------------------- 1 | MNEMONIC= -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | 4 | # Hardhat files 5 | /cache 6 | /artifacts 7 | 8 | # TypeChain files 9 | /typechain 10 | /typechain-types 11 | 12 | # solidity-coverage files 13 | /coverage 14 | /coverage.json 15 | 16 | # Hardhat Ignition default folder for deployments against a local node 17 | ignition/deployments/chain-31337 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Coinfabrik & Oscar Guindzberg 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **Sysethereum** – NEVM Contracts for the Syscoin <=> NEVM Bridge 2 | 3 | [![Build Status](https://travis-ci.com/syscoin/sysethereum-contracts.svg?branch=master)](https://travis-ci.com/syscoin/sysethereum-contracts) 4 | 5 | --- 6 | 7 | ## Table of Contents 8 | 9 | 1. [Introduction](#introduction) 10 | 2. [Architecture & Core Components](#architecture--core-components) 11 | 3. [Prerequisites & Installation](#prerequisites--installation) 12 | 4. [Running the Tests](#running-the-tests) 13 | 5. [Deployment](#deployment) 14 | 15 | --- 16 | 17 | ## Introduction 18 | 19 | **Sysethereum** is a set of smart contracts on the [NEVM (Syscoin’s EVM layer)](https://syscoin.org) that implements a _decentralized_ bridge between the **Syscoin UTXO blockchain** and the **NEVM**. It allows assets (Syscoin Platform Tokens or plain SYS) to move seamlessly between the two worlds. 20 | 21 | --- 22 | 23 | ## Architecture & Core Components 24 | 25 | ### SyscoinRelay 26 | 27 | - `SyscoinRelay.sol` is responsible for: 28 | - Verifying Syscoin blocks, transactions, and **Merkle proofs** on the NEVM side. 29 | - Informing the Vault Manager (`SyscoinVaultManager.sol`) when a Syscoin transaction has locked or unlocked funds on the UTXO chain. 30 | 31 | ### SyscoinVaultManager 32 | 33 | - `SyscoinVaultManager.sol` is responsible for: 34 | - **Holding deposits** or transferring tokens on the NEVM side. 35 | - Minting or transferring tokens when coins are locked on Syscoin UTXO side (UTXO -> NEVM). 36 | - Burning or locking tokens on NEVM when coins move back to Syscoin (NEVM -> UTXO). 37 | - Potential bridging for **ERC20**, **ERC721**, **ERC1155**, or native SYS. 38 | 39 | ### SyscoinMessageLibrary / SyscoinParser 40 | 41 | - A library used to parse and handle Syscoin transaction bytes, block headers, merkle proofs, etc. 42 | - Provides functions like `parseVarInt`, `parseCompactSize`, and big-endian / little-endian conversions. 43 | 44 | ### Additional Contracts 45 | 46 | - **Test Mocks** (e.g., `MockERC20.sol`, `MockERC721.sol`, `MockERC1155.sol`) – used to test bridging flows in local test environments. 47 | - **MaliciousReentrant** – a test contract that verifies the bridge’s `nonReentrant` safety. 48 | 49 | --- 50 | 51 | ## Prerequisites & Installation 52 | 53 | ### 1. System Requirements 54 | 55 | - **Node.js** v16 or later 56 | - **NPM** or **Yarn** – for installing JavaScript dependencies 57 | - **Hardhat** - for development, testing, and deployment 58 | 59 | ### 2. Cloning the Repository 60 | 61 | ```bash 62 | git clone https://github.com/syscoin/sysethereum-contracts.git 63 | cd sysethereum-contracts 64 | ``` 65 | 66 | ### 3. Install Dependencies 67 | 68 | ```bash 69 | npm install 70 | ``` 71 | 72 | ## Development 73 | 74 | ### 1. Compile Contracts 75 | 76 | ```bash 77 | npx hardhat clean 78 | npx hardhat compile 79 | ``` 80 | 81 | ### 2. Running a Local Node 82 | 83 | ```bash 84 | # Start a local Hardhat node 85 | npx hardhat node 86 | ``` 87 | 88 | ### 3. Run Tests (must deploy local node first) 89 | 90 | ```bash 91 | # Run all tests 92 | npx hardhat test 93 | 94 | ``` 95 | 96 | ## Deployment 97 | 98 | ### 1. Local Deployment 99 | 100 | ```bash 101 | # Deploy to local Hardhat network 102 | npx hardhat run scripts/deploy.ts --network localhost 103 | ``` 104 | 105 | ### 2. Syscoin Mainnet Deployment 106 | 107 | ```bash 108 | # Set your seed in .env file first 109 | # MNEMONIC=your_seed_phrase_here 110 | 111 | # Deploy and verify Syscoin mainnet 112 | npx hardhat run scripts/deploy.ts --network syscoin 113 | npx hardhat verify scripts/verify.ts --network syscoin 114 | ``` 115 | -------------------------------------------------------------------------------- /contracts/SyscoinRelay.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.20; 3 | 4 | import "./interfaces/ISyscoinRelay.sol"; 5 | import "./interfaces/ISyscoinTransactionProcessor.sol"; 6 | import "./lib/SyscoinMessageLibrary.sol"; 7 | 8 | contract SyscoinRelay is ISyscoinRelay, SyscoinMessageLibrary { 9 | bool public initialized = false; 10 | uint32 constant SYSCOIN_TX_VERSION_ALLOCATION_BURN_TO_NEVM = 141; 11 | uint constant ERR_INVALID_HEADER = 10000; 12 | uint constant ERR_INVALID_HEADER_HASH = 10010; 13 | uint constant ERR_PARSE_TX_SYS = 10020; 14 | uint constant ERR_MERKLE_ROOT = 10030; 15 | uint constant ERR_TX_64BYTE = 10040; 16 | uint constant ERR_TX_VERIFICATION_FAILED = 10040; 17 | uint constant ERR_OP_RETURN_PARSE_FAILED = 10050; 18 | bytes1 constant OP_PUSHDATA1 = 0x4c; 19 | bytes1 constant OP_PUSHDATA2 = 0x4d; 20 | 21 | address internal constant SYSBLOCKHASH_PRECOMPILE_ADDRESS = address(0x61); 22 | uint16 internal constant SYSBLOCKHASH_PRECOMPILE_COST = 200; 23 | 24 | ISyscoinTransactionProcessor public syscoinVaultManager; 25 | 26 | event VerifyTransaction(bytes32 txHash, uint returnCode); 27 | event RelayTransaction(bytes32 txHash, uint returnCode); 28 | 29 | function init(address _syscoinVaultManager) external { 30 | require(!initialized, "Already initialized"); 31 | require(_syscoinVaultManager != address(0), "Invalid address"); 32 | syscoinVaultManager = ISyscoinTransactionProcessor( 33 | _syscoinVaultManager 34 | ); 35 | initialized = true; 36 | } 37 | 38 | // Returns true if the tx output is an OP_RETURN output 39 | function isOpReturn( 40 | bytes memory txBytes, 41 | uint pos 42 | ) internal pure returns (bool) { 43 | // scriptPub format is 44 | // 0x6a OP_RETURN 45 | return txBytes[pos] == bytes1(0x6a); 46 | } 47 | 48 | function DecompressAmount(uint64 x) internal pure returns (uint64) { 49 | if (x == 0) return 0; 50 | x--; 51 | uint64 e = x % 10; 52 | x /= 10; 53 | uint64 n = 0; 54 | if (e < 9) { 55 | uint64 d = (x % 9) + 1; 56 | x /= 9; 57 | n = x * 10 + d; 58 | } else { 59 | n = x + 1; 60 | } 61 | while (e > 0) { 62 | n *= 10; 63 | e--; 64 | } 65 | return n; 66 | } 67 | 68 | function parseFirstAssetCommitmentInTx( 69 | bytes memory txBytes, 70 | uint pos, 71 | int opIndex 72 | ) internal pure returns (uint, uint64, uint) { 73 | uint numAssets; 74 | uint assetGuid; 75 | uint64 assetGuid64; 76 | uint bytesToRead; 77 | uint numOutputs; 78 | uint output_value; 79 | uint maxVal = 2 ** 64; 80 | (numAssets, pos) = parseCompactSize(txBytes, pos); 81 | 82 | for (uint assetIndex = 0; assetIndex < numAssets; assetIndex++) { 83 | if (assetIndex == 0) { 84 | (assetGuid, pos) = parseVarInt(txBytes, pos, maxVal); 85 | assetGuid64 = uint64(assetGuid); 86 | (numOutputs, pos) = parseCompactSize(txBytes, pos); 87 | for (uint i = 0; i < numOutputs; i++) { 88 | (bytesToRead, pos) = parseCompactSize(txBytes, pos); 89 | if (int(bytesToRead) == opIndex) { 90 | (output_value, pos) = parseVarInt(txBytes, pos, maxVal); 91 | } else { 92 | (, pos) = parseVarInt(txBytes, pos, maxVal); 93 | } 94 | } 95 | if (opIndex >= 0) { 96 | require( 97 | output_value > 0, 98 | "#SyscoinRelay parseFirstAssetCommitmentInTx(): output index not found" 99 | ); 100 | output_value = DecompressAmount(uint64(output_value)); 101 | } 102 | } else { 103 | (, pos) = parseVarInt(txBytes, pos, maxVal); 104 | (numOutputs, pos) = parseCompactSize(txBytes, pos); 105 | for (uint i = 0; i < numOutputs; i++) { 106 | (, pos) = parseCompactSize(txBytes, pos); 107 | (, pos) = parseVarInt(txBytes, pos, maxVal); 108 | } 109 | } 110 | } 111 | return (output_value, assetGuid64, pos); 112 | } 113 | 114 | // parse the burn transaction to find output_value, destination address, and assetGuid 115 | function scanBurnTx( 116 | bytes memory txBytes, 117 | uint opIndex, 118 | uint pos 119 | ) 120 | public 121 | pure 122 | returns ( 123 | uint output_value, 124 | address destinationAddress, 125 | uint64 assetGuid 126 | ) 127 | { 128 | uint numBytesInAddress; 129 | (output_value, assetGuid, pos) = parseFirstAssetCommitmentInTx( 130 | txBytes, 131 | pos, 132 | int(opIndex) 133 | ); 134 | // Now read the next opcode for the Ethereum address 135 | (numBytesInAddress, pos) = getOpcode(txBytes, pos); 136 | require( 137 | numBytesInAddress == 0x14, 138 | "#SyscoinRelay scanBurnTx(): Invalid destinationAddress" 139 | ); 140 | destinationAddress = readEthereumAddress(txBytes, pos); 141 | } 142 | 143 | function readEthereumAddress( 144 | bytes memory txBytes, 145 | uint pos 146 | ) internal pure returns (address) { 147 | uint256 data; 148 | assembly { 149 | data := mload(add(add(txBytes, 20), pos)) 150 | } 151 | return address(uint160(data)); 152 | } 153 | 154 | function getOpcode( 155 | bytes memory txBytes, 156 | uint pos 157 | ) private pure returns (uint8, uint) { 158 | require(pos < txBytes.length, "Out of bounds in getOpcode"); 159 | return (uint8(txBytes[pos]), pos + 1); 160 | } 161 | 162 | function getOpReturnPos( 163 | bytes memory txBytes, 164 | uint pos 165 | ) internal pure returns (uint, uint) { 166 | uint n_inputs; 167 | uint script_len; 168 | uint n_outputs; 169 | 170 | (n_inputs, pos) = parseCompactSize(txBytes, pos); 171 | if (n_inputs == 0x00) { 172 | (n_inputs, pos) = parseCompactSize(txBytes, pos); // flag 173 | require( 174 | n_inputs != 0x00, 175 | "#SyscoinRelay getOpReturnPos(): Unexpected dummy/flag" 176 | ); 177 | (n_inputs, pos) = parseCompactSize(txBytes, pos); 178 | } 179 | require( 180 | n_inputs < 100, 181 | "#SyscoinRelay getOpReturnPos(): Incorrect size of n_inputs" 182 | ); 183 | 184 | for (uint i = 0; i < n_inputs; i++) { 185 | pos += 36; 186 | (script_len, pos) = parseCompactSize(txBytes, pos); 187 | pos += script_len + 4; 188 | } 189 | 190 | (n_outputs, pos) = parseCompactSize(txBytes, pos); 191 | require( 192 | n_outputs < 10, 193 | "#SyscoinRelay getOpReturnPos(): Incorrect size of n_outputs" 194 | ); 195 | 196 | for (uint i = 0; i < n_outputs; i++) { 197 | pos += 8; 198 | (script_len, pos) = parseCompactSize(txBytes, pos); 199 | if (!isOpReturn(txBytes, pos)) { 200 | pos += script_len; 201 | continue; 202 | } 203 | pos += 1; 204 | bytes1 pushDataOp = txBytes[pos]; 205 | if (pushDataOp == OP_PUSHDATA1) { 206 | pos += 2; 207 | } else if (pushDataOp == OP_PUSHDATA2) { 208 | pos += 3; 209 | } else { 210 | pos += 1; 211 | } 212 | return (i, pos); 213 | } 214 | revert("#SyscoinRelay getOpReturnPos(): No OpReturn found"); 215 | } 216 | 217 | function verifyTx( 218 | bytes memory _txBytes, 219 | uint _txIndex, 220 | uint[] memory _siblings, 221 | bytes memory _blockHeaderBytes 222 | ) private returns (uint) { 223 | uint txHash = dblShaFlip(_txBytes); 224 | if (_txBytes.length == 64) { 225 | emit VerifyTransaction(bytes32(txHash), ERR_TX_64BYTE); 226 | return 0; 227 | } 228 | if ( 229 | helperVerifyHash(txHash, _txIndex, _siblings, _blockHeaderBytes) == 230 | 1 231 | ) { 232 | return txHash; 233 | } else { 234 | return 0; 235 | } 236 | } 237 | 238 | function verifySPVProofs( 239 | uint64 _blockNumber, 240 | bytes memory _syscoinBlockHeader, 241 | bytes memory _txBytes, 242 | uint _txIndex, 243 | uint[] memory _txSiblings 244 | ) private returns (uint) { 245 | if (_syscoinBlockHeader.length != 80) { 246 | emit VerifyTransaction(0, ERR_INVALID_HEADER); 247 | return 0; 248 | } 249 | bytes memory input = abi.encodePacked(_blockNumber); 250 | (bool success, bytes memory result) = SYSBLOCKHASH_PRECOMPILE_ADDRESS 251 | .staticcall{gas: SYSBLOCKHASH_PRECOMPILE_COST}(input); 252 | require(success, "SYSBLOCKHASH precompile call failed."); 253 | require( 254 | result.length > 0, 255 | "SYSBLOCKHASH precompile returned empty result." 256 | ); 257 | 258 | // compare precompile result 259 | if (uint256(bytes32(result)) != dblSha(_syscoinBlockHeader)) { 260 | emit VerifyTransaction(0, ERR_INVALID_HEADER_HASH); 261 | return 0; 262 | } 263 | // Now do a normal TX merkle check 264 | uint txHash = verifyTx( 265 | _txBytes, 266 | _txIndex, 267 | _txSiblings, 268 | _syscoinBlockHeader 269 | ); 270 | if (txHash == 0) { 271 | emit VerifyTransaction(0, ERR_TX_VERIFICATION_FAILED); 272 | return 0; 273 | } 274 | return txHash; 275 | } 276 | 277 | function relayTx( 278 | uint64 _blockNumber, 279 | bytes memory _txBytes, 280 | uint _txIndex, 281 | uint[] memory _txSiblings, 282 | bytes memory _syscoinBlockHeader 283 | ) external override returns (uint) { 284 | uint txHash = verifySPVProofs( 285 | _blockNumber, 286 | _syscoinBlockHeader, 287 | _txBytes, 288 | _txIndex, 289 | _txSiblings 290 | ); 291 | require(txHash != 0, "SPV proof verification failed"); 292 | 293 | ( 294 | uint errorCode, 295 | uint value, 296 | address destinationAddress, 297 | uint64 assetGuid 298 | ) = parseBurnTx(_txBytes); 299 | if (errorCode != 0) { 300 | emit RelayTransaction(bytes32(txHash), errorCode); 301 | return errorCode; 302 | } 303 | 304 | // pass assetGuid 305 | syscoinVaultManager.processTransaction( 306 | txHash, 307 | value, 308 | destinationAddress, 309 | assetGuid 310 | ); 311 | return value; 312 | } 313 | 314 | function parseBurnTx( 315 | bytes memory txBytes 316 | ) 317 | public 318 | pure 319 | returns ( 320 | uint errorCode, 321 | uint output_value, 322 | address destinationAddress, 323 | uint64 assetGuid 324 | ) 325 | { 326 | uint32 version; 327 | uint pos = 0; 328 | uint opIndex = 0; 329 | version = bytesToUint32Flipped(txBytes, pos); 330 | if (version != SYSCOIN_TX_VERSION_ALLOCATION_BURN_TO_NEVM) { 331 | return (ERR_PARSE_TX_SYS, 0, address(0), 0); 332 | } 333 | (opIndex, pos) = getOpReturnPos(txBytes, 4); 334 | (output_value, destinationAddress, assetGuid) = scanBurnTx( 335 | txBytes, 336 | opIndex, 337 | pos 338 | ); 339 | return (0, output_value, destinationAddress, assetGuid); 340 | } 341 | 342 | function dblSha(bytes memory _dataBytes) internal pure returns (uint) { 343 | return 344 | uint( 345 | sha256(abi.encodePacked(sha256(abi.encodePacked(_dataBytes)))) 346 | ); 347 | } 348 | 349 | function dblShaFlip(bytes memory _dataBytes) internal pure returns (uint) { 350 | return flip32Bytes(dblSha(_dataBytes)); 351 | } 352 | 353 | function getHeaderMerkleRoot( 354 | bytes memory _blockHeader 355 | ) internal pure returns (uint) { 356 | uint merkle; 357 | assembly { 358 | merkle := mload(add(add(_blockHeader, 32), 0x24)) 359 | } 360 | return flip32Bytes(merkle); 361 | } 362 | 363 | function helperVerifyHash( 364 | uint256 _txHash, 365 | uint _txIndex, 366 | uint[] memory _siblings, 367 | bytes memory _blockHeaderBytes 368 | ) private returns (uint) { 369 | uint merkle = getHeaderMerkleRoot(_blockHeaderBytes); 370 | if (computeMerkle(_txHash, _txIndex, _siblings) != merkle) { 371 | emit VerifyTransaction(bytes32(_txHash), ERR_MERKLE_ROOT); 372 | return ERR_MERKLE_ROOT; 373 | } 374 | return 1; 375 | } 376 | } 377 | -------------------------------------------------------------------------------- /contracts/SyscoinVaultManager.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.20; 3 | 4 | import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; 5 | import "@openzeppelin/contracts/utils/introspection/ERC165Checker.sol"; 6 | import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; 7 | import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; 8 | import "@openzeppelin/contracts/token/ERC721/IERC721.sol"; 9 | import "@openzeppelin/contracts/token/ERC1155/IERC1155.sol"; 10 | import "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; 11 | import "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; 12 | import "./interfaces/ISyscoinTransactionProcessor.sol"; 13 | import "@openzeppelin/contracts/access/Ownable.sol"; 14 | 15 | /** 16 | * @title SyscoinVaultManager 17 | * 18 | * A contract that can handle bridging for: 19 | * - Native SYS (on NEVM) 20 | * - ERC20 (fungible) 21 | * - ERC721 (NFT) 22 | * - ERC1155 (multi-token, both fungible & NFT) 23 | * 24 | * The bridging rules: 25 | * - We store a 64-bit 'assetGuid' where: 26 | * lower 32 bits = 'assetId' (registry ID) 27 | * upper 32 bits = 'tokenIdx' (for NFTs or ERC1155) 28 | * - For ERC20 bridging, 'tokenIdx' = 0 (fungible). 29 | * - For ERC721, bridging 1 token => 'tokenIdx' is new each time or looked up. 30 | * - For ERC1155, bridging 'amount' => 'tokenIdx' references specific tokenId in the contract. 31 | */ 32 | contract SyscoinVaultManager is 33 | ISyscoinTransactionProcessor, 34 | ReentrancyGuard, 35 | ERC1155Holder, 36 | ERC721Holder, 37 | Ownable 38 | { 39 | using SafeERC20 for IERC20Metadata; 40 | 41 | //------------------------------------------------------------------------- 42 | // Enums and Structs 43 | //------------------------------------------------------------------------- 44 | 45 | enum AssetType { 46 | INVALID, 47 | SYS, 48 | ERC20, 49 | ERC721, 50 | ERC1155 51 | } 52 | 53 | struct AssetRegistryItem { 54 | AssetType assetType; 55 | address assetContract; 56 | uint8 precision; 57 | uint32 tokenIdCount; 58 | mapping(uint32 => uint256) tokenRegistry; // tokenIdx => realTokenId 59 | mapping(uint256 => uint32) reverseTokenRegistry; // realTokenId => tokenIdx 60 | } 61 | 62 | // 9,999,999,999.99999999 => ~1e18 satoshis (10B -1) 63 | uint256 constant MAX_SYS_SUPPLY_SATS = 9999999999 * 100000000; 64 | //------------------------------------------------------------------------- 65 | // State Variables 66 | //------------------------------------------------------------------------- 67 | 68 | // The address of the contract (SyscoinRelay) that can call processTransaction 69 | address public trustedRelayerContract; 70 | 71 | // increment for new asset registrations 72 | uint32 public globalAssetIdCount; 73 | 74 | // track processed txHashes => prevent replays 75 | mapping(uint => bool) private syscoinTxAlreadyProcessed; 76 | 77 | // map assetId => registry item 78 | mapping(uint32 => AssetRegistryItem) public assetRegistry; 79 | 80 | // for quick lookup if we have an existing contract => assetId 81 | mapping(address => uint32) public assetRegistryByAddress; 82 | 83 | // The Syscoin asset GUID that references "native" SYS 84 | // if bridging native SYS from NEVM -> UTXO, or vice versa 85 | uint64 public immutable SYSAssetGuid; 86 | bool public paused; 87 | //------------------------------------------------------------------------- 88 | // Events 89 | //------------------------------------------------------------------------- 90 | 91 | event TokenFreeze( 92 | uint64 indexed assetGuid, 93 | address indexed freezer, 94 | uint satoshiValue, 95 | string syscoinAddr 96 | ); 97 | event TokenUnfreeze( 98 | uint64 indexed assetGuid, 99 | address indexed recipient, 100 | uint value 101 | ); 102 | event TokenRegistry( 103 | uint32 indexed assetId, 104 | address assetContract, 105 | AssetType assetType 106 | ); 107 | 108 | //------------------------------------------------------------------------- 109 | // Constructor 110 | //------------------------------------------------------------------------- 111 | 112 | /** 113 | * @param _trustedRelayerContract The SyscoinRelay or similar contract 114 | * @param _sysxGuid The Syscoin asset GUID representing "native SYS" (if needed) 115 | */ 116 | constructor( 117 | address _trustedRelayerContract, 118 | uint64 _sysxGuid, 119 | address _initialOwner 120 | ) Ownable(_initialOwner) { 121 | require(_trustedRelayerContract != address(0), "Invalid Relay"); 122 | trustedRelayerContract = _trustedRelayerContract; 123 | SYSAssetGuid = _sysxGuid; 124 | } 125 | 126 | //------------------------------------------------------------------------- 127 | // Modifiers 128 | //------------------------------------------------------------------------- 129 | 130 | modifier whenNotPaused() { 131 | require(!paused, "Bridge is paused"); 132 | _; 133 | } 134 | 135 | modifier onlyTrustedRelayer() { 136 | require( 137 | msg.sender == trustedRelayerContract, 138 | "Call must be from trusted relayer" 139 | ); 140 | _; 141 | } 142 | 143 | //------------------------------------------------------------------------- 144 | // External: processTransaction (Sys->NEVM) 145 | //------------------------------------------------------------------------- 146 | 147 | /** 148 | * @notice Called by trustedRelayerContract after verifying SPV proof 149 | * @param txHash unique tx hash from Syscoin 150 | * @param value bridging amount/quantity 151 | * @param destination final NEVM address 152 | * @param assetGuid 64-bit: upper32 => tokenIdIdx, lower32 => assetId 153 | */ 154 | function processTransaction( 155 | uint txHash, 156 | uint value, 157 | address destination, 158 | uint64 assetGuid 159 | ) external override onlyTrustedRelayer nonReentrant whenNotPaused { 160 | require(_insert(txHash), "TX already processed"); 161 | 162 | if (assetGuid == SYSAssetGuid) { 163 | // bridging in native SYS 164 | require(value > 0, "Value must be positive"); 165 | uint mintedAmount = scaleFromSatoshi(value, 18); 166 | _withdrawSYS(mintedAmount, payable(destination)); 167 | } else { 168 | (uint32 tokenIdx, uint32 assetId) = _parseAssetGuid(assetGuid); 169 | AssetRegistryItem storage item = assetRegistry[assetId]; 170 | require(item.assetType != AssetType.INVALID, "Unregistered asset"); 171 | 172 | if (item.assetType == AssetType.ERC20) { 173 | require(value > 0, "Value must be positive"); 174 | require(tokenIdx == 0, "ERC20 bridging requires tokenIdx=0"); 175 | uint mintedAmount = scaleFromSatoshi(value, item.precision); 176 | _withdrawERC20(item.assetContract, mintedAmount, destination); 177 | } else if (item.assetType == AssetType.ERC721) { 178 | // bridging 1 NFT => value=1 179 | require(value == 1, "ERC721 bridging requires value=1"); 180 | // look up the real tokenId 181 | uint realTokenId = item.tokenRegistry[tokenIdx]; 182 | require(realTokenId != 0, "Unknown 721 tokenIdx"); 183 | _withdrawERC721(item.assetContract, realTokenId, destination); 184 | } else if (item.assetType == AssetType.ERC1155) { 185 | require(value > 0, "Value must be positive"); 186 | uint realTokenId = item.tokenRegistry[tokenIdx]; 187 | require(realTokenId != 0, "Unknown 1155 tokenIdx"); 188 | _withdrawERC1155( 189 | item.assetContract, 190 | realTokenId, 191 | value, 192 | destination 193 | ); 194 | } 195 | } 196 | 197 | emit TokenUnfreeze(assetGuid, destination, value); 198 | } 199 | 200 | //------------------------------------------------------------------------- 201 | // External: freezeBurn (NEVM->Sys) 202 | //------------------------------------------------------------------------- 203 | 204 | /** 205 | * @notice Lock/burn tokens in this contract => bridging to Syscoin 206 | * @param value bridging amount 207 | * @param assetAddr if bridging native SYS => pass 0, else pass ERC20/721/1155 208 | * @param tokenId for NFTs 209 | * @param syscoinAddr the Syscoin destination (like a bech32 or base58) 210 | */ 211 | function freezeBurn( 212 | uint value, 213 | address assetAddr, 214 | uint256 tokenId, 215 | string memory syscoinAddr 216 | ) external payable nonReentrant whenNotPaused returns (bool) { 217 | require(bytes(syscoinAddr).length > 0, "Syscoin address required"); 218 | uint satoshiValue; 219 | if (assetAddr == address(0)) { 220 | // bridging native coin => must match msg.value 221 | require(value == msg.value, "Value mismatch for native bridging"); 222 | require(tokenId == 0, "SYS => bridging requires tokenId==0"); 223 | satoshiValue = scaleToSatoshi(value, 18); // "native SYS on NEVM" is 18 dec 224 | // just log the freeze => user must parse 225 | emit TokenFreeze( 226 | SYSAssetGuid, 227 | msg.sender, 228 | satoshiValue, 229 | syscoinAddr 230 | ); 231 | return true; 232 | } 233 | AssetType detectedType = _detectAssetType(assetAddr); 234 | uint32 assetId = assetRegistryByAddress[assetAddr]; 235 | 236 | // Asset registration check 237 | if (assetId == 0) { 238 | globalAssetIdCount++; 239 | if (globalAssetIdCount == uint32(SYSAssetGuid)) { 240 | globalAssetIdCount++; 241 | } 242 | assetId = globalAssetIdCount; 243 | assetRegistryByAddress[assetAddr] = assetId; 244 | 245 | AssetRegistryItem storage newItem = assetRegistry[assetId]; 246 | newItem.assetType = detectedType; 247 | newItem.assetContract = assetAddr; 248 | newItem.precision = _defaultPrecision(detectedType, assetAddr); 249 | emit TokenRegistry(assetId, assetAddr, detectedType); 250 | } 251 | 252 | AssetRegistryItem storage item = assetRegistry[assetId]; 253 | require(item.assetType == detectedType, "Mismatched asset type"); 254 | 255 | // Deposit handling 256 | if (detectedType == AssetType.ERC20) { 257 | require(value > 0, "ERC20 requires positive value"); 258 | require(tokenId == 0, "ERC20 tokenId must be zero"); 259 | satoshiValue = scaleToSatoshi(value, item.precision); 260 | _depositERC20(assetAddr, value); 261 | } else if (detectedType == AssetType.ERC721) { 262 | require(value == 1, "ERC721 deposit requires exactly 1"); 263 | require(tokenId != 0, "ERC721 tokenId required"); 264 | satoshiValue = value; 265 | _depositERC721(assetAddr, tokenId); 266 | } else if (detectedType == AssetType.ERC1155) { 267 | require(value > 0, "ERC1155 requires positive value"); 268 | require(tokenId != 0, "ERC1155 tokenId required"); 269 | satoshiValue = value; 270 | _depositERC1155(assetAddr, tokenId, value); 271 | } else { 272 | revert("Invalid asset type"); 273 | } 274 | // figure out tokenIndex if NFT 275 | uint32 tokenIndex = 0; 276 | if ( 277 | item.assetType == AssetType.ERC721 || 278 | item.assetType == AssetType.ERC1155 279 | ) { 280 | tokenIndex = _findOrAssignTokenIndex(item, tokenId); 281 | } 282 | // Calculate assetGuid correctly 283 | uint64 assetGuid = (uint64(tokenIndex) << 32) | uint64(assetId); 284 | emit TokenFreeze(assetGuid, msg.sender, satoshiValue, syscoinAddr); 285 | 286 | return true; 287 | } 288 | 289 | function setPaused(bool _paused) external onlyOwner { 290 | paused = _paused; 291 | } 292 | 293 | //------------------------------------------------------------------------- 294 | // Internal Helpers 295 | //------------------------------------------------------------------------- 296 | 297 | function _insert(uint txHash) private returns (bool) { 298 | if (syscoinTxAlreadyProcessed[txHash]) { 299 | return false; 300 | } 301 | syscoinTxAlreadyProcessed[txHash] = true; 302 | return true; 303 | } 304 | 305 | function _parseAssetGuid( 306 | uint64 guid 307 | ) internal pure returns (uint32 tokenIdx, uint32 assetId) { 308 | tokenIdx = uint32(guid >> 32); 309 | assetId = uint32(guid); 310 | } 311 | 312 | function _detectAssetType( 313 | address contractAddr 314 | ) internal view returns (AssetType) { 315 | bool supports165 = ERC165Checker.supportsERC165(contractAddr); 316 | if (supports165) { 317 | // 0x80ac58cd => ERC721 318 | // 0xd9b67a26 => ERC1155 319 | if (ERC165Checker.supportsInterface(contractAddr, 0x80ac58cd)) { 320 | return AssetType.ERC721; 321 | } 322 | if (ERC165Checker.supportsInterface(contractAddr, 0xd9b67a26)) { 323 | return AssetType.ERC1155; 324 | } 325 | } 326 | return AssetType.ERC20; 327 | } 328 | 329 | function _defaultPrecision( 330 | AssetType t, 331 | address contractAddr 332 | ) internal view returns (uint8) { 333 | if (t == AssetType.ERC20) { 334 | try IERC20Metadata(contractAddr).decimals() returns (uint8 dec) { 335 | return dec; 336 | } catch { 337 | return 18; 338 | } 339 | } 340 | // For NFTs, no decimals. We store 0 341 | return 0; 342 | } 343 | 344 | function _findOrAssignTokenIndex( 345 | AssetRegistryItem storage item, 346 | uint256 realTokenId 347 | ) internal returns (uint32) { 348 | uint32 tokenIdx = item.reverseTokenRegistry[realTokenId]; 349 | 350 | if (tokenIdx == 0) { 351 | // Token hasn't been registered yet; assign a new index 352 | item.tokenIdCount++; 353 | tokenIdx = item.tokenIdCount; 354 | 355 | item.tokenRegistry[tokenIdx] = realTokenId; 356 | item.reverseTokenRegistry[realTokenId] = tokenIdx; 357 | } 358 | // else, tokenIdx already exists; reuse it 359 | 360 | return tokenIdx; 361 | } 362 | 363 | /** 364 | * @dev scaleToSatoshi: Convert `rawValue` from `tokenDecimals` to 8 decimals. 365 | * Then require <= MAX_SYS_SUPPLY_SATS. 366 | * 367 | * Example: 368 | * tokenDecimals=18, rawValue=1000000000000000000 (1 token) 369 | * => scaleDown => 1 * 10^(8-18) => 1/10^10 => truncated => 0 if < 10^10 370 | * => if fromDecimals < 8 => scale up => leftover fraction => none, integer multiply 371 | */ 372 | function scaleToSatoshi( 373 | uint rawValue, 374 | uint8 tokenDecimals 375 | ) internal pure returns (uint) { 376 | uint scaled; 377 | if (tokenDecimals > 8) { 378 | // scale down => integer division truncates fraction 379 | scaled = rawValue / (10 ** (tokenDecimals - 8)); 380 | } else if (tokenDecimals < 8) { 381 | // scale up 382 | scaled = rawValue * (10 ** (8 - tokenDecimals)); 383 | } else { 384 | scaled = rawValue; 385 | } 386 | require(scaled <= MAX_SYS_SUPPLY_SATS, "Overflow bridging to Sys"); 387 | return scaled; 388 | } 389 | 390 | /** 391 | * @dev scaleFromSatoshi: Convert `satValue` in 8 decimals to `tokenDecimals`. 392 | * Typically no overflow check is needed, because we assume Sys side 393 | * never exceeds ~1e18. If you want to be extra safe, do an additional check. 394 | * 395 | * Example: 396 | * tokenDecimals=18, satValue=123 (1.23e2 => 1.23 sat) => scaleUp => 123 * 10^(18-8) => 123 * 10^10. 397 | */ 398 | function scaleFromSatoshi( 399 | uint satValue, 400 | uint8 tokenDecimals 401 | ) internal pure returns (uint) { 402 | if (tokenDecimals > 8) { 403 | // scale up 404 | return satValue * (10 ** (tokenDecimals - 8)); 405 | } else if (tokenDecimals < 8) { 406 | // scale down => integer div 407 | return satValue / (10 ** (8 - tokenDecimals)); 408 | } else { 409 | return satValue; 410 | } 411 | } 412 | 413 | //------------------------------------------------------------------------- 414 | // Token Transfers 415 | //------------------------------------------------------------------------- 416 | 417 | // deposit erc20 => use safeTransferFrom 418 | function _depositERC20(address assetContract, uint amount) internal { 419 | IERC20Metadata(assetContract).safeTransferFrom( 420 | msg.sender, 421 | address(this), 422 | amount 423 | ); 424 | } 425 | 426 | function _withdrawERC20( 427 | address assetContract, 428 | uint amount, 429 | address to 430 | ) internal { 431 | IERC20Metadata(assetContract).safeTransfer(to, amount); 432 | } 433 | 434 | function _depositERC721(address assetContract, uint tokenId) internal { 435 | IERC721(assetContract).safeTransferFrom( 436 | msg.sender, 437 | address(this), 438 | tokenId 439 | ); 440 | } 441 | 442 | function _withdrawERC721( 443 | address assetContract, 444 | uint tokenId, 445 | address to 446 | ) internal { 447 | IERC721(assetContract).transferFrom(address(this), to, tokenId); 448 | } 449 | 450 | function _depositERC1155( 451 | address assetContract, 452 | uint tokenId, 453 | uint amount 454 | ) internal { 455 | IERC1155(assetContract).safeTransferFrom( 456 | msg.sender, 457 | address(this), 458 | tokenId, 459 | amount, 460 | "" 461 | ); 462 | } 463 | 464 | function _withdrawERC1155( 465 | address assetContract, 466 | uint tokenId, 467 | uint amount, 468 | address to 469 | ) internal { 470 | IERC1155(assetContract).safeTransferFrom( 471 | address(this), 472 | to, 473 | tokenId, 474 | amount, 475 | "" 476 | ); 477 | } 478 | 479 | function _withdrawSYS(uint amount, address payable to) internal { 480 | require(address(this).balance >= amount, "Not enough SYS"); 481 | (bool success, ) = to.call{value: amount}(""); 482 | require(success, "Sys transfer failed"); 483 | } 484 | 485 | function getRealTokenIdFromTokenIdx( 486 | uint32 assetId, 487 | uint32 tokenIdx 488 | ) external view returns (uint256) { 489 | AssetRegistryItem storage item = assetRegistry[assetId]; 490 | return item.tokenRegistry[tokenIdx]; 491 | } 492 | 493 | function getTokenIdxFromRealTokenId( 494 | uint32 assetId, 495 | uint256 realTokenId 496 | ) external view returns (uint32) { 497 | AssetRegistryItem storage item = assetRegistry[assetId]; 498 | return item.reverseTokenRegistry[realTokenId]; 499 | } 500 | } 501 | -------------------------------------------------------------------------------- /contracts/interfaces/ISyscoinRelay.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.20; 3 | 4 | interface ISyscoinRelay { 5 | function relayTx( 6 | uint64 _blockNumber, 7 | bytes calldata _txBytes, 8 | uint _txIndex, 9 | uint[] calldata _txSiblings, 10 | bytes calldata _syscoinBlockHeader 11 | ) external returns (uint); 12 | } 13 | -------------------------------------------------------------------------------- /contracts/interfaces/ISyscoinTransactionProcessor.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.20; 3 | 4 | // Interface contract to be implemented by SyscoinVaultManager 5 | interface ISyscoinTransactionProcessor { 6 | function processTransaction( 7 | uint txHash, 8 | uint value, 9 | address destinationAddress, 10 | uint64 assetGuid 11 | ) external; 12 | 13 | function freezeBurn( 14 | uint value, 15 | address assetAddr, 16 | uint256 tokenId, 17 | string calldata syscoinAddress 18 | ) external payable returns (bool); 19 | } 20 | -------------------------------------------------------------------------------- /contracts/lib/SyscoinMessageLibrary.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.20; 3 | 4 | // parse a raw Syscoin transaction byte array 5 | contract SyscoinMessageLibrary { 6 | // Convert a compact size for wire for variable length fields 7 | function parseCompactSize( 8 | bytes memory txBytes, 9 | uint pos 10 | ) internal pure returns (uint, uint) { 11 | require(pos < txBytes.length, "Out of bounds"); 12 | 13 | uint8 ibit = uint8(txBytes[pos]); 14 | pos += 1; 15 | 16 | if (ibit < 0xfd) { 17 | return (ibit, pos); 18 | } else if (ibit == 0xfd) { 19 | require(pos + 2 <= txBytes.length, "Out of bounds"); 20 | return (getBytesLE(txBytes, pos, 16), pos + 2); 21 | } else if (ibit == 0xfe) { 22 | require(pos + 4 <= txBytes.length, "Out of bounds"); 23 | return (getBytesLE(txBytes, pos, 32), pos + 4); 24 | } else if (ibit == 0xff) { 25 | require(pos + 8 <= txBytes.length, "Out of bounds"); 26 | return (getBytesLE(txBytes, pos, 64), pos + 8); 27 | } 28 | revert("#SyscoinMessageLibrary parseCompactSize invalid opcode"); 29 | } 30 | 31 | // Convert a variable integer into something useful and return it and 32 | // the index to after it. 33 | function parseVarInt( 34 | bytes memory txBytes, 35 | uint pos, 36 | uint max 37 | ) public pure returns (uint, uint) { 38 | uint n = 0; 39 | while (true) { 40 | uint8 chData = uint8(txBytes[pos]); 41 | require( 42 | n <= (max >> 7), 43 | "#SyscoinMessageLibrary parseVarInt(): size too large" 44 | ); 45 | pos += 1; 46 | n = (n << 7) | (chData & 0x7F); 47 | if ((chData & 0x80) == 0) { 48 | require( 49 | n != max, 50 | "#SyscoinMessageLibrary parseVarInt(): size too large" 51 | ); 52 | break; 53 | } 54 | require( 55 | n + 1 > n, 56 | "#SyscoinMessageLibrary parseVarInt(): overflow detected" 57 | ); // overflow check 58 | n++; 59 | } 60 | return (n, pos); 61 | } 62 | 63 | // convert little endian bytes to uint 64 | function getBytesLE( 65 | bytes memory data, 66 | uint pos, 67 | uint bits 68 | ) internal pure returns (uint256 result) { 69 | for (uint256 i = 0; i < bits / 8; i++) { 70 | result += uint256(uint8(data[pos + i])) * 2 ** (i * 8); 71 | } 72 | } 73 | 74 | // @dev - Converts a bytes of size 4 to uint32, 75 | // e.g. for input [0x01, 0x02, 0x03 0x04] returns 0x01020304 76 | function bytesToUint32Flipped( 77 | bytes memory input, 78 | uint pos 79 | ) public pure returns (uint32 result) { 80 | assembly { 81 | let data := mload(add(add(input, 0x20), pos)) 82 | let flip := mload(0x40) 83 | mstore8(add(flip, 0), byte(3, data)) 84 | mstore8(add(flip, 1), byte(2, data)) 85 | mstore8(add(flip, 2), byte(1, data)) 86 | mstore8(add(flip, 3), byte(0, data)) 87 | result := shr(mul(8, 28), mload(flip)) 88 | } 89 | } 90 | 91 | // @dev - convert an unsigned integer from little-endian to big-endian representation 92 | // 93 | // @param _input - little-endian value 94 | // @return - input value in big-endian format 95 | function flip32Bytes(uint _input) internal pure returns (uint result) { 96 | assembly { 97 | let pos := mload(0x40) 98 | mstore8(add(pos, 0), byte(31, _input)) 99 | mstore8(add(pos, 1), byte(30, _input)) 100 | mstore8(add(pos, 2), byte(29, _input)) 101 | mstore8(add(pos, 3), byte(28, _input)) 102 | mstore8(add(pos, 4), byte(27, _input)) 103 | mstore8(add(pos, 5), byte(26, _input)) 104 | mstore8(add(pos, 6), byte(25, _input)) 105 | mstore8(add(pos, 7), byte(24, _input)) 106 | mstore8(add(pos, 8), byte(23, _input)) 107 | mstore8(add(pos, 9), byte(22, _input)) 108 | mstore8(add(pos, 10), byte(21, _input)) 109 | mstore8(add(pos, 11), byte(20, _input)) 110 | mstore8(add(pos, 12), byte(19, _input)) 111 | mstore8(add(pos, 13), byte(18, _input)) 112 | mstore8(add(pos, 14), byte(17, _input)) 113 | mstore8(add(pos, 15), byte(16, _input)) 114 | mstore8(add(pos, 16), byte(15, _input)) 115 | mstore8(add(pos, 17), byte(14, _input)) 116 | mstore8(add(pos, 18), byte(13, _input)) 117 | mstore8(add(pos, 19), byte(12, _input)) 118 | mstore8(add(pos, 20), byte(11, _input)) 119 | mstore8(add(pos, 21), byte(10, _input)) 120 | mstore8(add(pos, 22), byte(9, _input)) 121 | mstore8(add(pos, 23), byte(8, _input)) 122 | mstore8(add(pos, 24), byte(7, _input)) 123 | mstore8(add(pos, 25), byte(6, _input)) 124 | mstore8(add(pos, 26), byte(5, _input)) 125 | mstore8(add(pos, 27), byte(4, _input)) 126 | mstore8(add(pos, 28), byte(3, _input)) 127 | mstore8(add(pos, 29), byte(2, _input)) 128 | mstore8(add(pos, 30), byte(1, _input)) 129 | mstore8(add(pos, 31), byte(0, _input)) 130 | result := mload(pos) 131 | } 132 | } 133 | 134 | // @dev - For a valid proof, returns the root of the Merkle tree. 135 | // 136 | // @param _txHash - transaction hash 137 | // @param _txIndex - transaction's index within the block it's assumed to be in 138 | // @param _siblings - transaction's Merkle siblings 139 | // @return - Merkle tree root of the block the transaction belongs to if the proof is valid, 140 | // garbage if it's invalid 141 | function computeMerkle( 142 | uint _txHash, 143 | uint _txIndex, 144 | uint[] memory _siblings 145 | ) public pure returns (uint) { 146 | require( 147 | _siblings.length > 0, 148 | "#SyscoinMessageLibrary computeMerkle(): No siblings provided" 149 | ); 150 | 151 | uint length = _siblings.length; 152 | uint i; 153 | for (i = 0; i < length; i++) { 154 | _siblings[i] = flip32Bytes(_siblings[i]); 155 | } 156 | 157 | i = 0; 158 | uint resultHash = flip32Bytes(_txHash); 159 | 160 | while (i < length) { 161 | uint proofHex = _siblings[i]; 162 | 163 | uint left; 164 | uint right; 165 | if (_txIndex % 2 == 1) { 166 | // 0 means _siblings is on the right; 1 means left 167 | left = proofHex; 168 | right = resultHash; 169 | } else { 170 | left = resultHash; 171 | right = proofHex; 172 | } 173 | resultHash = uint( 174 | sha256(abi.encodePacked(sha256(abi.encodePacked(left, right)))) 175 | ); 176 | 177 | _txIndex /= 2; 178 | i += 1; 179 | } 180 | 181 | return flip32Bytes(resultHash); 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /contracts/test/MaliciousReentrant.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.20; 3 | 4 | import "../SyscoinVaultManager.sol"; 5 | import "@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol"; 6 | 7 | /** 8 | * @title MaliciousReentrant 9 | * @notice Tries to call `freezeBurn` in the vault again from onERC1155Received, 10 | * causing a re-entrancy attempt. 11 | */ 12 | contract MaliciousReentrant is IERC1155Receiver { 13 | SyscoinVaultManager public vault; 14 | address public erc1155Asset; 15 | bool public didAttack; 16 | bool public attackReverted; // to track if the vault call reverted 17 | 18 | constructor(address _erc1155Asset) { 19 | erc1155Asset = _erc1155Asset; 20 | didAttack = false; 21 | attackReverted = false; 22 | } 23 | 24 | function setVault(address _vault) external { 25 | vault = SyscoinVaultManager(_vault); 26 | } 27 | 28 | function doAttack(uint amount, uint32 assetId, uint32 tokenIdx) external { 29 | // The malicious calls the vault => processTransaction => calls _withdrawERC1155(..., this) => triggers onERC1155Received 30 | uint64 assetGuid = (uint64(tokenIdx) << 32) | uint64(assetId); 31 | // pick a random txHash => 111 32 | vault.processTransaction(111, amount, address(this), assetGuid); 33 | } 34 | 35 | // =============== IERC1155Receiver Implementation ================ 36 | function onERC1155Received( 37 | address /*operator*/, 38 | address /*from*/, 39 | uint256 id, 40 | uint256 value, 41 | bytes calldata /*data*/ 42 | ) external override returns (bytes4) { 43 | if (!didAttack) { 44 | didAttack = true; 45 | try 46 | vault.freezeBurn(value, erc1155Asset, id, "sysMaliciousAddress") 47 | { 48 | attackReverted = false; 49 | revert("Malicious attack succeeded"); 50 | } catch { 51 | attackReverted = true; 52 | } 53 | } 54 | return this.onERC1155Received.selector; 55 | } 56 | 57 | function onERC1155BatchReceived( 58 | address /*operator*/, 59 | address /*from*/, 60 | uint256[] calldata /*ids*/, 61 | uint256[] calldata /*values*/, 62 | bytes calldata /*data*/ 63 | ) external pure override returns (bytes4) { 64 | return this.onERC1155BatchReceived.selector; 65 | } 66 | 67 | function supportsInterface( 68 | bytes4 interfaceId 69 | ) external pure override returns (bool) { 70 | return 71 | interfaceId == this.onERC1155Received.selector || 72 | interfaceId == this.onERC1155BatchReceived.selector; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /contracts/test/MockERC1155.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.20; 3 | 4 | import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol"; 5 | 6 | contract MockERC1155 is ERC1155 { 7 | constructor(string memory uri_) ERC1155(uri_) {} 8 | 9 | function mint(address to, uint256 id, uint256 amount) external { 10 | _mint(to, id, amount, ""); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /contracts/test/MockERC20.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.20; 3 | 4 | import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; 5 | 6 | contract MockERC20 is ERC20 { 7 | uint8 private _decimals; 8 | 9 | constructor( 10 | string memory name_, 11 | string memory symbol_, 12 | uint8 decimals_ 13 | ) ERC20(name_, symbol_) { 14 | _decimals = decimals_; 15 | } 16 | 17 | function decimals() public view virtual override returns (uint8) { 18 | return _decimals; 19 | } 20 | 21 | function mint(address to, uint256 amount) external { 22 | _mint(to, amount); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /contracts/test/MockERC721.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.17; 3 | 4 | import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; 5 | 6 | contract MockERC721 is ERC721 { 7 | uint256 private _tokenIdCounter; 8 | 9 | constructor( 10 | string memory name_, 11 | string memory symbol_ 12 | ) ERC721(name_, symbol_) {} 13 | 14 | function mint(address to) external { 15 | _tokenIdCounter += 1; 16 | _mint(to, _tokenIdCounter); 17 | } 18 | 19 | function mint(address to, uint256 tokenId) external { 20 | _mint(to, tokenId); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /deployments/localhost.json: -------------------------------------------------------------------------------- 1 | { 2 | "SyscoinRelay": "0x5FbDB2315678afecb367f032d93F642f64180aa3", 3 | "SyscoinVaultManager": "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512", 4 | "deployer": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", 5 | "SYS_ASSET_GUID": "123456" 6 | } -------------------------------------------------------------------------------- /deployments/syscoin.json: -------------------------------------------------------------------------------- 1 | { 2 | "SyscoinRelay": "0xd714E7915362FF89388025F584726E6dF26D20f9", 3 | "SyscoinVaultManager": "0x7904299b3D3dC1b03d1DdEb45E9fDF3576aCBd5f", 4 | "deployer": "0x7E3F03E687977B7f2096BE55C08c3b1438dd1dEF", 5 | "SYS_ASSET_GUID": "123456" 6 | } -------------------------------------------------------------------------------- /deployments/tanenbaum.json: -------------------------------------------------------------------------------- 1 | { 2 | "SyscoinRelay": "0xd714E7915362FF89388025F584726E6dF26D20f9", 3 | "SyscoinVaultManager": "0x7904299b3D3dC1b03d1DdEb45E9fDF3576aCBd5f", 4 | "deployer": "0x7E3F03E687977B7f2096BE55C08c3b1438dd1dEF", 5 | "SYS_ASSET_GUID": "123456" 6 | } -------------------------------------------------------------------------------- /hardhat.config.ts: -------------------------------------------------------------------------------- 1 | import { HardhatUserConfig } from "hardhat/config"; 2 | import "@nomicfoundation/hardhat-toolbox"; 3 | 4 | const PRIVATE_KEY = process.env.PRIVATE_KEY || ""; 5 | const MNEMONIC = process.env.MNEMONIC || "test test test test test test test test test test test junk"; 6 | 7 | const config: HardhatUserConfig = { 8 | solidity: { 9 | version: "0.8.20", 10 | settings: { 11 | optimizer: { 12 | enabled: true, 13 | runs: 200 14 | }, 15 | } 16 | }, 17 | networks: { 18 | // Local development network 19 | localhost: { 20 | url: "http://127.0.0.1:8545", 21 | accounts: { 22 | mnemonic: MNEMONIC, 23 | } 24 | }, 25 | // Syscoin Mainnet 26 | syscoin: { 27 | url: "https://rpc.syscoin.org", 28 | chainId: 57, 29 | accounts: [PRIVATE_KEY], 30 | }, 31 | // Syscoin Tanenbaum Testnet 32 | tanenbaum: { 33 | url: "https://rpc.tanenbaum.io", 34 | chainId: 5700, 35 | accounts: [PRIVATE_KEY], 36 | }, 37 | }, 38 | defaultNetwork: 'localhost', 39 | etherscan: { 40 | apiKey: { 41 | syscoin: "empty", 42 | tanenbaum: "empty" 43 | }, 44 | customChains: [ 45 | { 46 | network: "syscoin", 47 | chainId: 57, 48 | urls: { 49 | apiURL: "https://explorer.syscoin.org/api", 50 | browserURL: "https://explorer.syscoin.org", 51 | }, 52 | }, 53 | { 54 | network: "tanenbaum", 55 | chainId: 5700, 56 | urls: { 57 | apiURL: "https://explorer.tanenbaum.io/api", 58 | browserURL: "https://explorer.tanenbaum.io", 59 | }, 60 | } 61 | ], 62 | }, 63 | mocha: { 64 | timeout: 40000 65 | }, 66 | typechain: { 67 | outDir: "typechain-types", 68 | target: "ethers-v6" 69 | }, 70 | gasReporter: { 71 | enabled: true, 72 | }, 73 | sourcify: { 74 | enabled: true, 75 | } 76 | }; 77 | 78 | export default config; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sysethereum-contracts", 3 | "version": "1.0.0", 4 | "description": "Syscoin <=> NEVM bridge smart contracts", 5 | "dependencies": { 6 | "dotenv": "^16.4.7", 7 | "hardhat": "^2.22.19" 8 | }, 9 | "devDependencies": { 10 | "@nomicfoundation/hardhat-chai-matchers": "^2.0.0", 11 | "@nomicfoundation/hardhat-ethers": "^3.0.0", 12 | "@nomicfoundation/hardhat-ignition": "^0.15.0", 13 | "@nomicfoundation/hardhat-ignition-ethers": "^0.15.0", 14 | "@nomicfoundation/hardhat-network-helpers": "^1.0.0", 15 | "@nomicfoundation/hardhat-toolbox": "^5.0.0", 16 | "@nomicfoundation/hardhat-verify": "^2.0.0", 17 | "@openzeppelin/contracts": "^5.2.0", 18 | "@typechain/ethers-v6": "^0.5.0", 19 | "@typechain/hardhat": "^9.0.0", 20 | "@types/chai": "^4.2.0", 21 | "@types/mocha": ">=9.1.0", 22 | "@types/node": ">=18.0.0", 23 | "chai": "^4.2.0", 24 | "ethers": "^6.4.0", 25 | "hardhat-gas-reporter": "^1.0.8", 26 | "solidity-coverage": "^0.8.0", 27 | "ts-node": ">=8.0.0", 28 | "typechain": "^8.3.0", 29 | "typescript": ">=4.5.0" 30 | }, 31 | "repository": { 32 | "type": "git", 33 | "url": "git+https://github.com/syscoin/sysethereum/sysethereum-contracts.git" 34 | }, 35 | "bugs": { 36 | "url": "https://github.com/syscoin/sysethereum/sysethereum-contracts/issues" 37 | }, 38 | "homepage": "https://github.com/syscoin/sysethereum/sysethereum-contracts#readme" 39 | } -------------------------------------------------------------------------------- /scripts/deploy.ts: -------------------------------------------------------------------------------- 1 | import { ethers, network } from "hardhat"; 2 | import fs from "fs"; 3 | import path from "path"; 4 | 5 | async function main() { 6 | console.log(`Deploying contracts to ${network.name}...`); 7 | 8 | const [deployer] = await ethers.getSigners(); 9 | console.log(`Deployer: ${deployer.address}`); 10 | 11 | const syscoinRelay = await ethers.deployContract("SyscoinRelay"); 12 | await syscoinRelay.waitForDeployment(); 13 | const syscoinRelayAddress = await syscoinRelay.getAddress(); 14 | console.log(`SyscoinRelay deployed at: ${syscoinRelayAddress}`); 15 | 16 | const SYS_ASSET_GUID = 123456n; 17 | 18 | const syscoinVaultManager = await ethers.deployContract("SyscoinVaultManager", [ 19 | syscoinRelayAddress, 20 | SYS_ASSET_GUID, 21 | deployer.address 22 | ]); 23 | await syscoinVaultManager.waitForDeployment(); 24 | const syscoinVaultManagerAddress = await syscoinVaultManager.getAddress(); 25 | console.log(`SyscoinVaultManager deployed at: ${syscoinVaultManagerAddress}`); 26 | // Initialize SyscoinRelay 27 | console.log("Initializing SyscoinRelay..."); 28 | const initTx = await syscoinRelay.init(syscoinVaultManagerAddress); 29 | await initTx.wait(); 30 | console.log("SyscoinRelay initialized successfully"); 31 | // Confirm setup 32 | const configuredVaultAddress = await syscoinRelay.syscoinVaultManager(); 33 | console.log("Relay configured VaultManager address:", configuredVaultAddress); 34 | 35 | if(configuredVaultAddress !== syscoinVaultManagerAddress) { 36 | throw new Error('SyscoinRelay initialization failed: VaultManager address mismatch'); 37 | } 38 | const deploymentsPath = path.resolve(__dirname, "../deployments"); 39 | if (!fs.existsSync(deploymentsPath)) { 40 | fs.mkdirSync(deploymentsPath); 41 | } 42 | 43 | fs.writeFileSync( 44 | path.join(deploymentsPath, `${network.name}.json`), 45 | JSON.stringify({ 46 | SyscoinRelay: syscoinRelayAddress, 47 | SyscoinVaultManager: syscoinVaultManagerAddress, 48 | deployer: deployer.address, 49 | SYS_ASSET_GUID: SYS_ASSET_GUID.toString() 50 | }, null, 4) 51 | ); 52 | 53 | console.log("Deployment completed!"); 54 | } 55 | 56 | main().catch((error) => { 57 | console.error(error); 58 | process.exit(1); 59 | }); 60 | -------------------------------------------------------------------------------- /scripts/verify.ts: -------------------------------------------------------------------------------- 1 | import { run, network } from "hardhat"; 2 | import fs from "fs"; 3 | import path from "path"; 4 | 5 | async function main() { 6 | const deploymentsPath = path.resolve(__dirname, "../deployments", `${network.name}.json`); 7 | 8 | if (!fs.existsSync(deploymentsPath)) { 9 | throw new Error(`Deployments file not found: ${deploymentsPath}`); 10 | } 11 | 12 | const { SyscoinRelay, SyscoinVaultManager, SYS_ASSET_GUID, deployer } = JSON.parse(fs.readFileSync(deploymentsPath, "utf-8")); 13 | 14 | try { 15 | console.log("Verifying SyscoinRelay..."); 16 | await run("verify:verify", { 17 | address: SyscoinRelay, 18 | constructorArguments: [], 19 | }); 20 | console.log("SyscoinRelay verified successfully."); 21 | } catch (error) { 22 | console.error("Verification of SyscoinRelay failed:", error); 23 | } 24 | 25 | try { 26 | console.log("Verifying SyscoinVaultManager..."); 27 | await run("verify:verify", { 28 | address: SyscoinVaultManager, 29 | constructorArguments: [SyscoinRelay, SYS_ASSET_GUID, deployer], 30 | }); 31 | console.log("SyscoinVaultManager verified successfully."); 32 | } catch (error) { 33 | console.error("Verification of SyscoinVaultManager failed:", error); 34 | } 35 | } 36 | 37 | main().catch((error) => { 38 | console.error(error); 39 | process.exit(1); 40 | }); 41 | -------------------------------------------------------------------------------- /test/MaliciousReentrant.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { ethers } from "hardhat"; 3 | import { loadFixture } from "@nomicfoundation/hardhat-network-helpers"; 4 | 5 | /** 6 | * This test triggers a re-entrancy by making the vault call 7 | * `_withdrawERC1155(..., malicious, ...)` in the same TX that 8 | * malicious calls freezeBurn(...) again. 9 | */ 10 | describe("Malicious Re-Entrancy Test", function () { 11 | async function deployFixture() { 12 | const [deployer, owner] = await ethers.getSigners(); 13 | 14 | // Deploy an ERC1155 15 | const MockERC1155Factory = await ethers.getContractFactory("MockERC1155"); 16 | const mock1155 = await MockERC1155Factory.deploy("https://test.api/"); 17 | await mock1155.waitForDeployment(); 18 | 19 | // Deploy malicious first 20 | const MaliciousReentrantFactory = await ethers.getContractFactory("MaliciousReentrant"); 21 | const attacker = await MaliciousReentrantFactory.deploy(await mock1155.getAddress()); 22 | await attacker.waitForDeployment(); 23 | 24 | // Now create the vault with `attacker.address` as the trusted relayer 25 | const SyscoinVaultManagerFactory = await ethers.getContractFactory("SyscoinVaultManager"); 26 | const vault = await SyscoinVaultManagerFactory.deploy( 27 | await attacker.getAddress(), // attacker as trustedRelayer 28 | 8888n, // SYSAssetGuid 29 | owner.address // initialOwner 30 | ); 31 | await vault.waitForDeployment(); 32 | 33 | // Then set the vault address inside the malicious constructor or a separate init function 34 | await attacker.setVault(await vault.getAddress()); 35 | 36 | return { vault, mock1155, attacker, deployer, owner }; 37 | } 38 | 39 | it("Attempt real re-entrancy => should revert", async function () { 40 | // Load the fixture 41 | const { vault, mock1155, attacker, owner } = await loadFixture(deployFixture); 42 | 43 | // 1) We want the vault to call `_withdrawERC1155(..., attacker, 1, ...)` 44 | // so we do a bridging scenario. Suppose we do a Sys->NEVM bridging logic 45 | // that calls `_withdrawERC1155(assetAddr, realTokenId, value, malicious)`. 46 | // For simplicity, let's just call `_withdrawERC1155` from a function 47 | // that the malicious triggers. We'll rely on your bridging 'processTransaction' 48 | // or a test function on the vault, etc. 49 | 50 | // We'll do a small patch: in MaliciousReentrant, we do: 51 | // attacker.doAttack => calls vault 'processTransaction' => vault sees 'destination=attacker' 52 | // => calls `_withdrawERC1155(..., to=attacker, amount=1) => triggers onERC1155Received 53 | 54 | // Step: the attacker triggers doAttack(1), so the vault sends 1 token to 'attacker' 55 | // => onERC1155Received => malicious calls freezeBurn => re-enter => revert 56 | // 57 | // Ensure the vault recognizes mock1155 as ERC1155 58 | // (auto-registration or manual). We'll do a quick auto-register by freezeBurn(0)? 59 | 60 | // In production, you'd do a real bridging, but let's do a direct 'processTransaction' call: 61 | // to simulate bridging 1 unit from Sys => NEVM with destination=attacker. 62 | // We must set up the registry for mock1155 => assetId => item.assetType=ERC1155, realTokenId=777 => tokenIdx=1 63 | // You can do that by freezeBurn first if you want. Or forcibly set item in the test. 64 | 65 | // For brevity, let's do a direct force: 66 | // (In a real scenario you'd do freezeBurn from NEVM side first or a public register.) 67 | 68 | // We'll pretend the bridging param => value=1 => 1 token => tokenIdx=1 => realTokenId=777 69 | // We'll do a direct 'vault.processTransaction(txHash=999, 1, attacker, guid= (1<<32|someAssetId))' 70 | 71 | // 2) Register the ERC1155 asset 72 | // a) we do a minimal freezeBurn with 0? or we can forcibly do: 73 | // We skip real bridging steps for brevity: 74 | await mock1155.mint(owner.address, 777, 10); 75 | await mock1155.connect(owner).setApprovalForAll(await vault.getAddress(), true); 76 | 77 | await vault.connect(owner).freezeBurn( 78 | 1, 79 | await mock1155.getAddress(), 80 | 777, 81 | "someSysAddr" 82 | ); 83 | // now asset is registered => tokenIndex=1 84 | 85 | // Add tokens to the vault for the test to work 86 | await mock1155.mint(await vault.getAddress(), 777, 2); 87 | 88 | // read assetId 89 | const assetId = await vault.assetRegistryByAddress(await mock1155.getAddress()); 90 | // tokenIndex=1 from the code => let's see 91 | // next bridging from Sys->NEVM => call processTransaction => 'destination=attacker' 92 | 93 | // doAttack => calls vault.processTx => which calls _withdrawERC1155(..., attacker) 94 | // => triggers onERC1155Received => calls freezeBurn => re-enter => revert 95 | await attacker.doAttack(1, assetId, 1); 96 | 97 | // check final flags 98 | const didAttack = await attacker.didAttack(); 99 | const attackReverted = await attacker.attackReverted(); 100 | 101 | expect(didAttack).to.equal(true, "didAttack should be set"); 102 | expect(attackReverted).to.equal(true, "attackReverted should be set => reentrancy blocked"); 103 | }); 104 | }); -------------------------------------------------------------------------------- /test/SyscoinRelay.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { ethers } from "hardhat"; 3 | import { loadFixture } from "@nomicfoundation/hardhat-network-helpers"; 4 | 5 | describe("SyscoinRelay", function () { 6 | async function deploySyscoinRelayFixture() { 7 | const [owner, otherAccount] = await ethers.getSigners(); 8 | 9 | const SyscoinRelayFactory = await ethers.getContractFactory("SyscoinRelay"); 10 | const syscoinRelay = await SyscoinRelayFactory.deploy(); 11 | await syscoinRelay.waitForDeployment(); 12 | 13 | return { syscoinRelay, owner, otherAccount }; 14 | } 15 | 16 | describe("Test pure/view computation functions", function () { 17 | it("should parse a burn transaction correctly (parseBurnTx)", async function () { 18 | const { syscoinRelay } = await loadFixture(deploySyscoinRelayFixture); 19 | 20 | // First example burn transaction 21 | const txBytes = "0x8D000000000101e04131c39b082d7837b67b78c1a7e8836552c5eb484c57bcd38092eba64495420100000000fdffffff020000000000000000206a1e0186c340020013017714bf76b51ddfbe584b92d039c95f6444fabc8956a6febcadf4010000001600146d810fc818716daec63c0a4ee0c2dc2efe73677c02483045022100f11cb8515593b85afbad0c879c395d78034ac64235a4e49df969c6410e56c1d1022058821088d43ee24b82f9257bbe2a67b29222bae741145de2c0cea92d195b27da0121020fbc960c4f095ff3cdb43947dd8238447459365a06eb9a688348d63127cb50d500000000"; 22 | 23 | const result = await syscoinRelay.parseBurnTx(txBytes); 24 | 25 | // Check the results 26 | expect(result.errorCode).to.equal(0); 27 | expect(result.assetGuid).to.equal(123456n); 28 | expect(result.output_value).to.equal(200000000n); 29 | expect(result.destinationAddress.toLowerCase()).to.equal("0xbf76b51ddfbe584b92d039c95f6444fabc8956a6"); 30 | }); 31 | 32 | it("should parse another burn transaction correctly (parseBurnTx1)", async function () { 33 | const { syscoinRelay } = await loadFixture(deploySyscoinRelayFixture); 34 | 35 | const txBytes = "0x8D000000000102506702ad3ead57b7dc7aa997bd9060048a06462950f08a36e1677c44baacd26a0000000000fdffffff506702ad3ead57b7dc7aa997bd9060048a06462950f08a36e1677c44baacd26a0200000000fdffffff030000000000000000296a27028799cfc862020006015686c3400102800c14bf76b51ddfbe584b92d039c95f6444fabc8956a63e32052a010000001600141b9ac7e9f0b5197939faddd721ea4344539417caa8020000000000001600141b9ac7e9f0b5197939faddd721ea4344539417ca02483045022100c76dcdd5737a137c1c0aacaf5f6d8c359ddeffc1f602932f2a60a9cf27edd0d102203824008de9d458b61a4b9b513a1a9bd8e352d33690765bec76a36561f0adfee50121039a4bfa6fa6bc1d9bdf1ba3d7dba686e1a2d3826f85ac44c477e77a5cfc76d907024830450221008ac3eee9036c67f21f9cf55e1d4468d37164c983a96aacc634670deadc56189a0220349fb3c105696f605a3d96d78d14916d7197b05a3e187b9a4ed65007138e83060121039094f42abf62812c8243b701ab5cbc17cc5d5c4cf03f50efe1db2a6ca762dc1c00000000"; 36 | 37 | const result = await syscoinRelay.parseBurnTx(txBytes); 38 | 39 | expect(result.errorCode).to.equal(0); 40 | expect(result.assetGuid).to.equal(2203329762n); 41 | expect(result.output_value).to.equal(100000n); 42 | expect(result.destinationAddress.toLowerCase()).to.equal("0xbf76b51ddfbe584b92d039c95f6444fabc8956a6"); 43 | }); 44 | 45 | it("should scan a burn transaction correctly (scanBurnTx)", async function () { 46 | const { syscoinRelay } = await loadFixture(deploySyscoinRelayFixture); 47 | 48 | const expectedAddress = '0xbf76B51dDfBe584b92d039c95F6444FABC8956A6'; 49 | const txBytes = "0x8D000000000101e04131c39b082d7837b67b78c1a7e8836552c5eb484c57bcd38092eba64495420100000000fdffffff020000000000000000206a1e0186c340020013017714bf76b51ddfbe584b92d039c95f6444fabc8956a6febcadf4010000001600146d810fc818716daec63c0a4ee0c2dc2efe73677c02483045022100f11cb8515593b85afbad0c879c395d78034ac64235a4e49df969c6410e56c1d1022058821088d43ee24b82f9257bbe2a67b29222bae741145de2c0cea92d195b27da0121020fbc960c4f095ff3cdb43947dd8238447459365a06eb9a688348d63127cb50d500000000"; 50 | const opIndex = 0; 51 | const pos = 60; 52 | 53 | const result = await syscoinRelay.scanBurnTx(txBytes, opIndex, pos); 54 | 55 | expect(result.destinationAddress.toLowerCase()).to.equal(expectedAddress.toLowerCase()); 56 | }); 57 | 58 | it("should scan another burn transaction correctly (scanBurnTx1)", async function () { 59 | const { syscoinRelay } = await loadFixture(deploySyscoinRelayFixture); 60 | 61 | const expectedAddress = '0xbf76B51dDfBe584b92d039c95F6444FABC8956A6'; 62 | const txBytes = "0x8D000000000102506702ad3ead57b7dc7aa997bd9060048a06462950f08a36e1677c44baacd26a0000000000fdffffff506702ad3ead57b7dc7aa997bd9060048a06462950f08a36e1677c44baacd26a0200000000fdffffff0300000000000000002b6a26028799cfc862020006015686c3400102800c14bf76b51ddfbe584b92d039c95f6444fabc8956a63e32052a010000001600141b9ac7e9f0b5197939faddd721ea4344539417caa8020000000000001600141b9ac7e9f0b5197939faddd721ea4344539417ca02483045022100c76dcdd5737a137c1c0aacaf5f6d8c359ddeffc1f602932f2a60a9cf27edd0d102203824008de9d458b61a4b9b513a1a9bd8e352d33690765bec76a36561f0adfee50121039a4bfa6fa6bc1d9bdf1ba3d7dba686e1a2d3826f85ac44c477e77a5cfc76d907024830450221008ac3eee9036c67f21f9cf55e1d4468d37164c983a96aacc634670deadc56189a0220349fb3c105696f605a3d96d78d14916d7197b05a3e187b9a4ed65007138e83060121039094f42abf62812c8243b701ab5cbc17cc5d5c4cf03f50efe1db2a6ca762dc1c00000000"; 63 | const opIndex = 0; 64 | const pos = 101; 65 | 66 | const result = await syscoinRelay.scanBurnTx(txBytes, opIndex, pos); 67 | 68 | expect(result.destinationAddress.toLowerCase()).to.equal(expectedAddress.toLowerCase()); 69 | }); 70 | 71 | it("should scan a third burn transaction correctly (scanBurnTx2)", async function () { 72 | const { syscoinRelay } = await loadFixture(deploySyscoinRelayFixture); 73 | 74 | const expectedAddress = '0x3779F14B66343CC6191060646bd8edB51e34f3B6'; 75 | const txBytes = "0x8D00000000010163cdd871d58292ee1699c02caa8aef5872a502337082b3ad106992dd5da59d270000000000feffffff020000000000000000206a1e0181f7a9a57901000a143779f14b66343cc6191060646bd8edb51e34f3b60809010000000000160014c9e8c2952caa6ea53a0e65a375105c3c80de96d40247304402201bc3edb4b9e62a677116bc298cdc9e12773abfec5ed9cf4d207755513839fefd022023743a923882d2dcecd4fe02a09160811c939077dc7ea7192fa5accd5dc2e4dd012102c22456739386731b3321de1aa3fa656c3d9b04bc625af7f303fb84b9efcf078100000000"; 76 | const opIndex = 0; 77 | const pos = 60; 78 | 79 | const result = await syscoinRelay.scanBurnTx(txBytes, opIndex, pos); 80 | 81 | expect(result.destinationAddress.toLowerCase()).to.equal(expectedAddress.toLowerCase()); 82 | }); 83 | }); 84 | }); -------------------------------------------------------------------------------- /test/SyscoinVaultManager.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { ethers } from "hardhat"; 3 | import { loadFixture } from "@nomicfoundation/hardhat-network-helpers"; 4 | 5 | // Helper functions to mimic the contract’s scaling functions 6 | function scaleToSatoshi(rawValue: bigint, tokenDecimals: number): bigint { 7 | if (tokenDecimals > 8) { 8 | // scale down: integer division truncates fraction 9 | return rawValue / 10n ** BigInt(tokenDecimals - 8); 10 | } else if (tokenDecimals < 8) { 11 | // scale up 12 | return rawValue * 10n ** BigInt(8 - tokenDecimals); 13 | } else { 14 | return rawValue; 15 | } 16 | } 17 | 18 | describe("SyscoinVaultManager", function () { 19 | // A fixture to deploy the contracts 20 | async function deployVaultFixture() { 21 | const [trustedRelayer, owner, user1, user2] = await ethers.getSigners(); 22 | 23 | // Deploy the vault contract 24 | const SyscoinVaultManagerFactory = await ethers.getContractFactory("SyscoinVaultManager"); 25 | const vault = await SyscoinVaultManagerFactory.deploy( 26 | trustedRelayer.address, // trustedRelayer 27 | 9999n, // SYSAssetGuid 28 | owner.address // initialOwner 29 | ); 30 | await vault.waitForDeployment(); 31 | 32 | // Deploy ERC20 mocks (one with 18, one with 6 decimals, one with 4) 33 | const MockERC20Factory = await ethers.getContractFactory("MockERC20"); 34 | const erc20 = await MockERC20Factory.deploy("MockToken", "MCK", 18); 35 | await erc20.waitForDeployment(); 36 | 37 | const erc20_6dec = await MockERC20Factory.deploy("SixDecToken", "SIX", 6); 38 | await erc20_6dec.waitForDeployment(); 39 | 40 | const erc20_4dec = await MockERC20Factory.deploy("FourDecToken", "FOUR", 4); 41 | await erc20_4dec.waitForDeployment(); 42 | 43 | // Deploy ERC721 and ERC1155 mocks 44 | const MockERC721Factory = await ethers.getContractFactory("MockERC721"); 45 | const erc721 = await MockERC721Factory.deploy("MockNFT", "MNFT"); 46 | await erc721.waitForDeployment(); 47 | 48 | const MockERC1155Factory = await ethers.getContractFactory("MockERC1155"); 49 | const erc1155 = await MockERC1155Factory.deploy("https://test.api/"); 50 | await erc1155.waitForDeployment(); 51 | 52 | return { vault, erc20, erc20_6dec, erc20_4dec, erc721, erc1155, trustedRelayer, owner, user1, user2 }; 53 | } 54 | 55 | // ─── ERC20 BRIDGING TESTS ──────────────────────────────────────────────── 56 | describe("ERC20 bridging tests", function () { 57 | it("should auto-register and freezeBurn small amounts", async function () { 58 | const { vault, erc20, owner } = await loadFixture(deployVaultFixture); 59 | const decimals = 18; 60 | const amount = ethers.parseEther("5"); // 5 tokens 61 | 62 | // Mint 100 tokens to owner and approve vault 63 | await erc20.mint(owner.address, ethers.parseEther("100")); 64 | await erc20.connect(owner).approve(await vault.getAddress(), amount); 65 | 66 | // FreezeBurn 5 tokens 67 | const tx = await vault.connect(owner).freezeBurn( 68 | amount, 69 | await erc20.getAddress(), 70 | 0, 71 | "sys1qtestaddress" 72 | ); 73 | 74 | // For ERC20, tokenIdx is fixed at 0. First registration yields assetId = 1. 75 | const expectedAssetGuid = (0n << 32n) | 1n; 76 | const satoshiValue = scaleToSatoshi(BigInt(amount.toString()), decimals); 77 | 78 | await expect(tx) 79 | .to.emit(vault, "TokenFreeze") 80 | .withArgs(expectedAssetGuid, owner.address, satoshiValue, "sys1qtestaddress"); 81 | 82 | const vaultBalance = await erc20.balanceOf(await vault.getAddress()); 83 | expect(vaultBalance).to.equal(amount); 84 | 85 | const userBalance = await erc20.balanceOf(owner.address); 86 | expect(userBalance).to.equal(ethers.parseEther("100") - amount); 87 | 88 | const assetId = await vault.assetRegistryByAddress(await erc20.getAddress()); 89 | expect(assetId).to.equal(1n); 90 | }); 91 | 92 | it("should revert bridging zero", async function () { 93 | const { vault, erc20, owner } = await loadFixture(deployVaultFixture); 94 | await erc20.mint(owner.address, ethers.parseEther("10")); 95 | await erc20.connect(owner).approve(await vault.getAddress(), ethers.parseEther("1")); 96 | await expect( 97 | vault.connect(owner).freezeBurn( 98 | 0, 99 | await erc20.getAddress(), 100 | 0, 101 | "sys1qtestaddress" 102 | ) 103 | ).to.be.revertedWith("ERC20 requires positive value"); 104 | }); 105 | 106 | it("should revert if scaledValue > 2^63-1", async function () { 107 | const { vault, erc20, owner } = await loadFixture(deployVaultFixture); 108 | // Mint a huge amount (1e10 tokens with 18 decimals) 109 | const hugeAmount = ethers.parseEther("10000000000"); 110 | await erc20.mint(owner.address, hugeAmount); 111 | await erc20.connect(owner).approve(await vault.getAddress(), hugeAmount); 112 | await expect( 113 | vault.connect(owner).freezeBurn( 114 | hugeAmount, 115 | await erc20.getAddress(), 116 | 0, 117 | "sys1qtestaddress" 118 | ) 119 | ).to.be.revertedWith("Overflow bridging to Sys"); 120 | }); 121 | 122 | it("should pass 10b - 1", async function () { 123 | const { vault, erc20, owner } = await loadFixture(deployVaultFixture); 124 | const amount = ethers.parseEther("9999999999"); 125 | await erc20.mint(owner.address, amount); 126 | await erc20.connect(owner).approve(await vault.getAddress(), amount); 127 | const tx = await vault.connect(owner).freezeBurn( 128 | amount, 129 | await erc20.getAddress(), 130 | 0, 131 | "sys1qtestaddress" 132 | ); 133 | const expectedAssetGuid = (0n << 32n) | 1n; 134 | const satoshiValue = scaleToSatoshi(BigInt(amount.toString()), 18); 135 | 136 | await expect(tx) 137 | .to.emit(vault, "TokenFreeze") 138 | .withArgs(expectedAssetGuid, owner.address, satoshiValue, "sys1qtestaddress"); 139 | 140 | const vaultBalance = await erc20.balanceOf(await vault.getAddress()); 141 | expect(vaultBalance).to.equal(amount); 142 | const userBalance = await erc20.balanceOf(owner.address); 143 | // In a fresh fixture the owner’s balance will be zero after depositing. 144 | expect(userBalance).to.equal(0); 145 | const assetId = await vault.assetRegistryByAddress(await erc20.getAddress()); 146 | expect(assetId).to.equal(1n); 147 | }); 148 | 149 | it("should handle a processTransaction from trustedRelayer", async function () { 150 | const { vault, erc20, trustedRelayer, owner, user1 } = await loadFixture(deployVaultFixture); 151 | const depositAmount = ethers.parseEther("100"); 152 | await erc20.mint(owner.address, depositAmount); 153 | await erc20.connect(owner).approve(await vault.getAddress(), depositAmount); 154 | await vault.connect(owner).freezeBurn( 155 | depositAmount, 156 | await erc20.getAddress(), 157 | 0, 158 | "sys1someaddr" 159 | ); 160 | const assetId = await vault.assetRegistryByAddress(await erc20.getAddress()); 161 | // ERC20: tokenIdx = 0 so assetGuid = (0 << 32) | assetId 162 | const assetGuid = (0n << 32n) | BigInt(assetId.toString()); 163 | 164 | const satoshiValue = scaleToSatoshi(depositAmount, 18); 165 | 166 | // Process the bridging (Sys -> NEVM) so that tokens are withdrawn to user1 167 | await vault.connect(trustedRelayer).processTransaction( 168 | 123, 169 | satoshiValue, 170 | user1.address, 171 | assetGuid 172 | ); 173 | const user1Balance = await erc20.balanceOf(user1.address); 174 | expect(user1Balance).to.equal(depositAmount); 175 | 176 | // Replay with the same txHash should revert 177 | await expect( 178 | vault.connect(trustedRelayer).processTransaction( 179 | 123, 180 | satoshiValue, 181 | owner.address, 182 | assetGuid 183 | ) 184 | ).to.be.revertedWith("TX already processed"); 185 | }); 186 | }); 187 | 188 | // ─── BRIDGING A 6-DECIMALS TOKEN ──────────────────────────────────────── 189 | describe("Bridging a 6-decimals token => scale up to 8 decimals", function () { 190 | it("should freezeBurn small amounts for 6-dec token => logs scaled satoshiValue", async function () { 191 | const { vault, erc20_6dec, owner } = await loadFixture(deployVaultFixture); 192 | const decimals = 6; 193 | const amount = ethers.parseUnits("5", 6); 194 | // Mint 20 tokens (20×1e6) 195 | await erc20_6dec.mint(owner.address, ethers.parseUnits("20", 6)); 196 | await erc20_6dec.connect(owner).approve(await vault.getAddress(), amount); 197 | const tx = await vault.connect(owner).freezeBurn( 198 | amount, 199 | await erc20_6dec.getAddress(), 200 | 0, 201 | "sys1qtestaddress" 202 | ); 203 | 204 | const assetId = await vault.assetRegistryByAddress(await erc20_6dec.getAddress()); 205 | expect(assetId).to.equal(1n); 206 | const satoshiValue = scaleToSatoshi(BigInt(amount.toString()), decimals); 207 | await expect(tx) 208 | .to.emit(vault, "TokenFreeze") 209 | .withArgs((0n << 32n) | assetId, owner.address, satoshiValue, "sys1qtestaddress"); 210 | const vaultBal = await erc20_6dec.balanceOf(await vault.getAddress()); 211 | expect(vaultBal).to.equal(amount); 212 | const userBal = await erc20_6dec.balanceOf(owner.address); 213 | // 20 - 5 = 15 tokens (in 6-decimal units) 214 | expect(userBal).to.equal(ethers.parseUnits("15", 6)); 215 | }); 216 | 217 | it("should revert bridging over 10B limit for 6-dec", async function () { 218 | const { vault, erc20_6dec, owner } = await loadFixture(deployVaultFixture); 219 | // Mint a huge amount: e.g. 2e17 tokens in 6-decimals 220 | const hugeAmount = ethers.parseUnits("200000000000000000", 6); 221 | await erc20_6dec.mint(owner.address, hugeAmount); 222 | await erc20_6dec.connect(owner).approve(await vault.getAddress(), hugeAmount); 223 | await expect( 224 | vault.connect(owner).freezeBurn( 225 | hugeAmount, 226 | await erc20_6dec.getAddress(), 227 | 0, 228 | "sys1qtestaddress" 229 | ) 230 | ).to.be.revertedWith("Overflow bridging to Sys"); 231 | }); 232 | }); 233 | 234 | // ─── BRIDGING A 4-DECIMALS TOKEN ───────────────────────────────────────── 235 | describe("Bridging a 4-decimals token => scale up x 10^(8-4)=10^4", function () { 236 | it("should freezeBurn 12.3456 tokens => logs 1234560000 sat", async function () { 237 | const { vault, erc20_4dec, owner } = await loadFixture(deployVaultFixture); 238 | // Mint 100 tokens (4 decimals) 239 | await erc20_4dec.mint(owner.address, ethers.parseUnits("100", 4)); 240 | // Bridging 12.3456 tokens (raw = 123456) 241 | const amount = ethers.parseUnits("12.3456", 4); 242 | await erc20_4dec.connect(owner).approve(await vault.getAddress(), amount); 243 | const tx = await vault.connect(owner).freezeBurn( 244 | amount, 245 | await erc20_4dec.getAddress(), 246 | 0, 247 | "sys1qtestaddress" 248 | ); 249 | const assetId = await vault.assetRegistryByAddress(await erc20_4dec.getAddress()); 250 | expect(assetId).to.equal(1n); 251 | const satoshiValue = scaleToSatoshi(BigInt(amount.toString()), 4); 252 | await expect(tx) 253 | .to.emit(vault, "TokenFreeze") 254 | .withArgs((0n << 32n) | assetId, owner.address, satoshiValue, "sys1qtestaddress"); 255 | const vaultBal = await erc20_4dec.balanceOf(await vault.getAddress()); 256 | expect(vaultBal).to.equal(amount); 257 | const userBal = await erc20_4dec.balanceOf(owner.address); 258 | // 100 - 12.3456 = 87.6544 tokens (raw units) 259 | expect(userBal).to.equal(ethers.parseUnits("87.6544", 4)); 260 | }); 261 | }); 262 | 263 | // ─── PAUSE FUNCTIONALITY ──────────────────────────────────────────────── 264 | describe("Pause Functionality", function () { 265 | it("should allow freezeBurn and processTransaction when not paused", async function () { 266 | const { vault, erc20, owner, trustedRelayer } = await loadFixture(deployVaultFixture); 267 | await erc20.mint(owner.address, ethers.parseEther("100")); 268 | await erc20.connect(owner).approve(await vault.getAddress(), ethers.parseEther("10")); 269 | await vault.connect(owner).freezeBurn( 270 | ethers.parseEther("10"), 271 | await erc20.getAddress(), 272 | 0, 273 | "sys1addr" 274 | ); 275 | const assetId = await vault.assetRegistryByAddress(await erc20.getAddress()); 276 | const assetGuid = (0n << 32n) | BigInt(assetId.toString()); 277 | await vault.connect(trustedRelayer).processTransaction( 278 | 123, 279 | scaleToSatoshi(ethers.parseEther("10"), 18), 280 | owner.address, 281 | assetGuid 282 | ); 283 | }); 284 | 285 | it("should revert freezeBurn and processTransaction when paused", async function () { 286 | const { vault, erc20, owner, trustedRelayer } = await loadFixture(deployVaultFixture); 287 | await vault.connect(owner).setPaused(true); 288 | await expect( 289 | vault.connect(owner).freezeBurn( 290 | ethers.parseEther("1"), 291 | await erc20.getAddress(), 292 | 0, 293 | "sys1addr" 294 | ) 295 | ).to.be.revertedWith("Bridge is paused"); 296 | const assetId = await vault.assetRegistryByAddress(await erc20.getAddress()); 297 | const assetGuid = (0n << 32n) | BigInt(assetId.toString()); 298 | await expect( 299 | vault.connect(trustedRelayer).processTransaction( 300 | 456, 301 | scaleToSatoshi(ethers.parseEther("1"), 18), 302 | owner.address, 303 | assetGuid 304 | ) 305 | ).to.be.revertedWith("Bridge is paused"); 306 | }); 307 | 308 | it("should resume operations after unpausing", async function () { 309 | const { vault, erc20, owner, trustedRelayer } = await loadFixture(deployVaultFixture); 310 | await vault.connect(owner).setPaused(true); 311 | await vault.connect(owner).setPaused(false); 312 | await erc20.mint(owner.address, ethers.parseEther("100")) 313 | await erc20.connect(owner).approve(await vault.getAddress(), ethers.parseEther("5")); 314 | const tx = await vault.connect(owner).freezeBurn( 315 | ethers.parseEther("5"), 316 | await erc20.getAddress(), 317 | 0, 318 | "sys1addr" 319 | ); 320 | await expect(tx).to.emit(vault, "TokenFreeze"); 321 | const assetId = await vault.assetRegistryByAddress(await erc20.getAddress()); 322 | const assetGuid = (0n << 32n) | BigInt(assetId.toString()); 323 | await vault.connect(trustedRelayer).processTransaction( 324 | 456, 325 | scaleToSatoshi(ethers.parseEther("5"), 18), 326 | owner.address, 327 | assetGuid 328 | ); 329 | }); 330 | }); 331 | 332 | // ─── ERC721 BRIDGING TESTS ──────────────────────────────────────────────── 333 | describe("ERC721 bridging tests", function () { 334 | it("should revert bridging ERC721 tokenId=0", async function () { 335 | const { vault, erc721, owner } = await loadFixture(deployVaultFixture); 336 | await expect( 337 | vault.connect(owner).freezeBurn( 338 | 1, 339 | await erc721.getAddress(), 340 | 0, 341 | "sys1addr" 342 | ) 343 | ).to.be.revertedWith("ERC721 tokenId required"); 344 | }); 345 | 346 | it("should revert bridging ERC1155 tokenId=0", async function () { 347 | const { vault, erc1155, owner } = await loadFixture(deployVaultFixture); 348 | await expect( 349 | vault.connect(owner).freezeBurn( 350 | 1, 351 | await erc1155.getAddress(), 352 | 0, 353 | "sys1addr" 354 | ) 355 | ).to.be.revertedWith("ERC1155 tokenId required"); 356 | }); 357 | 358 | it("should auto-register and freezeBurn an NFT", async function () { 359 | const { vault, erc721, owner } = await loadFixture(deployVaultFixture); 360 | // Mint an NFT (assumed tokenId = 1) 361 | await erc721["mint(address)"](owner.address); 362 | await erc721.connect(owner).approve(await vault.getAddress(), 1); 363 | const ownerBefore = await erc721.ownerOf(1); 364 | expect(ownerBefore).to.equal(owner.address); 365 | const tx = await vault.connect(owner).freezeBurn( 366 | 1, 367 | await erc721.getAddress(), 368 | 1, 369 | "sys1qNFTaddress" 370 | ); 371 | // For ERC721, the first NFT deposit assigns a tokenIdx(e.g. 1) 372 | // Assuming ERC20, 6dec, 4dec have taken assetIds 1–3, ERC721 gets assetId 1. 373 | await expect(tx) 374 | .to.emit(vault, "TokenFreeze") 375 | .withArgs((1n << 32n) | 1n, owner.address, 1n, "sys1qNFTaddress"); 376 | const assetId = await vault.assetRegistryByAddress(await erc721.getAddress()); 377 | expect(assetId).to.equal(1n); 378 | const ownerAfter = await erc721.ownerOf(1); 379 | expect(ownerAfter).to.equal(await await vault.getAddress()); 380 | }); 381 | 382 | it("should revert bridging ERC721 if value != 1", async function () { 383 | const { vault, erc721, owner } = await loadFixture(deployVaultFixture); 384 | await erc721["mint(address,uint256)"](owner.address, 2); 385 | await erc721.connect(owner).approve(await vault.getAddress(), 2); 386 | await expect( 387 | vault.connect(owner).freezeBurn( 388 | 2, 389 | await erc721.getAddress(), 390 | 2, 391 | "sys1qNFTaddress" 392 | ) 393 | ).to.be.revertedWith("ERC721 deposit requires exactly 1"); 394 | }); 395 | 396 | it("should bridge 1 NFT => value=1", async function () { 397 | const { vault, erc721, owner } = await loadFixture(deployVaultFixture); 398 | await erc721["mint(address,uint256)"](owner.address, 2); 399 | await erc721.connect(owner).approve(await vault.getAddress(), 2); 400 | const tx = await vault.connect(owner).freezeBurn( 401 | 1, 402 | await erc721.getAddress(), 403 | 2, 404 | "sys1mydestination" 405 | ); 406 | await expect(tx) 407 | .to.emit(vault, "TokenFreeze") 408 | .withArgs((1n << 32n) | 1n, owner.address, 1n, "sys1mydestination"); 409 | const newOwner = await erc721.ownerOf(2); 410 | expect(newOwner).to.equal(await vault.getAddress()); 411 | }); 412 | 413 | it("should revert bridging value=2 for ERC721", async function () { 414 | const { vault, erc721, owner } = await loadFixture(deployVaultFixture); 415 | await erc721["mint(address,uint256)"](owner.address, 3); 416 | await erc721.connect(owner).approve(await vault.getAddress(), 3); 417 | await expect( 418 | vault.connect(owner).freezeBurn( 419 | 2, 420 | await erc721.getAddress(), 421 | 3, 422 | "sys1mydestination" 423 | ) 424 | ).to.be.revertedWith("ERC721 deposit requires exactly 1"); 425 | }); 426 | 427 | it("should bridge in => value=1 => unlock the NFT", async function () { 428 | const { vault, erc721, trustedRelayer, owner, user1 } = await loadFixture(deployVaultFixture); 429 | // Mint and deposit an NFT (tokenId 3) 430 | await erc721["mint(address,uint256)"](owner.address, 3); 431 | await erc721.connect(owner).approve(await vault.getAddress(), 3); 432 | await vault.connect(owner).freezeBurn( 433 | 1, 434 | await erc721.getAddress(), 435 | 3, 436 | "sys1mydestination" 437 | ); 438 | const assetId = await vault.assetRegistryByAddress(await erc721.getAddress()); 439 | const assetGuid = (1n << 32n) | BigInt(assetId.toString()); 440 | const ownerInVault = await erc721.ownerOf(3); 441 | expect(ownerInVault).to.equal(await vault.getAddress()); 442 | // Process bridging to unlock the NFT 443 | await vault.connect(trustedRelayer).processTransaction( 444 | 1000, 445 | 1, 446 | user1.address, 447 | assetGuid 448 | ); 449 | const newOwner = await erc721.ownerOf(3); 450 | expect(newOwner).to.equal(user1.address); 451 | }); 452 | 453 | it("should bridge in => value=2 => reverts", async function () { 454 | const { vault, erc721, trustedRelayer, owner, user1 } = await loadFixture(deployVaultFixture); 455 | // Mint and deposit an NFT (tokenId 3) 456 | await erc721["mint(address,uint256)"](owner.address, 3); 457 | await erc721.connect(owner).approve(await vault.getAddress(), 3); 458 | await vault.connect(owner).freezeBurn( 459 | 1, 460 | await erc721.getAddress(), 461 | 3, 462 | "sys1mydestination" 463 | ); 464 | const assetId = await vault.assetRegistryByAddress(await erc721.getAddress()); 465 | const assetGuid = (1n << 32n) | BigInt(assetId.toString()); 466 | await expect( 467 | vault.connect(trustedRelayer).processTransaction( 468 | 1001, 469 | 2, 470 | user1.address, 471 | assetGuid 472 | ) 473 | ).to.be.revertedWith("ERC721 bridging requires value=1"); 474 | }); 475 | 476 | it("should revert when depositing already bridged ERC721 token", async function () { 477 | const { vault, erc721, owner } = await loadFixture(deployVaultFixture); 478 | await erc721["mint(address)"](owner.address); 479 | await erc721.connect(owner).approve(await vault.getAddress(), 1); 480 | // First deposit 481 | await vault.connect(owner).freezeBurn( 482 | 1, 483 | await erc721.getAddress(), 484 | 1, 485 | "sys1existingtoken" 486 | ); 487 | // Attempt to re-deposit the same token should revert 488 | await expect( 489 | vault.connect(owner).freezeBurn( 490 | 1, 491 | await erc721.getAddress(), 492 | 1, 493 | "sys1existingtoken" 494 | ) 495 | ).to.be.reverted; 496 | const tokenOwner = await erc721.ownerOf(1); 497 | expect(tokenOwner).to.equal(await vault.getAddress()); 498 | }); 499 | 500 | it("should correctly calculate assetGuid for ERC721", async function () { 501 | const { vault, erc721, owner } = await loadFixture(deployVaultFixture); 502 | await erc721["mint(address,uint256)"](owner.address, 4); // tokenId = 4 503 | await erc721.connect(owner).approve(await vault.getAddress(), 4); 504 | const tx = await vault.connect(owner).freezeBurn( 505 | 1, 506 | await erc721.getAddress(), 507 | 4, 508 | "sys1address" 509 | ); 510 | const assetId = await vault.assetRegistryByAddress(await erc721.getAddress()); 511 | const expectedAssetGuid = (1n << 32n) | BigInt(assetId.toString()); 512 | await expect(tx) 513 | .to.emit(vault, "TokenFreeze") 514 | .withArgs(expectedAssetGuid, owner.address, 1n, "sys1address"); 515 | }); 516 | 517 | it("should allow re-depositing ERC721 after withdrawal", async function () { 518 | const { vault, erc721, trustedRelayer, owner } = await loadFixture(deployVaultFixture); 519 | // Mint tokenId 10 and deposit it 520 | await erc721["mint(address,uint256)"](owner.address, 10); 521 | await erc721.connect(owner).approve(await vault.getAddress(), 10); 522 | const tx = await vault.connect(owner).freezeBurn( 523 | 1, 524 | await erc721.getAddress(), 525 | 10, 526 | "sys1addrNFT" 527 | ); 528 | const ownerInVault = await erc721.ownerOf(10); 529 | expect(ownerInVault).to.equal(await vault.getAddress()); 530 | const assetId = await vault.assetRegistryByAddress(await erc721.getAddress()); 531 | // Assume tokenIndex for tokenId 10 is 5 so assetGuid = (5 << 32) | assetId 532 | const assetGuid = (1n << 32n) | BigInt(assetId.toString()); 533 | await expect(tx) 534 | .to.emit(vault, "TokenFreeze") 535 | .withArgs(assetGuid, owner.address, 1n, "sys1addrNFT"); 536 | // Process withdrawal to return NFT to owner 537 | await vault.connect(trustedRelayer).processTransaction( 538 | 5555, 539 | 1, 540 | owner.address, 541 | assetGuid 542 | ); 543 | const ownerAfterWithdrawal = await erc721.ownerOf(10); 544 | expect(ownerAfterWithdrawal).to.equal(owner.address); 545 | // Re-deposit the same NFT 546 | await erc721.connect(owner).approve(await vault.getAddress(), 10); 547 | await vault.connect(owner).freezeBurn( 548 | 1, 549 | await erc721.getAddress(), 550 | 10, 551 | "sys1addrNFT" 552 | ); 553 | const ownerAfterRedeploy = await erc721.ownerOf(10); 554 | expect(ownerAfterRedeploy).to.equal(await vault.getAddress()); 555 | }); 556 | 557 | it("should bridge ERC721 token with large tokenId correctly using registry", async function () { 558 | const { vault, erc721, trustedRelayer, owner } = await loadFixture(deployVaultFixture); 559 | const largeTokenId = "123456789123456789123456789"; 560 | await erc721["mint(address,uint256)"](owner.address, largeTokenId); 561 | await erc721.connect(owner).approve(await vault.getAddress(), largeTokenId); 562 | await vault.connect(owner).freezeBurn( 563 | 1, 564 | await erc721.getAddress(), 565 | largeTokenId, 566 | "sys1largeidaddr" 567 | ); 568 | const assetId = await vault.assetRegistryByAddress(await erc721.getAddress()); 569 | // Retrieve tokenIdx using the contract’s lookup function 570 | const tokenIdx = await vault.getTokenIdxFromRealTokenId(assetId, largeTokenId); 571 | 572 | expect(tokenIdx).to.be.gt(0); 573 | const assetGuid = (BigInt(tokenIdx.toString()) << 32n) | BigInt(assetId.toString()); 574 | await vault.connect(trustedRelayer).processTransaction( 575 | 98765, 576 | 1, 577 | owner.address, 578 | assetGuid 579 | ); 580 | const newOwner = await erc721.ownerOf(largeTokenId); 581 | expect(newOwner).to.equal(owner.address); 582 | }); 583 | 584 | it("should correctly map ERC721 tokenIdx to realTokenId in registry", async function () { 585 | const { vault, erc721, owner } = await loadFixture(deployVaultFixture); 586 | const tokenId = 9999; 587 | await erc721["mint(address,uint256)"](owner.address, tokenId); 588 | await erc721.connect(owner).approve(await vault.getAddress(), tokenId); 589 | await vault.connect(owner).freezeBurn( 590 | 1, 591 | await erc721.getAddress(), 592 | tokenId, 593 | "sys1address9999" 594 | ); 595 | const assetId = await vault.assetRegistryByAddress(await erc721.getAddress()); 596 | const tokenIdx = await vault.getTokenIdxFromRealTokenId(assetId, tokenId); 597 | expect(tokenIdx).to.be.gt(0); 598 | const realTokenId = await vault.getRealTokenIdFromTokenIdx(assetId, tokenIdx); 599 | expect(realTokenId.toString()).to.equal(tokenId.toString()); 600 | }); 601 | }); 602 | 603 | // ─── ERC1155 BRIDGING TESTS ─────────────────────────────────────────────── 604 | describe("ERC1155 bridging tests", function () { 605 | it("should auto-register and freezeBurn quantity", async function () { 606 | const { vault, erc1155, owner } = await loadFixture(deployVaultFixture); 607 | // Mint item id=777 with quantity=10 608 | await erc1155.mint(owner.address, 777, 10); 609 | await erc1155.connect(owner).setApprovalForAll(await vault.getAddress(), true); 610 | const tx = await vault.connect(owner).freezeBurn( 611 | 5, 612 | await erc1155.getAddress(), 613 | 777, 614 | "sys1155Address" 615 | ); 616 | const assetId = await vault.assetRegistryByAddress(await erc1155.getAddress()); 617 | const tokenIdx = await vault.getTokenIdxFromRealTokenId(assetId, 777); 618 | await expect(tx) 619 | .to.emit(vault, "TokenFreeze") 620 | .withArgs(((BigInt(tokenIdx.toString()) << 32n)) | assetId, owner.address, 5n, "sys1155Address"); 621 | const vaultBal = await erc1155.balanceOf(await vault.getAddress(), 777); 622 | expect(vaultBal).to.equal(5); 623 | }); 624 | 625 | it("should revert bridging zero quantity in ERC1155", async function () { 626 | const { vault, erc1155, owner } = await loadFixture(deployVaultFixture); 627 | await expect( 628 | vault.connect(owner).freezeBurn( 629 | 0, 630 | await erc1155.getAddress(), 631 | 999, 632 | "sys1155Address" 633 | ) 634 | ).to.be.revertedWith("ERC1155 requires positive value"); 635 | }); 636 | 637 | it("should freezeBurn 100 integer units in 1155 => bridging to Sys", async function () { 638 | const { vault, erc1155, owner } = await loadFixture(deployVaultFixture); 639 | await erc1155.mint(owner.address, 777, 1000); 640 | await erc1155.connect(owner).setApprovalForAll(await vault.getAddress(), true); 641 | const tx = await vault.connect(owner).freezeBurn( 642 | 200, 643 | await erc1155.getAddress(), 644 | 777, 645 | "sys1address" 646 | ); 647 | const assetId = await vault.assetRegistryByAddress(await erc1155.getAddress()); 648 | const tokenIdx = await vault.getTokenIdxFromRealTokenId(assetId, 777); 649 | const assetGuid = (BigInt(tokenIdx.toString()) << 32n) | assetId; 650 | await expect(tx) 651 | .to.emit(vault, "TokenFreeze") 652 | .withArgs(assetGuid, owner.address, 200n, "sys1address"); 653 | const vaultBal = await erc1155.balanceOf(await vault.getAddress(), 777); 654 | expect(vaultBal).to.equal(200); 655 | }); 656 | 657 | it("should revert bridging >10B for 1155", async function () { 658 | const { vault, erc1155, owner } = await loadFixture(deployVaultFixture); 659 | await erc1155.mint(owner.address, 777, "20000000000"); 660 | await erc1155.connect(owner).setApprovalForAll(await vault.getAddress(), true); 661 | await vault.connect(owner).freezeBurn( 662 | "20000000000", 663 | await erc1155.getAddress(), 664 | 777, 665 | "sys1address" 666 | ); 667 | const vaultBal = await erc1155.balanceOf(await vault.getAddress(), 777); 668 | expect(vaultBal).to.equal("20000000000"); 669 | }); 670 | 671 | it("should processTransaction for 1155 integer bridging", async function () { 672 | const { vault, erc1155, trustedRelayer, owner } = await loadFixture(deployVaultFixture); 673 | 674 | await erc1155.mint(owner.address, 777, 1000); 675 | await erc1155.connect(owner).setApprovalForAll(await vault.getAddress(), true); 676 | await vault.connect(owner).freezeBurn( 677 | 1000, 678 | await erc1155.getAddress(), 679 | 777, 680 | "sys1address" 681 | ); 682 | const assetId = await vault.assetRegistryByAddress(await erc1155.getAddress()); 683 | const tokenIdx = await vault.getTokenIdxFromRealTokenId(assetId, 777); 684 | const assetGuid = (BigInt(tokenIdx.toString()) << 32n) | assetId; 685 | // Mint tokens to vault to simulate a deposit (e.g. 300 units) 686 | 687 | await vault.connect(trustedRelayer).processTransaction( 688 | 999, 689 | 300, 690 | owner.address, 691 | assetGuid 692 | ); 693 | const userBal = await erc1155.balanceOf(owner.address, 777); 694 | expect(userBal).to.equal(300); 695 | }); 696 | 697 | it("should bridge 1155 quantity up to 10B-1", async function () { 698 | const { vault, erc1155, owner } = await loadFixture(deployVaultFixture); 699 | await erc1155.mint(owner.address, 777, "9999999999"); 700 | await erc1155.connect(owner).setApprovalForAll(await vault.getAddress(), true); 701 | const tx = await vault.connect(owner).freezeBurn( 702 | "9999999999", 703 | await erc1155.getAddress(), 704 | 777, 705 | "sys1mydestination" 706 | ); 707 | const assetId = await vault.assetRegistryByAddress(await erc1155.getAddress()); 708 | const tokenIdx = await vault.getTokenIdxFromRealTokenId(assetId, 777); 709 | const assetGuid = (BigInt(tokenIdx.toString()) << 32n) | assetId; 710 | await expect(tx) 711 | .to.emit(vault, "TokenFreeze") 712 | .withArgs(assetGuid, owner.address, BigInt("9999999999"), "sys1mydestination"); 713 | const vaultBal = await erc1155.balanceOf(await vault.getAddress(), 777); 714 | expect(vaultBal).to.equal("9999999999"); 715 | }); 716 | 717 | it("should bridge 1155 quantity => direct integer unlock", async function () { 718 | const { vault, erc1155, trustedRelayer, owner, user1 } = await loadFixture(deployVaultFixture); 719 | 720 | await erc1155.mint(owner.address, 777, 1000); 721 | await erc1155.connect(owner).setApprovalForAll(await vault.getAddress(), true); 722 | await vault.connect(owner).freezeBurn( 723 | 1000, 724 | await erc1155.getAddress(), 725 | 777, 726 | "sys1address" 727 | ); 728 | const assetId = await vault.assetRegistryByAddress(await erc1155.getAddress()); 729 | const tokenIdx = await vault.getTokenIdxFromRealTokenId(assetId, 777); 730 | const assetGuid = (BigInt(tokenIdx.toString()) << 32n) | assetId; 731 | 732 | // Mint tokens to vault to simulate deposit 733 | await erc1155.mint(await vault.getAddress(), 777, 500); 734 | await vault.connect(trustedRelayer).processTransaction( 735 | 998, 736 | 500, 737 | user1.address, 738 | assetGuid 739 | ); 740 | const userBal = await erc1155.balanceOf(user1.address, 777); 741 | expect(userBal).to.equal(500); 742 | }); 743 | 744 | it("should reuse tokenIdx for same ERC1155 tokenId", async function () { 745 | const { vault, erc1155, owner } = await loadFixture(deployVaultFixture); 746 | // First deposit for tokenId 777 747 | await erc1155.mint(owner.address, 777, 10); 748 | await erc1155.connect(owner).setApprovalForAll(await vault.getAddress(), true); 749 | await vault.connect(owner).freezeBurn( 750 | 10, 751 | await erc1155.getAddress(), 752 | 777, 753 | "sys1addr1" 754 | ); 755 | // Get tokenIdx using the contract’s lookup function (assetId assumed to be 5) 756 | const tokenIdx1 = await vault.getTokenIdxFromRealTokenId(5, 777); 757 | // Deposit more of the same tokenId 758 | await erc1155.mint(owner.address, 777, 10); 759 | await vault.connect(owner).freezeBurn( 760 | 5, 761 | await erc1155.getAddress(), 762 | 777, 763 | "sys1address" 764 | ); 765 | const tokenIdx2 = await vault.getTokenIdxFromRealTokenId(5, 777); 766 | expect(tokenIdx1.toString()).to.equal(tokenIdx2.toString()); 767 | }); 768 | 769 | it("should reuse same tokenIdx and update balances correctly on repeated ERC1155 deposits", async function () { 770 | const { vault, erc1155, owner } = await loadFixture(deployVaultFixture); 771 | const tokenId = 888; 772 | await erc1155.mint(owner.address, tokenId, 100); 773 | await erc1155.connect(owner).setApprovalForAll(await vault.getAddress(), true); 774 | await vault.connect(owner).freezeBurn( 775 | 10, 776 | await erc1155.getAddress(), 777 | tokenId, 778 | "sys1address" 779 | ); 780 | let vaultBal = await erc1155.balanceOf(await vault.getAddress(), tokenId); 781 | expect(vaultBal).to.equal(10); 782 | let userBal = await erc1155.balanceOf(owner.address, tokenId); 783 | expect(userBal).to.equal(90); 784 | const tokenIdxBefore = await vault.getTokenIdxFromRealTokenId(5, tokenId); 785 | await vault.connect(owner).freezeBurn( 786 | 20, 787 | await erc1155.getAddress(), 788 | tokenId, 789 | "sys1addrERC1155" 790 | ); 791 | const tokenIdxAfter = await vault.getTokenIdxFromRealTokenId(5, tokenId); 792 | expect(tokenIdxAfter.toString()).to.equal(tokenIdxBefore.toString()); 793 | vaultBal = await erc1155.balanceOf(await vault.getAddress(), tokenId); 794 | expect(vaultBal).to.equal(30); 795 | userBal = await erc1155.balanceOf(owner.address, tokenId); 796 | expect(userBal).to.equal(70); 797 | }); 798 | }); 799 | }); 800 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "module": "commonjs", 5 | "esModuleInterop": true, 6 | "forceConsistentCasingInFileNames": true, 7 | "strict": true, 8 | "skipLibCheck": true, 9 | "resolveJsonModule": true 10 | } 11 | } 12 | --------------------------------------------------------------------------------