EOLink 0.2.4: making BrightLink a Chainlink client

If you like this content consider tipping in ETH, LINK or an ERC20 stablecoin at this address: 0xf9f6849230cBe10200B43dB103511b778898e71C

Code associated with this post is available on Github

Until now, the BrightLink contract has been triggered by a dummy variable passed from an external script/console. Depending on the value of that dummy variable, a payment was sent to one of two wallets, representing a donor and a customer. However, the vision for the project is that the payment will be triggered by the value of some summary statistic from a remote sensing app. In this post I’ll make a significant step towards that goal by making the BrightLink contract a Chainlink client that grabs data from an external API and uses that data to trigger the payment. The remote sensing app is not yet available, but I can mock it by exposing a static file at a url that can be accessed by a GET request via the Chainlink oracle, as the contract-side architecture will be identical regardless of whether the external app is a fully-functional remote sensing analysis system or a static file serving some simple json data. You may also wish to visit my earlier, simpler project that detailed the very basics of making a oracle GET request from inside a Solidity contract.

The financial infrastructure

The previous posts in this series have explained BrightLink’s financial model in detail, so I’ll outline it very briefly here. The idea is that an environmental charity, philanthropist or business with CSR funds makes a donation to the BrightLink contract in DAI. The donated DAI is moved to an Aave lending pool where it accrues interest. The donation is designed to incentivize a community to improve their local environment in some measurable way, for example a project may aim to increase the albedo of snow and ice surfaces to slow their rate of melting, or an urban community may aim to increase the area covered by greenery, both of which can be measured from aerial data. A target value for the albedo/green coverage is pre-agreed. After some pre-agreed period of time, the funds are withdrawn from the Aave pool, back into escrow in the contract. The contract checks whether the community has achieved their target. If so, the original deposit is transferred to their wallet. The interest generated by the Aave pool is sent to the contract owner. If the community has not met their target, the deposited funds are returned to the donor, and the interest is still kept by the contract owner.

The contract is only “aware” of data that lives on its own host blockchain. External data cannot easily be brought onto the blockchain. This is the problem that Chainlink oracles solve. The oracle is a piece of middleware that accesses the data from the external source (in this case the API that exposes the data that determines how to distribute funds) and brings it securely onto the blockchain. Without this middleware, the contract would not be able to act on real-world data. This is why, in this post, the BrightLink contract will become a Chainlink client.

The Data

I used Github Pages to expose a very simple json file that ca be accessed by a GET request to the following URL: “https://raw.githubusercontent.com/jmcook1186/jmcook1186.github.io/main/Data/example.json”

The json file contains the following dummy data:

{
"data": [
         {
          "number": "3500",
          "OtherNumber": "1000"
                    
         }
        ]
}

Of the two pieces of information in this json file, one is useless to us, but I included it in order to demonstrate data selection later on in the oracle request section. The “number” data will be used to determine where to send the DAI held in escrow in the contract.

The contract

To make the BrightLink contract into a Chainlink client, we need to add two functions and then make some small adjustments to the constructor to enable those functions to execute. The two functions are both related to the API call. The Chainlink oracle works by splitting the API call into two processes. The first is to send a job to the oracle, which requires the buildChainlinkRequest function imported from the Chainlink client contract. Therefore, we should import this contract, using:

import "@chainlink/contracts/src/v0.6/ChainlinkClient.sol";

This includes a remapping of @chainlink to a specific chainlink repository, which must be defined in the project’s brownie-confg.yaml file as follows:

compiler:
  solc:
    remappings:
      - '@chainlink=smartcontractkit/chainlink-brownie-contracts@1.0.2'

Out BrightLink contract needs to inherit the functionality of the Chainlink client contract, which we can do when we instantiate the contract:

