Tutorial: Executing swap with Shell Protocol

This tutorial will walk you through the example of executing a swap (DAI <> USDC) on Arbitrum Goerli testnet using the two most important Shell Protocol components: the Ocean, a unified accounting system, and Proteus, Shell's novel AMM primitive.

You don't need to worry about the inner workings of the Ocean and Proteus for now.

Getting started

If you have already completed this steps by following Quickstart guide you can skip this section. If not, follow along.

Clone the Shell Protocol public repo, cd into it and run npm install

$ git clone https://github.com/Shell-Protocol/Shell-Protocol/tree/main
$ cd Shell-Protocol
$ npm i

In the root folder, go to hardhat.config.js and add your private key on line 27.

WARNING: Do not just simply paste the private key. Use something like dot-env and environment variables instead. Be sure that you ad the file containing secrets to the .gitignore

Be sure that your account has enough test tokens on Arbitrum Goerli network. You can use this faucet to fund your account https://faucet.quicknode.com/arbitrum/goerli. You can also obtain up to 1000 testnet ERC-20 tokens by calling the claimTokens function on the following contract: https://testnet.arbiscan.io/address/0xEaE5B59499a461887fBf2BF47887e4e4cB91D703#writeContract

Now go to scripts folder and create new file swap.js. We will write the whole swap logic inside this file and let the hardhat execute the script.

Writing the swap script

The first thing we will do is to import hardhat and create an empty main() function which will be called upon running swap.js by the Hardhat. It's a standard way of writing Hardhat scripts.

const hre = require("hardhat");

async function main() {

}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

First thing we will need is an instance of the Ocean smart contract. As Shell Protocol's accounting system, the Ocean is responsible for updating balances and "moving" tokens between the user and the AMM. It's not necessary that you understand how Ocean works for the sake of this tutorial, but if you want to learn more you can start read the The Ocean page in the deep dive section. Note that reading Important concepts section before that is highly recommended. So let's create and instance of The Ocean in line 4:

const hre = require("hardhat");

