Aptos Dapp Journey - Part 3
I've been grappling with a question lately: how do I actually talk to the blockchain? It's time to explore ways of getting data and interacting with smart contracts. This journey into web3 has been quite a ride. I started by setting up a project template, then dove into smart contracts.
Now, I'm facing my next challenge: connecting my app to the blockchain.
In this part of my adventure, I'm aiming to:
Discover tools for fetching on-chain data
Learn how to read from smart contracts
Figure out how to write to the blockchain
It's not always clear-cut, and I'm still learning. I'll share what I've found about SDKs, other data sources, and making transactions. Let's see what we can uncover together.
Exploring SDKs
As I dive deeper into blockchain development, I've hit a roadblock. How do I efficiently interact with the blockchain without reinventing the wheel? This is where SDKs, or Software Development Kits, come into play, which are tools designed to make a developer's life easier. They're like a Swiss Army knife for blockchain interaction, offering pre-built functions and utilities.
But why use them? SDKs can significantly speed up development. They handle many low-level details, allowing me to focus on building my app's unique features. In my search for solutions, I discovered that Aptos offers SDKs for various languages:
Unity SDK for game development
C++ / Unreal SDK for more game options
Python and Rust SDKs for backend work
Kotlin SDK for mobile development
TypeScript SDK for full stack applications
The TypeScript SDK caught my eye. It is the natural choice since I'm building a web app with React and TypeScript. It's also the most popular and recommended option, which gives me confidence.
It's well-documented and includes key features such as key generation, transaction signing, and submission. The Github repository offers a thorough set of examples covering various use cases. Additionally, it includes built-in support for NFT operations.
However, while the TypeScript SDK offers a robust set of tools, it's just one piece of the puzzle. To truly understand how to interact with the Aptos blockchain, I need to explore different ways of getting on-chain data.
Different ways to load data from chain
SDKs are just one path to on-chain data. There's a whole ecosystem of tools and APIs to explore. First, there's the Node API. It's the foundation on which most SDKs are built. Understanding these API calls has been crucial for me to grasp what's happening under the hood of the SDK I'm using.
Then, I discovered the Indexer API. It's been a game-changer for more complex queries. While SDKs are great for common tasks, the Indexer API lets me craft custom queries for specific data needs. It's like having a powerful magnifying glass for blockchain data. During development, I've found two tools incredibly handy:
The command-line interface (CLI) has become my go-to for quick actions from the terminal. There's no need to switch away from my editor, and I can interact with the blockchain with a few keystrokes.
I use the Explorer when I need a visual representation of the data. It's a fast way to view and verify data in a user-friendly interface.
For my project, I'm leaning towards a combination of Surf by Thala Labs, built on top of the Aptos TypeScript SDK, which offers type safety and ease of use, and the Indexer API with GraphQL for our more advanced queries. I’m building a Product Hunt-style dApp on the blockchain. Here's what I will implement:
Users can mint products as NFTs
Users can upvote products
Display a list of product NFTs
Show recent activities from on-chain events
This mix of features requires both simple and complex data fetching. While the Indexer API will be crucial for our more intricate queries, I want to start with the basics. Let's begin by exploring how to fetch data using the TypeScript SDK. This will be a solid foundation for diving into more advanced querying techniques.
Fetching data with the TypeScript SDK
As I started working with the Aptos TypeScript SDK, I found it helpful to test API calls before integrating them into the frontend. I created the following script at scripts/ts/surf_product_nft_mint.ts
, to iterate quickly and understand the SDK's behavior:
import {
Account,
Aptos,
AptosConfig,
Ed25519PrivateKey,
Network,
NetworkToNetworkName,
} from "@aptos-labs/ts-sdk"
import { createSurfClient } from "@thalalabs/surf"
import dotenv from "dotenv"
import { ABI } from "../../frontend/utils/abi-product_nft"
dotenv.config()
const APTOS_NETWORK: Network =
NetworkToNetworkName[process.env.VITE_APP_NETWORK ?? Network.TESTNET]
const config = new AptosConfig({ network: APTOS_NETWORK })
const aptos = new Aptos(config)
const surfProductNFT = createSurfClient(aptos).useABI(ABI)
if (!process.env.PRIVATE_KEY) throw new Error("PRIVATE_KEY not found")
// Use existing user account
const privateKey = new Ed25519PrivateKey(process.env.PRIVATE_KEY)
const user = Account.fromPrivateKey({
privateKey: privateKey,
})
const main = async () => {
console.log("=== Address ===\n")
console.log(`User's address is: ${user.accountAddress}`)
console.log("\n=== Product NFT Mint ===\n")
/**
* View Function
*/
const [collectionName] = await surfProductNFT.view.get_collection_name({
functionArguments: [],
typeArguments: [],
})
console.log("Collection Name: ", collectionName)
/**
* Entry Function
*/
const PRODUCT_NAME = "Open Forge 1"
const TOKEN_URI =
"https://gateway.irys.xyz/DECscf3teYKE86hM8SmiUxPYmnfedQNRGiQhPmofBNRo"
await mintProductNFT(
PRODUCT_NAME,
"Connecting Aptos builders and backers",
TOKEN_URI
)
await upvoteProduct(PRODUCT_NAME)
}
const mintProductNFT = async (
name: string,
description: string,
uri: string
) => {
const result = await surfProductNFT.entry.mint_product({
functionArguments: [name, description, uri],
typeArguments: [],
account: user,
})
const tx = await aptos.waitForTransaction({
transactionHash: result.hash,
})
console.log(`Mint transaction status: ${tx.success ? "Success" : "Failed"}`)
console.log(
`View transaction on explorer: https://explorer.aptoslabs.com/txn/${tx.hash}?network=${process.env.VITE_APP_NETWORK}`
)
}
const upvoteProduct = async (productName: string) => {
const result = await surfProductNFT.entry.upvote_product({
functionArguments: [productName],
typeArguments: [],
account: user,
})
const tx = await aptos.waitForTransaction({
transactionHash: result.hash,
})
console.log(`Upvote transaction status: ${tx.success ? "Success" : "Failed"}`)
console.log(
`View transaction on explorer: https://explorer.aptoslabs.com/txn/${tx.hash}?network=${process.env.VITE_APP_NETWORK}`
)
}
main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error)
process.exit(1)
})
Let’s explore important parts of this code.
First, I initialized the Aptos client and created a client for my ProductNFT
contract. I use the Surf library to ensure my contract methods are type-safe.
Next, I used an existing private key to create a user account. This allows me to interact with the blockchain and sign transactions.
After I finished the initialization, I used the ProductNFT
client inside my main
function to interact with the blockchain.
Reading data from the chain via view methods doesn’t require paying gas fees and is done with the following code:
await surfProductNFT.view.get_collection_name({
functionArguments: [],
typeArguments: [],
})
Writing data to the chain via transactions costs gas fees and is done with this code:
await surfProductNFT.entry.mint_product({
functionArguments: [name, description, uri],
typeArguments: [],
account: user,
})
To wait for the confirmation of a transaction to ensure it was processed before I move on, I used this code:
await aptos.waitForTransaction({
transactionHash: result.hash,
})
Setting up the frontend
I'm leveraging the create-aptos-template as a foundation. This template provides a solid starting point for Aptos-based applications. I'm particularly inspired by the aptos_friend example, which demonstrates practical implementation of Aptos interactions in a frontend environment.
Here is what's inside the frontend/utils/aptosClient.ts
file, where we set up our Aptos client for frontend use:
import { Aptos, AptosConfig } from "@aptos-labs/ts-sdk"
import { NETWORK } from "@/lib/constants"
const aptos = new Aptos(new AptosConfig({ network: NETWORK }))
// Reuse same Aptos instance to utilize cookie based sticky routing
export function aptosClient() {
return aptos
}
How to fetch NFT data
Let’s see how I display NFT data on the Explore page at frontend/pages/Explore.tsx
. It showcases a list of product NFTs and recent activities.
Let's break down the key components and how they interact with Aptos:
import { Header } from "@/components/Header"
import ProjectList from "@/components/ProjectList"
import RecentActivity from "@/components/RecentActivity"
export function Explore() {
return (
<div className="min-h-screen bg-background">
<Header title="Explore" />
<main className="container mx-auto px-4 py-8">
<div className="flex flex-col md:flex-row gap-8">
<div className="w-full md:w-2/3">
<ProjectList />
</div>
<div className="w-full md:w-1/3">
<RecentActivity />
</div>
</div>
</main>
</div>
)
}
It serves as a container for two crucial components: ProjectList
and RecentActivity
.
I imported it to the router in frontend/App.tsx so I can access it during development at http://localhost:5173/explore.
import { Explore } from "./pages/Explore"
// ...
const router = createBrowserRouter([
{
element: <Layout />,
children: [
{
path: "/explore",
element: <Explore />,
},
// other routes
Fetching on-chain events
Let’s see how to fetch and display recent activity by diving into the RecentActivity component in frontend/components/RecentActivity.tsx
to understand how to interact with the Aptos blockchain.
import { PRODUCT_NFT_ADDR } from "@/lib/constants"
import { aptosClient } from "@/utils/aptosClient"
import { useQuery } from "@tanstack/react-query"
import { formatDistanceToNow } from "date-fns"
import { User } from "lucide-react"
type UpvoteUpdateEvent = {
account_address: string
creation_number: number
data: {
upvoter: string
timestamp: string
new_upvotes: string
product_addr: string
product_name: string
}
event_index: number
sequence_number: number
transaction_block_height: number
transaction_version: number
type: string
indexed_type: string
}
async function fetchProductEvents(): Promise<UpvoteUpdateEvent[]> {
const eventType = `${PRODUCT_NFT_ADDR}::product_nft::UpvoteUpdate`
try {
const events = await aptosClient().getModuleEventsByEventType({
eventType: eventType,
})
return events as UpvoteUpdateEvent[]
} catch (error) {
console.error("Error fetching events:", error)
return []
}
}
const RecentActivity: React.FC = () => {
const {
data: events,
isLoading,
error,
} = useQuery({
queryKey: ["productEvents"],
queryFn: fetchProductEvents,
})
if (isLoading) return <div>Loading...</div>
if (error) return <div>Error fetching events</div>
return (
<div className="p-4 rounded-lg shadow-sm bg-white">
<h2 className="font-semibold mb-4">Recent Activity</h2>
<ul className="space-y-4">
{events?.slice(0, 5).map((event, index) => (
<li key={index} className="flex items-center space-x-3">
<div className="rounded-full p-2">
<User size={16} />
</div>
<div>
<p>
<span className="font-semibold">
{event.data.upvoter.slice(0, 8)}...
</span>{" "}
upvoted {event.data.product_name}
</p>
<p>
{formatDistanceToNow(
new Date(Number.parseInt(event.data.timestamp) * 1000)
)}{" "}
ago
</p>
</div>
</li>
))}
</ul>
</div>
)
}
export default RecentActivity
The core of the data fetching lies in the fetchProductEvents
function, which consists of the following steps:
I construct the
eventType
string using the contract address and the specific event I’m interested in. This structure (${address}::${module}::${event}
) is how Aptos identifies events.I use the
aptosClient().getModuleEventsByEventType()
method to fetch events of this specific type. This is a powerful feature of the Aptos SDK, allowing me to filter events directly on the blockchain.The events we're fetching have a specific structure defined by the
UpvoteUpdateEvent
.
This structure corresponds to the data emitted by our smart contract's UpvoteUpdate
event. It includes who upvoted, when they upvoted, the new upvote count, and details about the product.
To manage this data fetching in a React component, I’m using React Query:
const {
data: events,
isLoading,
error,
} = useQuery({
queryKey: ["productEvents"],
queryFn: fetchProductEvents,
})
This setup allows for efficient caching and refetching of the event data. In the component's render method, I’m displaying the most recent 5 events:
{events?.slice(0, 5).map((event, index) => (
<li key={index} className="flex items-center space-x-3">
{/* Event display logic */}
</li>
))}
Displaying a product list
The ProjectList component in frontend/components/ProjectList.tsx
is responsible for fetching and displaying product data.
Let’s explore the components code:
import { useCollectionNFTs } from "@/hooks/useCollectionNFTs"
import { useUpvoteProduct } from "@/hooks/useProductNFT"
import { ChevronUp } from "lucide-react"
import type React from "react"
import { Link } from "react-router-dom"
interface Project {
id: string
name: string
description: string
image: string
tags: string[]
votes: number
}
const ProjectList: React.FC = () => {
const { loading, error, nftsWithMetadata } = useCollectionNFTs()
const upvoteProduct = useUpvoteProduct()
const handleUpvote = (name: string) => {
upvoteProduct.mutate({
productName: name,
})
console.log(`Upvoted project with id: ${name}`)
}
if (loading) return <div>Loading...</div>
if (error) return <div>Error: {error.message}</div>
const projects: Project[] = nftsWithMetadata.map((nft) => ({
id: nft.token_data_id,
name: nft.token_name,
description: nft.description,
image: nft.image || "/api/placeholder/80/80",
tags: Object.keys(nft.token_properties).filter(
(key) => key !== "Upvote Count" && key !== "Product Status"
),
votes: Number.parseInt(nft.token_properties["Upvote Count"] || "0", 10),
}))
return (
<div className="space-y-4">
{projects.map((project) => (
<Link key={project.id} to={`/project/${project.id}`} className="block">
<div className="p-4 rounded-lg shadow-sm flex items-center space-x-4 bg-white">
<img
src={project.image}
alt={project.name}
className="w-20 h-20 rounded-lg object-cover"
/>
<div className="flex-grow">
<h2 className="text-lg font-semibold">{project.name}</h2>
<p className="text-sm">{project.description}</p>
<div className="mt-2 space-x-2">
{project.tags.map((tag) => (
<span
key={tag}
className="inline-block rounded-full px-3 py-1 text-xs font-semibold"
>
{tag}
</span>
))}
</div>
</div>
<button
type="button"
onClick={(e) => {
e.preventDefault()
handleUpvote(project.name)
}}
className="flex flex-col items-center justify-center p-2 rounded-md"
>
<ChevronUp size={20} />
<span className="text-sm font-semibold">{project.votes}</span>
</button>
</div>
</Link>
))}
</div>
)
}
export default ProjectList
This component uses two important hooks: A custom one for fetching advanced data using GraphQL and one for interacting with our smart contract.
Let’s start with looking at the custom useCollectionNFTs
hook.
Getting on-chain data with an indexer
Indexers are crucial in blockchain ecosystems because they provide efficient access to historical and aggregated data. They allow us to query complex data structures that are impractical or impossible to fetch directly from the blockchain.
Let's look at how I use an indexer by examining the useCollectionNFTs
hook in frontend/hooks/useCollectionNFTs.tsx
:
import { COLLECTION_NAME } from "@/lib/constants"
import { GET_COLLECTION_NFTS } from "@/utils/graphql-doc"
import { useQuery } from "@apollo/client"
import { useEffect, useState } from "react"
interface NFT {
token_name: string
description: string
token_uri: string
collection_id: string
last_transaction_timestamp: string
token_data_id: string
token_properties: {
"Upvote Count": string
"Product Status": string
}
image?: string
}
interface NFTMetadata {
name: string
description: string
image: string
external_url: string
}
interface GetCollectionNftsData {
current_token_datas_v2: NFT[]
}
interface GetCollectionNftsVars {
collection_name: string
}
export function useCollectionNFTs() {
const [nftsWithMetadata, setNftsWithMetadata] = useState<NFT[]>([])
const { loading, error, data } = useQuery<
GetCollectionNftsData,
GetCollectionNftsVars
>(GET_COLLECTION_NFTS, {
variables: { collection_name: COLLECTION_NAME },
skip: !COLLECTION_NAME,
})
useEffect(() => {
const fetchNFTMetadata = async (nft: NFT) => {
try {
const response = await fetch(nft.token_uri)
const metadata: NFTMetadata = await response.json()
return { ...nft, image: metadata.image }
} catch (error) {
console.error("Error fetching NFT metadata:", error)
return nft
}
}
const updateNFTsWithMetadata = async () => {
if (data?.current_token_datas_v2) {
const updatedNFTs = await Promise.all(
data.current_token_datas_v2.map(fetchNFTMetadata)
)
setNftsWithMetadata(updatedNFTs)
}
}
updateNFTsWithMetadata()
}, [data])
return { loading, error, nftsWithMetadata }
}
This hook encapsulates the logic for fetching NFT data from the collection using an indexer through GraphQL. Here's what's happening:
I use Apollo Client's
useQuery
hook to execute a GraphQL query (GET_COLLECTION_NFTS
) against our indexer.The query fetches data for all NFTs in our collection, identified by
COLLECTION_NAME
.Once I receive the data, I use a
useEffect
hook to fetch additional metadata for each NFT from itstoken_uri
.I combine the on-chain data from the indexer with the off-chain metadata to create a complete picture of each NFT.
The hook returns the loading state, potential errors, and the fully populated NFT data.
I created a query with Hasura GraphQL and placed it in the lib/constants.ts
file. I built the query on the Hasura website at https://cloud.hasura.io/public/graphiql?endpoint=https://api.testnet.aptoslabs.com/v1/graphql.
import { gql } from "@apollo/client";
/**
* Edit on Hasura
* https://cloud.hasura.io/public/graphiql?endpoint=https://api.testnet.aptoslabs.com/v1/graphql
*/
export const GET_COLLECTION_NFTS = gql`
query GetCollectionNfts($collection_name: String) {
current_token_datas_v2(
where: {current_collection: {collection_name: {_eq: $collection_name}}}
order_by: {last_transaction_timestamp: desc}
) {
token_name
description
token_uri
collection_id
last_transaction_timestamp
token_data_id
token_properties
}
}
`
Displaying up-to-date information is only half of the equation. To make the experience truly interactive, users must be able to write data back to the blockchain.
This is where making transactions comes into play. After exploring how to efficiently retrieve on-chain data using indexers and GraphQL, let's learn more about the writing side of blockchain interaction.
Sending transactions
Users should be able to do things on the blockchain, like minting new product NFTs and upvoting existing ones.
To make this happen, I created two main functions:
useMintProductNFT
for creating new product NFTsuseUpvoteProduct
for upvoting products
I used React Query's useMutation
hook for both functions. It seemed like a good fit for handling these blockchain updates. As I worked on this, I encountered a few challenges:
I had to figure out how to send transactions and wait for them to finish.
I needed to show clear error messages when things went wrong.
I had to update the UI after each transaction to show the latest blockchain data.
I wanted to let users know when their actions succeeded or failed.
Let's look at the useUpvoteProduct
function I ended up with:
This is my hook in frontend/hooks/useProductNFT.tsx
import { Button } from "@/components/ui/button"
import { ABI } from "@/utils/abi-product_nft"
import { aptosClient } from "@/utils/aptosClient"
import { GET_COLLECTION_NFTS } from "@/utils/graphql-doc"
import { useApolloClient } from "@apollo/client"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { useWalletClient } from "@thalalabs/surf/hooks"
import { toast } from "sonner"
export type MintProductNFTArguments = {
name: string
description: string
uri: string
}
export type UpvoteProductArguments = {
productName: string
}
export const useMintProductNFT = () => {
const { client } = useWalletClient()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ name, description, uri }: MintProductNFTArguments) => {
if (!client) throw new Error("Wallet client not available")
const result = await client.useABI(ABI).mint_product({
arguments: [name, description, uri],
type_arguments: [],
})
return result.hash
},
onSuccess: async (hash) => {
const executedTransaction = await aptosClient().waitForTransaction({
transactionHash: hash,
})
queryClient.invalidateQueries()
const explorerUrl = `https://explorer.aptoslabs.com/txn/${executedTransaction.hash}?network=${process.env.VITE_APP_NETWORK}`
toast("Success", {
description: (
<div>
<p>Mint transaction succeeded, hash: {executedTransaction.hash}</p>
<Button
variant="outline"
size="sm"
onClick={() => window.open(explorerUrl, "_blank")}
className="mt-2"
>
View on Explorer
</Button>
</div>
),
duration: 5000,
})
console.log(`View transaction on explorer: ${explorerUrl}`)
},
onError: (error) => {
console.error(error)
toast("Error", {
description: "Failed to mint product NFT",
})
},
})
}
export const useUpvoteProduct = () => {
const { client } = useWalletClient()
const queryClient = useQueryClient()
const apolloClient = useApolloClient()
return useMutation({
mutationFn: async ({ productName }: UpvoteProductArguments) => {
if (!client) throw new Error("Wallet client not available")
const result = await client.useABI(ABI).upvote_product({
arguments: [productName],
type_arguments: [],
})
return result.hash
},
onSuccess: async (hash) => {
const executedTransaction = await aptosClient().waitForTransaction({
transactionHash: hash,
})
queryClient.invalidateQueries()
await apolloClient.refetchQueries({
include: [GET_COLLECTION_NFTS],
})
const explorerUrl = `https://explorer.aptoslabs.com/txn/${executedTransaction.hash}?network=${process.env.VITE_APP_NETWORK}`
toast.success("Success", {
description: (
<div>
<p>
Upvote transaction succeeded, hash: {executedTransaction.hash}
</p>
<Button
variant="outline"
size="sm"
onClick={() => window.open(explorerUrl, "_blank")}
className="mt-2"
>
View on Explorer
</Button>
</div>
),
duration: 5000,
})
console.log(`View transaction on explorer: ${explorerUrl}`)
},
onError: (error) => {
console.error(error)
toast.error("Error", {
description: "Failed to upvote product",
})
},
})
}
Here's what this function does:
It sends an upvote transaction to the blockchain using
client.useABI(ABI).upvote_product(…)
.It waits for the transaction to complete using
aptosClient().waitForTransaction
.It updates our app's data to reflect the changes with
apolloClient.refetchQueries({ include: [GET_COLLECTION_NFTS] })
.It shows a message to let the user know what happened with
toast.success("Success"…)
This approach helped me create a smoother user experience. SDKs are the way to go. However, I could also use the Node API or Indexer API to fetch data, which I'll discuss next.
The Node and Indexer API
The Aptos blockchain offers two main APIs for data access: the Node API and the Indexer API. Each had its quirks and strengths, so I had to figure out which would work best for my app.
The Node API
The Node API is a low-level REST interface built right into Aptos full nodes. I found it great for quick, simple queries and real-time data. This was my go-to when submitting transactions or getting the latest blockchain state. It felt fast and straightforward, which I appreciated as I was learning.
Indexer API
The Indexer API is a powerful GraphQL interface that does much of the heavy lifting for you. It processes and indexes blockchain data, making running complex queries or digging into historical data easier. It can handle specialized data like NFTs and token activities.
I recommend starting with the Node API because it is simpler and faster. When you hit its limitations, consider using the more powerful Indexer API.
Challenges
As I built this Product Hunt-style dApp on Aptos, I encountered several challenges:
NFT-based Product Representation: I decided to represent each product as an NFT. This choice came with both benefits and challenges:
Benefit: It made querying easier since NFTs follow a standard structure.
Challenge: If I had used a custom struct instead, I might have needed to build custom off-chain indexing to fetch the list of items efficiently.
Data Fetching Complexity: Balancing between simple and complex data fetching needs was tricky. I had to use a combination of the SDK for basic interactions and the Indexer API for more intricate queries.
Real-time Updates: Ensuring the UI reflects the latest blockchain state after transactions required careful management of query invalidation and refetching.
Future plans
My next big focus is exploring keyless solutions to improve the user experience. Right now, users have to approve transactions through their web3 wallet every time, which isn't ideal for frequent interactions. I plan to implement authentication using Google social login, which allows interactions without constant approvals.
Conclusion
Looking back on this journey of building our Product Hunt-style dApp on Aptos, I've learned a lot. Each step has been a new challenge, from figuring out how to fetch data using SDKs and indexers to making transactions and handling NFTs. I started with just a basic template and a lot of questions. Now, I have a working app that lets users mint product NFTs, upvote them, and see recent activities.
It could be better, and there's still more to learn, but seeing how far we've come is exciting. Sometimes, the simple Node API is enough, and other times, we need the power of the Indexer API. It's all about finding the right balance for what we're trying to build.
Additional resources
create-aptos-dapp
https://aptos.dev/en/build/create-aptos-dapp
explorer
https://explorer.aptoslabs.com/?network=testnet
Reference
https://aptos.dev/en/build/apis/fullnode-rest-api-reference#tag/accounts
Testnet Playground
https://fullnode.testnet.aptoslabs.com/v1/spec#/
Indexer API Examples
https://aptos.dev/en/build/indexer/get-nft-collections
Indexer API Testnet Playground
https://cloud.hasura.io/public/graphiql?endpoint=https://api.testnet.aptoslabs.com/v1/graphql
Subscribe to my newsletter
Read articles from John Fu Lin directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
John Fu Lin
John Fu Lin
Follow your curiosity.