Тестування смарт контрактів Ethereum на прикладі DAO

При створенні смарт контрактів на платформі Ethereum розробник закладає певну логіку роботи, що визначає, як методи повинні змінювати стан контракту, які повинні емітуватися події, коли і кому потрібно здійснити переказ коштів, а коли кинути виняток. Інструменти налагодження смарт контрактів ще не дуже розвинені, тому тести найчастіше стають необхідним інструментом розробки, т. к. запускати контракти після кожної зміни може бути досить тривалою процедурою. Також, у випадку виявлення помилок, змінити код розгорнутого в мережі контракту вже неможливо, можна тільки знищити контракт і створити новий, тому тестування варто проводити максимально докладно, особливо методи пов'язані з платежами. У статті будуть показані деякі прийоми тестування, з якими стикаються розробники при створенні і налагодженні смарт контрактів на Solidity.

Децентралізована автономна організації (DAO)
Влітку 2016 нашуміла історія з THE DAO, з якого зловмисник відвів значні кошти. DAO — це смарт контракт, який позиціонує себе як організацію, всі процеси якої описані кодом, працюючим в блокчейн середовищі, при цьому не є юридичною особою і управляється колективно усіма її інвесторам. Ще в березні розробники DAO підкреслили важливість тестування і навіть покрили свій смарт контракт тестами, використовуючи свій фреймворк на суміші Python і Javascript, але на жаль тести не закрили використану пізніше вразливість.

Код смарт контракту The DAO занадто великий для прикладу, тому в якості об'єкта тестування візьмемо смарт контракт Congress, реалізує принципи DAO, який наводиться в статті How to build a democracy on the blockchain на сайті http://ethereum.org. Надалі передбачається знайомство з основними принципами розробки смарт контрактів.

Як відбувається тестування смарт контрактів
Загальний принцип схожий з тестуванням будь-якого іншого коду — створюється набір еталонних викликів методів у визначеному оточенні, для яких результатів прописуються затвердження. Для тестування зручно використовувати практики BDD – Behavior Driven Development, які поряд з тестами дозволяють створити документацію та приклади використання.

Інструменти тестування
В даний час розроблено ряд фреймворків та бібліотек тестування смарт контрактів Ethereum:

Truffle
Truffle v.2 тести розробляються на JavaScript, використовуються фреймворк Mocha і бібліотека Chai. У версії 3 додалася можливість писати тести Solidity.

DApple
DApple тести реалізуються на Solidity, з використанням методів спеціально розроблених базових смарт контрактів.

EmbarkJS
EmbarkJS підхід схожий на Truffle, тести пишуться на Javascript, що використовується фреймворк Mocha.

Розробка тестів на Solidity досить обмежена можливостями цієї мови, тому будемо використовувати Javascript, всі приклади будуть з використанням Тruffle Framework. Також компоненти Truffle Framework, такі як truffle-contract або truffle-artifactor можна використовувати для створення своїх кастомних рішень для взаємодії зі смарт контрактами.

Тестовий клієнт
З урахуванням того, що блокчейн системи, зокрема Ethereum, працюють не дуже швидко, для тестування використовуються «тестові» клієнти блокчейн, наприклад, TestRPC, який майже повністю емулюють роботу JSON-RPC API клієнтів Ethereum. Крім стандартних методів, TestRPC також реалізує ряд додаткових методів, які зручно використовувати при тестуванні, такі як evm_increaseTime, evm_mine та ін

Альтернативний варіант — можна використовувати один із стандартних клієнтів, наприклад Parity, що працює в dev режимі, при якому транзакції підтверджуються моментально. У подальших прикладах буде використаний TestRPC.

Налаштування оточення
Тестовий клієнт
Інсталяція через npm:

npm install -g ethereumjs-testrpc

TestRPC повинен бути запущений в окремому терміналі. При кожному запуску тестовий клієнт генерує 10 нових акаунтів, на кожному з яких уже розміщені кошти.

Фреймворк Truffle
Інсталяція через npm:

npm install -g truffle

Для створення структури проекту потрібно виконати команду truffle init

$ mkdir solidity-test-example
$ cd solidity-test-example/
$ truffle init

