Skip to main content

Connect the client

In this section, we will establish the connection between our frontend and the blockchain using the postchain-client library along with the FT4-wrapper. Our goal is to create a ContextProvider and define custom React hooks to integrate blockchain capability into our app seamlessly.

Setup

Before diving into the integration process, we must set up our project with the necessary dependencies. Specifically, we require the FT4-wrapper to work with the Chromia blockchain:

npm install @chromia/ft4@0.8.0

Create a context

We will create a context within our app to manage blockchain-related data and state efficiently. This context will serve as a central hub for handling blockchain interactions. We start by creating a file named ContextProvider.tsx within the src/components directory. Here, we'll declare and define our contexts:

src/components/ContextProvider.tsx
"use client";

import {
Session,
createKeyStoreInteractor,
createSingleSigAuthDescriptorRegistration,
createWeb3ProviderEvmKeyStore,
hours,
registerAccount,
registrationStrategy,
ttlLoginRule,
} from "@chromia/ft4";
import { createClient } from "postchain-client";
import { ReactNode, createContext, useContext, useEffect, useState } from "react";
import { getRandomUserName } from "../app/user";

// Create context for Chromia session
const ChromiaContext = createContext<Session | undefined>(undefined);

export function ContextProvider({ children }: { children: ReactNode }) {
// Initialize session and EVM address states
const [session, setSession] = useState<Session | undefined>(undefined);

// Additional state initialization will be defined here

return <ChromiaContext.Provider value={session}>{children}</ChromiaContext.Provider>;
}

In the ContextProvider component, we define a context:

  1. ChromiaContext: This context will hold a Session object, a wrapper for the FT4 client. It manages the current session's state, including keypairs for signing transactions.

We initialize the respective states within this component and wrap our app components with these contexts. To utilize this context, wrap the NavBar and {children} tags within src/app/layout.tsx:

src/app/layout.tsx
<ContextProvider>
<NavBar />
{children}
</ContextProvider>

Initialize the session

When it comes to initializing a new session, let's visualize the process with a simple flow diagram:

