DEV Community

Cover image for Create a cross chain ETH/ICP application using Azle and TypeScript
Kristofer
Kristofer

Posted on • Edited on • Originally published at kristoferlund.se

Create a cross chain ETH/ICP application using Azle and TypeScript

A TypeScript smart contract with a React/Vite frontend using Sign in with Ethereum (SIWE) for authentication.

The Internet Computer (ICP) is a blockchain network designed to host and execute smart contracts, offering a secure and scalable platform for decentralized applications and data storage.

Smart contracts on ICP are called "canisters" and can be written in a variety of programming languages, including Motoko, Rust, C, and now... TypeScript, thanks to Azle.

This article looks at how you can get started building canisters in TypeScript and how you can use Sign in with Ethereum (SIWE) to easily authenticate users. Building cross-chain applications has never been easier!

Before we dig in, let's look more closely at how ICP works and how it differs from other blockchain networks.

ICP runs full-stack applications on the blockchain

ICP introduces a novel approach to blockchain technology, offering a decentralized and serverless cloud infrastructure. Unlike traditional blockchains, ICP employs "canisters", an advanced form of smart contracts, to enable the creation of scalable and tamperproof applications directly on the blockchain. This structure allows for the development of a wide range of applications, from social networks to enterprise systems, without relying on external servers or cloud services. Furthermore, ICP's unique reverse gas model simplifies user interaction with blockchain applications, as it doesn't require users to hold tokens or set up a wallet, making it more accessible and user-friendly compared to other blockchain platforms.

Canisters are compiled to WebAssembly. Once deployed, they can be accessed by users through a web interface or through other canisters. Canisters run in full isolation from each other and can be composed to form larger applications.

Use Azle to write TypeScript canisters

Azle, developed by Demergent Labs, is a significant advancement for the ICP ecosystem, enabling TypeScript developers to write canisters. It achieves feature parity with existing Rust and Motoko CDKs, meaning developers can access almost all IC functionality through TypeScript. This integration not only simplifies the development process for those familiar with TypeScript but also enriches the ICP developer community by incorporating the extensive resources and tools available in the TypeScript ecosystem. Demergent Labs also provides Kybra, a Python based CDK.

One goal of Azle is that you should eventually be able to run virtually any Node.js backend code on ICP. Already today, you can run Express, Apollo Server, SQLite, and many other popular Node.js libraries.

Sign in with Ethereum (SIWE) for cross-chain authentication

The SIWE standard defines a protocol for off-chain authentication of Ethereum accounts. At the core of the protocol is the SIWE message, which is a signed message that contains the Ethereum address of the user and some additional metadata. The SIWE message is signed by the user's Ethereum wallet and then sent to the application's backend. The backend verifies the signature and Ethereum address and then creates a session for the user.

Siwe for ICP

Let's build!

This article is not a follow-along step-by-step tutorial, but rather a guide to get you started building your own cross-chain applications. Instead of providing a full tutorial that would run the risk of becoming too lengthy, I will dive in and explain critical parts of the example repository this article is based on.

SIWE React demo app

The example application allows users to connect their Ethereum wallet and then login using SIWE. After logging in, users can create a user profile with their name and image. This profile is stored in a backend canister. In the frontend of the application, all user profiles are displayed in a list.

The app is composed of three canisters:

  1. ic_siwe_provider - A pre-built canister that provides the SIWE authentication functionality.
  2. backend - The Azle TypeScript canister that stores user profiles and provides an API for the frontend application.
  3. frontend - A React/Vite application that interact with the backend and SIWE provider canisters. Yes, ICP can host frontend applications as well!

You can find the full code for the example application on GitHub: ic-siwe-react-demo-ts.

Install the IC SDK

The IC SDK is the software development kit used for creating and managing canisters. The IC SDK includes dfx, the command-line interface for the SDK. dfx is used to create, build, deploy, and manage canisters. dfx also includes a local replica of the ICP blockchain, which can be used for testing and development.

Install the SDK:

sh -ci "$(curl -fsSL https://internetcomputer.org/install.sh)"
Enter fullscreen mode Exit fullscreen mode

ℹ️ If you are using a machine running Apple silicon, you will need to have Rosetta installed. You can install Rosetta by running softwareupdate --install-rosetta in your terminal.

Clone the example repository

Before you proceed, clone the example repository to your local machine. It includes the full source code for all three canisters.

