простое руководство по созданию приложений для смарт-контрактов

🏃‍♀️ SpeedRun:

Https://www.youtube.com/watch?v=7rq3TPL-tgI

🤩 Введение

Мое первое «А-ха!» момент с Ethereum читал эти 10 строк кода:

💡 Этот код отслеживает owner при создании контракта и позволяет только owner вызывать withdraw() с помощью оператора require().

🤔 ОЙ! Этот смарт-контракт контролирует собственные деньги. У него есть адрес и баланс, он может отправлять и получать средства, он может даже взаимодействовать с другими смарт-контрактами.

🤖 Это постоянно работающий общедоступный * конечный автомат *, который можно программировать, и каждый в мире может с ним взаимодействовать!

👩‍💻 Предпосылки

Вам потребуется установить NodeJS› = 10 , Yarn и Git .

В этом руководстве предполагается, что вы имеете базовое представление о разработке веб-приложений и, возможно, даже немного знакомы с основными концепциями Ethereum. Вы всегда можете узнать больше о Solidity в документации, но сначала попробуйте следующее:

🙇‍♀️ Начало работы

Откройте терминал и клонируйте репозиторий scaffold-eth. В нем есть все необходимое для создания прототипа и создания децентрализованного приложения:

git clone https://github.com/austintgriffith/scaffold-eth
cd scaffold-eth
git checkout part1-smart-contract-wallet-social-recovery
yarn install

☢️ Предупреждение, вы можете получить предупреждения, похожие на ошибки, когда вы запустите yarn install продолжить и выполните следующие три команды. Наверное, сработает!

💡 Обратите внимание, как мы берем ветку part1-smart-contract-wallet-social-recovery для этого урока. 🏗 scaffold-eth - это стек разработки Ethereum с возможностью разветвления, и каждое руководство будет веткой, которую вы можете разветвлять и использовать!

Откройте код локально в вашем любимом редакторе и осмотритесь:

В packages/buidler/contracts вы найдете SmartContractWallet.sol. Это наш смарт-контракт (бэкэнд).

В packages/react-app/src это App.js, а SmartContractWallet.js это наше веб-приложение (интерфейс).

Запустите свой интерфейс:

yarn start

☢️ Предупреждение, ваш процессор просто сойдет с ума, если не будут запущены следующие две строки:

Запустите локальный блокчейн на базе Buidler во втором терминале:

yarn run chain

В третьем терминале скомпилируйте и разверните свой контракт:

yarn run deploy

☢️ Внимание! В этом проекте есть несколько разных каталогов с именами «контракты». Уделите дополнительную секунду, чтобы убедиться, что вы нашли SmartContractWallet.sol в папке packages/buidler/contracts.

💡 Код нашего смарт-контракта компилируется в «артефакты», называемые bytecode и ABI. Этот ABI определяет, как взаимодействовать с нашим контрактом, а bytecode - это «машинный код». Вы можете найти эти артефакты в папке: packages/buidler/artifacts.

💡 Для развертывания контракта bytecode отправляется в транзакции, а затем наш контракт будет жить в определенном address в нашей локальной сети. Эти артефакты автоматически вводятся в наш интерфейс, чтобы мы могли взаимодействовать с нашим контрактом.

Откройте http: // localhost: 3000 в веб-браузере:

🗺 Давайте быстро осмотрим эти леса, чтобы увидеть местность… 🔭

🛰 Провайдеры

Откройте наш интерфейс App.js в packages/react-app/src с помощью редактора.

🏗 scaffold-eth имеет трех различных провайдеров для вас в App.js:

mainnetProvider: Infura поддерживает основную сеть Ethereum только для чтения. Это используется для получения балансов в основной сети и разговора с существующими действующими контрактами, такими как цена ETH из Uniswap или поиск имени ENS.

localProvider: локальная цепочка Buidler, в которой развертываются ваши контракты, пока мы выполняем локальную итерацию Solidity. Локальный кран работает от первой учетной записи этого провайдера.

injectedProvider: начинается с провайдера записи (мгновенная учетная запись при загрузке страницы), но затем вы можете нажать connect, чтобы ввести более безопасный кошелек на базе Web3Modal. Этот провайдер действует как наша подписывающая сторона для отправки транзакций в обе наши локальные и основные сети.

