Хакатон Nervos - Broaden the Spectrum. Трансформация.
Tags: blockchain, ethereum, nervos, polijuice
Published: 2021-08-21 01:37:19

В рамках Хакатона на площадке Gitcoin, нужно описать процедуру трансформации или переноса Ethereum приложения на Nervos Network Layer 2.

В одном посте вряд-ли получится описать процесс в деталях, особенно, если он похож на матрешку. Поэтому, настоятельно рекомендую ориентироваться по данному репозиторию:

https://github.com/Kuzirashi/gw-gitcoin-instruction

Я не буду сразу переходить к процессу переносу, так как считаю нужным хотя бы чуток объяснить как происходит работа со смарт-контрактами на базе Nervos Network Layer 2.

Первое, что нужно сделать это клонировать учебный репозиторий Хакатона, в котором лежат все нужные нам скрипты.

`git clone https://github.com/kuzirashi/gw-gitcoin-instruction`

И устанавливаем все нужные зависимости:

`yarn install-all`

### Работаем со смарт-контрактами - компиляция и развертывание на блокчейне Nervos Layer 2.

Для начала, неможко гугл-транслейта документации:

> Polyjuice - это среда исполнения, совместимая с Ethereum EVM, которая позволяет запускать смарт-контракты на основе Solidity на Nervos. Цель проекта - 100% совместимость, позволяющая всем контрактам Ethereum работать на Nervos без каких-либо изменений.

> Polyjuice разработан для использования со сводной структурой Godwoken Layer 2. Это позволяет Polyjuice полностью перенести выполнение смарт-контрактов с уровня 1 на уровень 2, обеспечивая масштабируемость, выходящую далеко за рамки того, на что сегодня способна основная сеть Ethereum.


Другими словами, команда Nervos Network постаралась(и дальше старается), сделать так, чтобы смарт-контракты Ethereum (Solidity) были 100% совместимы с их блокчейном. Так что, если у нас есть готовый смарт-контракт в файле .sol, поидее, не должно возникнуть проблем чтобы развернуть его на блокчейне Nervos. Чем мы дальше и займемся. 

Рабочая папка для нас сейчас это `src/examples/2-deploy-contract/` клонированного выше репозитория. Там же, в папке `contracts` и лежит файл с простейшим смарт-контрактом `SimpleStorage.sol`:

```
pragma solidity >=0.8.0;

contract SimpleStorage {
  uint storedData;

  constructor() payable {
    storedData = 123;
  }

  function set(uint x) public payable {
    storedData = x;
  }

  function get() public view returns (uint) {
    return storedData;
  }
}
```
Чтобы скомпилировать этот файл, запускаем:

`yarn compile`

В результате, у нас должен появиться в папке `build/contracts/` новый файл `SimpleStorage.json` скомпилированный truffle в EVM байткод (тут я не буду вдаваться в детали). 


