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 eventually should 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.
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.
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:
ic_siwe_provider
- A pre-built canister that provides the SIWE authentication functionality.backend
- The Azle TypeScript canister that stores user profiles and provides an API for the frontend application.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)"
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
Then, navigate to the project directory and install the dependencies:
cd ic-siwe-react-demo-ts
npm install
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
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
}
Let’s break down the dfx.json
file:
-
ic_siwe_provider
is the prebuilt canister that provides the SIWE authentication method. Thecandid
andwasm
properties point to the canister’s interface definition and WebAssembly binary, respectively. -
backend
points to the Azle TypeScript canister. Thecandid
property points to the canister’s interface definition, and themain
property points to the canister’s entry point. Thebuild
property specifies the command to build the canister, and thewasm
property points to the canister’s WebAssembly binary. -
frontend
is the assets canister that will host the React/Vite application. Thedependencies
property specifies that the canister depends on thebackend
andic_siwe_provider
canisters. Thesource
property points to the directory containing the built frontend assets, and thebuild
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
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)\"; \
}; \
} \
)"
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
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
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>
);
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>
);
}
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>
);
}
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
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
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"
}
}
Building the canister is straight forward using the Azle CLI tool in the root dir of the project:
npx azle backend
This should render an output like this:
Building canister backend
Done in 29.40s
🎉 Built canister backend at .azle/backend/backend.wasm
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;
};
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,
});
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);
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" };
}
}
);
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)\")"
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
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, feel free to reach out to me on email, X or GitHub.