💡 Блокчейны имеют сеть узлов, которые содержат текущее состояние. Мы могли бы запустить собственный узел, если бы хотели получить доступ к сети Ethereum, но мы не хотим, чтобы нашим пользователям приходилось синхронизировать цепочку только для того, чтобы использовать наше приложение. Вместо этого мы поговорим с «поставщиком» инфраструктуры, используя простые веб-запросы.

🔗 Крючки

Мы также будем использовать кучу вкусных ловушек из 🏗 scaffold-eth, например useBalance(), чтобы отслеживать баланс адреса или useContractReader(), чтобы синхронизировать наше состояние с нашими контрактами. Подробнее о хуках React читайте здесь.

🎛 Компоненты

Этот каркас также включает в себя набор удобных компонентов для создания децентрализованных приложений. Хорошим примером является <AddressInput/>, которое мы увидим чуть позже. Подробнее о компонентах React читайте здесь.

⚙️ Функции

Давайте создадим функцию под названием isOwner() inSmartContractWallet.sol в packages/buidler/contracts. Эта функция позволяет нам спросить кошелек, является ли конкретный адрес владельцем:

function isOwner(address possibleOwner) public view returns (bool) {
  return (possibleOwner==owner);
}

💡 Обратите внимание, как эта функция отмечена как view? Функции могут записывать в состояние или просто читать из него. Когда нам нужно написать в состояние, мы должны заплатить газ, чтобы отправить транзакцию по контракту, но чтение - это легко и дешево, потому что мы можем просто запросить состояние у любого поставщика.

🤔 ОЙ! Чтобы вызвать функцию смарт-контракта, вы отправляете транзакцию на адрес контракта.

Давайте также создадим функцию write с именем updateOwner(), которая позволяет текущему владельцу установить нового владельца:

function updateOwner(address newOwner) public {
  require(isOwner(msg.sender),"NOT THE OWNER!");
  owner = newOwner;
}

💡 Мы используем msg.sender и msg.value, вы можете узнать больше о единицах измерения и глобальных переменных здесь. msg.sender - это адрес, по которому была отправлена ​​транзакция, а msg.value - это количество эфира, отправленное с транзакцией.

💡 Обратите внимание, как этот оператор require() гарантирует, что msg.sender является текущим owner. Если это не так, произойдет revert() и вся транзакция будет отменена.

🤔 ОЙ! Транзакции Ethereum являются атомарными; либо все работает, либо все наоборот. Если мы отправим один токен Алисе и в том же вызове контракта не сможем взять один токен у Боба, вся транзакция будет отменена.

Сохраните, скомпилируйте и разверните свой контракт:

yarn run deploy

Когда подойдет договор, мы увидим, что ваш адрес не принадлежит владельцу:

Давайте передадим нашу учетную запись в смарт-контракт, когда он будет развернут, так что мы будем владельцем. Сначала скопируйте свою учетную запись сверху справа:

Затем отредактируйте файл SmartContractWallet.args в packages/buidler/contracts и измените адрес на свой адрес. Затем разверните заново:

yarn run deploy

💡 Мы используем автоматический сценарий, который пытается найти наши контракты и развернуть их. В конце концов, нам понадобится более индивидуальное решение, но вы можете взглянуть на scripts/deploy.js в каталоге packages/buidler.

Теперь ваш адрес должен быть владельцем контракта:

⛽️ Вам понадобится тестовый эфир, чтобы заплатить за газ, чтобы взаимодействовать с вашим контрактом:

Следуйте «✅ TODO LIST» и отправьте на наш счет несколько тестовых ETH. Скопируйте свой адрес из верхнего правого угла и вставьте его в кран в левом нижнем углу (и нажмите «Отправить»). Вы можете дать своим адресам любой тестовый эфир, который хотите.

Затем попробуйте внести средства в свой смарт-контракт с помощью кнопки 📥 Deposit:

☢️ Это должно произойти сбой, транзакции, отправляющие значение в ваш смарт-контракт, будут отменены, потому что мы еще не добавили «резервную» функцию.

Давайте добавим payable fallback() функцию к SmartContractWallet.sol, чтобы она могла принимать транзакции. Отредактируйте свой смарт-контракт в packages/buidler, чтобы добавить:

fallback() external payable {    
  console.log(msg.sender,"just deposited",msg.value);  
}

🤖 «Резервная» функция вызывается автоматически всякий раз, когда кто-то взаимодействует с нашим контрактом без указания имени функции для вызова. Например, если они просто отправят ETH прямо на адрес контракта.