Контракти повинні бути розташовані у теці contracts/, при компіляції контрактів Truffle Framework очікує, що кожен контракт розміщений в окремому файлі, назва контракту одно назві файлу. Тести розміщуються в теці test/. При виконанні команди truffle init також створюються тестові контракти Metacoin та ін

У подальших прикладах буде використовуватися проект https://github.com/vitiko/solidity-test-example, в якому розміщені код смарт контракту Congress і тести для нього. Тести виконуються у середовищі Truffle v.2, у версії v.3, яка нещодавно вийшла, є невеликі відмінності у частині підключення згенерованого Truffle коду і у форматі даних транзакцій, що повертаються після виклику методів, що змінюють стан.

Розробка тестів на базі Truffle framework
Організація тестів
У тестах використовуються JavaScript-об'єкти, що являють собою абстракції для роботи за контрактами, що виробляють маппінг між операціями над об'єктами і викликами JSON-RPC методів клієнта Ethereum. Дані об'єкти створюються автоматично при компіляції вихідного коду *.sol файлів. Виклики всіх методів асинхронні і повертають Promise, це дозволяє не піклуватися про відстеження підтвердження транзакцій, все реалізовано «під капотом» компонент Truffle.

У прикладах на сайті Truffle Framework використовується стиль написання .then() ланцюжками. Якщо описувати великий сценарій, код тестів виходить досить об'ємний. Набагато лаконичее і читаемее виходить код тестів з використанням async / await, далі буде використовуватися даний стиль написання тестів. Також, у прикладах на сайті розробника використовуються екземпляри смарт контрактів, розгортання яких прописывется міграціях. Щоб не змішувати міграції і створення тестових екземплярів, зручніше їх явно створювати в тесті, для цього можна використовувати код, який створює новий екземпляр контракту перед викликом кожної тестової функції. У наведеному нижче прикладі функція beforeEach, в якій створюється екземпляр об'єкта Congress.