Для того, чтобы развернуть смарт-контракт на новом блокчейне находим в текущей рабочей папке файл `index.js`:
```
const { existsSync } = require('fs');
const Web3 = require('web3');
const { PolyjuiceHttpProvider, PolyjuiceAccounts } = require("@polyjuice-provider/web3");

const contractName = process.argv.slice(2)[0];

if (!contractName) {
    throw new Error(`No compiled contract specified to deploy. Please put it in "src/examples/2-deploy-contract/build/contracts" directory and provide its name as an argument to this program, eg.: "node index.js SimpleStorage.json"`);
}

let compiledContractArtifact = null;
const filenames = [`./build/contracts/${contractName}`, `./${contractName}`];
for(const filename of filenames)
{
    if(existsSync(filename))
    {
        console.log(`Found file: ${filename}`);
        compiledContractArtifact = require(filename);
        break;
    }
    else
        console.log(`Checking for file: ${filename}`);
}

if(compiledContractArtifact === null)
    throw new Error(`Unable to find contract file: ${contractName}`);

const DEPLOYER_PRIVATE_KEY = 'ETHEREUM PRIVATE KEY'; // Replace this with your Ethereum private key with funds on Layer 2.

const GODWOKEN_RPC_URL = 'http://godwoken-testnet-web3-rpc.ckbapp.dev';

const polyjuiceConfig = {
    rollupTypeHash: '0x4cc2e6526204ae6a2e8fcf12f7ad472f41a1606d5b9624beebd215d780809f6a',
    ethAccountLockCodeHash: '0xdeec13a7b8e100579541384ccaf4b5223733e4a5483c3aec95ddc4c1d5ea5b22',
    web3Url: GODWOKEN_RPC_URL
};

const provider = new PolyjuiceHttpProvider(
    GODWOKEN_RPC_URL,
    polyjuiceConfig,
);

const web3 = new Web3(provider);

web3.eth.accounts = new PolyjuiceAccounts(polyjuiceConfig);
const deployerAccount = web3.eth.accounts.wallet.add(DEPLOYER_PRIVATE_KEY);
web3.eth.Contract.setProvider(provider, web3.eth.accounts);

(async () => {
    const balance = BigInt(await web3.eth.getBalance(deployerAccount.address));

    if (balance === 0n) {
        console.log(`Insufficient balance. Can't deploy contract. Please deposit funds to your Ethereum address: ${deployerAccount.address}`);
        return;
    }

    console.log(`Deploying contract...`);

    const deployTx = new web3.eth.Contract(compiledContractArtifact.abi).deploy({
        data: getBytecodeFromArtifact(compiledContractArtifact),
        arguments: []
    }).send({
        from: deployerAccount.address,
        to: '0x' + new Array(40).fill(0).join(''),
        gas: 6000000,
        gasPrice: '0',
    });

    deployTx.on('transactionHash', hash => console.log(`Transaction hash: ${hash}`));

    const receipt = await deployTx;

    console.log(`Deployed contract address: ${receipt.contractAddress}`);
})();

function getBytecodeFromArtifact(contractArtifact) {
    return contractArtifact.bytecode || contractArtifact.data?.bytecode?.object
}
```

Этот скрипт принимает в качестве параметра название файла смарт-контракта, в нашем случае это `SimpleStorage.json`. Также, импортирует все необходимые библиотеки для работы с Web3 и Polyjuice.

Ну и самое основное, это нужно указать PRIVATE KEY вашего Ethereum аккаунта, от имени которого мы хотим развернуть наш смарт-контракт:

`const DEPLOYER_PRIVATE_KEY = 'ETHEREUM PRIVATE KEY'; // Replace this with your Ethereum private key with funds on Layer 2.`

