WEB3 Points Library: Example App Tutorial
A tutorial walk-through of our latest experiment - a points library built directly on Ceramic!
As mentioned in our post introducing our points library, the Ceramic Solutions SDK also contains examples (found within the demo directory) designed to help developers understand how to use the libraries within a larger application context. The remainder of this article will walk through a tutorial of the "full-stack app" example. If you prefer to follow a video tutorial, visit our YouTube video of this tutorial.
The Use Case: Rewarding Points for Joining a Community
This example offers a simple demonstration of how one could use the points library to reward community members for their engagement such as joining key platforms. To keep things simple, this tutorial will walk through the act of joining a Discord server as the trigger.
Getting Started
While an identical version of this example lives directly in the SDK repository, we've pulled it into a separate codebase for this walk-through. To get started, clone the code, install your dependencies, and duplicate the example environment file:
// if you don't have pnpm installed
npm install -g pnpm@9
// otherwise:
git clone https://github.com/ceramicstudio/points-example && cd points-example
pnpm install
cp .env.example .env
Next, we'll need to configure a few environment variables:
NEXTAUTH_SECRET
We will be using NextAuth as our open-source authentication solution (in order to verify Discord server membership). You can create a secret easily by running the following in your terminal:
openssl rand -base64 32
DISCORD_CLIENT_ID and DISCORD_CLIENT_SECRET
This app will use Discord as the authentication provider (wrapped by NextAuth). To obtain these credentials, navigate to the Discord Developer Portal and set up a new application. On the left-hand panel, click on "OAuth2" and bring over your Client ID and Client Secret from the "Client Information" box.
Finally, since you'll be running the application locally, set the following URI value within the "Redirects" box (found under "Client Information"): http://localhost:3000/api/auth/callback/discord
.
CERAMIC_PRIVATE_KEY
This is the private key your application will use to instantiate a static key:did in order to write points using the library. This DID will act as the identifier for the issuer of points for your application (you).
If you have the ComposeDB CLI installed globally, you can run the following command in your terminal to create one:
composedb did:generate-private-key
AGGREGATION_ID
A default value for this environment variable has been provided for you within the .env.example file. Please leave this as-is. You can reference the "extending the default library interfaces" below to learn more about this variable.
PROJECT_ID
We will be using WalletConnect's Web3Modal for Web3 authentication. In your new .env file, assign your project id to the key labeled `NEXT_PUBLIC_PROJECT_ID`.
You can set up a developer account for free by visiting cloud.walletconnect.com. Once authenticated, create a new app and copy over the "Project ID" value (found in the dashboard view for that corresponding app).
Extending the Default Library Interfaces
For our use case let's assume the following about our point reward structure:
- Participants can earn points related to engaging on certain platforms (such as Discord)
- There are various ways participants can earn points across each platform (for example, following, posting, liking, and so on)
As such, our application logic has the following requirements:
- Our application needs to easily access platform-specific subtotals for each participant
- Our application also needs to easily access "global" totals for each participant as a sum of all eligible behavior
The points library's default implementation of the PointsAggregationInterface
serves only the second requirement above:
type SimplePointsAggregation implements PointsAggregationInterface
@createModel(
description: "Simple points aggregation to an account at a specific date"
accountRelation: SET
accountRelationFields: ["recipient"]
) {
issuer: DID! @documentAccount
recipient: DID! @accountReference
points: Int!
date: DateTime!
}
This default model defines a SET
accountRelation using the "recipient" subfield, which ensures that there can be only 1 model instance document any given account can create that points to a specific recipient. This will be useful for logging the global total for each participant, but would not be ideal for our first requirement.
Conversely, the SimplePointsAllocation
model, the default type implementation of the PointsAllocationInterface
(found in schemas), defines a LIST
accountRelation, and is designed to track the history of allocation events rather than the current sum.
We've therefore chosen this example to show how easily developers can extend the defaults to meet more nuanced use cases. For this example app, we've defined a new type to fit our needs:
type ContextPointAggregation implements PointsAggregationInterface
@createModel(
description: "A simple context-based point aggregation model"
accountRelation: SET
accountRelationFields: ["recipient", "context"]
) {
issuer: DID! @documentAccount
recipient: DID! @accountReference
points: Int!
date: DateTime!
context: String! @string(maxLength: 100)
}
The extension you'll notice is that our SET relation now sits on two fields - "recipient" and "context". If, for example, we wanted to sum up all Discord-eligible behavior under a "discord" context value, this new accountRelation scheme ensures that our application (as the issuer) can create only 1 model instance per recipient+context combination.
For this tutorial, we've already pre-deployed this special type to the default node endpoint (if you want to create your own custom extensions, you will need to set up a node first). The model's stream ID is the corresponding value for the AGGREGATION_ID
key provided for you in your .env file.
Using the Points Library in the Application
You can find the points library being used in our app's server logic as API routes and a context utility. If you look at our context.ts file, you can see how we've imported our CERAMIC_PRIVATE_KEY
and AGGREGATION_ID
environment variables to instantiate our reader and writer classes:
import { PointsWriter, PointsReader } from '@ceramic-solutions/points'
import { getAuthenticatedDID } from '@ceramic-solutions/key-did'
import { fromString } from 'uint8arrays'
const CERAMIC_PRIVATE_KEY: string = process.env.CERAMIC_PRIVATE_KEY ?? ''
const aggregationModelID: string | undefined = process.env.AGGREGATION_ID ?? undefined
const seed = fromString(CERAMIC_PRIVATE_KEY, 'base16') as Uint8Array
// create a context writer
const contextWriter = await PointsWriter.fromSeed({
aggregationModelID,
seed,
})
// create a total writer
const writer = await PointsWriter.fromSeed({
seed,
})
// generate issuer for reader context
const issuer = await getAuthenticatedDID(seed)
//create a context reader
const contextReader = PointsReader.create({
issuer: issuer.id,
aggregationModelID,
})
//create a total reader
const reader = PointsReader.create({
issuer: issuer.id,
})
export { contextWriter, writer, contextReader, reader }
We're purposely creating 2 instances of each, one of which is blank and therefore uses the default value of aggregationModelID
(the SimplePointsAggregation
discussed above), while the other uses our imported extension.
In our API routes you'll find simple endpoints for reading and creating points. Notice how we import our writer instances from our context utility in order to mutate or create two separate documents:
import { type NextApiRequest, type NextApiResponse } from 'next'
import { contextWriter, writer } from '@/utils/context'
import type { ModelInstanceDocument } from '@composedb/types'
import { type PointsContent, type AggregationContent } from '@/utils/types'
interface Request extends NextApiRequest {
body: {
recipient: string
amount: number
context: string
}
}
interface Response extends NextApiResponse {
status(code: number): Response
send(data: { contextTotal: number; total: number } | { error: string }): void
}
export default async function handler(req: Request, res: Response) {
try {
const { recipient, amount, context } = req.body
// get context aggregation doc if exists
const aggregationDoc: ModelInstanceDocument<AggregationContent> | null =
await contextWriter.loadAggregationDocumentFor([recipient, context])
// if aggregation doc does not exist for that context, set points aggregation for both context and global total
if (aggregationDoc === null) {
// update context-specific aggregation
const updatedContextAgg: ModelInstanceDocument<AggregationContent> =
await contextWriter.setPointsAggregationFor([recipient, context], amount, {
recipient,
points: amount,
date: new Date().toISOString(),
context,
} as Partial<PointsContent>)
// update total aggregation
const updatedTotalAgg: ModelInstanceDocument<AggregationContent> =
await writer.updatePointsAggregationFor([recipient], (content) => {
return {
points: content ? content.points + amount : amount,
date: new Date().toISOString(),
recipient,
}
})
res.status(200).send({
contextTotal: updatedContextAgg.content ? updatedContextAgg.content.points : 0,
total: updatedTotalAgg.content ? updatedTotalAgg.content.points : 0,
})
}
} catch (error) {
console.error(error)
res.status(500).send({ error: 'Internal Server Error' })
}
}
As you might've noticed, the current logic only issues the points if the recipient has not yet been rewarded for the input context, which makes sense given that the app in its current form only confirms Discord server membership.
You'll notice that our readPoints.ts route is even simpler, making use of the getAggregationPointsFor
method our reader instances offer.
Requesting to View Users' Servers with NextAuth
If you navigate to our auth.ts route you'll notice the inclusion of an authorization URI that specifies the scope of read access we need to view a user's server (or guild) membership:
providers: [
DiscordProvider({
clientId: env.DISCORD_CLIENT_ID,
clientSecret: env.DISCORD_CLIENT_SECRET,
authorization:
"https://discord.com/api/oauth2/authorize?scope=identify+email+guilds",
}),
],
We use this access within the frontend component of our single-page app to request guild membership and determine if one of them is named "Ceramic" (alongside checking the user's points and awarding them if eligible):
const checkPoints = async (context: string, recipient: string) => {
try {
const response = await fetch("/api/readPoints", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ recipient, context }),
});
const data = await response.json() as { contextTotal: number; total: number };
setTotals(data);
return data;
} catch (error) {
console.error(error);
}
};
const awardPoints = async (
context: string,
recipient: string,
amount: number,
) => {
try {
const response = await fetch("/api/createPoints", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ recipient, context, amount }),
});
const data = await response.json() as { contextTotal: number; total: number };
console.log(data);
setTotals(data);
return data;
} catch (error) {
console.error(error);
}
};
const getGuilds = async (
accessToken: string,
): Promise<Guilds[] | undefined> => {
const guilds = await fetch("/api/servers", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ token: accessToken }),
})
.then(function (response) {
return response.json();
})
.then(function (data) {
return data as Guilds[] | undefined;
});
console.log(guilds);
return guilds;
};
...
const guilds = await getGuilds(access);
// check if user is in the Ceramic server
const ceramic = guilds?.find((guild) => guild.name === "Ceramic");
const points = await checkPoints("discord",`did:pkh:eip155:1:${address.toLowerCase()}`);
if (ceramic && (points?.contextTotal ?? 0) === 0) {
setClaim(true);
}
// ... if claim
await awardPoints("discord",`did:pkh:eip155:1:${address.toLowerCase()}`,
20);
Running the Application
Once you've correctly generated and entered your environment variables, you can run the application in developer mode:
pnpm dev
Navigate to localhost:3000 in your browser and you should see the following:
Go ahead and self-authenticate by clicking "Connect Wallet." Once connected, you should see the following on your screen:
Given the instructions, you'll want to join the Ceramic Discord server (if you haven't already).
After both joining the Ceramic Discord server and self-authenticating with Discord, you should now be able to check the status of your eligibility:
Go ahead and click "Check Status" to view your current server membership and eligibility to claim points (this action queries our Discord NextAuth provider mentioned above, and automatically triggers a call to the readPoints.ts route):
If Ceramic now appears as one of the servers you're a member of, you'll be able to select "Claim Points" in order to engage your createPoints.ts route.
Finally, once the mutation is successful, you should now see the points you've just earned appear in the UI.
Integrate Allocations and Gitcoin Passport
As you might've noticed, this simple demo only uses the aggregation docs - after all, in this example, we're only awarding points for Discord engagement.
If you're interested in experimenting with an example that also uses the allocation docs, feel free to check out the with-gitcoin repository branch (which uses Gitcoin Passport scoring as an additional means to earn points).
What's Next?
While this demo showcases a straightforward example of how to put the point library into action, we encourage our community to fork this repository and build out features you'd like to see! The library is flexible and extendable by design.
Whatever your idea is, we'd love to hear about it as you build. Feel free to contact me directly at mzk@3box.io, or start a conversation on the Ceramic Forum. We look forward to hearing from you!