git clone https://github.com/kristoferlund/ic-siwe-react-demo-ts
Enter fullscreen mode Exit fullscreen mode

Then, navigate to the project directory and install the dependencies:

cd ic-siwe-react-demo-ts
npm install
Enter fullscreen mode Exit fullscreen mode

The project is created as a monorepo and consists of two main packages: frontend and backend. The frontend package is the React/Vite application, and the backend package is the Azle TypeScript canister. The SIWE provider does not get a package of its own, as it is a pre-built canister.

ic-siwe-react-demo-ts
├─ packages
│  ├─ backend
│  │  ├─ src
│  │  ├─ backend.did
│  │  ├─ package.json
│  │  ├─ tsconfig.json
│  ├─ frontend
│  │  ├─ src
│  │  ├─ package.json
│  │  ├─ tsconfig.json
│  │  ├─ vite.config.ts
├─ dfx.json
├─ package.json
├─ Makefile
Enter fullscreen mode Exit fullscreen mode

dfx.json configures the project canisters

In the root folder of the project, you will find a file called dfx.json. This file configures the project canisters and their dependencies.

{
  "canisters": {
    "ic_siwe_provider": {
      "type": "custom",
      "candid": "https://github.com/kristoferlund/ic-siwe/releases/download/v0.0.4/ic_siwe_provider.did",
      "wasm": "https://github.com/kristoferlund/ic-siwe/releases/download/v0.0.4/ic_siwe_provider.wasm.gz"
    },
    "backend": {
      "type": "custom",
      "main": "packages/backend/src/index.ts",
      "candid": "packages/backend/backend.did",
      "build": "npx azle backend",
      "wasm": ".azle/backend/backend.wasm",
      "gzip": true
    },
    "frontend": {
      "dependencies": ["backend", "ic_siwe_provider"],
      "source": ["dist"],
      "type": "assets",
      "build": ["npm --prefix packages/frontend run build"]
    }
  },
  "output_env_file": ".env",
  "version": 1
}

Enter fullscreen mode Exit fullscreen mode

Let's break down the dfx.json file:

  • ic_siwe_provider is the prebuilt canister that provides the SIWE authentication method. The candid and wasm properties point to the canister's interface definition and WebAssembly binary, respectively.

  • backend points to the Azle TypeScript canister. The candid property points to the canister's interface definition, and the main property points to the canister's entry point. The build property specifies the command to build the canister, and the wasm property points to the canister's WebAssembly binary.

  • frontend is the assets canister that will host the React/Vite application. The dependencies property specifies that the canister depends on the backend and ic_siwe_provider canisters. The source property points to the directory containing the built frontend assets, and the build property specifies the command to build the frontend assets. The canister type, assets, indicates that the canister will host static assets.

Configure and deploy the ic_siwe_provider canister

Building composable applications on ICP is easy thanks to WebAssembly! The functionality to provide Ethereum wallet-based authentication is provided by a Rust library. If we were to build a Rust canister, we could have chosen to integrate the library directly into the canister. However, we are building a TypeScript canister, so that is not possible. Luckily, there is a pre-built canister available that we can use!

By adding the pre-built ic_siwe_provider canister to the dfx.json of an ICP project, we can quickly enable Ethereum wallet-based authentication. Later we will interact with it from our frontend application as well as from our backend canister.

Let's start a local replica of ICP blockchain now and deploy the ic_siwe_provider canister:

dfx start --background
Enter fullscreen mode Exit fullscreen mode

Now, we have a fully functional local replica of the ICP blockchain running in the background!

When deploying the ic_siwe_provider canister, we need to provide it with some settings to tell it how to behave. These settings are located in the Makefile of the project:

create-canisters:
    dfx canister create --all

deploy-provider:
    dfx deploy ic_siwe_provider --argument "( \
        record { \
            domain = \"127.0.0.1\"; \
            uri = \"http://127.0.0.1:5173\"; \
            salt = \"some-random-salt\"; \
            chain_id = opt 1; \
            scheme = opt \"http\"; \
            statement = opt \"Login to the SIWE/IC demo app\"; \
            sign_in_expires_in = opt 300000000000; /* 5 minutes */ \
            session_expires_in = opt 604800000000000; /* 1 week */ \
            targets = opt vec { \
                \"$$(dfx canister id ic_siwe_provider)\"; \
                \"$$(dfx canister id backend)\"; \
            }; \
        } \
    )"
Enter fullscreen mode Exit fullscreen mode

