Understanding how the Reach smart contract interacts with a web frontend

Introduction

For developers new to the Reach programming language, one of the most difficult hurdles to cross is understanding exactly how a Reach smart contract interacts with the frontend. This is mostly because the smart contract interacts with the frontend asynchronously.

By itself, the smart contract logic is relatively straightforward. The program can include loops with definite conditions for how many times they run. It can also include races where multiple participants race to submit data to the smart contract. It can include forks that act like the conventional Javascript switch statement. Nothing out of the ordinary. However, when the smart contract logic meets frontend logic, it can be confusing. I'll try to unravel the knots surrounding this union.

The first thing to note is that different types of entities can be defined in the smart contract. How the frontend interacts with the smart contract depends on which entity type the frontend is representing. Some of these entities include:

  • Participant

  • ParticipantClass

  • API

  • View

  • Event

Participant

In my opinion, Participants are the easiest to implement. They interact with the smart contract through their interact object. The smart contract decides the structure of this object, specifying its interface. The frontend only needs to provide an object that follows the expected interface and then connect that object to the smart contract. The smart contract can read the provided object's values, as well as call the object's methods.

Through the object's values, data is passed from the frontend to the smart contract. Through the object's methods, data goes back and forth between them depending on the method's return values. To visualize this let's look at an example.

Say there's a participant Alice who provides an amount wager to the smart contract, and can perform two actions getHand and seeOutcome.

const Alice = Participant('Alice', {
    wager: UInt,
    getHand: Fun([], UInt),
    seeOutcome: Fun([UInt], Null)
})
  • wager is a number and must have a value when the object gets initialized on the frontend.

  • getHand is a function with no arguments. It returns a number.

  • seeOutcome is a function with one argument, a number. It doesn't return anything.

On the frontend, this is how the object is initialized:

...

//define interact object
const Alice = {
    wager: 7, //because prime numbers rock
    getHand: () => {
        //logic for computing return value
        //value gets sent to smart contract
        //value can be computed asynchronously or synchronously

        let hand = 5; 
        return hand
    },
    seeOutcome: (numberFromSmartContract) => {
        console.log(numberFromSmartContract)
    }
}

//connect to smart contract
const contract = account.contract(backend);
backend.Alice(contract, Alice);

On the smart contract, the values and methods on the frontend object get called here:

  Alice.only(() => {
    const wager = declassify(interact.wager);
    const hand = declassify(interact.getHand());
    interact.seeOutcome(100);
  });
  Alice.publish(wager, hand);

When the flow of the program on the smart contract gets to interact.wager, the wager value (7) specified by the frontend gets stored in the wager variable on the smart contract.

When interact.getHand() gets called, the smart contract program flow pauses and waits for a return value from the frontend. Until that is provided, the smart contract pauses and waits. The smart contract behaves this way for every interact method with a return value.

When interact.seeOutcome(100) gets called, the value 100 gets passed to the frontend as an argument to the seeOutcome method of the Alice object. This step isn't asynchronous.

ParticipantClass

The ParticipantClass interacts with the frontend the same way as the Participant. But it behaves weirdly when the smart contract expects data from the ParticipantClass interact function. The ParticipantClass has been deprecated by Reach and should be avoided entirely.

API

APIs are functions you can call on a smart contract. Unlike with the Participant, these functions aren't called by the smart contract, but instead by the frontend. The smart contract decides when the function can be called, but only during that window can the function be called. Calling the function outside that window would send an error message to the frontend.

The error message looks something like this; Error: Expected the DApp to be in state(s) [2], but it was actually in state 1.

Like with the Participant, the smart contract defines the interface for the API functions. On the smart contract, this is how the API class is defined:

const UserActions = API('UserActions', {
    checkValue: Fun([], UInt),
    incrementValue: Fun([UInt], Null)
})

Here the API class has two functions that can be called on the frontend. The checkValue function has no arguments. This means no data gets to the smart contract from the frontend. This is because the function gets called on the frontend, and the caller of a function is responsible for providing its arguments. It has a return value which means data gets sent from the smart contract to the frontend. This will make more sense to you when you see a code snippet of the function call on the frontend.

incrementValue has a function argument. This means data gets sent to the smart contract from the frontend. However, it doesn't have a return value, meaning no data gets sent back to the frontend from the smart contract.

There are two ways to initiate an API function in a smart contract: either as part of a fork (the equivalent of a Javascript switch statement) or by itself. Here's an example of initiating the checkValue API function by itself.

...
//The program pauses here until the checkValue function is called on 
//the frontend
const [_, resolve] =
    call(UserActions.checkValue);
    resolve(10);
