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.

D_D Newsletter CTA

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.

D_D Newsletter CTA

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!