În acest articol, vom prezenta aspectele de bază ale Solidity, unul din limbajele consacrate în dezvoltarea de Smart Contracts (contracte inteligente) în cadrul Ethereum sau a blockchainurilor Ethereum clonate. Teoria și exemplele prezentate în acest articol sunt inspirate cu precădere din două cărți. Prima, "Mastering Ethereum", este scrisă de doi cercetători britanici: Andreas M. Antonopoulos, un susținător vestit al criptomonedei Bitcoin și Gavin Wood, creatorul limbajului de programare Solidity. A doua carte, "Building Ethereum Dapps", scrisă de Roberto Infante pentru Manning, este favorita noastră: este ușor de înțeles, deși abordează aspecte complexe legate de Solidity și de ecosistemul Ethereum.
Mai există trei limbaje de programare interesante pentru scrierea de Smart Contracts. Primul este LLL, inspirat de Lisp. Celelalte două sunt Viper și Serpent, ambele inspirate de Python. Având în vedere că aceste limbaje alternative sunt folosite pentru maxim 20% din numărul total de contracte Ethereum, ne vom axa pe Solidity, de departe cel mai popular limbaj pentru programarea mașinilor virtuale Ethereum, (Ethereum Virtual Machines - EVM). Solidity se inspiră dintr-o serie de limbaje, dar, în principal, din Java și Javascriptd, două dintre cele mai cunoscute limbaje bazate pe C.
Nu există multe cărți despre Solidity ca limbaj de programare. Se pare că marile corporații din tehnologie nu doresc să disemineze prea repede informații despre programarea Smart Contracts și Decentralized Applications (Dapps). De exemplu, la o simplă căutare printre miile de cursuri de pe Pluralsight, nu veți găsi niciun curs despre Ethereum și Solidity.
Ca programatori avem două soluții pentru a face deployment la Smart Contracts: prima este un nod local ce trebuie instalat și sincronizat complet cu întregul blockchain; a doua este folosirea unei unelte (tool) software care se conectează la un nod la distanță, de unde face o instalare. Vom alege a doua variantă, deoarece instalarea și configurarea unui nod local poate fi dificilă, iar sincronizarea nodului local cu întregul blockchain Ethereum poate dura câteva zile în condițiile unei conexiuni mai lente de Internet.
Prima soluție este destinată pentru dezvoltarea profesionistă, atunci când interacționăm cu instalarea unui nod local Ethereum și când folosim un mediu de test precum Javascript Mocha și un framework Smart Contract terț ca Truffle sau Ganache.
Reiterăm că nu vom complica lucrurile inutil, prin urmare, nu vom folosi medii complexe de programare pentru mostrele prezentate mai jos. Vom folosi un IDE (integrated development environment) online numit Remix.
Pentru o experiență de dezvoltare completă va trebui să instalați în browser add-on-ul Metamask. Această aplicație de mici dimensiuni este una din pietrele de temelie din interacțiunea cu blockchains. Pe fundal folosește librăria web3 Javascript, despre care vom vorbi în articolul trei din această serie. După ce ați instalat cu succes Metamask, va trebui să conectați Metamask la versiunea Test a Binance Smart Chain (BSC), mostrele noastre aflându-se în această rețea. Puteți găsi aici instrucțiuni despre cum puteți conecta blockchainul BSC Testnet. Conexiunea la blockchainul Testnet este importantă, deoarece va fi folosită de programatori pentru a testa contractele inteligente. Mainnet este principalul blockchain care folosește monede reale și bani reali. Va trebui să accesați și un site Fawcet unde veți primi monede false (fake BNBs sau fake stablecoins) pe care să le folosiți în BSC Testnet. Va trebui să specificați adresa metamask unde se vor trimite instant monedele false (BNB).
Acum vom configura aplicația online Remix.
Selectați Environment -> Injected Web3, iar Metamask se va deschide într-un popup, cerându-vă permisiunea de a conecta Metamask la site-ul https://remix.ethereum.org/. Selectați Next/Allow, după care suntem gata să folosim mostrele din acest articol!
Accesați File Menu și adăugați un fișier numit SimpleCoin.sol. În continuare, accesați meniul Solidity Compiler Menu și selectați versiunea 0.4.26 precum în imaginea de mai jos.
Adăugați următoarea bucată de cod în fișierul SimpleCoin.sol creat anterior:
pragma solidity ^0.4.12;
contract SimpleCoin {
mapping (address => uint256) public coinBalance;
event Transfer(address indexed from,
address indexed to, uint256 value);
constructor() public {
coinBalance[0x14723A09ACff6D2A60DcdF7aA4AFf308FD
DC160C] = 10000;
}
function transfer(address to, uint256 amount) public {
require(coinBalance[msg.sender] > amount);
require(coinBalance[to] + amount >=
coinBalance[to]);
coinBalance[msg.sender] -= amount;
coinBalance[to] += amount;
emit Transfer(msg.sender, to, amount);
}
}
Codul ar trebui să arate precum în imaginea de mai sus. Dacă dați click pe Compile SimpleCoin.sol (1), acesta va analiza fișierul, îl va compila și ne va notifica dacă acea compilare s-a finalizat cu succes (2). În caz că sunteți curioși să aflați mai multe despre aspectele interne ale Solidity, selectați ABI (3) și copiați rezultatul din clipboard într-un fișier text. Application Binary Interface (ABI) reprezintă enumerarea funcțiilor (din contract) cu care puteți interacționa și semnătura lor (numele și tipul parametrilor, dar și tipul returnat). ABI este afișat în format JSON. Dacă aveți cunoștințe avansate, s-ar putea să vă intereseze opțiunea Bytecode (3) care returnează (în format text copiat din clipboard) codul mașină în bytes al contractului împreună cu codurile operației care sunt compilate în cod mașină. Operation Codes (opcode) seamănă cu Assembly Language.
Ca cele mai multe limbaje statice (statically typed), Solidity necesită declararea explicită a fiecărei variabile sau, are nevoie cel puțin de tipul care va fi inferat univoc de compilator. Sistemul său de tipuri de date include atât tipuri valoare, cât și tipuri referință. O variabilă de tip valoare este stocată pe stiva EVM (EVM stack), fapt ce alocă un singur spațiu de memorie pentru a reține valoarea. Când o variabilă de tip valoare este asignată unei alte variabile sau este transmisă unei funcții ca parametru, valoarea sa este copiată într-o nouă instanță separată a variabilei. În consecință, orice schimbare în valoarea variabilei asignate nu afectează valoarea variabilei originale. Tipurile valoare includ majoritatea tipurilor native și majoritatea enums. Avem tipurile native "clasice" (boolean, integer, enumeration), așa cum se poate observa în codul de mai jos (pe care vă invităm să îl copiați într-un nou fișier în Remix, iar după aceea să îl compilați).
Puteți declara variabilele de tip integer fie ca int (signed), fie ca uint (unsigned). De asemenea, puteți specifica o dimensiune exactă cu valori între 8 și 256 bits, în multipli de 8. Menționăm un aspect important: Solidity nu conținue tipuri reale, sau în virgulă mobilă, ci doar tipuri intregi! Adresele obiectelor pe care, în general, le declarați folosind un literal ce conține până la 40 hexadecimal digits prefixate cu 0x, pot ține 20 bytes. Variabilele declarate drept bool pot avea valoarea adevărat sau fals. Un enum este un tip de dată customizată ce include un set de valori numite. Puteți defini o variabilă bazată pe tipul enum ca mai jos. Valoarea integer a fiecărui element enum este determinată implicit de poziția sa în cadrul definiției enum. În exemplul anterior valoarea High este 0, iar cea Low este 2. Puteți afla valoarea unei variabile enum de tip integer convertind explicit variabila enum la int. Conversiile implicite nu sunt permise. Toate liniile comentate de mai jos sunt conversii interzise ce vor da eroare la compilare.
pragma solidity ^0.4.12;
contract ValueTypeIntConversions {
enum InvestmentLevel {High, Medium, Low}
bool isComplete = false;
InvestmentLevel level = InvestmentLevel.Medium;
int256 bigNumber = 150000000000;
int32 mediumNegativeNumber = -450000;
uint16 smallPositiveNumber = 15678;
uint32 public newMediumNumber =
smallPositiveNumber;
int256 public newBigNumber =
mediumNegativeNumber;
int16 levelValue = int16(level);
// int16 newSmallNumber = bigNumber;
// uint64 newMediumPositiveNumber =
mediumNegativeNumber;
// uint256 public newboolConverted = isComplete;
// int16 levelValue2 = level;
}
Variabilele de tip referință sunt accesate prin referința lor (locația primului lor element). Le puteți stoca în oricare dintre următoarele locuri de date, pe care le puteți specifica explicit când le declarați, în anumite cazuri:
Valorile Memorie (Memory—Values) nu sunt persistate permanent și există doar în memorie.
Mai jos puteți găsi o serie de variabile de tip referință declarate în diferite locații de date.
pragma solidity ^0.4.0;
contract ReferenceTypesSample {
uint[] storageArray;
function f(uint[] fArray) {}
function g(uint[] storage gArray) internal {}
function h(uint[] memory hArray) internal {}
}
Locația datelor storageArray este definită explicit ca fiind de tip stocare.
Locația datelor fArray este definită implicit ca fiind de tip memorie.
Locația datelor gArray este definită explicit ca fiind de tip stocare.
Locația datelor hArray este definită explicit ca fiind de tip memorie.
Există patru clase de tip referință: Arrays, Strings, Structs și Mappings.
Un string (șir) se aseamănă cu tipul corespondent din mediile Java/.Net, putând fi inițializat cu un literal, precum: string name = "Dan";
Un struct este un tip definit de utilizator ce conține un set de elemente care, în general, sunt de tipuri diferite. Exemplele de mai jos se referă la un contract ce declară mai multe tipuri struct care sunt referențiate în variabile de tip stare. Tipul struct este echivalentul tipului omonim din limbajul C. Acest exemplu arată și cum puteți folosi enums într-un struct.
pragma solidity ^0.4.0;
contract Voting {
enum UserType {Voter, Admin, Owner}
enum MajorityType {SimpleMajority,
AbsoluteMajority, SuperMajority, Unanimity}
struct UserInfo {
address account;
string name;
string surname;
UserType uType;
}
struct Candidate {
address account;
string description;
}
struct VotingSession {
uint sessionId;
string description;
MajorityType majorityType;
uint8 majorityPercent;
Candidate[] candidates;
mapping (address => uint) votes;
}
uint numVotingSessions;
mapping (uint => VotingSession) votingSessions;
}
Un global namespace este un set de variabile declarate implicit, dar și de funcții pe care le puteți referenția și utiliza direct în codul contract.
Global namespace oferă următoarele cinci tipuri de variabile:
block - păstrează informație referitoare la cel mai recent bloc blockchain.
msg - oferă date referitoare la mesajele de intrare.
tx - oferă datele de tranzacționare.
this - este o referință la contractul curent.
Următoarele două funcții, disponibile ca parte a global namespace, vor genera o excepție și vor readuce starea contractului la valoarea inițială, dacă acea condiția asociată nu este îndeplinită. Deși funcționează la fel, scopul lor este puțin diferit.
require(bool condition)—Este folosită pentru a valida input-ul funcției.
Puteți termina execuția și să readuceți starea contractului la valoarea inițială în mod explicit apelând revert().
Variabilele de tip stare încapsulează starea contractului. Acestea au trei niveluri de acces posibile, pe care le puteți specifica în momentul declarării lor.
public - Compilatorul generează automat o funcție getter pentru fiecare variabilă publică de tip stare. Puteți folosi variabile publice de tip stare direct din contract și să le accesați funcția getter asociată din contractul extern sau din codul client.
internal - Contractul și orice contract moștenit pot accesa variabilele de tip stare internă. Acesta este nivelul default al variabilelor de tip stare. Este echivalentul cuvântului-cheie "protected" din Java/.Net
Bucata de cod de mai jos exemplifică nivelurile de acces posibile:
pragma solidity ^0.4.0;
contract StateVariablesAccessibility {
mapping (address => bool) private frozenAccounts;
uint isContractLocked;
mapping (address => bool) public tokenBalance;
}
Solidity oferă suport și pentru variabilele de stare constantă (constant state variables) așa cum se poate observa mai jos:
pragma solidity ^0.4.0;
contract ConstantStateVariables {
uint constant maxTokenSupply = 10000000;
string constant contractVersion ="2.1.5678";
}
Puteți specifica inputul unei funcții și parametrii de ieșire în mai multe feluri. În codul de mai jos, puteți observa două abordări unde funcția returnează un tuple:
pragma solidity ^0.4.0;
contract Functions {
function calculate1(int _x, int _y, int _z, bool _flag)
returns (int _alpha, int _beta, int _gamma) {
_alpha = _x + _y;
_beta = _y + _z;
if (_flag)
_gamma = _alpha / _beta;
else
_gamma = _z;
}
function calculate2(int _x, int _y,
int _z, bool _flag)
returns (int, int, int) {
int _alpha = _x + _y;
int _beta = _y + _z;
if (_flag)
return (_alpha, _beta, _alpha / _beta);
return (_alpha, _beta, _z);
}
}
Când invocăm o funcție, putem transfera parametrii în orice ordine dacă specificăm numele lor, ca în exemplul de mai jos:
pragma solidity ^0.4.0;
contract TaxCalculator {
function calculateAlpha(int _x, int _y, int _z)
public returns (int _alpha) {
_alpha = _x + this.calculateGamma({_z:_z,
_y:_y});
}
function calculateGamma(int _y, int _z)
public returns (int _gamma) {
_gamma = _y *3 +7*_z;
}
}
Am făcut doar o prezentare a ceea ce poate face Solidity. Unul din aspectele inedite ale Solidity (pentru programatorii avansați) este protecția împotriva atacurilor de securitate de tip denial of service, ceea ce face contractul inutilizabil, sau a celor de tip front-running unde o tranzacție este favorizată în detrimentul alteia, anticipându-se o schimbare de preț. Vă invităm să citiți cărțile menționate în această introducere pentru a afla detalii legate de Securitate, precum și multe alte aspecte fascinante ale limbajului.
de Kiss Tibor
de Ovidiu Mățan