Конструктор смарт контракту Congress
/* First time setup */
function Congress(
uint minimumQuorumForProposals,
uint minutesForDebate,
int marginOfVotesForMajority, address congressLeader
) payable {
changeVotingRules(minimumQuorumForProposals, minutesForDebate, marginOfVotesForMajority);
if (congressLeader != 0) owner = congressLeader;
// It's necessary to add an empty first member
addMember(0, ");
// and let's add the founder, to save a step later
addMember(owner, 'founder');
}

const congressInitialParams = {
minimumQuorumForProposals: 3,
minutesForDebate: 5,
marginOfVotesForMajority: 1,
congressLeader: accounts[0]
};
let congress;
beforeEach(async function() {
congress = await Congress.new(...Object.values(congressInitialParams));
});

Тестування зміни стану смарт контракту
Для початку спробуємо протестувати метод addMember змінює стан смарт контракту — метод повинен записати інформацію про учасника DAO в масив структур members.

Код функції addMember смарт контракту
/*make member*/
function addMember(address targetMember, string memberName) onlyOwner {
uint id;
if (memberId[targetMember] == 0) {
memberId[targetMember] = members.length;
id = members.length++;
members[id] = Member({member: targetMember, memberSince: now, name: memberName});
} else {
id = memberId[targetMember];
Member m = members[id];
}

MembershipChanged(targetMember, true);
}

У тесті ми, використовуючи масив з тестовими акаунтами, додаємо учасників контракт. Потім перевіряємо, що функція members (геттер для масиву структур members): повертає внесені дані. Потрібно зауважити, що при кожному виклику методу addMember створюється транзакція і змінюється стан блокчейна, тобто інформація записується в розподілений реєстр.

it("should allow owner to add members", async function() {
// додаємо 3 учасника
for (let i = 1; i < = 3; i++) {
let addResult = await congress.addMember(accounts[i], 'Name for account' + i);

// позиції в масиві members доданих учасників починаються з 2.
// т. к. members[0] - empty, members[1] - аккаунт, який створив контракт (див. конструктор)
let memberInfoFromContract = await congress.members(i + 1);

// метод members(pos) повертає масив з даними структури Member
// де [0] - адресу облікового запису учасника, [1] - ім'я облікового запису учасника
assert.equal(memberInfoFromContract[0], accounts[i]);
assert.equal(memberInfoFromContract[1], 'Name for account' + i);
}
});

Тестування подій
События Ethereum мають досить універсальне застосування, вони можуть використовуватися:

  • Для повернення значень з методів, що змінюють стан контракту, в т. ч. в користувальницький інтерфейс
  • Для створення історії зміни стану смарт контрактів
  • В якості дешевого сховища інформації (вартість розміщення даних в протоколі подій значно дешевше ніж в стані контракту)
У наступному прикладі будемо перевіряти, що при виклику методу newProposal, який додає пропозиції в контракт Congress, створюється запис події Proposal Added

Код функції newProposal смарт контракту
/* Function to create a new proposal */
function newProposal(
address beneficiary,
uint etherAmount,
string JobDescription,
bytes transactionBytecode
)
onlyMembers
returns (uint proposalID)
{
proposalID = proposals.length++;
Proposal p = proposals[proposalID];
p.recipient = beneficiary;
p.amount = etherAmount;
p.description = JobDescription;
p.proposalHash = sha3(beneficiary, etherAmount, transactionBytecode);
p.votingDeadline = now + debatingPeriodInMinutes * 1 minutes;
p.executed = false;
p.proposalPassed = false;
p.numberOfVotes = 0;
ProposalAdded(proposalID, beneficiary, etherAmount, JobDescription);
numProposals = proposalID+1;

return proposalID;
}

Для цього в тесті спочатку створюємо учасника DAO і від його імені створюємо пропозицію. Потім створюємо передплатника на подію ProposalAdded і перевіряємо, що після виклику методу newProposal подія сталася і його атрибути відповідають переданими даними.

it("should fire event 'ProposalAdded' when add member proposal", async function() {
let proposedAddedEventListener = congress.ProposalAdded();
const proposalParams = {
beneficiary : accounts[9],
etherAmount: 100,
JobDescription : 'Some job description',
transactionBytecode : web3.sha3('some content')
};

await congress.addMember(accounts[5], 'Name for account 5');
await congress.newProposal(...Object.values (proposalParams), {
from: accounts[5]
});

let proposalAddedLog = await new Promise(
(resolve, reject) => proposedAddedEventListener.get(
(error, log) => error ? reject(error) : resolve(log)
));

assert.equal(proposalAddedLog.length, 1, 'should be 1 event');
let eventArgs = proposalAddedLog[0].args;
assert.equal(eventArgs.proposalID , 0);
assert.equal(eventArgs.recipient , proposalParams.beneficiary);
assert.equal(eventArgs.amount , proposalParams.etherAmount);
assert.equal(eventArgs.description , proposalParams.JobDescription);
});
});

Тестування помилок і перевірка відправника повідомлень
Стандартним методом переривання роботи методу контракту, є винятки, які можна створювати за допомогою інструкції throw. Виняток може знадобитися, наприклад, якщо потрібно обмежити доступ до методу. Для цього реалізується модифікатор, який перевіряє адресу облікового запису, що викликав метод, і якщо він не задовольняє умовам створюється виняток. Для прикладу, створимо тест, що перевіряє, що якщо метод addMember викликає не власник контракту — створюється виняток. У коді нижче контракт Сопдгеѕѕ створений від імені accounts[0], потім викликається метод addMember від імені іншого облікового запису.

it("should disallow no owner to add members", async function() {
let addError;
try {
//тут створюється виняток, коли accounts[0] != accounts[9]
await congress.addMember(accounts[1], 'Name for account 1', {
from: accounts[9]
});
} catch (error) {
addError = error;
}
assert.notEqual(addError, undefined, 'Error must be thrown');
// докладного повідомлення про помилку не видається, в ньому тільки точно повинна бути 
// підрядок "invalid JUMP" 
assert.isAbove(addError.message.search('invalid JUMP'), -1, 
'invalid JUMP error must be returned');
});

Тестування зміни балансу смарт контракту, використання поточного часу в смарт контракті
Можливо, найбільш відповідальною функцією смарт контракту Congress, що реалізує принципи DAO, є функція executeProposal, яка запускає перевірку того, що пропозиція отримала необхідну кількість голосів і обговорення пропозиції тривало не менше ніж мінімальний необхідний час, заданий при створенні смарт контракту.