*Как экспортировать ключ от своего Ethereum аккаунта можно почитать [здесь](https://github.com/Kuzirashi/gw-gitcoin-instruction/blob/master/src/component-tutorials/5.extract.ethereum.private.key.md).*

Теперь кульминация. Запускаем:

`node index.js SimpleStorage.json`

В результате мы получаем `Deployed contract address`, который  мы можем использовать вместе с файлом скомпилированного ранее контракта. 

*Важный момент - чтобы развернуть приложение на блокчейне нужен Godwoken Layer 2 аккаунт. Как его сделать и пополнить почитать можно [здесь](https://github.com/Kuzirashi/gw-gitcoin-instruction/blob/master/src/component-tutorials/4.layer2.deposit.md).
Я специально упускаю эти шаги, так как мне пришлось бы описывать весь Хакатон...*

**Итак, самый важный вывод - таким способом мы можем использовать любой Ethereum смарт-контракт. Но это еще не все - теперь нужно научиться использовать этот смарт-контракт в нашем приложении.**

#### Использование смарт-контракта

Теперь переходим в нашу новую рабочую папку `gw-gitcoin-instruction/src/examples/3-call-contract`, в которой лежит `index.js`:

```
const Web3 = require('web3');
const { PolyjuiceHttpProvider, PolyjuiceAccounts } = require("@polyjuice-provider/web3");

const ACCOUNT_PRIVATE_KEY = ''; // Replace this with your Ethereum private key with funds on Layer 2.

const CONTRACT_ABI = [
    {
      "inputs": [],
      "stateMutability": "payable",
      "type": "constructor"
    },
    {
      "inputs": [
        {
          "internalType": "uint256",
          "name": "x",
          "type": "uint256"
        }
      ],
      "name": "set",
      "outputs": [],
      "stateMutability": "payable",
      "type": "function"
    },
    {
      "inputs": [],
      "name": "get",
      "outputs": [
        {
          "internalType": "uint256",
          "name": "",
          "type": "uint256"
        }
      ],
      "stateMutability": "view",
      "type": "function"
    }
  ]; // this should be an Array []
  
const CONTRACT_ADDRESS = ''; // Deployed contract address

const GODWOKEN_RPC_URL = 'http://godwoken-testnet-web3-rpc.ckbapp.dev';

const polyjuiceConfig = {
    rollupTypeHash: '0x4cc2e6526204ae6a2e8fcf12f7ad472f41a1606d5b9624beebd215d780809f6a',
    ethAccountLockCodeHash: '0xdeec13a7b8e100579541384ccaf4b5223733e4a5483c3aec95ddc4c1d5ea5b22',
    web3Url: GODWOKEN_RPC_URL
};
  
const provider = new PolyjuiceHttpProvider(
    GODWOKEN_RPC_URL,
    polyjuiceConfig,
);

const web3 = new Web3(provider);

web3.eth.accounts = new PolyjuiceAccounts(polyjuiceConfig);
const account = web3.eth.accounts.wallet.add(ACCOUNT_PRIVATE_KEY);
web3.eth.Contract.setProvider(provider, web3.eth.accounts);

async function readCall() {
    const contract = new web3.eth.Contract(CONTRACT_ABI, CONTRACT_ADDRESS);

    const callResult = await contract.methods.get().call({
        from: account.address
    });

    console.log(`Read call result: ${callResult}`);
}

async function writeCall() {
    const contract = new web3.eth.Contract(CONTRACT_ABI, CONTRACT_ADDRESS);

    const tx = contract.methods.set(10).send(
        {
            from: account.address,
            to: '0x' + new Array(40).fill(0).join(''),
            gas: 6000000,
            gasPrice: '100',
        }
    );

    tx.on('transactionHash', hash => console.log(`Write call transaction hash: ${hash}`));

    const receipt = await tx;

    console.log('Write call transaction receipt: ', receipt);
}

(async () => {
    const balance = BigInt(await web3.eth.getBalance(account.address));

    if (balance === 0n) {
        console.log(`Insufficient balance. Can't issue a smart contract call. Please deposit funds to your Ethereum address: ${account.address}`);
        return;
    }

    console.log('Calling contract...');

    // Check smart contract state before state change.
    await readCall();

    // Change smart contract state.
    await writeCall();

    // Check smart contract state after state change.
    await readCall();
})();
```
В этом скрипте вначале мы также указываем ключ нашего Ethereum аккаунта. 

`const CONTRACT_ABI = []` - это объект `ABI` нашего смарт-контракта. Мы его просто копируем с файла .json, и вставляем как именно массив.

`const CONTRACT_ADDRESS = ''` - это адресс смарт-контракта развернутого в предыдущем шаге.

Следующий блок кода создает PolyjuiceHttpProvider с константными параметрами, который потом мы передаем Web3. Именно здесь начинается магия и приложение начинает работать с Nervos Layer 2:

```
const provider = new PolyjuiceHttpProvider(
    GODWOKEN_RPC_URL,
    polyjuiceConfig,
);
const web3 = new Web3(provider);
web3.eth.accounts = new PolyjuiceAccounts(polyjuiceConfig);
const account = web3.eth.accounts.wallet.add(ACCOUNT_PRIVATE_KEY);
web3.eth.Contract.setProvider(provider, web3.eth.accounts);
```
Ну, а теперь давайте наконец-то поработаем с нашим смарт-контрактом. Для этого нам нужно вызвать те функции, которые в нем прописаны (если забыли, просьба вернуться к исходному коду нашего контракта):

Следующий блок кода вызывает функцию `get()` смарт-контракта, которая читает данные:

```
async function readCall() {
    const contract = new web3.eth.Contract(CONTRACT_ABI, CONTRACT_ADDRESS);

    const callResult = await contract.methods.get().call({
        from: account.address
    });

    console.log(`Read call result: ${callResult}`);
}
```

А здесь мы вызываем функцию set(), которая принимает параметр (смотреть исходный код контракта), а значит мы записываем данные. Для этого в конце, вместо `call()` уже используется `send()`:

```
async function writeCall() {
    const contract = new web3.eth.Contract(CONTRACT_ABI, CONTRACT_ADDRESS);

    const tx = contract.methods.set(10).send(
        {
            from: account.address,
            to: '0x' + new Array(40).fill(0).join(''),
            gas: 6000000,
            gasPrice: '100',
        }
    );

    tx.on('transactionHash', hash => console.log(`Write call transaction hash: ${hash}`));

    const receipt = await tx;

    console.log('Write call transaction receipt: ', receipt);
}
```
Запускам `node index.js` и результат:

![Call contract](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/8q98bc1x19bo7m00e7b9.png)

**Вот теперь, мы научились использовать наш Ethereum смарт-контракт, который был развернут на Layer 2 Nervos Network c помощью Polyjuice**

#### Перенос Ethereum dapp приложения на Polyjuice

*То, ради чего этот пост был написан.*

И так, первое что нужно сделать это выбрать любое Ethereum приложение. Я выбрал ["Список задач" с этого репозитория](https://github.com/AndrewJBateman/blockchain-ethereum-contract).

Клонируем:

`git clone https://github.com/AndrewJBateman/blockchain-ethereum-contract`