Скомпилируйте и повторно разверните свой смарт-контракт с помощью:

yarn run deploy

🎉 Теперь, когда вы вносите средства, он должен их принимать!

Но это программируемые деньги, давайте добавим код, чтобы ограничить общую сумму ETH до 0,005 (1 доллар США по сегодняшней цене), чтобы быть уверенным, что никто не вложит миллион долларов в наш неаудированный контракт 😅. Замените свой fallback() на:

uint constant public limit = 0.005 * 10**18;
fallback() external payable {
  require(((address(this)).balance) <= limit, "WALLET LIMIT REACHED");
  console.log(msg.sender,"just deposited",msg.value);
}

💡 Обратите внимание, как мы умножаем на 10¹⁸? Solidity не поддерживает числа с плавающей запятой, поэтому все является целым числом. 1 ETH равен 10¹⁸ wei. Кроме того, если вы отправляете транзакцию со значением 1, это означает 1 wei, наименьшую возможную единицу в Ethereum. Цена 1 ETH на момент написания:

Теперь развернитесь и попробуйте внести депозит несколько раз. Вы должны получить сообщение об ошибке при достижении лимита.

💡 Обратите внимание на то, как у нас есть ценные отзывы во внешнем интерфейсе с сообщением из второго аргумента оператора require() в нашем смарт-контракте. Используйте это, чтобы помочь вам отладить свой смарт-контракт вместе с console.log, который отображается в вашем yarn run chain терминале:

Вы можете настроить лимит кошелька или просто повторно развернуть новый контракт, чтобы все сбросить:

yarn run deploy

💾 Хранение и вычисления

Допустим, мы хотим отслеживать адреса друзей, которым разрешено взаимодействовать с нашим контрактом. Мы могли бы сохранить whilelist[] массив, но тогда нам пришлось бы перебирать массив, сравнивая значения, чтобы увидеть, находится ли данный адрес в белом списке. Мы также можем отслеживать отображение, но тогда мы не сможем повторять их. Придется решить, что лучше. 🧐

💡 Хранение данных в цепочке относительно дорого. Каждому майнеру по всему миру необходимо выполнять и сохранять каждое изменение состояния. Вам нужно помнить о дорогих циклах или чрезмерных вычислениях. Стоит изучить несколько примеров и узнать больше о EVM.

🤔 ОЙ! Вот почему эта штука такая устойчивая и устойчивая к цензуре. Тысячи (мотивированных) третьих сторон выполняют один и тот же код и согласовывают состояние, которое они все хранят, без централизованного управления. Это невозможно остановить! 🤖 😳

Вернемся к смарт-контракту, давайте воспользуемся маппингом для хранения балансов. Мы не можем перебирать всех друзей внутри контракта, но это позволяет нам быстро читать и записывать доступ к bool для любого заданного address. Добавьте этот код в свой контракт:

mapping(address => bool) public friends;

💡 Обратите внимание, как мы пометили это friends отображение как public? Это публичный блокчейн, поэтому вы должны предполагать, что все публично.

☢️ Предупреждение: даже если мы установим для этого сопоставления значение private, это просто означает, что внешние контракты не могут его прочитать, все еще могут читать частные значения вне сети .

Создайте функцию, которая позволяет нам вызывать updateFriend() в true или false:

function updateFriend(address friendAddress, bool isFriend) public {
  require(isOwner(msg.sender),"NOT THE OWNER!");
  friends[friendAddress] = isFriend;
  console.log(friendAddress,"friend bool set to",isFriend);
}

💡 Обратите внимание, как мы повторно используем конкретную строку кода, которая требует, чтобы msg.sender было owner? Вы можете очистить это с помощью модификатора. Затем каждый раз, когда вам понадобится функция, которую может запустить только владелец, вы можете добавить к функции onlyOwner modifier вместо этой строки. (совершенно необязательно)

Теперь давайте развернем это и вернемся к нашему интерфейсу:

yarn run deploy

🤔 ОЙ! Мы можем параллельно вносить небольшие инкрементные изменения как во внешний интерфейс, так и в смарт-контракт. Эти жесткие циклы разработки позволяют нам быстро выполнять итерации и тестировать новые идеи или механизмы.

Нам нужно добавить форму в display в SmartContractWallet.js в каталоге packages/react-app/src. Сначала добавим переменную состояния:

