Solidity Basics for JavaScript Devs
In the spirit of Dhaiwats guide to Web3 for Web2 frontend devs, I wrote a few articles that explained Solidity concepts to JavaScript devs. Because it got over 10k views, I decided to merge them into one and cross-post it here in the hopes more newcomers see it!
I will explain smart contracts written in Solidity with JavaScript equivalents to clear things up and explain some main differences between these languages. I will also talk about a few things that you should keep in mind when using tools like Ethers.js or Waffle.
Static vs. Dynamic Typing
The main difference between JavaScript and Solidity is typing. Typing in Solidity happens statically at build time, and in JavaScript, it's done dynamically at runtime.
The reasoning is that the Ethereum Virtual Machine (EVM) is very nitpicky about the costs of calculations and storage. Everything has to be accounted for so Ethereum can charge you accordingly.
JavaScript's goal was a bit more ease of use.
JavaScript
let x = 10;
Solidity
int256 x = 10;
So, Solidity is a bit like Java or C in that regard.
You also have to type your function arguments and return values.
JavaScript
function f(a, b) {
return a + b;
}
Solidity
function f(int256 a, int256 b) returns (int256) {
return a + b;
}
If you have more complex types like arrays or structs, the typing system requires you to define the memory location the data will be live.
JavaScript
function f(a, b) {
let c = [];
for(let i = 0; i < a.length; i++) {
c[i] += a[i] + b;
}
return c;
}
Solidity
function f(int256[] calldata a, int256 b) returns (int256[] memory) {
int256[] memory c;
for(uint i = 0; i < a.length; i++) {
c[i] = a[i] + b;
}
return c;
}
Here I defined the first argument a
as an array of int256
and said its location is in the calldata
. calldata
isn't persistent and can't be modified, and I only read a
and never write it in the function.
The other variables are either explicitly stored in the memory
location or have basic types that don't require defining the location.
Integers vs. Numbers
Another fundamental difference between the two languages is their default number type. JavaScript uses number
, which is always a floating-point number. Solidity uses various sizes of int
.
The idea behind this is that Solidity, deep down at its core, is about payments, and if you have a currency that is worth thousands of dollars per one whole unit, it could get costly to have rounding errors, which are the norm with JavaScript's number
type.
It's a bit like working with the dollar and using 1234 cents as storage type instead of 12,34 dollars.
Also, Solidity programmers like the int256
type as their default type, which can't be mapped 1:1 to JavaScript's number
. Luckily JavaScipt got a new number type some time ago called BigInt
, which can store all Solidity numbers with no problem.
JavaScript
let x = 9999999999999999;
// will become 10,000,000,000,000,000
// because the number type can't store that big numbers reliably
let y = 9999999999999999n;
// will become 9,999,999,999,999,999
// because the n at the end tells JS that this is a BigInt and not a number
Solidity
int256 x = 9999999999999999;
Contract vs Class
Solidity's contracts are similar to JavaScript classes, but they are different. These contracts are why Solidity applications are called smart contracts.
Solidity is a bit like Java in the regard that a contract is the entry point of a Solidity application. Contracts look like classes in JavaScript, but the difference lies in the instance creation.
When you create an object from a class in JavaScript, that is a relatively straightforward task. You use the new
keyword with the class name, and that's it.
You can do this with contracts too. Using the new
keyword on a contract name also leads to a new instance deployed to the blockchain.
JavaScript
class MyClass {
#value = 10;
setValue(x) {
this.#value = x;
}
}
Solidity
contract MyContract {
int256 private value = 10;
function setValue(int256 x) external {
value = x;
}
}
As you can see, this
is implied in contract methods. So, the contract attributes are always in scope in all methods.
The contract instance, the object, so to say, and its data live on the blockchain and not just inside your Solidity applications memory.
When you deploy a contract to the Ethereum blockchain, you're essentially instancing the contract, and then you can call it from other contracts or a blockchain Client like Ethers.js.
The contract gets an address you can use later to interact with it. If you deploy the contract multiple times, you have multiple addresses to interact with the different instances.
JavaScript
let x = new MyClass();
x.setValue(3);
Solidity
MyContract x = new MyContract(); // creates a new instance
x.setValue(3);
MyContract x = MyContract(contractAddress); // uses an existing instace
x.setValue();
In JavaScript, the objects you create get deleted if you close the application; in Solidity, the contract instances are persistent on the blockchain.
Interfaces
You need the contract's code to use an already deployed contract, which isn't always available. That's why Solidity also has interfaces, which you can define and use as the type when loading an existing contract.
Solidity
interface MyInterface {
function setValue(int256 x) external;
}
...
MyInterface x = MyInterface(contractAddress); // uses an existing instace
x.setValue();
There are many standardized interfaces for contracts. For example, fungible and non-fungible tokens are standardized, which means we can look in the standard, copy the function signatures we need, and create an interface to call them inside our contracts. Projects like OpenZeppelin also supply us with libraries that already include these well-known interfaces; we don't have to create them ourselves.
NPM for Package Management
Solidity uses the NPM package manager we already know from JavaScript; we can reuse many of the skills we already have.
With the following command, we get a library with all the interfaces that are out in the wild:
$ npm i @openzeppelin/contracts
Global Variables and payable
Some hidden global variables are available in every function. Like there is a global window
object in JavaScript, there is a msg
object in Solidity that contains the data of the caller of the function.
Here is an example in JavaScript that loads data from the global window
object into a private attribute of a class.
JavaScript
class MyClass {
#title = null;
constructor() {
this.#title = window.document.title;
}
}
Same in Solidity, but the contract owner will be set from the global msg
variable this time.
Solidity
contract MyContract {
address paybale public owner;
constructor() payable {
owner = payable(msg.sender);
}
}
The msg
variable contains information about the sender of a message. In this case, the address you used to deploy the contract.
The constructor
is called implicitly when someone creates a new contract instance, just with new objects from classes in JavaScript. Someone had to make the instance, so their blockchain address ended up in the msg.sender
variable.
In the example, all these functions and variables are payable
, which means a caller can send Ether to them.
This is awesome because it allows us to use payments for our Solidity application standardized for the whole Ethereum eco-system right in at the language level. There isn't an equivalent in JavaScript; we would have to program it on our own or use a third-party library.
Solidity Events are for the Frontend
Solidity has an event construct/type. It allows you to define specific events for your smart contract that can emit when things you deem interesting.
event MyEvent( uint256 value1, uint256 value2);
function f() public {
emit MyEvent(123, 456);
}
Interesting for whom? For your frontend code!
If I understood it correctly, event data resides on-chain, but it isn't accessible within smart contracts.
Event data is for listeners from outside the blockchain.
Your frontend can add event listeners for these events, and then, when it starts a transaction, it can react to events in the frontend.
smartContract.on("MyEvent", (valueA, valueB) => {
console.log(valueA, valueB);
})
await smartContract.f();
Ethers.js Uses BigNumber
Instead of BigInt
Solidity usually has to handle huge integers, too big for the Number
type of JavaScript. That's why Ethers.js created their type, called BigNumber
, to get around this problem.
Today, modern JavaScript engines have a BigInt
type that can handle such values with no problem, but this wasn't always the case, and Ethers.js wanted to be backward compatible.
I don't know why they didn't use a BigInt
polyfill instead, but at least they offer a method toBigInt()
. But you have to use BigNumber
methods for calculations!
const value1 = ethers.utils.parseEther("1")
const value2 = ethers.utils.parseEther("2")
const result = value1.add(value2)
console.log(result.toBigInt())
Anyway, don't mistake BigNumber
for BigInt
or you'll have a bad time!
Setting the msg
Object from Ether.js
Some global variables inside your Solidity smart contract are generated automatically before someone calls your function.
One of them is called msg
, and it contains implicit data your function can access, like msg.sender
for the address calling the function or msg.value
for the amount of Ether sent with the call.
function f(uint256 arg1, uint256 arg2) public payable {
// These are obviously arguments
uint256 a = arg1 + arg2;
// But where do these come from?
address x = msg.sender;
uint256 y = msg.value;
}
As this data isn't a function argument, how do you pass it to the smart contract from the Ethers.js side?
After all the regular arguments, an overrides object is passed as the last argument to such a (payable
) function. Other values, like msg.sender
, get set implicitly on the smart contract side of things.
const overrides = {value: 123}
await smartContract.payableFunction(arg1, arg2, overrides)
Multiple returns will become an Array in Ethers.js
Solidity allows returning multiple values from one function.
function f() public returns(uint256 a, uint256 b) {
return (123, 456);
}
I saw some examples, seemingly for web3.js, that would use an object as a return value on the JavaScript side.
const {a, b} = await smartContract.f();
This didn't work for me; I used an array to extract the return values depending on their position.
const [a, b] = await smartContract.f();
Using Waffle with Chai for Tests
The book I'm reading used low-level assertions with try-catch constructs to test smart contract-specific behavior. Waffle wasn't a thing back then, but now you can use an asynchronous call.
it("emits", async () => {
await expect(smartContract.f()).to.emit("EventType")
})
You can use an asynchronous call to expect
with reverted
to test that your contract reverts correctly.
it("emits", async () => {
await expect(smartContract.f()).to.be.revertedWith("Error Message")
})
Contracts have a receive
/fallback
Function
The EVM calls two special functions when someone sends transactions to your contract that no other functions could handle. They don't need the function
keyword and must be external payable
.
contract MyContract {
fallback() external payable {
// called when none of the contract's functions
// match the called function signature
}
receive() external payable {
// called when the call data is empty
}
}
The Fallback Function
You can use the fallback
function to delegate calls to different contracts. Since contracts deployed on the Ethereum blockchain are immutable, you need some indirection if you want to upgrade them over time. This can be done with a contract that only implements the fallback
function that will relay calls to any function to a contract address. You can change this address and, in turn, indirectly change the implementation of the contract.
The Receive Function
You can use the receive
function to handle calls only concerned with Ether and nothing else. For example, when you want to store a token amount into the smart contract's balance.
Solidity Variables are Initialized by Default
Because solidity uses static typing, the language knows what type every variable at compile time. Each of these types has an initial value the EVM will use when it executes.
Initial Values
- address:
0x0
- array (dynamic):
[]
- array (fixed): fixed-size array with initial values
- boolean:
false
- enum: first enum element
- function (external): a function that always throws an error
- function (internal): a function that returns initial values if a return is defined
- int:
0
- mapping: empty mapping
- string:
""
- struct: a struct where all members are set to initial values
- uint:
0
Requesting a Wallet Account Requires a Manual Call of eth_requestAccounts
Browser extension wallets usually block access to the accounts they manage from websites. The user has to allow the website to access it manually.
Somehow Ethers.js doesn't ask for permissions automatically when you want to use a wallet account. You have to manually send a request to the wallet before you can use an account/signer.
const provider = new ethers.providers.Web3Provider(
window.ethereum,
chainId
)
await provider.send("eth_requestAccounts", [])
const account = provider.getSigner()
The call to the asynchronous send
method will block until the user accepts the request.
Summary
Solidity is a straightforward language, and its baked-in payment mechanisms are probably the killer feature that will propel it in the long run.
JavaScript developers should be very familiar with most of the syntax, and you can learn the few differences that exist relatively quickly. The ecosystem also uses NPM makes things even more excellent for JavaScript devs.
This guide isn't exhaustive and talks about a few basics that I saw. I'm a Solidity beginner since I only played around with it for three weeks or so.
If you are interested in more content in that direction, I'm currently writing a course for frontend devs that want to start with Web3, check it out!