commit();
...
  • _ indicates that the function has no arguments.

  • resolve is how data gets sent back to the frontend. In this example, the value 10 gets sent back to the frontend. 10 must be of the same data type defined in the checkValue function's interface.

For the incrementValue API function:

...
//The program pauses here until the incrementValue function is called on the frontend
const [newValue, resolve] =
    call(UserActions.incrementValue);
    resolve();
commit();

//newValue is available for the rest of the program
...

newValue is the argument passed to the function when called on the frontend. It becomes available for computations on the smart contract.

On the frontend, this is how to call the checkValue function:

...

//should be within an async function scope

const contract = account.contract(backend, contractInfo);
const returnedValue = await contract.apis.UserActions.checkValue();
console.log(returnedValue);
...
  • The return value gets stored in returnedValue .

  • Wrap the function call in a try-catch to handle potential errors due to incorrect timing or invalid arguments.

For the incrementValue function:

...

//should be within an async function scope
await contract.apis.UserActions.incrementValue(14);

...

incrementValue doesn't have any return values and hence doesn't need to be stored to a variable.

Ensuring the API functions get called by the frontend at proper sync with the smart contract can be challenging. The best way to go about this is to keep track of the program flow on the smart contract, and only make those functions available when viable.

For example, the incrementValue function can only be called after the checkValue function gets called.

View

Like the API, Views are functions that can be called on the smart contract from the frontend. However, Views behave differently from how the API class does. After a View function has been defined, it can be called on the frontend at any point in time as long as the smart contract is still on.

Through its return value, data gets passed from the smart contract to the frontend. However, this value gets wrapped in a Maybe type (because this View may not be initialized).

The Maybe type
The Maybe data type is a concept used to represent the possibility of an absence of a value. It represents data in one of these two formats: [Some, data] or [None, null]. [some, data] is for when the data exists, while [None, null] gets returned when the data doesn't exist.

Lets define a simple View function that returns the square of any number passed to it.

const Square = View({
    getSquare: Fun([UInt], UInt), 
    //accepts a number from frontend and returns a number to frontend 
});

...

//During consensus step
Square.getSquare.set((m) => m * m);

...

Now the View function can be accessed on the frontend of the program, even if the contract has moved past the line of code where the function was implemented.

On the frontend:

...

/* wrap scope in async function */

const contract = account.contract(backend);
const square = await contract.v.getSquare(4); //16
console.log(`The square of 4 is ${square}`);

...

Calling getSquare here triggers it's smart contract counterpart, no async nightmares to worry about.

Events

Events are quite interesting in how they work. They allow data to flow only from the smart contract to the frontend. With Events, the frontend can keep track of the program flow on the smart contract, kind of like the Javscript console.log() .

After an Event is created on the smart contract, the frontend can subscribe to it and get notified everytime that Event gets fired on the smart contract.

Suppose there is an Event named fullCycle that gets triggered every time a while loop completes a cycle. It would be written like this on the smart contract:

...

const Notify = Events({
    fullCycle: [UInt]  //data flows in only one direction
});

...

var [x] = [0];
invariant(balance() == 0);
while(x < 10){

    ...

    //Must be in consensus step
    Notify.fullCycle(x);

    ...

    [x] = [ x + 1 ]
    continue;
}

The iterating value x gets passed to the frontend through the fullCycle event. One really cool thing about Events is that other data get passed to the frontend as well, not just the function argument. The timeStamp for that event, in network time, gets sent as well.

On the frontend, this is how the Event gets subscribed to:

...
const contract = acc.contract(backend);

contract.e.fullCycle.monitor((evt) => {
    const { when, what: [ iteration ] } = evt;
    console.log(`${iteration} registered at ${when}`);
});

...

Unlike the API and View classes which are functions on the frontend, the Event is an object with methods. Each method provides a different way to interact with the Event on the smart contract. Our only concern for now is the monitor method.

The monitor method has an evt argument. evt is the object { when: Time, what: T }, where T is the data passed from the smart contract to the frontend through the event, and when is the network time-stamp of the event. T is passed as a tuple, and has to be destructured as one.

Everytime the Event gets triggered on the smart contract, the monitor method gets called. Programming logic can then be added to this method to perform actions like trigger a notification for users, or take them to a different webpage etc.

Conclusion

Playing around with each entity will improve your understanding of how to use them effectively and enable you to make better decisions about which entity to use in different situations. Each of them has particular scenarios where it best fits.

0
Subscribe to my newsletter

Read articles from Timothy Ogwulumba directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Timothy Ogwulumba
Timothy Ogwulumba