async function main() {
  const ocean = await hre.ethers.getContractAt("Ocean", "OCEAN_ADDRESS_HERE");
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

You should pass the correct Ocean contract address that can be found here.

Congratulations! We just created and instance of the Ocean smart contract. Now we have to pass to that instance a set of instructions (as Interactions) we want it to execute. In order to swap 100 DAI for USDC we will need to do the following:

  1. Wrap 100 DAI to the Ocean

  2. Ask Proteus AMM Engine to calculate how many USDC should we get for 100 DAIs

  3. Ask The Ocean to transfer computed amount of USDC to our wallet

Now that we know what to do, we need to determine how to do it. As we already mentioned, The Ocean executes instructions by passing Ocean Interactions to it in order we want them to be executed. So, in this case we need to pass the following interactions:

  1. WrapErc20 with the DAI token address as an input token and amount set to the 100

  2. ComputeOutputAmount passing DAI as an input token, 100 as amount and USDC as an output token

  3. UnwrapErc20 to transfer USDC from the Ocean to the specified address

So, the next step is to encode this three interactions and prepare them for execution. Interactions within the Ocean are uniformly encoded via the Interaction struct.

struct Interaction {
    bytes32 interactionTypeAndAddress;
    uint256 inputToken;
    uint256 outputToken;
    uint256 specifiedAmount;
    bytes32 metadata;
}

That means that we have to create and array of three interaction objects and we will use helper methods for that. Helper methods for creating correct format interaction objects can be found inside interactions.js file in the utils-js folder. We will need wrapERC20, computeInputAmount and unwrapERC20 helper method specifically so let's import them and see how to use them.

const hre = require("hardhat");
const { ethers } = require("hardhat");
const { wrapERC20, computeInputAmount, unwrapERC20 } = require("../utils-js/interactions");

async function main() {
  const ocean = await hre.ethers.getContractAt("Ocean", "OCEAN_ADDRESS_HERE");

  const interactions = [
    wrapERC20({
      address: "DAI_ADDRESS_HERE",
      amount: 100
    }),
    computeInputAmount({
      address: "DAI_USDC_POOL_ADDRESS_HERE",
      inputToken: "WRAPPED_DAI_OCEAN_ID",
      outputToken: "WRAPPED_USDC_OCEAN_ID",
      specifiedAmount: 100,
      metadata: "0x0000000000000000000000000000000000000000000000000000000000000000"
    }),
    unwrapERC20({
      address: "USDC_ADDRESS_HERE",
      amount: ethers.constants.MaxUint256
    })
  ];
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

Now we just have to pass the right values instead of the template strings. DAI contract address on Arbitrum Goerli is 0xEaE5B59499a461887fBf2BF47887e4e4cB91D703 while USDC is at the following address 0x1f84761D120F2b47E74d201aa7b90B73cCC3312c.

Finally, DAI USDC Pool address we need is 0x785402A418B56fd9a05F60B194B3CeecaB42E78f. All Proteus pool address can be found in Contract addresses page.

Ok, but what are these "WRAPPED_DAI_OCEAN_ID" and "WRAPPED_USDC_OCEAN_ID" that we are passing as input and output tokens to the computeInputAmount helper function?

Well, as an accounting system Ocean is responsible for handling both tokens from the external ledgers (ERC20, ERC721, ERC1155), i.e. wrapped tokens, and Ocean native tokens. In order to keep the track of everything, every token inside the Ocean has its own ID which we call Ocean ID. We won't be going into explaining this concept here as it's not necessary to understand how it works to complete this tutorial, it's throughly explained in the Ocean page under the Deriving token's Ocean ID section. All we need to know right now is that we have another helper function which we can use to get the correct token id. The helper method name is calculateWrappedTokenId and it's located inside utils.js file in the utils-js folder. Let's swap the template strings with the real values:

const hre = require("hardhat");
const { ethers } = require("hardhat");
const { wrapERC20, computeInputAmount, unwrapERC20 } = require("../utils-js/interactions");
const { calculateWrappedTokenId } = require("../utils-js/utils");

async function main() {
  const ocean = await hre.ethers.getContractAt("Ocean", "OCEAN_ADDRESS_HERE");

  const interactions = [
    wrapERC20({
      address: "0xEaE5B59499a461887fBf2BF47887e4e4cB91D703",
      amount: 100
    }),
    computeInputAmount({
      address: "0x785402A418B56fd9a05F60B194B3CeecaB42E78f",
      inputToken: calculateWrappedTokenId({address: "0xEaE5B59499a461887fBf2BF47887e4e4cB91D703", id: 0}),
      outputToken: calculateWrappedTokenId({address: "0x1f84761D120F2b47E74d201aa7b90B73cCC3312c", id: 0}),
      specifiedAmount: 100,
      metadata: "0x0000000000000000000000000000000000000000000000000000000000000000"
    }),
    unwrapERC20({
      address: "0x1f84761D120F2b47E74d201aa7b90B73cCC3312c",
      amount: ethers.constants.MaxUint256
    })
  ];
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

Let's see what we have so far. We have created an instance of the Ocean contract and we have encrypted three interactions we want to execute. All we have to do now is to tell the Ocean to execute them.

We will use another helper function executeInteractions defined in index.js under the utils-js folder. This helper method receives three params: an instance of the Ocean contract, signer and an array of interactons.

executeInteractions(ocean, signer, interactions);

All this helper method does is it calculates interaction IDs and calls an Ocean contract method doMultipleInteractions on the behalf of signer. We already have an instance of the Ocean created at line 7 and an array of interactions prepared. We just need a signer. Let's fetch it with the etherjs library and place it above the Ocean instance creation on line 7.

const hre = require("hardhat");
const { ethers } = require("hardhat");
const { wrapERC20, computeInputAmount, unwrapERC20 } = require("../utils-js/interactions");
const { calculateWrappedTokenId } = require("../utils-js/utils");

async function main() {
  const [signer] = await ethers.getSigners();
  const ocean = await hre.ethers.getContractAt("Ocean", "OCEAN_ADDRESS_HERE");

  const interactions = [
    wrapERC20({
      address: "0xEaE5B59499a461887fBf2BF47887e4e4cB91D703",
      amount: 100
    }),
    computeInputAmount({
      address: "0x785402A418B56fd9a05F60B194B3CeecaB42E78f",
      inputToken: calculateWrappedTokenId({address: "0xEaE5B59499a461887fBf2BF47887e4e4cB91D703", id: 0}),
      outputToken: calculateWrappedTokenId({address: "0x1f84761D120F2b47E74d201aa7b90B73cCC3312c", id: 0}),
      specifiedAmount: 100,
      metadata: "0x0000000000000000000000000000000000000000000000000000000000000000"
    }),
    unwrapERC20({
      address: "0x1f84761D120F2b47E74d201aa7b90B73cCC3312c",
      amount: ethers.constants.MaxUint256
    })
  ];
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

Only thing we have to do now in order to complete the swap and this tutorial is to call the executeInteractions

const hre = require("hardhat");
const { ethers } = require("hardhat");
const { wrapERC20, computeInputAmount, unwrapERC20 } = require("../utils-js/interactions");
const { calculateWrappedTokenId} = require("../utils-js/utils");
const { executeInteractions } = require("../utils-js");

async function main() {
  const [signer] = await ethers.getSigners()
  const ocean = await hre.ethers.getContractAt("Ocean", "OCEAN_ADDRESS_HERE");

  const interactions = [
    wrapERC20({
      address: "0xEaE5B59499a461887fBf2BF47887e4e4cB91D703",
      amount: transferAmount
    }),
    computeInputAmount({
      address: "0x785402A418B56fd9a05F60B194B3CeecaB42E78f",
      inputToken: calculateWrappedTokenId({address: "0xEaE5B59499a461887fBf2BF47887e4e4cB91D703", id: 0}),
      outputToken: calculateWrappedTokenId({address: "0x1f84761D120F2b47E74d201aa7b90B73cCC3312c", id: 0}),
      specifiedAmount: 100,
      metadata: "0x0000000000000000000000000000000000000000000000000000000000000000"
    }),
    unwrapERC20({
      address: "0x1f84761D120F2b47E74d201aa7b90B73cCC3312c",
      amount: ethers.constants.MaxUint256
    })
  ];

  await executeInteractions({ocean, signer, interactions});
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

Congratulations! 🥳 You have successfully completed a swap using Shell Protocol's accounting contract, The Ocean and native AMM engine Proteus. In this tutorial we tried to explain the complete system form a higher level, using as much abstractions as possible without loosing clarity, while also not going deeper than we need into specifics. In the deep dive section you'll be able to go behind those abstractions and learn the inner workings of every component used in this example.

Since you have successfully completed this tutorial you deserve appreciation.Don't forget to tweet about your accomplishment!

Last updated