##### Добавление новой сети в MetaMask.

Для того, чтобы наше приложение могло общаться с новым блокчейном, нам необходима специальная сеть. 
Чтобы добавить новую сеть, нажимаем на значок расширения, дальше во избежания исчезнования окошка, нажимите на вертикальное троиточие и выбирете Expand View, чтобы открыть его в отдельном окне. Дальше, нажимаем на список Networks, и выбираем Custom RPC.  

Вводим необходимые параметры для нашей сети:

Network Name: `Godwoken Test Network`
New RPC URL: `https://godwoken-testnet-web3-rpc.ckbapp.dev/`
Chain ID: `71393`
Currency Symbol (optional): `N/A`
Block Explorer URL (optional): `N/A`

Нажимаем Save. Выглядеть это должно вот так:

![Godwoken Test Network](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/f6xgc2946twiypqqu7nx.png)


##### Развертывание смарт-контракта

Выбранное приложение имеет один смарт-контракт - 'TasksContract.sol', который необходимо скомпилировать и развернуть. 

Здесь есть 3 варианта:
* Первый. Использование нашего учебного репозитория. Просто копируем в папку `contracts` `.json` файл контракта и запускаем те же команды описанные выше. На выходе нам нужно получить адрес по которому был развернут смарт-контракт, а также забрать `.json` файл с папки `build/contracts/`