To summarize the settings: We tell the provider canister to create sessions that expire after 1 week, and we tell it that the generated identities will be valid for the backend and ic_siwe_provider canisters. For more in-depth information about the settings, please refer to the ic_siwe_provider documentation.

To deploy the ic_siwe_provider canister, run the following commands:

make create-canisters
make deploy-provider
Enter fullscreen mode Exit fullscreen mode

The first command creates all canisters defined in the dfx.json file. When creating canisters, they are initially empty. The second command deploys the ic_siwe_provider canister and provides it with the settings we defined in the Makefile.

The output should look something like this:

Deployed canisters.
URLs:
  Frontend canister via browser
    frontend: http://127.0.0.1:4943/?canisterId=be2us-64aaa-aaaaa-qaabq-cai
  Backend canister via Candid interface:
    backend: http://127.0.0.1:4943/?canisterId=bw4dl-smaaa-aaaaa-qaacq-cai&id=bd3sg-teaaa-aaaaa-qaaba-cai
    ic_siwe_provider: http://127.0.0.1:4943/?canisterId=bw4dl-smaaa-aaaaa-qaacq-cai&id=br5f7-7uaaa-aaaaa-qaaca-cai
Enter fullscreen mode Exit fullscreen mode

Try clicking on the last link to open the ic_siwe_provider canister in your browser. You should see a simple web interface that allows you to interact with the canister.

The frontend application

With the ic_siwe_provider canister in place, now let's take a look at the frontend application.

There is nothing special about the frontend application, it is 100% a regular React/Vite application. Another great ICP feature is its ability to host web applications built using most frameworks.

main.tsx:

To setup the frontend application, we need to wrap the <App /> with a few providers that are used throughout the application.

import { _SERVICE } from "./declarations/ic_siwe_provider/ic_siwe_provider.did";
import { canisterId, idlFactory } from "./declarations/ic_siwe_provider/index";

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <WagmiConfig config={wagmiConfig}>
      <RainbowKitProvider>
        <SiweIdentityProvider<_SERVICE>
          canisterId={canisterId}
          idlFactory={idlFactory}
        >
          <Actors>
            <App />
          </Actors>
        </SiweIdentityProvider>
      </RainbowKitProvider>
    </WagmiConfig>
    <Toaster />
  </React.StrictMode>
);
Enter fullscreen mode Exit fullscreen mode

We start out by wrapping the <App /> with a WagmiConfig and RainbowKitProvider. These providers are used to interact with the Ethereum wallet of the user.

Find more information RainbowKit here: RainbowKit

The SiweIdentityProvider is used to interact with the ic_siwe_provider canister and to keep track of the user's identity.

Note that we are using the idlFactory and canisterId from a folder called declarations. These declarations are generated by the dfx command line tool. Setting up the SiweIdentityProvider with this information allows it to interact with the ic_siwe_provider canister in a typesafe way.

For more details on the setup of the SiweIdentityProvider, please refer to the ic-use-siwe-identity documentation.

ic/Actors.tsx

In the Actors component, we wrap our application with yet another provider. This time, it's the ActorProvider that is used to interact with the backend canister.

import { canisterId, idlFactory } from "../declarations/backend/index";
import { _SERVICE } from "../declarations/backend/backend.did";