Here's a step-by-step explanation of the session initialization process:

  1. Start the app: Our app initiates by connecting with MetaMask, a popular Ethereum wallet provider.
  2. Check the user existence (within our dapp): We determine whether the user's Ethereum wallet is already associated with an FT-account in our decentralized app (dapp).
  3. Create a session (user exists): If the user's Ethereum wallet is already linked to an account in our dapp, we create a new session. This session allows us to seamlessly interact with the FT-protocol.
  4. Create an account (user doesn't exist): In cases where the user's Ethereum wallet isn't connected to an account in our dapp, we create a new account. This involves a detailed process explained in Module 1.
  5. Create a session: We establish a session after successfully creating a new account. This session enables us to interact with the blockchain using the FT4-wrapper.

Now, let's implement the initialization flow:

src/components/ContextProvider.tsx
// 2.
declare global {
interface Window {
ethereum: any;
}
}

export function ContextProvider({ children }: { children: ReactNode }) {
const [session, setSession] = useState<Session | undefined>(undefined);

useEffect(() => {
const initSession = async () => {
console.log("Initializing Session");
// 1. Initialize Client
const client = await createClient({
nodeUrlPool: "http://localhost:7740",
blockchainRid: 0,
});

// 2. Connect with MetaMask
const evmKeyStore = await createWeb3ProviderEvmKeyStore(window.ethereum);

// 3. Get all accounts associated with evm address
const evmKeyStoreInteractor = createKeyStoreInteractor(client, evmKeyStore);
const accounts = await evmKeyStoreInteractor.getAccounts();

if (accounts.length > 0) {
// 4. Start a new session
const { session } = await evmKeyStoreInteractor.login({
accountId: accounts[0].id,
config: {
rules: ttlLoginRule(hours(2)),
flags: ["MySession"],
},
});
setSession(session);
} else {
// 5. Create a new account by signing a message using metamask
const authDescriptor = createSingleSigAuthDescriptorRegistration(["A", "T"], evmKeyStore.id);
const { session } = await registerAccount(
client,
evmKeyStore,
registrationStrategy.open(authDescriptor, {
config: {
rules: ttlLoginRule(hours(2)),
flags: ["MySession"],
},
}),
{
name: "register_user",
args: [getRandomUserName()],
}
);
setSession(session);
}
console.log("Session initialized");
};

initSession().catch(console.error);
}, []);

return <ChromiaContext.Provider value={session}>{children}</ChromiaContext.Provider>;
}

Lets break this down:

  1. We start by initializing the client and connecting to the local blockchain node with blockchainIid 0. When you initiate a test node using chr node start, you'll find logs indicating the port for the REST API connection, the internal (chain id) and external (blockchain Rid) for the blockchain.

    During testing, utilizing the internal Id (blockchainIid) is more convenient since it accurately reflects the chain's state. However, when you're connecting the client to a blockchain deployed on a network, it becomes imperative to use the blockchain's referential Id (blockchainRid). This ensures proper identification and interaction with the specific blockchain on the network.

  2. We connect with MetaMask, ensuring that the ethereum object is declared globally on the Window interface to eliminate compilation warnings.

  3. We retrieve all accounts associated with the Ethereum wallet address from our dapp. In our dapp, we expect either 0 or 1 account per wallet.

  4. If an account exists, we start a new session with the "MySession" flag as defined in the authentication handlers of our dapp. We set an expiration time for the session to two hours, after which the user will need to sign in again.

  5. If no accounts exist, we create a new account by signing a message in MetaMask and sending a transaction to our dapp. The complete flow for this process is described in more detail on the Conceptual design page. For the EVM key, we add the flags "A" and "T" to allow signing administrative and transfer operations for this key, and the "MySession" flag on the session key that is used internally by the Session. We mark register_user as the register operation to be able to pass the user name as argument.

We also utilize a function getRandomUserName defined in src/app/user.tsx to generate a random user name for account creation.

src/app/user.tsx
const funnyAnimalNames = [
"SneakyLlama",
"CheekyMonkey",
"LaughingPenguin",
"CrazyKangaroo",
"GigglingHedgehog",
"WackyWalrus",
"DancingDolphin",
"BumblingBee",
"HoppingHare",
"SingingSeagull",
];

export function getRandomUserName() {
const randomIndex = Math.floor(Math.random() * funnyAnimalNames.length);
return funnyAnimalNames[randomIndex];
}

Access context with custom hooks

To streamline the interaction with our backend, we've created custom hooks that allow us to call queries and operations from our frontend easily. We'll start by defining hooks for the two contexts we've set up in ContextProvider.tsx:

src/components/ContextProvider.tsx
// Define hooks for accessing context
export function useSessionContext() {
return useContext(ChromiaContext);
}

Next, we create a new file called src/app/hooks.tsx, where we introduce a new hook called useQuery:

src/app/hooks.tsx
// Create a custom hook for queries
import { useSessionContext } from "@/components/ContextProvider";
import { useEffect, useState } from "react";
import { RawGtv, DictPair } from "postchain-client";

// Custom hook for queries and operations
export function useQuery<TReturn extends RawGtv, TArgs extends DictPair | undefined = DictPair>(
name: string,
args?: TArgs
) {
const session = useSessionContext();
const [serializedArgs, setSerializedArgs] = useState(JSON.stringify(args));
const [data, setData] = useState<TReturn | undefined>();

// Function to send the query
const sendQuery = useCallback(async () => {
if (!session || !args) return;
const data = await session.query<TReturn>({ name: name, args: args });
setSerializedArgs(JSON.stringify(args));
setData(data!!);
}, [session, name, args]);

// Trigger the query when session, query name, or arguments change
useEffect(() => {
sendQuery().catch(console.error);
}, [session, name, serializedArgs]);

// Return query result and reload function
return {
result: data,
reload: sendQuery,
};
}

With this custom hook, we can retrieve the necessary context and initiate a query whenever there are changes to the session, query name, or arguments. We use a serialized version of the arguments to ensure stability and avoid unintended queries. Additionally, we gracefully handle cases where the arguments might still need to be initialized, postponing the query until they are defined.

The news feed

Now, we can implement the news feed. In the NewsFeed.tsx component, we first define DTO (Data Transfer Object) objects corresponding to the return type of the get_posts query. These objects help us structure the data:

src/components/NewsFeed.tsx
export type User = {
name: string;
id: number;
account: number;
};
export type PostDto = {
timestamp: number;
user: User;
content: string;
};
export type GetPostsReturnType = {
pointer: number;
posts: PostDto[];
};

We utilize the custom hook we previously created, useQuery, to fetch data in the NewsFeed component. We retrieve information about the user, followers, following, and the actual news feed posts:

src/components/NewsFeed.tsx
export default function NewsFeed() {
const session = useSessionContext()
const accountId = session?.account.id;
const { result: userName } = useQuery<string>("get_user_name", accountId ? { user_id: accountId } : undefined);
const { result: followersCount } = useQuery<number>("get_followers_count", accountId ? { user_id: accountId } : undefined);

const { result: followingCount } = useQuery<number>("get_following_count", accountId ? { user_id: accountId } : undefined);
const { result: newsFeed, reload: reloadPosts } = useQuery<GetPostsReturnType>("get_posts", accountId ? { user_id: accountId, n_posts: 10, pointer: 0 } : undefined);

// Refresh posts every 10 seconds
useEffect(() => {
const refreshPosts = setInterval(() => {
reloadPosts();
}, 10000);
return () => {
clearInterval(refreshPosts);
}
});

The queries are directed to fetch specific data: get_user_name, get_followers_count, get_following_count, and get_posts. We refresh the posts every 10 seconds. To ensure smooth loading, we pass undefined if evmContext is not initialized. Here's how we incorporate these values into our style:

src/components/NewsFeed.tsx
return (
<div className="p-4 md:p-8">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold">{userName}</h1>

<div className="flex text-center">
{/* Followers Box */}
<div className="bg-white m-1 p-2 rounded-lg shadow">
<h3 className="text-lg font-semibold">Followers</h3>
<p className="text-3xl font-bold">{followersCount}</p>
</div>

{/* Following Box */}
<div className="bg-white m-1 p-2 rounded-lg shadow">
<h3 className="text-lg font-semibold">Following</h3>
<p className="text-3xl font-bold">{followingCount}</p>
</div>
</div>
</div>

{/* News Feed */}
<div className="bg-white p-4 rounded-lg shadow">
<ul>
{newsFeed ? (
newsFeed.posts.map((post, index) => (
<li key={index} className="mb-4">
<div className="flex">
<div className="font-semibold">{post.user.name}</div>
<div className="text-gray-500 text-sm ml-2">{new Date(post.timestamp).toLocaleString()}</div>
</div>
<div className="mt-2">{post.content}</div>
{/* Add a horizontal line between posts */}
<hr className="my-4 border-t border-gray-300" />
</li>
))
) : (
<p>Loading...</p>
)}
</ul>
</div>
</div>
);

The user list

In the UserList component, we replace our dummy value with the following code snippet to fetch 100 users for the app:

src/components/UserList.tsx
export default function UsersList() {
const { result: users } = useQuery<GetUsersReturnType>("get_users", { n_users: 100, pointer: 0 });

This code allows the app to retrieve the first 100 users. While it's possible to extend both the get_users and get_posts queries to fetch more users dynamically while scrolling, we'll keep it simple for this course.

The user item

To make the follow/unfollow button of the UserItem component functional, we need to check if we're currently following the user and call the appropriate operation, either follow_user or unfollow_user, to change the follow state. As we've already prepared the code structure in Lesson 2, all we need to do is add the hook to the session and call our operation:

src/components/UserItem.tsx
export default function UserItem({ user }: { user: UsersDto }) {
// Step 1: Initialize state variables
const session = useSessionContext();
const accountId = session?.account.id;
const { result: isFollowing, reload: updateIsFollowing } = useQuery<boolean>("is_following", accountId ? { my_id: accountId, your_id: user.id } : undefined);
...
// Step 2: Handle follow/unfollow click
const handleFollowClick = async (userId: Buffer, follow: boolean) => {
if (!session) return
try {
setIsLoading(true);
// Step 3: Handle follow/unfollow logic
await session.call({
name: follow ? "follow_user" : "unfollow_user",
args: [userId]
});
updateIsFollowing();

We begin by replacing the isFollowing state with an actual query to obtain the current following state. Then, in the handleFollowClick function, we call either the follow_user or unfollow_user operation to change the follow status and finalize by updating the isFollowing state by reloading the query.

The new post component

To enable users to create new posts, we must implement a similar pattern as in the UserItem. Building upon the scaffold created in Lesson 2, we add the following logic:

src/components/NewPost.tsx
export default function NewPost() {
// Step 1: Initialize state variables
const session = useSessionContext();
...
// Step 3: Handle form submission
const onSubmit = async (data: string) => {
if (!session) return
try {
if (data.trim() !== '') {
setIsLoading(true);
// Step 4: Content submission
await session.call({
name: "make_post",
args: [data],
})
router.push('/');

With these changes, your dapp should now allow users to create and send posts.