* Второй. Использование команды `truffle migrate --network godwoken`. 
Файл настроек сети и Polyjuice провайдера лежит тут -  [`truffle-config.js`](https://github.com/x777/todo-dapp-polyjuice/blob/main/truffle-config.js).
Этот config файл использует файл `.env` с такими параметрами:

```
PRIVATE_KEY='' - Ethereum ключ
WEB3_PROVIDER_URL='https://godwoken-testnet-web3-rpc.ckbapp.dev'
ROLLUP_TYPE_HASH='0x4cc2e6526204ae6a2e8fcf12f7ad472f41a1606d5b9624beebd215d780809f6a'
ETH_ACCOUNT_LOCK_CODE_HASH='0xdeec13a7b8e100579541384ccaf4b5223733e4a5483c3aec95ddc4c1d5ea5b22'
```

*Замечение: этот способ требует знание truffle и неможко танцев с бубном...*

* Третий. Это развертывание смарт-контракта внутри самого приложения - просто добавляется проверка адреса и если по этому адресу контракт не найден, тогда скрипт выполняет код, который выполняет этот процесс показанный в примере.

##### Добавляем Polyjuice провайдер

Так как выбранное мною приложение не использует никакой фронтенд фреймворк, подключаем в `client/index.html` нужные с библиотеки помощью `<script>`:

```
<script src="/libs/web3/dist/web3.min.js"></script>
<script src="/libs/@truffle/contract/dist/truffle-contract.min.js"></script>
<script src="./nervos-godwoken-integration.js"></script>
<script src="./polyjuice.js"></script>
```
Не знаю почему, но мой Nodejs отказался работать с require(), поэтому я использовал утилиту [`browserify`](https://browserify.org/), чтобы "запаковать" главный файл `client/app.js`

Следующим шагом, для удобства, создаем файл [`client/config.js`](https://github.com/x777/todo-dapp-polyjuice/blob/main/client/config.js):

```
const CONFIG = {
    WEB3_PROVIDER_URL: 'https://godwoken-testnet-web3-rpc.ckbapp.dev',
    ROLLUP_TYPE_HASH: '0x4cc2e6526204ae6a2e8fcf12f7ad472f41a1606d5b9624beebd215d780809f6a',
    ETH_ACCOUNT_LOCK_CODE_HASH: '0xdeec13a7b8e100579541384ccaf4b5223733e4a5483c3aec95ddc4c1d5ea5b22',
    DEFAULT_SEND_OPTIONS: {
        gas: 6000000
    },
    CONTRACT_ADDRESS: '0x57a4d44de477A569766Ab934Fff38281d034f3EF',
    SUDT_ERC20_PROXY_ADDRESS: '0xD5A6a78E967cd70C6791d5289B3E4b1D5D55eC27'
};

module.exports = {CONFIG}
```

Почти все параметры уже были использованны нами в учебном репозитории, это предустановленные настройки которые нужны для Polyjuice провайдера. 

`CONTRACT_ADDRESS` - это адрес нашего смарт-контракта, который был развернут в предыдущем шаге.
`SUDT_ERC20_PROXY_ADDRESS` - это адрес отдельного смарт-контракта, который используется для отображения нужного SUDT (токена). Более подробно о SUDT можно почитать [тут](https://github.com/Kuzirashi/gw-gitcoin-instruction/blob/master/src/tasks/4.issue.sudt.deposit.md) и [тут](https://github.com/Kuzirashi/gw-gitcoin-instruction/blob/master/src/tasks/6.use.force.bridge.to.deposit.md)

Далее, переходим в файл [`client/app.js`](https://github.com/x777/todo-dapp-polyjuice/blob/main/client/app.js) и подключаем наш `config.js` и добавляем Polyjuice провайдер:
```
loadWeb3: async () => {
		if (web3) {
			// Polyjuice provider config
			const providerConfig = {
				rollupTypeHash: CONFIG.ROLLUP_TYPE_HASH,
				ethAccountLockCodeHash: CONFIG.ETH_ACCOUNT_LOCK_CODE_HASH,
				web3Url: CONFIG.WEB3_PROVIDER_URL
				};
				
			// Polyjuice provider
			App.web3Provider = new PolyjuiceHttpProvider(CONFIG.WEB3_PROVIDER_URL, providerConfig);
			web3 = new Web3(App.web3Provider);
		} else {
			alert("To use this DAPP you need setup godwoken network in your Metamask")
			console.log("No ethereum browser is installed. Try installing MetaMask");
		}
		  
	}
```

Загружаем смарт-контракты:

```
// load already deployed contract by address, 
	// to deploy use: truffle migrate --network godwoken
	// 
	loadContract: async () => {
		try {
			const res = await fetch("TasksContract.json");
			const tasksContractJSON = await res.json();
			// Use CONFIG for contracts address
			App.tasksContract = new web3.eth.Contract(tasksContractJSON.abi, CONFIG.CONTRACT_ADDRESS);
			

			console.log('Task contract loaded:', App.tasksContract)
		} catch (error) {
			alert('Contract not found. Please deploy before continue')
			console.error('Cannot find deployed contract:', error);
		}
	},

	loadSudtContract: async () => {
		try {
			const res = await fetch("ERC20.json");
			const sudtContract = await res.json();
			// Use CONFIG for contracts address
			App.sudtContract = new web3.eth.Contract(sudtContract.abi, CONFIG.SUDT_ERC20_PROXY_ADDRESS);

			console.log('SUDT contract loaded:', App.sudtContract)
		} catch (error) {
			alert('Contract not found. Please deploy before continue')
			console.error('Cannot find deployed contract:', error);
		}
	}
```

Отображение баланса аккаунтов:

```
 // render account balance as inner text
	renderBalance: async () => {
		const addressTranslator = new AddressTranslator();
		const polyjuiceAddress = addressTranslator.ethAddressToGodwokenShortAddress(App.account);
		const ckETHAddress = await addressTranslator.getLayer2DepositAddress(web3, App.account);
		
		const urlDeposit = "https://force-bridge-test.ckbapp.dev/bridge/Ethereum/Nervos?recipient=" + ckETHAddress.addressString
		
		const ckbShn = BigInt(await web3.eth.getBalance(App.account));
		const ckbBalance = parseInt(BigInt(ckbShn / 10n ** 8n));

		const ckEth = BigInt(await App.sudtContract.methods.balanceOf(polyjuiceAddress).call({
			from: App.account
		}));
		
		ckEthBalance = parseInt(BigInt(ckEth / 10n**8n));
		
		document.getElementById("account").innerText = App.account;
		document.getElementById("polyjuice-account").innerText = polyjuiceAddress;
		document.getElementById("ck-eth").innerText = ckETHAddress.addressString;
		// document.getElementById("ckb-balance").innerText = ckbBalance + " $CKB";
		document.getElementById("do-deposit").href = urlDeposit
		document.getElementById("ckb-balance").innerText = ckbBalance + " $CKB";
		document.getElementById("cketh-balance").innerText = ckEthBalance + " $ckETH";
		
		//   Copy address
		document.querySelector("#do-deposit").addEventListener("click", function copy() {
			var copyText = document.querySelector("#ck-eth");
			var elementText = copyText.textContent;
			navigator.clipboard.writeText(elementText);
		});

		// console.log(await App.sudtContract.methods.balanceOf(polyjuiceAddress).call({
		// 	from: App.account
		// }));
	},
```

Вызываем функцию смарт-контракта, которая выдает список всех задач и проходимся по каждой из них:

```
// render tasks
	renderTasks: async () => {
		const taskCounter = await App.tasksContract.methods.taskCounter().call(
			{
				from: App.account,
				...CONFIG.DEFAULT_SEND_OPTIONS ,
			}
		);
		const taskCounterNumber = parseInt(taskCounter);

		let html = "";

		for (let i = 1; i <= taskCounterNumber; i++) {
			const task = await App.tasksContract.methods.tasks(i).call({
				from: App.account,
				...CONFIG.DEFAULT_SEND_OPTIONS ,
			});
			const taskId = parseInt(task[0]);
			const taskTitle = task[1];
			const taskDescription = task[2];
			const taskDone = task[3];
			const taskCreatedAt = task[4];

			// Creating a task Card
			let taskElement = `
        <div class="card bg-light rounded-0 mb-2">
          <div class="card-header d-flex justify-content-between align-items-center">
            <span>${taskTitle}</span>
            <div class="form-check form-switch">
              <input class="form-check-input" data-id="${taskId}" type="checkbox" onchange="App.toggleDone(this)" ${
				taskDone === true && "checked"
			}>
            </div>
          </div>
          <div class="card-body">
            <span>${taskDescription}</span>
            
            <p class="text-muted">Task was created ${new Date(
							taskCreatedAt * 1000
						).toLocaleString()}</p>
            </label>
          </div>
        </div>
      `;
			html += taskElement;
		}

		document.querySelector("#tasksList").innerHTML = html;
	},
```

Вызываем функцию смарт-контракта, которая создает новую задачу:

```
createTask: async (title, description) => {
		try {
			const result = await App.tasksContract.methods.createTask(title, description).send({
				...CONFIG.DEFAULT_SEND_OPTIONS,
				from: App.account,
			});
			alert("Transaction completed, page will be reload.")
			window.location.reload();
		} catch (error) {
			console.error(error);
		}
	},
```

Здесь важный момент использования `send()` вместо `call()`, о котором я упоминал выше в учебном примере.

Ну, и последняя функция смарт-контракта, которая меняет статус задачи:

```
toggleDone: async (element) => {
		const taskId = element.dataset.id;
		console.log(taskId)
		await App.tasksContract.methods.toggleDone(taskId).send({
			...CONFIG.DEFAULT_SEND_OPTIONS,
			from: App.account,
		});
		alert("Transaction completed, page will be reload.")
		window.location.reload();
	},
```
Также, используется `send()`.

На этом процесс портирование (перенос) Ethereum dapp приложения закончен. В моем случае осталось только подправить макет единственной страницы `index.html` и все, можно запускать `npm run dev`:

Видео работающего приложения на [YouTube](https://www.youtube.com/watch?v=0HOUiy4MVj0)

Скриншоты:

![Polyjuice App](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/j3r9zr2s73af6ongr0l9.png)

![Polyjuice App](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/trsjhezll0ecb2u5lqil.png)


##### Ссылки:

[Github оригинального Ethereum приложения](https://github.com/AndrewJBateman/blockchain-ethereum-contract)
[Github трансформированного приложения на Polyjuice](https://github.com/x777/todo-dapp-polyjuice)
[Хакатон Nervos - Broaden The Spectrum](https://gitcoin.co/hackathon/nervos?)


[Знакомство с Nervos Network для новичков](https://teletype.in/@vladislav_vl25/nervosguideru)