export default function Actors({ children }: { children: ReactNode }) {
  const { identity, clear } = useSiweIdentity();

  // ...

  return (
    <ActorProvider<_SERVICE>
      canisterId={canisterId}
      context={actorContext}
      identity={identity}
      idlFactory={idlFactory}
    >
      {children}
    </ActorProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Again, we are here using the idlFactory and canisterId from the declarations folder to inform the frontend code about the details of the backend canister.

In addition to that, note that we are using the useSiweIdentity hook to get the user's identity. This hook is provided by the ic-use-siwe-identity package. Connecting the ActorProvider with the user's identity means it can perform authenticated calls on behalf of the user, once authenticated.

For more details on the setup of the ActorProvider, please refer to the ic-use-actor documentation.

components/profile/EditProfile.tsx

The last frontend component I want to highlight is the EditProfile component. It is used to create and update user profiles. It is a simple form that allows the user to enter their name and a link to an avatar image.

export default function EditProfile() {
  const { actor } = useActor();

  async function submit(event: FormEvent<HTMLFormElement>) {
    const response = await actor.save_my_profile(name, avatarUrl);

    if (response && "Ok" in response) {
      // Handle success
    } else {
      // Handle error
    }
  }

  return (
    <div >
        <form
          className="flex flex-col items-center w-full gap-5"
          onSubmit={submit}
        >
          {/* ... */}
          <Button type="submit">
            {submitText}
          </Button>
        </form>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

What I wanted to show here is the actor.save_my_profile call. This is a call to the backend canister that will save the user's profile. The actor object is provided by the ActorProvider. We access the actor object using the useActor hook.

By connecting the ActorProvider with the backend canister interfaces, we get a fully typed actor object that we can use to interact with the canister.

The IC support libraries together with the useActor hook abstracts away most of the complexity of making authenticated calls to ICP canisters.

Build and deploy the frontend canister

Now, let's go ahead and deploy the frontend! The details for building and deploying are defined in the Makefile:

deploy-frontend:
    npm install
    dfx deploy frontend
Enter fullscreen mode Exit fullscreen mode

Nothing too fancy here, we are simply installing the dependencies and then deploying the frontend canister using the build instructions defined in the dfx.json file. To build and deploy, run the following command:

make deploy-frontend
Enter fullscreen mode Exit fullscreen mode

The backend

Now, let's have a look at the backend canister. The package is setup like a regular TypeScript project, requiring only azle as a dependency. Nice!

package.json:

{
  "dependencies": {
    "azle": "^0.20.1"
  },
  "devDependencies": {
    "ts-node": "^10.9.1",
    "typescript": "^5.2.2"
  }
}
Enter fullscreen mode Exit fullscreen mode

Building the canister is straight forward using the Azle CLI tool in the root dir of the project:

npx azle backend
Enter fullscreen mode Exit fullscreen mode

This should render an output like this:

Building canister backend

Done in 29.40s

🎉 Built canister backend at .azle/backend/backend.wasm
Enter fullscreen mode Exit fullscreen mode

Before deploying the backend canister, let's have a look at the code! Every canister has a service interface that defines the methods that can be called on the canister. The service interface is defined in a file called backend.did:

service : (siwe_provider_canister : text) -> {
  "get_my_profile" : () -> (GetMyProfileResponse) query;
  "save_my_profile" : (Name, AvatarUrl) -> (SaveMyProfileResponse);
  "list_profiles" : () -> (ListProfilesResponse) query;
};
Enter fullscreen mode Exit fullscreen mode

Here, we define three methods: get_my_profile, save_my_profile, and list_profiles. The get_my_profile and list_profiles methods are marked as query, meaning they cannot change the state of the canister. The save_my_profile method is not marked as query, meaning it can change the state of the canister.

index.ts

This is the entry point of the backend canister. It is the file that will be called when the canister is initialized.

import { Canister, Principal, init } from "azle";
import {
  SiweProviderCanister,
  initializeSiweProviderCanister,
} from "./siwe_provider";

import { get_my_profile } from "./service/get_my_profile";
import { list_profiles } from "./service/list_profiles";
import { save_my_profile } from "./service/save_my_profile";

export default Canister({
  init: init([Principal], (siweProviderPrincipal) => {
    initializeSiweProviderCanister(SiweProviderCanister(siweProviderPrincipal));
  }),

  get_my_profile,
  save_my_profile,
  list_profiles,
});
Enter fullscreen mode Exit fullscreen mode

Okay, what happens here?

We begin by importing a Canister and a Principal from the azle package. azle provides us with all the objects and types we need to interact with ICP, conveniently exported at the top level.

Exporting a Canister object is the entry point of the canister. In addition to the three methods defined in the service interface, the backend canister also has an init method that is called when the canister is initialized. The init method takes the principal of the ic_siwe_provider canister as an argument and saves this information for later use.

user_profiles.ts

We need a suitable data structure to store the user profiles. In this file, we define a UserProfile type and a profileStore. The profileStore is a StableBTreeMap that maps user addresses to user profiles. The name StableBTreeMap gives a hint that this is a special kind of map. It has access to what is called stable memory, one of the most powerful features of ICP.

import { Record, StableBTreeMap, text } from "azle";

const UserKey = text;

type UserKey = typeof UserKey.tsType;

export const UserProfile = Record({
  address: text,
  name: text,
  avatar_url: text,
});

export type UserProfile = typeof UserProfile.tsType;

export let profileStore = StableBTreeMap<UserKey, UserProfile>(0);
Enter fullscreen mode Exit fullscreen mode

Stable memory is a feature unique to ICP that provides a long-term, persistent data storage option separate from a canister's heap memory. When a canister is stopped or upgraded, the data stored in stable memory is not cleared or removed. The stable memory is preserved throughout the process while any other WebAssembly state is discarded. The maximum storage limit for the stable memory of one canister is 400GB!

Note, that is GB with a G, not an M or a K!

In addition to creating the UserProfile object, we also define the UserProfile type. azle provides us with a utility attribute tsType that we can use to extract the TypeScript type. Thanks for that!

save_my_profile.ts

Let's also take a closer look at one of the methods defined in the service interface: save_my_profile.

save_my_profile is defined as an async method that accepts two arguments: name and avatar_url. In addition to saving those two values to the profile, we also want to save the Ethereum address of the user.

import { UserProfile, profileStore } from "../user_profiles";
import { Variant, ic, text, update } from "azle";

import { SIWE_PROVIDER_CANISTER } from "../siwe_provider";

const SaveMyProfileResponse = Variant({
  Ok: UserProfile,
  Err: text,
});

export type SaveMyProfileResponse = typeof SaveMyProfileResponse.tsType;

async function get_address() {
  //...
  const response = await ic.call(SIWE_PROVIDER_CANISTER.get_address, {
    args: [ic.caller().toUint8Array()],
  });
  if (response.Err) throw new Error(response.Err);
  if (!response.Ok) throw new Error("Failed to get the caller address");
  return response.Ok;
}

export const save_my_profile = update(
  [text, text],
  SaveMyProfileResponse,
  async (name, avatar_url): Promise<SaveMyProfileResponse> => {
    try {
      const address = await get_address();
      const profile: UserProfile = {
        address: address.toString(),
        name,
        avatar_url,
      };
      profileStore.insert(ic.caller().toString(), profile);
      return { Ok: profile };
    } catch (error) {
      if (error instanceof Error) return { Err: error.message };
      return { Err: "Failed to save profile" };
    }
  }
);
Enter fullscreen mode Exit fullscreen mode

The backend canister has initially no knowledge of the Ethereum address of the user. All it sees is an incoming call from an authenticated IC user. To get the Ethereum address, we need to call the get_address method on the ic_siwe_provider canister. That is called a cross canister call, and it is done using the ic.call method.

Note here, the argument we are passing along to the ic.call method. We are passing ic.caller().toUint8Array(), that returns the principal of the caller. If the principal - that is, the user identity - was generated by the ic_siwe_provider canister, the get_address method will return the Ethereum address of that user.

Build and deploy the backend canister

We are now ready to deploy the backend canister. The deploy details are defined in the Makefile:

deploy-backend:
    npm install
    dfx deploy backend --argument "(principal \"$$(dfx canister id ic_siwe_provider)\")"
Enter fullscreen mode Exit fullscreen mode

Note here that we are passing the principal of the ic_siwe_provider canister as an argument to the backend canister. This is the principal that we saved in the init method of the backend canister.

To deploy the backend canister, run the following command:

make deploy-backend
Enter fullscreen mode Exit fullscreen mode

That's it!

With all three canisters deployed, the application is now up and running! Users can connect their Ethereum wallet and create user profiles. The profiles are stored in the backend canister and displayed in the frontend application.

Access the application by opening the frontend canister in your browser. You can find the URL in the output of the dfx deploy frontend command.

To learn more about how to publish Azle TypeScript canisters to ICP, check out the Azle documentation. For more informtion about the features and inner workings of ICP, check out the extensive Internet Computer documentation.

Conclusion

In this article, we have looked at how to build a cross chain application on ICP using TypeScript and Sign in with Ethereum (SIWE) for authentication. We have seen how to use Azle to write TypeScript canisters and how to use stable memory as a way to store data that survives canister upgrades.

ICP offers a unique and powerful platform for building decentralized applications. In addition to the features we have looked at in this article, ICP also has a number of additional strengths:

  • True onchain randomness
  • Can handle secrets in a secure way even in a decentralized environment
  • Canisters can perform HTTPS outbound requests, effectively making them decentralised oracles
  • Canisters can hold BTC natively
  • Canisters can hold ETH natively
  • Plus much more!

I hope this article has given you a good understanding of how to get started building cross chain applications on ICP. If you have any questions or comments, please post them in the comments!

💜 Happy coding!

Top comments (0)