При написании смартконтрактов важно помнить, что после загрузки в блокчейн, они уже не могут быть изменены, а следовательно, не могут быть внесены какие-либо улучшения или исправлены какие-то найденные ошибки! Все мы знаем, что ошибки есть в любой программе, а вернувшись к написанному пару месяцев назад коду мы всегда найдем, что там можно улучшить. Как же быть? Единственно возможный вариант – это загрузить новый контракт с исправленным кодом. Но как же быть, если на базе имеющегося контракта уже выпущены токены? На помощь нам приходит миграция! За последний год я попробовал много разных техник ее реализации, проанализировал применяемые в других крупных блокчейн проектах и что-то поизобретал сам. Подробности под катом.


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

Миграция с ERC20-совместимого контракта


Начнем рассмотрение с самого простого и распространенного случая, когда исходный контракт, уже загруженный в блокчейн, не содержит каких-либо специальных механизмов для того, чтобы помочь нам с миграцией, т.е. по сути мы имеем обычный ERC20-совместимый контракт. Единственное, что мы полезного можем взять из исходного контракта, это балансы всех держателей токенов и общее количество выпущенных токенов для проверки, что мы никого не забыли при миграции.

contract ERC20base {
    uint public totalSupply;
    function balanceOf(address _who) public constant returns(uint);
}

К сожалению, интерфейс ERC20-совместимого контракта не позволяет узнать список всех держателей токенов, поэтому при миграции нам придется выяснить полный список держателей из какого-то другого источника, например, выгрузив его из etherscan.io. Пример контракта, на который осуществляется миграция, приведен в следующем листинге:

contract NewContract {
    uint public totalSupply;
    mapping (address => uint) balanceOf;

    function NewContract(address _migrationSource, address [] _holders) public {
        for(uint i=0; i<_holders.length; ++i) {
            uint balance = ERC20base(_migrationSource).balanceOf(_holders[i]);
            balanceOf[_holders[i]] = balance;
            totalSupply += balance;
        }
        require(totalSupply == ERC20base(_migrationSource).totalSupply());
    }
}

Конструктор контракта получает в качестве параметров адрес исходного ERC20-совместимого контракта, а также список держателей токенов, выгруженный вручную через etherscan.io. Следует обратить внимание, что в последней сроке конструктора проверяем, что количество токенов после миграции не изменилось, а следовательно, ни один держатель токенов не забыт. Необходимо учитывать, что такая миграция возможна лишь в том случае, если количество держателей токенов невелико и цикл по ним всем возможен в рамках одной транзакции (лимита газа, установленного в Ethereum для одной транзакции). Если все же количество держателей токенов не позволяет мигрировать за одну транзакцию, то эту функциональность придется вынести в отдельную функцию, которую можно будет вызвать необходимое количество раз, а контракт в этом случае будет выглядеть так:

contract NewContract {
    uint public totalSupply;
    mapping (address => uint) balanceOf;
    address public migrationSource;
    address public owner;

    function NewContract(address _migrationSource) public {
        migrationSource = _migrationSource;
        owner = msg.sender;
    }

    function migrate(address [] _holders) public
        require(msg.sender == owner);
        for(uint i=0; i<_holders.length; ++i) {
            uint balance = ERC20base(_migrationSource).balanceOf(_holders[i]);
            balanceOf[_holders[i]] = balance;
            totalSupply += balance;
        }
    }
}

В конструкторе данного контракта запоминается адрес исходного контракта, а также инициализируется поле owner, чтобы запомнить адрес владельца контракта, чтобы только он имел право вызывать функцию migrate(), вызвав которую несколько раз, мы можем мигрировать любое количество держателей токенов с исходного контракта.

Недостатки данного решения заключаются в следующем:

  1. На старом смартконтракте токены останутся у их владельцев, а на новом просто продублируются их балансы. Насколько это плохо, зависит от того, как составлен ваш Tokens sale agreement или любой другой документ, описывающий объем ваших обязательств перед держателями токенов вашего проекта, и не удвоятся ли ваши обязательства перед ними после создания «дубликата».
  2. На миграцию вы расходуете собственный газ, но это, вобщем-то, логично, т.к. делать миграцию придумали вы и в любом случае доставляете неудобства вашим пользователям, хоть оно и ограничивается тем, что им надо переписать в своих кошельках адрес смартконтракта со старого на новый.
  3. В процессе осуществления миграции, если она конечно не умещается в одну транзакцию, могут произойти трансферы токенов между адресами их владельцев, а следовательно, могут добавиться новые держатели и может измениться баланс существующих.

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

Миграция между этапами краудсейла


В мире современных ICO довольно распространена практика, когда для различных этапов сбора средств делают отдельные контракты, мигрируя выданные токены на новые контракты нового этапа. Это, конечно, можно делать так, как мы рассмотрели выше, но, если мы точно знаем, что нам придется мигрировать, то почему бы сразу не упростить себе жизнь? Для этого достаточно ввести публичное поле

    address [] public holders;

В это поле необходимо добавлять всех держателей токенов. Если контракт уже на ранних этапах сбора разрешает делать держателям перемещения токенов, т.е. реализует transfer(), необходимо позаботиться о том, чтобы массив обновлялся, например, как-то так

    mapping (address => bool) public isHolder;
    address [] public holders;
    ….
    if (isHolder[_who] != true) {
        holders[holders.length++] = _who;
        isHolder[_who] = true;
    }