const [ friendAddress, setFriendAddress ] = useState("")

Затем давайте создадим функцию, которая создает функцию, вызывающую updateFriend():

const updateFriend = (isFriend)=>{
  return ()=>{
    tx(writeContracts['SmartContractWallet'].updateFriend(friendAddress, isFriend))
    setFriendAddress("")
  }
}

💡 Обратите внимание на структуру кода для вызова функции в нашем контракте: _74 _._ 75_ (*args*) все заключены в tx(), чтобы мы могли отслеживать ход транзакции. Вы также можете await эту tx() функцию, чтобы получить результирующий хэш, статус и т. Д.

🤖 Когда вы пишете address public owner, он автоматически создает функцию «геттер» для этой owner переменной, и мы можем очень легко получить это с помощью ловушки useContractReader().

Затем давайте создадим ownerDisplay раздел, который будет отображаться только для owner. Это отобразит AddressInput с двумя кнопками для updateFriend(false) и updateFriend(true).

let ownerDisplay = []
if(props.address==owner){
  ownerDisplay.push(
    <Row align="middle" gutter={4}>
      <Col span={8} style={{textAlign:"right",opacity:0.333,paddingRight:6,fontSize:24}}>Friend:</Col>
      <Col span={10}>
        <AddressInput
          value={friendAddress}
          ensProvider={props.ensProvider}
          onChange={(address)=>{setFriendAddress(address)}}
        />
      </Col>
      <Col span={6}>
        <Button style={{marginLeft:4}} onClick={updateFriend(true)} shape="circle" icon={<CheckCircleOutlined />} />
        <Button style={{marginLeft:4}} onClick={updateFriend(false)} shape="circle" icon={<CloseCircleOutlined />} />
      </Col>
    </Row>
  )
}

Наконец, добавьте {ownerDisplay} в display под строкой владельца:

