Open Source Algorithms
Subgraph
Last Updated: May 26th, 2025
Aemula utilizes The Graph Protocol to index data from our smart contract into a Subgraph, which allows us to build our Perspective Map, curate article recommendations, and rank articles. The Subgraph acts as a translater to allow users to easily interact with onchain data via the Aemula Platform.
The below Subgraph Schema outlines the data we index and how we structure it for our use case. Comments are included throughout for ease of reference.
For suggested improvements or requested changes, email community@aemula.com.
We are in process of setting up the community governance of Aemula's Subgraph Schema, allowing community members to make pull requests of the source code from Github and propose changes for vote by the community. We can also establish a community resource pool to fund API keys that allow users to independently query our Subgraph to run custom algorithms.
If you are interested in interacting with our Subgraph to learn more, check out our Publicly Queryable Endpoint to query the Subgraph using GraphQL in your browser.
Schema
Though simple, this represents all of the data we need to operate our curation and ranking algorithms on the Aemula Platform.
// Schema for user entities
type User @entity {
id: ID! // The public address of the user's ERC-4337 smart account
subscriptionExpiry: BigInt! // Timestamp when the user's subscription expires
isAnnual: Boolean! // True/False if the user has an annual subscription
timestamp: BigInt! // Timestamp when the user started their current subscription
}
// Schema for article entities
type Article @entity(immutable: true) {
id: ID! // The IPFS CID of the article data
author: ID! // The public address of the author
}
// Relationships between users and articles
type Association @entity(immutable: true) {
id: ID! // The Base transaction ID of the interaction event
user: ID! // The public address of the user
article: ID! // The IPFS CID of the article
type: AssociationType! // The type of relationship
timestamp: BigInt! // The timestamp of the interaction
}
// Types of relationships
enum AssociationType {
AUTHOR
SUPPORT
NEUTRAL
DISAGREE
REPORT
}
Event Mappings
// Import the events we index from our smart contract
import {
NewArticle as NewArticleEvent,
NewUser as NewUserEvent,
ReadDisagree as ReadDisagreeEvent,
ReadNeutral as ReadNeutralEvent,
ReadReport as ReadReportEvent,
ReadSupport as ReadSupportEvent,
SubscriptionEnded as SubscriptionEndedEvent,
SubscriptionStarted as SubscriptionStartedEvent
} from "../generated/ERC1967Proxy/AemulaV1"
// Import our Schema as listed above
import {
User,
Article,
Association
} from "../generated/schema"
// Utility to log warnings for development
import { log } from '@graphprotocol/graph-ts';
// Function to handle how to index a new article event
export function handleNewArticle(event: NewArticleEvent): void {
// Read author from data emitted by the onchain event
let authorId = event.params.authorID.toHex(); // Author public address
let user = User.load(authorId); // Find the User entity of the author
// Warning if the author is not a user
if (!user) {
log.warning("User {} does not exist. Skipping event processing.", [authorId]);
return;
}
// Read the article from data emitted by the onchain event
let articleId = event.params.ipfsCID; // IPFS CID of the article
// Check if the article already exists and skip creating a duplicate
let existingArticle = Article.load(articleId);
if (existingArticle) {
log.warning("Article {} already exists. Skipping duplicate creation.", [articleId]);
return;
}
// Create a new article entity using our schema
let article = new Article(articleId); // set the ID to the IPFS CID
article.author = authorId; // set the authorID to the public address of the author
article.save(); // save the entity
// use transaction hash of the event as the associationId for the AUTHOR relationship
// including logIndex of the specific event within a transaction to handle batches (multiple articles published in a single transaction)
let associationId = event.transaction.hash.toHex() + "-" + event.logIndex.toString();
// create a new Association entity to create the relationship between the author and the article
let association = new Association(associationId);
association.user = authorId;
association.article = articleId;
association.type = "AUTHOR";
association.timestamp = event.block.timestamp; // Timestamp of the block the publication transction occurred in
association.save();
}
// Function to handle how to index a new user event
export function handleNewUser(event: NewUserEvent): void {
// Read the user's public address from the event data emitted onchain
let user = new User(event.params.walletID.toHex());
// Read the timestamp when the user's subscription will expire from the event data
// For all new users, this is the defined by trialPeriodDays in our smart contract
let expiry = event.params.expiry;
// Structure the data into a User entity using our schema
user.subscriptionExpiry = expiry;
user.isAnnual = false; // first time users are on trial periods (not annual subscriptions)
user.timestamp = event.block.timestamp; // Timestamp of the block the user was created in
user.save();
}
// ***************************************
// Functions to index article interactions
// ***************************************
// Function for "disagree" interaction events
export function handleReadDisagree(event: ReadDisagreeEvent): void {
// Read the user's public address from the event data emitted onchain
let userId = event.params.walletID.toHex();
let user = User.load(userId);
// Warning if the user doesn't exist
if (!user) {
log.warning("User {} does not exist. Skipping event processing.", [userId]);
return;
}
// Read the article's IPFS CID from the event data emitted onchain
let articleId = event.params.ipfsCID;
let article = Article.load(articleId);
// Warning if the article doesn't exist
if (!article) {
log.warning("Article {} does not exist. Skipping event processing.", [articleId]);
return;
}
// use transaction hash as associationId
// logIndex used to note specific index within the transcation to handle batch interactions
let associationId = event.transaction.hash.toHex() + "-" + event.logIndex.toString();
// create a new Association entity to link user interaction to the article
let association = new Association(associationId);
association.user = userId;
association.article = articleId;
association.type = "DISAGREE";
association.timestamp = event.block.timestamp;
association.save();
}
// See above comments for handleReadDisagree
export function handleReadNeutral(event: ReadNeutralEvent): void {
let userId = event.params.walletID.toHex();
let user = User.load(userId);
if (!user) {
log.warning("User {} does not exist. Skipping event processing.", [userId]);
return;
}
let articleId = event.params.ipfsCID;
let article = Article.load(articleId);
if (!article) {
log.warning("Article {} does not exist. Skipping event processing.", [articleId]);
return;
}
// use transaction hash as associationId
let associationId = event.transaction.hash.toHex() + "-" + event.logIndex.toString();
// create a new Association entity to link user interaction to the article
let association = new Association(associationId);
association.user = userId;
association.article = articleId;
association.type = "NEUTRAL";
association.timestamp = event.block.timestamp;
association.save();
}
// See above comments for handleReadDisagree
export function handleReadReport(event: ReadReportEvent): void {
let userId = event.params.walletID.toHex();
let user = User.load(userId);
if (!user) {
log.warning("User {} does not exist. Skipping event processing.", [userId]);
return;
}
let articleId = event.params.ipfsCID;
let article = Article.load(articleId);
if (!article) {
log.warning("Article {} does not exist. Skipping event processing.", [articleId]);
return;
}
// use transaction hash as associationId
let associationId = event.transaction.hash.toHex() + "-" + event.logIndex.toString();
// create a new Association entity to link user interaction to the article
let association = new Association(associationId);
association.user = userId;
association.article = articleId;
association.type = "REPORT";
association.timestamp = event.block.timestamp;
association.save();
}
// See above comments for handleReadDisagree
export function handleReadSupport(event: ReadSupportEvent): void {
let userId = event.params.walletID.toHex();
let user = User.load(userId);
if (!user) {
log.warning("User {} does not exist. Skipping event processing.", [userId]);
return;
}
let articleId = event.params.ipfsCID;
let article = Article.load(articleId);
if (!article) {
log.warning("Article {} does not exist. Skipping event processing.", [articleId]);
return;
}
// use transaction hash as associationId
let associationId = event.transaction.hash.toHex() + "-" + event.logIndex.toString();
// create a new Association entity to link user interaction to the article
let association = new Association(associationId);
association.user = userId;
association.article = articleId;
association.type = "SUPPORT";
association.timestamp = event.block.timestamp;
association.save();
}
// Function to handle how to index a subscription ending event
export function handleSubscriptionEnded(event: SubscriptionEndedEvent): void {
// Read the user's public address from the event data emitted onchain
let user = User.load(event.params.walletID.toHex());
// Make sure the user exists
// Our smart contract handles this logic, but need to add the check to resolve subgraph errors
if (user) {
// set subscriptionExpiry to the current block timestamp to immediately end the subscription
user.subscriptionExpiry = event.block.timestamp;
user.save()
} else {
log.warning("User not found with wallet ID: {}", [event.params.walletID.toHex()]);
}
}
// Function to handle new subscription events
export function handleSubscriptionStarted(event: SubscriptionStartedEvent): void {
// Read the data emitted by the event onchain
let user = User.load(event.params.walletID.toHex()); // User's public address
let expiry = event.params.expiry; // The timestamp when the new subscription will end
let isAnnual = event.params.isAnnual; // True/false if the subscription is annual
// Make sure the user exists (logic handled in smart contract, but check resolves subgraph errors)
if (user) {
// Update the user entity to reflect the new subscription
user.subscriptionExpiry = expiry;
user.isAnnual = isAnnual;
user.timestamp = event.block.timestamp;
user.save()
} else {
log.warning("User not found with wallet ID: {}", [event.params.walletID.toHex()]);
}
}