Теперь на стороне приемного контракта можно использовать аналогичную рассмотренной ранее технологию миграции, но теперь нет необходимости передавать массив в качестве параметра, достаточно обратиться к уже готовому массиву в исходном контракте. Также следует помнить, что размер массива может не позволить проитерировать его за одну транзакцию по причине ограничения газа на одну транзакцию, а следовательно, нужно предусмотреть функцию migrate(), которая будет получать два индекса – номера начального и конечного элементов массива для обработки в рамках данной транзакции.

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

Миграция со сжиганием исходных токенов


Все-таки раз мы говорим про миграцию, а не про дублирование токенов в новом смартконтракте, то необходимо озаботиться вопросом уничтожения (сжигания) токенов на исходном контракте при создании их копии на новом. Очевидно, что недопустимо оставлять в смартконтракте «дыру», которая позволила бы кому угодно, будь он даже владельцем смартконтракта, сжигать токены других держателей. Такой смартконтракт будет просто скамовым! Осуществлять такого рода манипуляции над своими токенами может только их держатель, а следовательно, и осуществлять миграцию должен сам держатель. Владелец смартконтракта в данном случае может только запустить эту миграцию (перевести смартконтракт в состояние миграции). Пример реализации такой миграции я встретил в проекте GOLEM (ссылка на их гитхаб в конце поста), затем реализовал ее в нескольких своих проектах.

В исходном контракте определим интерфейс MigrationAgent, который впоследствии должен быть реализован в контракте, на который осуществляется миграция.

contract MigrationAgent {
    function migrateFrom(address _from, uint256 _value);
}

В исходном контракте токена должна быть реализована следующая дополнительная функциональность:

contract TokenMigration is Token {
    address public migrationAgent;

    // Migrate tokens to the new token contract
    function migrate() external {
        require(migrationAgent != 0);
        uint value = balanceOf[msg.sender];
        balanceOf[msg.sender] -= value;
        totalSupply -= value;
        MigrationAgent(migrationAgent).migrateFrom(msg.sender, value);
    }

    function setMigrationAgent(address _agent) external {
        require(msg.sender == owner && migrationAgent == 0);
        migrationAgent = _agent;
    }
}

Таким образом, владелец исходного смартконтракта должен вызвать setMigrationAgent(), передав ему в качестве параметра адрес смартконтракта, на который осуществляется миграция. После этого все держатели токенов исходного смартконтракта должны вызвать функцию migrate(), которая осуществит уничтожение их токенов в исходном смартконтракте и добавление в новом (путем вызова функции migrateFrom() нового контракта). Ну а новый контракт должен собственно содержать реализацию интерфейса MigrationAgent, например, так:

contract NewContact is MigrationAgent {
    uint256 public totalSupply;
    mapping (address => uint256) public balanceOf;
    address public migrationHost;
 
    function NewContract(address _migrationHost) {
        migrationHost = _migrationHost;
    }

    function migrateFrom(address _from, uint256 _value) public {
        require(migrationHost == msg.sender);
        require(balanceOf[_from] + _value > balanceOf[_from]); // overflow?
        balanceOf[_from] += _value;
        totalSupply += _value;
    }
}

В этом решении прекрасно все! Кроме того, что пользователю надо вызвать функцию migrate(). Ситуация существенно осложняется тем, что вызов функций поддерживают лишь единицы кошельков и они, как правило, не являются самыми удобными. Поэтому, поверьте, если среди держателей ваших токенов есть не только криптогики, но и простые смертные люди, они вас просто проклянут, когда вы будете объяснять им, что надо установить какой-нибудь Mist, а затем вызвать какую-то функцию (слава Богу, хоть без параметров). Как же быть?

А можно поступить очень просто! Ведь любой пользователь криптовалюты, даже самый-самый начинающий, умеет хорошо делать одно – отправлять крипту со своего адреса на какой-то другой. Так пусть таким адресом будет адрес нашего смартконтракта, а его fallback функция в режиме «миграции» будет просто вызвать migrate(). Таким образом, держателю токенов для осуществления миграции будет достаточно перевести хотя бы 1 wei на адрес смартконтракта, находящегося в режиме «миграции», чтобы произошло чудо!

function () payable {
    if (state = State.Migration) {
        migrate();
    } else { … }
}

Заключение


Рассмотренные решения концептуально покрывают все возможные способы осуществления миграции токенов, хотя возможны вариации в конкретных реализациях. Отдельного внимания достоин подход «перегонного сосуда» (ссылка в конце поста). Независимо от используемого вами подхода к миграции, помните, что смартконтракт – это не просто программа, выполняемая внутри виртуальной машины Ethereum, а это некий отчужденный независимый договор, а любая миграция предполагает, что вы меняете условия этого договора. Уверены ли вы, что держатели токенов хотят поменять условия договора, который заключили, приобретая токены? Это на самом деле хороший вопрос. И существует очень правильная практика, «спрашивать» держателей токенов о том, хотят ли они «переехать» на новый контракт. Именно осуществление миграции через голосование я реализовал в смартконтракте своего проекта PROVER, с текстом контракта можно познакомиться на моем GitHub-е. Ну и конечно приглашаю присоединяться к ICO моих проектов PROVER и OpenLongevity.

Надеюсь, что все это кому-то полезно и нужно :).

Полезные ссылки


Комментарии (0)