Попробуйте щелкнуть по экрану после горячей перезагрузки приложения. (Вы можете перейти к http: // localhost: 3000 в новом браузере или в режиме инкогнито, чтобы получить новую учетную запись сеанса для копирования нового адреса.)

Трудно сказать, что происходит, не имея возможности перебирать адреса. Трудно перечислить всех наших друзей и их статус в интерфейсе.

Это работа для мероприятий.

🛎 События

События почти как форма хранения. Они относительно дешевы для вывода из смарт-контракта во время выполнения, но главное в том, что смарт-контракты не могут читать события.

Вернемся к смарт-контракту SmartContractWallet.sol.

Создайте событие выше или ниже функции updateFriend():

event UpdateFriend(address sender, address friend, bool isFriend);

Затем внутри функции updateFriend() добавьте это emit:

emit UpdateFriend(msg.sender,friendAddress,isFriend);

Скомпилируйте и разверните изменения:

yarn run deploy

Затем в нашем интерфейсе мы можем добавить перехватчик событий. Добавьте этот код с остальными нашими хуками в SmartContractWallet.js:

const friendUpdates = useEventListener(readContracts,contractName,"UpdateFriend",props.localProvider,1);

(Эта строка ^ уже добавлена ​​для вас, потому что она используется для списка TODO 😅.)

В нашем рендере сразу после ‹/Card› добавьте отображение ‹List›:

<List
  style={{ width: 550, marginTop: 25}}
  header={<div><b>Friend Updates</b></div>}
  bordered
  dataSource={friendUpdates}
  renderItem={item => (
    <List.Item style={{ fontSize:22 }}>
      <Address 
        ensProvider={props.ensProvider} 
        value={item.friend}
      /> {item.isFriend?"✅":"❌"}
    </List.Item>
  )}
/>

🎉 Теперь, когда он перезагружается, мы можем добавлять и удалять друзей!

👨‍👩‍👧‍👦 Социальное восстановление

Теперь, когда в нашем контракте установлено friends, давайте создадим «режим восстановления», который они могут запускать.

Давайте представим, что каким-то образом мы потеряли закрытый ключ для owner, и теперь мы заблокированы от нашего кошелька смарт-контрактов. Нам нужно, чтобы один из наших друзей вызвал какое-то выздоровление.

Мы также должны быть уверены, что если друг случайно (или злонамеренно 😏) запустит восстановление, а у нас все еще есть доступ к учетной записи owner, мы сможем отменить восстановление в течение нескольких timeDelay секунд.

Во-первых, давайте настроим несколько переменных в SmartContractWallet.sol:

uint public timeToRecover = 0;
uint constant public timeDelay = 120; //seconds
address public recoveryAddress;

Затем дайте владельцу возможность установить recoveryAddress:

function setRecoveryAddress(address _recoveryAddress) public {
  require(isOwner(msg.sender),"NOT THE OWNER!");
  console.log(msg.sender,"set the recoveryAddress to",recoveryAddress);
  recoveryAddress = _recoveryAddress;
}

☢️ В этом руководстве много копий и вставок кода. Обязательно выделите секунду, чтобы замедлиться и прочитать ее, чтобы понять, что происходит. 🧐

💬 Если вы когда-нибудь застряли и расстроились, напишите мне DM в Twitter, и мы посмотрим, сможем ли мы разобраться в этом вместе! Проблемы с Github тоже отлично подходят для обратной связи!

Давайте добавим функцию, по которой наши друзья смогут позвонить, чтобы помочь нам вернуть наши средства:

function friendRecover() public {
  require(friends[msg.sender],"NOT A FRIEND");
  timeToRecover = block.timestamp + timeDelay;
  console.log(msg.sender,"triggered recovery",timeToRecover,recoveryAddress);
}

💡Мы используем block.timestamp, подробнее о специальных переменных можно прочитать здесь.

Если friendRecover() случайно срабатывает, мы хотим, чтобы наш владелец мог отменить восстановление:

function cancelRecover() public {
  require(isOwner(msg.sender),"NOT THE OWNER");
  timeToRecover = 0;
  console.log(msg.sender,"canceled recovery");
}

Наконец, если мы находимся в режиме восстановления и прошло достаточно времени, 🤖 любой может уничтожить наш контракт и отправить весь эфир на recoveryAddress:

function recover() public {
  require(timeToRecover>0 && timeToRecover<block.timestamp,"NOT EXPIRED");
  console.log(msg.sender,"triggered recover");
  selfdestruct(payable(recoveryAddress));
}

💡 selfdestruct() удалит наш смарт-контракт из блокчейна и вернет все средства в recoveryAddress.

☢️ Внимание! Смарт-контракт с owner, который может вызывать selfdestruct() в любое время, на самом деле не является «децентрализованным». Разработчики должны очень внимательно относиться к созданию механизмов, которые никто или организация не может контролировать или подвергать цензуре.

Давайте скомпилируем, развернем и вернемся к нашему интерфейсу:

yarn run deploy

В нашем SmartContractWallet.js, с другими нашими хуками, мы захотим отслеживать recoveryAddress:

const [ recoveryAddress, setRecoveryAddress ] = useState("")

Вот код формы, которая позволяет владельцу устанавливать recoveryAddress:

ownerDisplay.push(
  <Row align="middle" gutter={4}>
    <Col span={8} style={{textAlign:"right",opacity:0.333,paddingRight:6,fontSize:24}}>Recovery:</Col>
    <Col span={10}>
      <AddressInput
        value={recoveryAddress}
        ensProvider={props.ensProvider}
        onChange={(address)=>{
          setRecoveryAddress(address)
        }}
      />
    </Col>
    <Col span={6}>
      <Button style={{marginLeft:4}} onClick={()=>{
        tx(writeContracts['SmartContractWallet'].setRecoveryAddress(recoveryAddress))
        setRecoveryAddress("")
      }} shape="circle" icon={<CheckCircleOutlined />} />
    </Col>
  </Row>
)

Затем мы хотим отслеживать currentRecoveryAddress из нашего контракта с:

const currentRecoveryAddress = useContractReader(readContracts,contractName,"recoveryAddress",1777);

Давайте также отследим timeToRecover и localTimestamp:

const timeToRecover = useContractReader(readContracts,contractName,"timeToRecover",1777);
const localTimestamp = useTimestamp(props.localProvider)

И отобразите адрес восстановления с помощью <Address /> сразу после кнопки восстановления. Кроме того, мы добавим кнопку для владельца в cancelRecover(). Поместите этот код сразу после кнопки setRecoveryAddress():

{timeToRecover&&timeToRecover.toNumber()>0 ? (
  <Button style={{marginLeft:4}} onClick={()=>{
    tx( writeContracts['SmartContractWallet'].cancelRecover() )
  }} shape="circle" icon={<CloseCircleOutlined />}/>
):""}
{currentRecoveryAddress && currentRecoveryAddress!="0x0000000000000000000000000000000000000000"?(
  <span style={{marginLeft:8}}>
    <Address
      minimized={true}
      value={currentRecoveryAddress}
      ensProvider={props.ensProvider}
    />
  </span>
):""}

💡 Здесь мы используем ENS для перевода имени в адрес и обратно. Это работает аналогично традиционному DNS, где вы можете зарегистрировать имя.

Теперь в наших хуках давайте отследим, если пользователь isFriend:

const isFriend = useContractReader(readContracts,contractName,"friends",[props.address],1777);

Если они друзья, давайте покажем им кнопку для вызова friendRecover(), а затем, в конце концов, recover(), когда localTimestamp будет после timeToRecover. Добавьте это большое "else if" в конце проверки владельца if(props.address==owner){:

}else if(isFriend){
  let recoverDisplay = (
    <Button style={{marginLeft:4}} onClick={()=>{
      tx( writeContracts['SmartContractWallet'].friendRecover() )
    }} shape="circle" icon={<SafetyOutlined />}/>
  )
  if(localTimestamp&&timeToRecover.toNumber()>0){
    const secondsLeft = timeToRecover.sub(localTimestamp).toNumber()
    if(secondsLeft>0){
      recoverDisplay = (
        <div>
          {secondsLeft+"s"}
        </div>
      )
    }else{
      recoverDisplay = (
        <Button style={{marginLeft:4}} onClick={()=>{
          tx( writeContracts['SmartContractWallet'].recover() )
        }} shape="circle" icon={<RocketOutlined />}/>
      )
    }
  }
  ownerDisplay = (
    <Row align="middle" gutter={4}>
      <Col span={8} style={{textAlign:"right",opacity:0.333,paddingRight:6,fontSize:24}}>Recovery:</Col>
      <Col span={16}>
        {recoverDisplay}
      </Col>
    </Row>
  )
}

🚀 Попробуйте все, прочувствуйте приложение. Настройте контракты, настройте интерфейс. Теперь твое! 😬

💡 Вы можете создать столько учетных записей, сколько вам нужно, с разными браузерами и режимами инкогнито. Затем используйте кран, чтобы дать им немного эфира.

☢️ Внимание! Мы получаем метку времени из нашей локальной цепочки, а блоки не добываются с регулярными интервалами, как в реальной цепочке. Следовательно, нам придется отправлять некоторые транзакции сюда и туда, чтобы получить метку времени для обновления. ⏰

🎉 Поздравляю!

Мы создали децентрализованное приложение на основе смарт-контрактного кошелька с ограничением безопасности и социальным восстановлением !!!

У вас должно быть достаточно контекста, чтобы клонировать 🏗 scaffold-eth и, возможно, даже создать величайшее неудержимое приложение !!!

Представляете, есть ли у этого кошелька какой-то «автономный рыночный слой», на котором любой человек в мире может покупать и продавать активы с динамическими ценами?

Что, если мы чеканим 🧩 предметов коллекционирования и продадим их по кривой ?!

Что, если бы мы создали «мгновенный кошелек» для быстрой отправки и получения средств ?!

Что, если бы мы создали ⛽️ приложение без газа для плавной адаптации пользователей !?

Что, если бы мы создали 🕹 игру со случайными числами фиксации / раскрытия ?!

Что, если бы мы создали местный рынок прогнозов, в котором могли бы участвовать только наши друзья и друзья ?!

Что, если бы мы развернули токен 👨‍💼 $ me, а затем создали приложение, которое позволяет держателям делать ставки в пользу вас, создавая ваше следующее приложение ?!

Что, если бы мы могли транслировать эти жетоны 👨‍💼 $ me для сеансов помощи о создании крутых вещей на 🏗 scaffold-eth!?!

🤩 О возможности !!! 📟 📠 🧭 🕰 📡 💎 ⚖️ 🔮 🚀

📓 Если вы хотите узнать больше о Solidity, я рекомендую сыграть в Ethernaut, Crypto Zombies, а затем, возможно, даже в RTFM. 🤣

Перейдите на https://ethereum.org/developers для получения дополнительных ресурсов.

💬 Не стесняйтесь писать мне Twitter DM или в репо! Спасибо !!!

[🙋‍♂️ Присоединяйтесь к этой временной группе Telegram для обратной связи / устранения неполадок]