Код функції executeProposal смарт контракту
function executeProposal(uint proposalNumber, bytes transactionBytecode) {
Proposal p = proposals[proposalNumber];
/* Check if the proposal can be executed:
- Has the voting deadline arrived?
- It Has already been executed or is it being executed?
- Does the transaction code match the proposal?
- Has a minimum quorum?
*/

if (now < p.votingDeadline
|| p.executed
|| p.proposalHash != sha3(p.recipient, p.amount, transactionBytecode)
|| p.numberOfVotes < minimumQuorum)
throw;

/* execute result */
/* If difference between support and opposition is larger than margin */
if (p.currentResult > majorityMargin){
// Avoid recursive calling

p.executed = true;
if (!p.recipient.call.value(p.amount * 1 ether)(transactionBytecode)) {
throw;
}

p.proposalPassed = true;
} else {
p.proposalPassed = false;
}
// Events Fire
ProposalTallied(proposalNumber, p.currentResult, p.numberOfVotes, p.proposalPassed);
}

Для імітації пройденого часу використовуємо метод evm_increaseTime, який реалізований в testrpc — c його допомогою можна змінити внутрішній час блокчейн клієнта.

it("should pay for executed proposal", async function() {
const proposalParams = {
beneficiary: accounts[9],
etherAmount: 1,
JobDescription: 'Some job description',
transactionBytecode: web3.sha3('some content')
};
// Баланс облікового запису accounts[9] до виконання executeProposal
let curAccount9Balance = web3.eth.getBalance(accounts[9]).toNumber();

// Створюємо пропозицію, у реченні зазначено, що в разі його прийняття
// кошти повинні бути перераховані accounts[9]
await congress.newProposal(...Object.values(proposalParams), {
from: accounts[0] //accounts[0] вже учасник, оскільки від його імені створюється контракт
});

// правила DAO, задані в конструкторі тестового контракту 
// потребують наявності як мінімум 3х голосів за пропозицію
// тому додаємо 3х учасників DAO і голосуємо за пропозицію 0 від їх імені
for (i let of[3, 4, 5]) {
await congress.addMember(accounts[i], 'Name for account' + i);
await congress.vote(0, true, 'Some justification text from account' + i {
from: accounts[i]
});
}
// поточний стан пропозиції
let curProposalState = await congress.proposals(0);

// далі можна перевірити, що голосів більше мінімально необхідної кількості 
// та ін. умови
//...

// збільшуємо час в testrpc на 10 хвилин, більше ніж мінімально 
// визначене в конструкторі час для обговорення minutesForDebate (5)
await new Promise((resolve, reject) =>
web3.currentProvider.sendAsync({
jsonrpc: "2.0",
method: "evm_increaseTime",
params: [10 * 600],
id: new Date().getTime()
}, (error, result) => error ? reject(error) : resolve(result.result))
);
// Пропозиція повинна пройти - є 3 голоси "ЗА",
// також пройшло мінімально необхідний час
await congress.executeProposal(0, proposalParams.transactionBytecode);

// Перевіряємо, що балас облікового запису accounts[9]
// збільшився на суму, вказану при створенні пропозиції
let newAccount9Balance = web3.eth.getBalance(accounts[9]).toNumber();
assert.equal(web3.fromWei(newAccount9Balance - curAccount9Balance, 'ether'),
proposalParams.etherAmount,
'balance of acccounts[9] must to increase proposalParams.etherAmount');

let newProposalState = await congress.proposals(0);
assert.isOk(newProposalState[PROPOSAL_PASSED_FIELD]);
});

Допоміжні функції
В процесі написання тестів згодилися функції, які зручно використовувати для часто використовуваних операцій при тестуванні, таких як перевірка на виняток, отримання лода подій, зміна часу тестового клієнта. Функції розміщені у вигляді пакета https://www.npmjs.com/package/solidity-test-utilкод розміщений на github. Нижче наведено приклад використання функції testUtil.assertThrow для перевірки винятки:

it("should disallow no owner to add members", async function() {
await testUtil.assertThrow(() => congress.addMember(accounts[1], 'Name for account 1', {
from: accounts[9]
}));
});

Інші приклади з використанням функцій solidity-test-util можна побачити здесь.

Висновок
В результаті розробки тестів ми отримуємо як автоматизовану перевірку коректності роботи смарт контрактів, так і специфікацію і приклади використання, загалом все, що можна сказати про тестування будь-якого іншого програмного коду.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.