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()]);
  }
}
Previous
Smart Contracts