contract BrightLink_v01 is ChainlinkClient{

With these prerequisites in place, we can build the API request function. To do so, we can define a new variable “request” of type Chainink.request and pass it a buildChainlinkRequest object inherited from the ChainlinkClient contract. The arguments passed to the request object are the jobID, the contract address and this.fulfill.selector. The jobID argument provides the request with a specific oracle task that we wish to complete. The callback address is the address where we ultimately want the oracle data to end up (the address of our contract). The final parameter is the function that is going to process the data from the oracle and make it available inside the contract, which we have not yet written but will be named fulfill(), which is located at “this” address (i.e. inside the contract).

These arguments are required to configure the oracle to do a specific job (GET request) and associate the returned value to the BrightLink contract address. The code looks like this:

function requestDataFromAPI() public returns (bytes32 requestId) {

    Chainlink.Request memory request = buildChainlinkRequest( 
        jobID, address(this),this.fulfill.selector
        );

Notice that the function requestDataFromAPI() does not return the actual data from the oracle, it returns a requestID. This ID is used to extract the desired data from the oracle later, after we have paid the oracle in LINK to cover its gas costs. To this point, we have defined a request struct and it has the necessary information to configure an oracle to make a GET request and associate the response to our contract, but it does not yet know where to make a GET request to. We can add this information to the object. We can then finish the function by sending the request to the oracle using the inherited sendChainlinkRequest function, passing the oracle address, request object and fee denominated in LINK. The result of this transaction is the bytes32 requestID data we included in the return statement of the function definition, and it represents the ID of the request as it is defined at the oracle, allowing us to retrieve our specific piece of data from the oracle later. The full function looks like this:

function requestDataFromAPI() public returns (bytes32 requestId){
    Chainlink.Request memory request = buildChainlinkRequest( 
        jobID, address(this),this.fulfill.selector
         );
        
    // Set the URL to perform the GET request on
    request.add(
        "get", 
        "https://<....>example.json"
         );

    request.add("path", "data.0.number");
        
    // Sends the request
    return sendChainlinkRequestTo(oracle, request, fee);
}

The URL address is added to the request object using request.add(). The exposed data is in json format and it includes data that is not important to us in this contract. Therefore, we can add a json path that instructs the oracle to only ingest the specific value we are interested in (“number”).

The function requestDataFromAPI() returns a requestID, but the actual data we want to access still needs to be grabbed from the oracle. The “fulfill()” function sends the requestID to the oracle and assigns the returned value to a variable inside the contract where it can be assigned to a variable ans used to determine the payment outcome. The fulfill() function looks like this:

function fulfill(bytes32 _requestId, uint256 _value) public recordChainlinkFulfillment( _requestId){

    // fulfill request and instantiate warningLevel var with retrieved value
    value = _value;
}

The data from the oracle request is now assigned to the variable “value”. I decided to add another small public view function to allow the user to query the value returned by the oracle:

function viewValueFromOracle() public view returns(uint256 viewValue){
        
    viewValue = value;
}

The previous version of the contract included a setDummyVariable() function that instantiated var value with anumber provided by the contract owner. This is now redundant as we instantiate var value using the oracle, so delete the setDummyVariable() function from the contract.

One more function I decided to add was retrieveLINK(), which returns any LINK deposited in the contract that was not spent on oracle gas back to the contract owner. This is a simple transfer function but it also requires instantiating the LINK token from the LINK token interface, which must be present in the /interfaces directory in the brownie project (this makes it available to the compiler).

function retrieveLINK() public onlyOwner{
        
    // instantiate LINK token
    LinkTokenInterface link = LinkTokenInterface(chainlinkTokenAddress());

    // transfer remaining contract LINK balance to sender
    require(link.transfer(owner, link.balanceOf(address(this))), "Unable to transfer");
}

Finally, we need to adjust the constructor and add some variable definitions to the start of the contract. The constructor now needs to receive the LINK token address, oracle address, jobID, and oracle fee, in addition to the information passed int he previous version. These are passed as parameters that are used to instantiate variables defined in the contract, so we can also add the variable definitions outside the constructor and the instantiations inside the constructor. The contract definition and constructor therefore look like:

contract BrightLink_v01 is ChainlinkClient {

    uint256 public depositedFunds;
    address private owner;
    uint16 private referral = 0;
    address public dai_address;
    address public adai_address;
    address public poolAddress;
    address public poolAddressProvider;
    address private customer;
    address private donor;
    uint16 private threshold;
    uint256 public value;
    address private oracle; // oracle address
    bytes32 private jobID; // oracle jobID
    uint256 private fee; // oracle fee
    IERC20 public dai;
    IERC20 public adai;
    ILendingPoolV2 public lendingPool;
    ILendingPoolAddressesProviderV2 public provider;

    constructor(
        address _dai_address, address _adai_address, address _link,
        address  _poolAddressProvider, address _oracle, address _customer, 
        address _donor, string memory _jobID, uint256 _fee, uint16 _threshold) public{
    

        owner = msg.sender;
        customer = _customer;
        donor = _donor;
        depositedFunds = 0;
        dai_address = _dai_address;
        adai_address = _adai_address;
        poolAddressProvider = _poolAddressProvider;
        threshold = _threshold;
        dai = IERC20(dai_address);
        adai = IERC20(adai_address);
        provider = ILendingPoolAddressesProviderV2(poolAddressProvider); 
        poolAddress = provider.getLendingPool();
        lendingPool = ILendingPoolV2(poolAddress);
        oracle = _oracle;
        jobID = stringToBytes32(_jobID);
        fee = _fee;

        // set link token address depending on network
        if (_link == address(0)) {
            setPublicChainlinkToken();
        } else {
            setChainlinkToken(_link);
        }

    }

The contract has now been updated so that it gets its “trigger” data from an external GET request handled by a Chainlink oracle. The full contract is available on Github.

Deployment

The contract now has quite substantial data requirements for deployment and it has become much more convenient to do this in scripts than to manually define all the arguments in the console. Overall, though, the deployment script is simple and does not change much from the previous version, except with a few added parameters to pass. The deployment script looks like this:

#!/usr/bin/python3
from brownie import BrightLink_v01, accounts, network, config


def main():

    owner = load_account('main') # load account
    customer = load_account('account2')
    donor = load_account('account3')
    dai_address = config["networks"][network.show_active()]["dai_address"]
    adai_address = config["networks"][network.show_active()]["adai_address"]
    poolAddressProvider = config["networks"][network.show_active()]["poolAddressProvider"]
    link_address = config["networks"][network.show_active()]["link_address"]
    oracle_address = config["networks"][network.show_active()]["oracle"]
    oracle_fee = config["networks"][network.show_active()]["fee"]
    oracle_jobID = config["networks"][network.show_active()]["jobID"]
    threshold = 1500

    deploy_contract(owner,dai_address,adai_address,link_address,poolAddressProvider,\
        oracle_address, customer, donor, oracle_jobID, oracle_fee, threshold)


    return


def load_account(accountName):

    account = accounts.load(accountName)

    return account


def deploy_contract(owner,dai_address,adai_address,link_address,\
     poolAddressProvider,oracle_address,customer,donor,oracle_jobID, oracle_fee, threshold):

    assert network.show_active() == 'kovan'
    
    print("Deploying contract to {} network".format(network.show_active()))
    BrightLink_v01.deploy(dai_address, adai_address, link_address, poolAddressProvider,\
        oracle_address, customer, donor, oracle_jobID, oracle_fee, threshold, {'from':owner})

    return

This is executed in the terminal using:

brownie run /scripts/deploy_BrightLinkv1.py

This will return a contract address that is needed for interacting with the contract in the testing stages.

Testing

As in the previous posts, there will be two stages to this testing: unitary and integrative. The unit tests will simply execute each function in the contract sequentially and ensure that the individual functions execute as expected. The integration tests will test the entire workflow in one discrete test. As this uses an external API call to GET data to determine the distribution of funds, the ingested data cannot easily be iterated through to get test coverage. This will require either mocking the oracle or updating the data at the url in a loop. I will not do this in this post, but will come back to it later. Each function will update the state of the blockchain in some specific way that can be tested using a series of assert statements.

I started with a check that the contract and customer account have the expected initial balances by writing a simple function, test_initial_balances().

import pytest
import time
from brownie import (

    interface
)

def test_initial_balances(load_customer, getDeployedContract):

    dai = interface.IERC20('0xFf795577d9AC8bD7D90Ee22b6C1703490b6512FD')
    adai = interface.IERC20('0xdCf0aF9e59C002FA3AA091a46196b37530FD48a8')
    contract = getDeployedContract

    assert dai.balanceOf(contract) ==0
    assert adai.balanceOf(contract)==0
    assert dai.balanceOf(load_customer)==0

    return

Then, a single function called test_system() that sequentially hits each function in the contract in the correct sequence. The contract flow is as follows:

  1. transfer DAI from donor to contract
  2. “lock in” initial deposit amount
  3. transfer DAI from contract to Aave pool
  4. withdraw balance from Aave pool
  5. make oracle GET request
  6. distribute funds according to oracle data

This sequence was replicated in test_system(). The assert statements check that the state of the blockchain is consistent with expectations after each function call. I also made use of the contract’s built-in checks, such as viewValueFromOracle() and checkBalance(). The full test_system() function is as follows:

def test_system(set_deposit_amount, getDeployedContract, load_owner, load_customer, load_donor, set_threshold):

    dai = interface.IERC20('0xFf795577d9AC8bD7D90Ee22b6C1703490b6512FD')
    adai = interface.IERC20('0xdCf0aF9e59C002FA3AA091a46196b37530FD48a8')
    link = interface.LinkTokenInterface('0xa36085F69e2889c224210F603D836748e7dC0088')

    contract = getDeployedContract

    link.transfer(contract,0.2e18,{'from':load_owner})
    assert link.balanceOf(contract)!=0

    dai.transfer(contract,set_deposit_amount,{'from':load_donor})
    assert dai.balanceOf(contract) == set_deposit_amount

    contract.lockDepositBalance({'from':load_owner})
    assert contract.checkBalance()[0] == set_deposit_amount

    contract.depositFundsToAave({'from':load_owner})
    assert dai.balanceOf(contract)==0
    assert adai.balanceOf(contract)!=0
    
    time.sleep(30)

    contract.requestDataFromAPI({'from':load_owner})
    trigger = contract.viewValueFromOracle()
    assert trigger != 0

    contract.WithdrawFundsFromAave({'from': load_owner})
    assert dai.balanceOf(contract)>set_deposit_amount
    assert adai.balanceOf(contract)==0

    initialOwnerBalance = dai.balanceOf(load_owner)
    initialCustomerBalance = dai.balanceOf(load_customer)
    initialDonorBalance = dai.balanceOf(load_donor)

    contract.retrieveDAI({'from':load_owner})

    if trigger > set_threshold:

        assert adai.balanceOf(contract) == 0
        assert dai.balanceOf(contract) == 0
        assert dai.balanceOf(load_owner) > initialOwnerBalance
        assert dai.balanceOf(load_customer) == initialCustomerBalance+set_deposit_amount
        assert dai.balanceOf(load_donor) == initialDonorBalance
    
    else:
        assert adai.balanceOf(contract) == 0
        assert dai.balanceOf(contract) == 0
        assert dai.balanceOf(load_owner) > initialOwnerBalance
        assert dai.balanceOf(load_customer) == initialCustomerBalance
        assert dai.balanceOf(load_donor) == initialDonorBalance+set_deposit_amount

    # reset wallet balances before next iteration

    if dai.balanceOf(contract) !=0:
        dai.transfer(load_owner,dai.balanceOf(contract),{'from':contract})
    
    if dai.balanceOf(load_customer) !=0:
        dai.transfer(load_donor,dai.balanceOf(load_customer),{'from':load_customer})


    return

This could, and should also be broken down into individual unit tests for each function because that way, each function is considered to be a separate test and it is clear where and why any failure has occurred. These specific unit tests are included in the script below:

import pytest
import time
from brownie import (

    interface
)

def check_initial_balances(load_customer):

    dai = interface.IERC20('0xFf795577d9AC8bD7D90Ee22b6C1703490b6512FD')
    adai = interface.IERC20('0xdCf0aF9e59C002FA3AA091a46196b37530FD48a8')
    contract = getDeployedContract

    assert dai.balanceOf(contract) ==0
    assert adai.balanceOf(contract)==0
    assert dai.balanceOF(load_customer)==0

    return

def test_initial_deposit(set_deposit_amount, getDeployedContract, load_donor, load_owner):

    dai = interface.IERC20('0xFf795577d9AC8bD7D90Ee22b6C1703490b6512FD')
    contract = getDeployedContract

    initialContractBalance = dai.balanceOf(contract)
    initialDonorBalance = dai.balanceOf(load_donor)

    dai.transfer(contract,set_deposit_amount,{'from':load_donor})
    
    time.sleep(2)

    contract.lockDepositBalance({'from':load_owner})

    finalContractBalance = dai.balanceOf(contract)
    finalDonorBalance = dai.balanceOf(load_donor)

    assert set_deposit_amount > 0
    assert finalContractBalance == initialContractBalance + set_deposit_amount
    assert finalDonorBalance == initialDonorBalance - set_deposit_amount

    return


def test_move_funds_to_aave(set_deposit_amount, getDeployedContract, load_owner):

    dai = interface.IERC20('0xFf795577d9AC8bD7D90Ee22b6C1703490b6512FD')
    adai = interface.IERC20('0xdCf0aF9e59C002FA3AA091a46196b37530FD48a8')
    contract = getDeployedContract

    initialDaiBalance = dai.balanceOf(contract)
    initialAdaiBalance = adai.balanceOf(contract)

    assert initialDaiBalance == set_deposit_amount
    assert initialAdaiBalance == 0
    
    contract.depositFundsToAave({'from':load_owner})

    time.sleep(10)

    finalDaiBalance = dai.balanceOf(contract)
    finalAdaiBalance = adai.balanceOf(contract)

    assert finalDaiBalance == 0
    assert finalAdaiBalance >= set_deposit_amount
    
    return

def test_aave_interest(getDeployedContract):
    
    contract = getDeployedContract
    adai = interface.IERC20('0xdCf0aF9e59C002FA3AA091a46196b37530FD48a8')
    t1 = adai.balanceOf(contract)
    time.sleep(20)
    t2 = adai.balanceOf(contract)
    time.sleep(20)
    t3 = adai.balanceOf(contract)

    assert t3 > t2
    assert t2 > t1

    return



def test_withdrawal_from_aave(set_deposit_amount, getDeployedContract, load_owner):

    dai = interface.IERC20('0xFf795577d9AC8bD7D90Ee22b6C1703490b6512FD')
    adai = interface.IERC20('0xdCf0aF9e59C002FA3AA091a46196b37530FD48a8')
    contract = getDeployedContract

    contract.WithdrawFundsFromAave({'from': load_owner})

    assert dai.balanceOf(contract) >= set_deposit_amount
    assert adai.balanceOf(contract) == 0

    return


def test_send_link(getDeployedContract, load_owner):
    
    nLINK = 0.3e18
    link = interface.LinkTokenInterface('0xa36085F69e2889c224210F603D836748e7dC0088')
    initial_LINK_balance = link.balanceOf(getDeployedContract)
    link.transfer(getDeployedContract,nLINK,{'from':load_owner})
    assert link.balanceOf(getDeployedContract) == initial_LINK_balance + nLINK

    return


def test_oracle_request(getDeployedContract, load_owner):
    
    contract = getDeployedContract

    contract.requestDataFromAPI({'from':load_owner})
    trigger = contract.viewValueFromOracle()
    assert trigger != 0

    return


def test_withdrawal_from_contract(set_deposit_amount, getDeployedContract, load_owner, load_customer, load_donor, set_threshold):

    dai = interface.IERC20('0xFf795577d9AC8bD7D90Ee22b6C1703490b6512FD')
    adai = interface.IERC20('0xdCf0aF9e59C002FA3AA091a46196b37530FD48a8')
    contract = getDeployedContract
    trigger = contract.viewValueFromOracle()

    assert adai.balanceOf(contract) == 0
    assert dai.balanceOf(contract) > set_deposit_amount

    initialOwnerBalance = dai.balanceOf(load_owner)
    initialCustomerBalance = dai.balanceOf(load_customer)
    initialDonorBalance = dai.balanceOf(load_donor)

    contract.retrieveDAI({'from':load_owner})
    
    time.sleep(20)

    if trigger > set_threshold:

        assert adai.balanceOf(contract) == 0
        assert dai.balanceOf(contract) == 0
        assert dai.balanceOf(load_owner) > initialOwnerBalance
        assert dai.balanceOf(load_customer) == initialCustomerBalance+set_deposit_amount
        assert dai.balanceOf(load_donor) == initialDonorBalance
    
    else:
        assert adai.balanceOf(contract) == 0
        assert dai.balanceOf(contract) == 0
        assert dai.balanceOf(load_owner) > initialOwnerBalance
        assert dai.balanceOf(load_customer) == initialCustomerBalance
        assert dai.balanceOf(load_donor) == initialDonorBalance+set_deposit_amount

    return

def test_reset_fund_allocation(getDeployedContract, load_owner,load_customer,load_donor):

    contract = getDeployedContract
    dai = interface.IERC20('0xFf795577d9AC8bD7D90Ee22b6C1703490b6512FD')
    adai = interface.IERC20('0xdCf0aF9e59C002FA3AA091a46196b37530FD48a8')
    link = interface.LinkTokenInterface('0xa36085F69e2889c224210F603D836748e7dC0088')

    if link.balanceOf(contract) > 0:
        contract.retrieveLINK({'from':load_owner})
    
    if dai.balanceOf(contract) > 0:
        contract.escapeHatch({'from':load_owner})
    
    if dai.balanceOf(load_customer) >0:
        dai.transfer(load_donor,dai.balanceOf(load_customer),{'from':load_customer})

    return

Assuming all is well, running

$ brownie test ./tests/integrative --network kovan

should collect 2 items and pass them both.

For the unit tests, running

$ brownie test ./tests/unitary --network kovan

should collect 8 items and pass them all.

These tests indicate successful flow of funds between wallets, contract and Aave, and a successful oracle GET request which is correctly applied to distribute DAI to the customer, donor and contract owner.

More info

More information about connecting chainlink to external APIs available here, here, here, here.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s