În primul articol de anul trecut scris pe această temă am realizat o prezentare a platformei financiare descentralizate (blockchainul) Ethereum și a clonelor acestuia, precum și un foarte scurt istoric al instrumentelor financiare folosite de-a lungul timpului în economia de piață. În al doilea articol am expus elementele sintactice fundamentale ale limbajului de programare Solidity, limbajul de referință pentru contracte inteligente înregistrate în blockchain. De asemenea, în aceste prime articole am arătat cum utilizăm un browser pentru a ne conecta la blockchain prin intermediul extensiilor și a librăriilor Web3.
În al treilea și ultimul articol din această serie vom ilustra dezvoltarea graduală a unui contract inteligent utilizând un mediu de lucru folosit curent în această industrie. Ca exemplu concret, dorim să implementăm un contract de muncă ce poate fi folosit de angajatori și angajați. Fiecare individ va fi identificat prin propria adresă din blockchain. Regulile contractului vor fi următoarele:
Un angajator depune bani care sunt destinați unui angajat. Pentru simplitate, în acest articol banii vor fi exprimați în moneda nativă ETH a blockchainului Ethereum, dar nu există nici o limitare în a folosi un stablecoin de genul USDT sau USDC.
Banii depuși vor fi emiși către angajat începând cu o anumită dată, iar emiterea se va încheia la o altă dată prestabilită. Emiterea banilor va fi continuă. În rândurile următoare vom detalia ce înseamnă o emisie continuă. Dacă, de exemplu, un angajator A depune sâmbătă 1000 de dolari (exprimați în ETH) destinați contului angajatului B, cu o emisie de 5 zile, începând cu următoarea luni la ora 0 dimineața și sfârșind cu următoarea seară de vineri (de asemenea, la miezul nopții), în fiecare zi începând de luni vor fi emiși către angajat câte 200 de dolari. Și mai mult, în fiecare din cele 24 de ore ale fiecărei zile vor fi emiși 200 $/24=8.33 $. Emiterea de bani va fi chiar și mai granulară, aproape la secundă, în funcție de intervalul de timp în care este acceptat în blockchain un nou bloc de tranzacții. (La momentul scrierii articolului, în Ethereum, timpul mediu de creare a unui bloc este de 13 secunde iar în rețeaua BSC este de 3 secunde).
Angajatul poate oricând să ceară banii care i se cuvin în funcție de trecerea timpului. Pornind de la exemplul de mai sus, dacă angajatul B va cere banii în dimineața zilei de marți, va primi în contul personal 200 de dolari, iar dacă va cere miercuri dimineața va primi 400. Aici apare un caz interesant: daca angajatul B a cerut deja bani în ziua de marți, el a primit deja 200 de dolari, așa că, dacă va cere și miercuri, va primi doar restul care i se cuvine, adică încă 200 de dolari. Evident, timpul de pretindere a banilor este flexibil, el putând să-i ceară oricând vrea, și de oricâte ori vrea, angajatul B urmând să primească suma cuvenită de la ultima pretindere a banilor în funcție de scurgerea timpului.
Angajatul poate oricând să vadă banii care i se cuvin în funcție de trecerea timpului și de pretinderile anterioare, dacă au fost.
Conform exemplului precedent, dacă angajatul B a cerut deja 200 de dolari în ziua de marți, miercuri va vedea că i se cuvin doar 200 de dolari, și nu 400 $ cât ar fi văzut dacă n-ar fi pretins în ziua de marți banii cuveniți
Atât Angajatul cât și Angajatorul pot anula oricând contractul. Dacă oricare dintre personajele menționate inițiază anularea contractului, banii emiși până în momentul respectiv vor intra în contul angajatului B, iar restul de bani se va întoarce în contul angajatorului A.
Ca unelte necesare scrierii acestui contract, vom avea nevoie în primul rând de aplicația VSCode împreună cu extensia Solidity+Hardhat.
Această extensie ne va facilita lucrul în VSCode cu Solidity, limbajul de programare de referință pentru contracte inteligente, și Hardhat, mediul de dezvoltare de bază pentru aplicații Ethereum. În ultimul an în lumea programării blockchainului au intervenit enorm de multe schimbări, iar Hardhat este una dintre cele mai notabile, practic detronându-i pe Truffle și Ganache ca cel mai popular mediu de dezvoltare și testare automată a contractelor inteligente.
A doua cerință este aplicația NodeJS destinată programării Javascript, care vine la pachet împreună cu NPM, instalatorul de module javascript. Testele unitare și automate pe care le vom crea vor fi scrise în limbajul Javascript folosind librăriile Hardhat.
Abia acum suntem pregătiți pentru începerea dezvoltării aplicației!
În sistemul de fișiere din computerul personal vom crea un director (folder) de bază unde vom scrie toate fișierele ce compun aplicația. Vom crea în acest folder două fișiere: package.json și hardhat.config.js
Fișierul package.json are următorul conținut:
{
“name”: “overview”,
“version”: “1.0.0”,
“description”: “”,
“main”: “index.js”,
“scripts”: {
“test”: “npx hardhat test”
},
„author”: „Petrut Suciu & Dan Sabadis”,
“license”: “ISC”,
“dependencies”: {
“@nomiclabs/hardhat-ethers”: “^2.1.0”,
“@nomiclabs/hardhat-waffle”: “^2.0.3”,
“chai”: “^4.3.6”,
“ethereum-waffle”: “3.4.4”,
“ethers”: “5.6.9”,
“hardhat”: “2.10.1”,
“solidity-coverage”: “0.7.21”
}
}
Acest fișier definește în formatul JSON toate librăriile și dependințele Javascript de care vom avea nevoie pentru testarea aplicației. Librăriile au ultima versiune disponibilă la data scrierii aplicației. Programatorii de aplicații moderne Javascript sunt familiari cu fișierul package.json .
Al doilea fișier hardhat.config.js definește configurările de bază ale platformei hardhat pe care le vom folosi în acest articol. El are următorul conținut:
require(„@nomiclabs/hardhat-waffle”);
module.exports = {
solidity: {
version: “0.8.15”
}
}
După cum vedem, în acest fișier hardhat cea mai importantă informație este versiunea de compilator Solidity pe care o vom folosi: 0.8.15, care este cel mai recent disponibilă la data scrierii aplicației.
Vom verifica dacă cerințele prealabile de mai sus au fost corect descrise.
Deschidem VSCode și apoi din Meniul File, deschidem folderul de bază creat (care conține cele două fișiere descrise mai sus):
Odată deschis folderul de bază, dacă selectăm Explorerul (CTRL-SHIFT-E) vom vedea cele două fișiere. Din Meniul Terminal selectăm New Terminal și ni se va deschide o fereastră (3) unde vom putea iniția comenzile de bază pentru aplicație.
Observăm că folderul activ din terminal este deja setat către folderul nostru de bază. Alternativ, dacă folosim Windows putem deschide CMD Prompt și să schimbăm directorul activ către folderul nostru.
Rulăm comanda: npm install
.
Aceasta va instala toate dependințele Javascript. Procesul va dura câteva minute deoarece librăria Hardhat este imensă și consistă din mii de fișiere.
Dacă toate pachetele au fost instalate cu succes, în sfârșit suntem pregătiți pentru scrierea contractului!
Înăuntrul directorului de bază creăm un sub-folder numit contracts. În acest sub-director « contracts » creăm un fișier numit « WorkContract.sol » (extensia .sol vine evident de la limbajul Solidity). Adăugăm în acest fișier codul din imaginea de mai jos:
1 : // SPDX-License-Identifier: MIT
2 : pragma solidity 0.8.15;
3 :
4: contract WorkContract {
5: address public owner;
6:
7: constructor() {
8: owner = msg.sender;
9: }
10: }
Contractul este minimal.
Linia 1 conține un comentariu cu privire la licența folosită, care este obligatoriu prezent în orice fișier Solidity.
Linia 2 conține o referință la versiunea de Solidity folosită.
Linia 5 conține o variabilă owner a cărui adresă este stabilită în constructor, adică în momentul instalării (deploymentului) contractului în blockchain.
Acum vom crea un test automat Javascript ce va verifica dacă într-adevăr adresa deținătorului (ownerul) contractului este setată în mod corect la deployment.
Creăm un sub-folder numit test și înăuntrul lui creăm un fișier javascript WorkContract.spec.js cu următorul conținut:
const { expect, assert } = require("chai");
const { ethers } = require("hardhat");
describe("Deployment", () => {
let testSigner, workContract;
beforeEach("#deploy", async () => {
let workContractFactory =
await ethers.getContractFactory("WorkContract");
[testSigner] = await ethers.getSigners();
workContract =
await workContractFactory.deploy();
await workContract.deployed();
});
describe("#success", function () {
describe("Deployment", function () {
it("Should set the right owner",
async function () {
expect(await workContract.owner()).to
.equal(testSigner.address);
});
});
});
});
Rulăm comanda: npm test
.
Dacă a rulat cu succes avem un prim test automat unitar ce ne verifică faptul cel care instalează contractul în blockchain îl și deține!
Două lucruri mai merită menționate despre Fișierul de test « WorkContract.spec.js » :
Metoda BeforeEach(" #deploy") va rula înainte de executarea fiecărei metode de test "It".
În această metodă BeforeEach apelul ethers.getSigners() creează 20 de conturi de test, fiecare cu 10 mii de ETH. Noi salvăm în variabila testSigner doar primul cont astfel creat și pe acest cont l-am desemnat să facă deploymentul contractului.
În acest moment, proiectul nostru conține patru fișiere: cele două package.json și hardhat.config.js din folderul rădăcină al proiectului, care definesc setările de bază ale proiectului, un fișier Solidity contracts/WorkContract.sol care ne definește contractul pe care îl dezvoltăm și un fișier test/WorkContract.spec.js ce conține teste automate pentru contract.
În contractul WorkContract vom defini o structură Stream (1) ce descrie legătura financiară dintre angajator și angajat. Câmpul recipient va conține adresa angajatului, câmpul sender, adresa angajatorului, câmpul deposit va conține suma depozitată inițial de angajator, câmpurile startTime și endTime vor conține timpul exact (exprimat în secunde) al intervalului de emitere a banilor, câmpul rate va conține rata de emitere pe secundă, iar variabila balance va conține suma pe care o poate retrage angajatorul la un moment dat.
Toate aceste streamuri vor fi înregistrate și supervizate de data "streams" (2) de tip mapping. Tipul mapping este asemănător unui Dicționar sau HashTable din mediile .Net/Java și este înregistrat fizic (I/O storage) în fiecare computer care a downloadat blockchainul.
Câmpul numeric streamIdCounter (3) definește identificatorul cheie pentru ultimul Stream creat la un moment dat. Primul Stream creat în WorkContract va avea id-ul 1, al doilea va avea id-ul 2 și așa mai departe.
contract WorkContract {
struct Stream
{
address recipient;
address sender;
uint256 deposit;
uint256 startTime;
uint256 stopTime;
uint256 rate;
uint256 balance;
}
mapping(uint32 => Stream) private streams;
uint32 public streamIdCounter;
address public owner;
constructor() {
owner = msg.sender;
}
În continuare, imediat după constructor, vom defini metoda createStream, care va fi invocabilă de către angajatorul care vrea să depună bani în WorkContract, bani care vor fi emiși apoi angajatului între datele specificate.
function createStream(address _recipient,
uint256 _startTime, uint256 _stopTime)
public payable {
27: require(_recipient != address(this),
"Stream to the contract itself");
require(_recipient != msg.sender,
"Stream to the caller");
require(msg.value > 0,
"Deposit is equal to zero");
require(_startTime >= block.timestamp,
"Start time before block timestamp");
31: require(_startTime < _stopTime,
"Start time after stop time");
uint256 l_duration = _stopTime - _startTime;
uint256 l_deposit = msg.value;
require(l_deposit >= l_duration,
"Deposit smaller than duration");
44: streamIdCounter += 1;
uint32 l_currentStreamId = streamIdCounter;
// Rate Per second
uint256 l_rate = l_deposit / l_duration;
uint256 l_finalAmount = l_deposit - l_deposit
% l_duration;
streams[l_currentStreamId] = Stream({
balance: l_finalAmount,
deposit: l_finalAmount,
rate: l_rate,
recipient: _recipient,
sender: msg.sender,
startTime: _startTime,
stopTime: _stopTime
});
(bool success,) = msg.sender.call{value:
(l_deposit - l_finalAmount)}("");
if (!success) revert ("Funds transfer reverted");
}
În linia 25, în semnătura funcției putem vedea că aceasta a fost declarată "public payable". "Public" înseamnă că poate fi apelată de oricine, iar "payable" înseamnă că această funcție poate accepta banii trimiși de angajator.
În liniile 27-31, am adăugat niște verificări de rutină pentru parametri. Dacă validările eșuează, se va emite o eroare, și angajatorul își va primi banii înapoi.
În linia 33, calculăm durata intervalului de emisie a banilor, exprimată în secunde.
La linia 42, calculăm rata de emisie pe secundă a sumei depuse.
La linia 43, calculăm suma care va fi depusă de către angajator. Dacă suma depusă nu se împarte exact la durata în secunde a intervalului, returnăm restul angajatorului restul (în linia 55), iar diferența (suma inițială minus restul), care se va împarți exact, o depunem în contract pentru a începe să fie emisă la o dată ulterioară (_startTime).
Acum vom adăuga un fișier de test numit CreateStream.spec.js
unde vom crea testele ce vor verifica validitatea metodei createStream
.
const { expect, assert } = require("chai");
const {BigNumber} = require("ethers");
const { ethers } = require("hardhat");
describe("Create Stream", () => {
let streamingContract;
let owner;
let sender;
let recipient;
let startTimestamp;
let stopTimestamp;
let deposit = ethers.utils.parseEther("1");
let now;
beforeEach("#deploy", async () => {
[ owner, sender, recipient ] =
await ethers.getSigners();
let workContractFactory =
await ethers.getContractFactory("WorkContract");
streamingContract =
await workContractFactory.deploy();
await streamingContract.deployed();
const delay = 100;
const duration = 100;
now = (await ethers.provider.getBlock()).timestamp;
startTimestamp = now + delay;
stopTimestamp = startTimestamp + duration;
});
describe("#reverts", function () {
it("should revert when recipient address is the
contract itself", async function () {
await expect(
streamingContract.connect(sender).createStream(
streamingContract.address,
startTimestamp,
stopTimestamp,
{ value: deposit }
)
).to.be
.revertedWith("Stream to the contract itself");
});
});
});
Mai adăugăm încă un failing test (linia 43) în care ne asigurăm că angajatorul nu își poate trimite bani lui însuși declarându-se și angajat. Restul testelor care validează verificările făcute în contract cu metoda "require" (la liniile 27-31 din metoda createStream
) pot rămâne ca temă pentru cei interesați.
describe("#reverts", function () {
it("should revert when recipient address is the
contract itself", async function () {
await expect(
streamingContract.connect(sender).createStream(
streamingContract.address,
startTimestamp,
stopTimestamp,
{ value: deposit }
)
).to.be.revertedWith(
"Stream to the contract itself");
});
43: it("should revert when sender and recipient are
same", async function () {
await expect(
streamingContract.connect(sender).createStream(
sender.address,
startTimestamp,
stopTimestamp,
{ value: deposit }
)
).to.be.revertedWith("Stream to the caller");
});
});
După aceea, vom adăuga primul test de succes (linia 56, în suita de teste describe( #success)) care verifică corectitudinea incrementării variabilei streamIdCounter la linia 44.
mapping(uint32 => Stream) private streams;
17: uint32 public streamIdCounter;
address public owner;
constructor() {
owner = msg.sender;
}
function createStream(address _recipient,
uint256 _startTime, uint256 _stopTime)
public payable {
require(_recipient != address(this),
"Stream to the contract itself");
require(_recipient != msg.sender,
"Stream to the caller");
require(msg.value > 0,
"Deposit is equal to zero");
require(_startTime >= block.timestamp,
"Start time before block timestamp");
require(_startTime < _stopTime,
"Start time after stop time");
uint256 l_duration = _stopTime - _startTime;
uint256 l_deposit = msg.value;
require(l_deposit >= l_duration,
"Deposit smaller than duration");
44: streamIdCounter += 1;
uint32 l_currentStreamId = streamIdCounter;
56: describe("#success", function () {
it("should increase the id counter",
async function () {
let streamIdCounter =
await streamingContract.streamIdCounter();
expect(streamIdCounter).to.be.equal(0);
expect( await streamingContract
.connect(sender).createStream(
recipient.address,
startTimestamp,
stopTimestamp,
{ value: deposit })
).to.not.throw;
streamIdCounter =
await streamingContract.streamIdCounter();
expect(streamIdCounter).to.be.equal(1);
});
});
Declarăm evenimentul CreateStream (linia 21)
21:event CreateStream(
uint32 indexed _streamId,
address indexed _sender,
address indexed _recipient
);
constructor() {
owner = msg.sender;
}
După care publicăm evenimentul la sfârșitul metodei createStream (linia 64) și creăm testul corespunzător (linia 71) de verificare a emiterii evenimentului.
……………………………………
//this is inside createStream
(bool success,) = msg.sender.call{
value: (l_deposit - l_finalAmount)}("");
if (!success) revert ("Funds transfer reverted");
64: emit CreateStream(
l_currentStreamId,
msg.sender,
_recipient
);
}
describe("#success", function () {
it("should increase the id counter",
async function () {
let streamIdCounter = await streamingContract
.streamIdCounter();
expect(streamIdCounter).to.be.equal(0);
expect(await streamingContract.connect(sender)
.createStream(
recipient.address,
startTimestamp,
stopTimestamp,
{ value: deposit })
).to.not.throw;
streamIdCounter = await streamingContract
.streamIdCounter();
expect(streamIdCounter).to.be.equal(1);
});
71: it("should emit CreateStream event",
async function () {
await expect(
streamingContract.connect(sender).createStream(
recipient.address,
startTimestamp,
stopTimestamp,
{ value: deposit })
).to.emit(streamingContract, "CreateStream")
.withArgs(
1,
sender.address,
recipient.address
);
});
});
Mai adăugăm un test în care verificăm corectitudinea calculării balanței după efectuarea depozitului (liniile 48,49: să nu uităm că depozitul probabil nu se va împărți exact la numărul de secunde ale intervalului de timp în care se vor emite banii).
……………………………………
//this is inside createStream
uint256 l_duration = _stopTime - _startTime;
uint256 l_deposit = msg.value;
require(l_deposit >= l_duration,
"Deposit smaller than duration");
streamIdCounter += 1;
uint32 l_currentStreamId = streamIdCounter;
// Rate Per second
48: uint256 l_rate = l_deposit / l_duration;
49: uint256 l_finalAmount = l_deposit - l_deposit
% l_duration;
streams[l_currentStreamId] = Stream({
balance: l_finalAmount,
deposit: l_finalAmount,
rate: l_rate,
recipient: _recipient,
sender: msg.sender,
startTime: _startTime,
stopTime: _stopTime
});
(bool success,) = msg.sender.call{value:
(l_deposit - l_finalAmount)}("");
if (!success) revert ("Funds transfer reverted");
emit CreateStream(
l_currentStreamId,
msg.sender,
_recipient
);
}
it("should emit CreateStream event",
async function () {
await expect(
streamingContract.connect(sender).createStream(
recipient.address,
startTimestamp,
stopTimestamp,
{ value: deposit })
).to.emit(streamingContract, "CreateStream")
.withArgs(
1,
sender.address,
recipient.address
);
});
87: it("should increase the contract balance by
deposit amount", async function () {
let balance = await ethers.provider
.getBalance(streamingContract.address);
expect(balance).to.be.equal(0);
expect(await streamingContract.connect(sender)
.createStream(
recipient.address,
startTimestamp,
stopTimestamp,
{ value: deposit })
).to.not.throw;
balance = await ethers.provider
.getBalance(streamingContract.address);
let remainder = deposit.mod(stopTimestamp
- startTimestamp);
expect(balance).to.be.equal(deposit
.sub(remainder));
});