Links
🕸

Onchain Graph

Learn how to use Airstack to display the Onchain Graph of users.
The Onchain Graph is the web3 address book. It analyzes all of a user/wallet's onchain interactions and recommends contacts based on their strengh of relationship. It currently brings together all of the user's onchain interactions & tokens in common across POAPs, NFTs, token transfers, and Lens and Farcaster.
Developers are utilizing Onchain Graph for recommendation engines, pre-populating friends lists, address books, spam filters, product enhancements, and more.

Live Demo

We have integrated onchain graph into the Airstack Explorer as you can see below. In Airstack Explorer you can enter any 0x address, Lens, Farcaster, or ENS and get the user's onchain graph.
Onchain Graph integration on Airstack Explorer
To try it out yourself, click here:
Airstack Explorer
betashop.eth's onchain graph

Table Of Contents

In this tutorial, you'll learn how to build an onchain graph for your web3 social application using either JavaScript or Python.
Currently, Airstack Explorer's onchain graph implementation has no backend and hence it takes time to scan and fetch all the data.
For backend integrations, it is best practice that you take the following approach for the best user experience:
  1. 1.
    fetch your users' onchain graph data periodically (e.g. once a day) as a cronjob
  2. 2.
    store your user's onchain graph data into your preferred database
  3. 3.
    Fetched the data from your frontend and cache it
With this approach, your user shall receive their onchain graph data almost instantaneously instead of calling the API on-demand which could take minutes.
In the future, we shall provide webhooks and a dedicated Onchain Graph API for lighter-weight integrations.
The algorithm for building onchain graph will be as follows:

Pre-requisites

  • An Airstack account (free)
  • Basic knowledge of GraphQL

Get Started

To get started, install the Airstack SDK:
npm
yarn
pnpm
pip
React
npm install @airstack/airstack-react
Node
npm install @airstack/node
React
yarn add @airstack/airstack-react
Node
yarn add @airstack/node
React
pnpm install @airstack/airstack-react
Node
pnpm install @airstack/node
pip install airstack

Step 1: Fetch All Onchain Graph Data

In order to build a comprehensive onchain graph of a user, it'll require various kinds of data to analyze. Those data comprises of:
  • common POAP holders that are also attended by the user
  • Lens and Farcaster social followers and following of the given user
  • Token transfers senders and receivers
  • common Ethereum, Polygon, Base, and Zora NFT holders that are also held by the user
In this step, you'll learn to fetch all the data that you need to build the onchain graph of a user.

Step 1.1: Fetch Common POAP Holders Data

In order to fetch the common POAP holders that hold the POAPs attended by a given user, it will require 2 steps:
Fetch all non-virtual POAPs' event IDs owned by a user
You can use Airstack to fetch all the POAPs that are hold by a given user, e.g. vitalik.eth, and check if the events are non-virtual or not:
Demo
https://app.airstack.xyz/query/t6zJ93uJ3A
Show me all POAPs owned by vitalik.eth with their event IDs and whether they are virtual or not
Code
Query
Variables
Response
query MyQuery($user: Identity!) {
Poaps(input: { filter: { owner: { _eq: $user } }, blockchain: ALL }) {
Poap {
eventId
poapEvent {
isVirtualEvent
}
}
}
}
{
"user": "vitalik.eth"
}
{
"data": {
"Poaps": {
"Poap": [
{
"eventId": "80393",
"poapEvent": {
"isVirtualEvent": false
}
},
{
"eventId": "79011",
"poapEvent": {
"isVirtualEvent": false
}
},
{
"eventId": "15678",
"poapEvent": {
"isVirtualEvent": false
}
}
// other POAPs owned by vitalik.eth
]
}
}
}
Then, the response can be filtered to only non-virtual POAPs and be formatted into an array of event IDs to be used in the next step:
JavaScript
Python
const eventIds =
data?.Poaps.Poap?.filter((poap) => !poap?.poapEvent?.isVirtualEvent).map(
(poap) => poap?.eventId
) ?? [];
event_ids = [
poap.get('eventId')
for poap in data.get('Poaps', {}).get('Poap', [])
if not poap.get('poapEvent', {}).get('isVirtualEvent')
] if data and 'Poaps' in data and 'Poap' in data['Poaps'] else []
where data is the response from the API. The formatted result, will be an array of event IDs of the non-virtual POAPs owned by vitalik.eth:
[
"80393",
"79011",
"15678",
"76134",
"149333"
// other non-virtual POAP event IDs held by vitalik.eth
]
Fetch all POAP holders of an array of POAP event IDs
Using the array of event IDs from the first step, you can fetch all POAP holders that hold any of the POAPs that the given user, e.g. vitalik.eth, owned/attended:
Try Demo
https://app.airstack.xyz/query/UMgiOv8Uwk
show me POAP holders of an array of POAP event IDs
Code
Query
Variables
Response
query MyQuery($eventIds: [String!]) {
Poaps(input: { filter: { eventId: { _in: $eventIds } }, blockchain: ALL }) {
Poap {
eventId
poapEvent {
eventName
contentValue {
image {
extraSmall
}
}
}
attendee {
owner {
addresses
domains {
name
isPrimary
}
socials {
dappName
blockchain
profileName
profileImage
profileTokenId
profileTokenAddress
}
xmtp {
isXMTPEnabled
}
}
}
}
}
}
{
"eventIds": [
"80393",
"79011",
"15678",
"76134",
"149333",
"117166",
"74916",
"69822",
"68648",
"84",
"74803",
"11",
"3606",
"74216",
"4",
"129645",
"65",
"129619",
"107435",
"98262",
"124221",
"145196",
"67256",
"74441",
"7426",
"100475",
"71115",
"38",
"129422",
"69787",
"102610",
"36",
"92705",
"48474",
"34123",
"153310",
"101153",
"74699",
"67650"
]
}
{
"data": {
"Poaps": {
"Poap": [
{
"eventId": "79011",
"poapEvent": {
"eventName": "ITU Blockchain - Devcon Satellite",
"contentValue": {
"image": {
"extraSmall": "https://assets.airstack.xyz/image/poap/Llu3dveWH3HHEYsATZWtJQ==/extra_small.png"
}
}
},
"attendee": {
"owner": {
"addresses": ["0x3f27512a67f663c31522a6dd81ee739ddc44f0ea"],
"domains": null,
"socials": [
{
"dappName": "lens",
"blockchain": "polygon",
"profileName": "lens/@egeagus",
"profileImage": "",
"profileTokenId": "66698",
"profileTokenAddress": "0xdb46d1dc155634fbc732f92e853b10b288ad5a1d"
}
],
"xmtp": null
}
}
}
// Other POAP holders
]
}
}
}
The response then can be formatted further with the following formatting function to extract all the recommended users that has common POAPs with the given user:
JavaScript
Python
utils/formatPoapsData.js
function formatPoapsData(poaps, exitingUser = []) {
const recommendedUsers = [...exitingUser];
for (const poap of poaps ?? []) {
const { attendee, poapEvent, eventId } = poap ?? {};
const { eventName: name, contentValue } = poapEvent ?? {};
const { addresses } = attendee?.owner ?? {};
const existingUserIndex = recommendedUsers.findIndex(
({ addresses: recommendedUsersAddresses }) =>
recommendedUsersAddresses?.some?.((address) =>
addresses?.includes?.(address)
)
);
if (existingUserIndex !== -1) {
recommendedUsers[existingUserIndex].addresses = [
...(recommendedUsers?.[existingUserIndex]?.addresses ?? []),
...addresses,
]?.filter((address, index, array) => array.indexOf(address) === index);
const _poaps = recommendedUsers?.[existingUserIndex]?.poaps || [];
const poapExists = _poaps.some((poap) => poap.eventId === eventId);
if (!poapExists) {
_poaps?.push({ name, image: contentValue?.image?.extraSmall, eventId });
recommendedUsers[existingUserIndex].poaps = [..._poaps];
}
} else {
recommendedUsers.push({
...(attendee?.owner ?? {}),
poaps: [{ name, image: contentValue?.image?.extraSmall, eventId }],
});
}
}
return recommendedUsers;
}
export default formatPoapsData;
utils/poaps.py
def format_poaps_data(poaps, existing_user=None):
if existing_user is None:
existing_user = []
recommended_users = existing_user.copy()
for poap in poaps or []:
attendee = poap.get('attendee', {})
poap_event = poap.get('poapEvent', {})
event_id = poap.get('eventId')
name = poap_event.get('eventName')
content_value = poap_event.get('contentValue', {})
addresses = attendee.get('owner', {}).get('addresses', [])
existing_user_index = -1
for index, recommended_user in enumerate(recommended_users):
recommended_user_addresses = recommended_user.get('addresses', [])
if any(addr in recommended_user_addresses for addr in addresses):
existing_user_index = index
break
image = content_value.get('image', {}).get(
'extraSmall') if content_value.get('image') else None
new_poap = {
'name': name,
'image': image,
'eventId': event_id
}
if existing_user_index != -1:
recommended_user = recommended_users[existing_user_index]
_addresses = set(recommended_user.get('addresses', []))
_addresses.update(addresses)
recommended_user['addresses'] = list(_addresses)
_poaps = recommended_user.get('poaps', [])
if event_id and all(poap['eventId'] != event_id for poap in _poaps):
_poaps.append(new_poap)
recommended_user['poaps'] = _poaps
else:
new_user = attendee.get('owner', {})
new_user['poaps'] = [new_poap]
recommended_users.append(new_user)
return recommended_users
The formatted result will have a format as follows:
[
{
"addresses": ["0xd35f7c2f23fdc341aa8c7534f0e521679206a036"],
"domains": [ { "name": "taoliu.eth", "isPrimary": true } ],
"socials": [
{
"dappName": "lens",
"blockchain": "polygon",
"profileName": "lens/@colinlt",
"profileImage": "",
"profileTokenId": "33481",
"profileTokenAddress": "0xdb46d1dc155634fbc732f92e853b10b288ad5a1d"
}
],
"xmtp": null,
"poaps": [ // show all common POAPs also owned by vitalik.eth
{
"name": "Rocket Pool Bot Catcher POAP",
"image": undefined,
"eventId": "7426"
}
]
},
// other onchain graph users
]
Iterate to fetch all common POAP holders data
With the queries for fetching common POAP holders established, it will be essential to fetch all the data using paginations.
In order to paginate through all the data, you can utilize fetchQueryWithPagination and execute_paginated_query from the JavaScript (React & Node) and Python SDKs, respectively. The full code implementation for this will be as follows:
JavaScript
Python
functions/fetchPoapsData.js
import { init, fetchQueryWithPagination } from "@airstack/node"; // or @airstack/airstack-react for frontend javascript
import formatPoapsData from "../utils/formatPoapsData";
// get your API key at https://app.airstack.xyz/profile-settings/api-keys
init("YOUR_AIRSTACK_API_KEY");
const userPoapsEventIdsQuery = `
query MyQuery {
Poaps(input: {filter: {owner: {_eq: "vitalik.eth"}}, blockchain: ALL}) {
Poap {
eventId
poapEvent {
isVirtualEvent
}
}
}
}
`;
const poapsByEventIdsQuery = `
query MyQuery($eventIds: [String!]) {
Poaps(input: {filter: {eventId: {_in: $eventIds}}, blockchain: ALL}) {
Poap {
eventId
poapEvent {
eventName
contentValue {
image {
extraSmall
}
}
}
attendee {
owner {
addresses
domains {
name
isPrimary
}
socials {
dappName
blockchain
profileName
profileImage
profileTokenId
profileTokenAddress
}
xmtp {
isXMTPEnabled
}
}
}
}
}
}
`;
const fetchPoapsData = async (address, existingUsers = []) => {
let poapsDataResponse;
let recommendedUsers = [...existingUsers];
while (true) {
if (!poapsDataResponse) {
// Paagination #1: Fetch All POAPs
poapsDataResponse = await fetchQueryWithPagination(
userPoapsEventIdsQuery,
{
user: address,
}
);
}
const {
data: poapsData,
error: poapsError,
hasNextPage: poapsHasNextPage,
getNextPage: poapsGetNextPage,
} = poapsDataResponse ?? {};
if (!poapsError) {
const eventIds =
poapsData?.Poaps.Poap?.filter(
(poap) => !poap?.poapEvent?.isVirtualEvent
).map((poap) => poap?.eventId) ?? [];
let poapHoldersDataResponse;
while (true) {
if (eventIds.length === 0) break;
if (!poapHoldersDataResponse) {
// Pagination #2: Fetch All POAP holders
poapHoldersDataResponse = await fetchQueryWithPagination(
poapsByEventIdsQuery,
{
eventIds,
}
);
}
const {
data: poapHoldersData,
error: poapHoldersError,
hasNextPage: poapHoldersHasNextPage,
getNextPage: poapHoldersGetNextPage,
} = poapHoldersDataResponse;
if (!poapHoldersError) {
recommendedUsers = [
...formatPoapsData(poapHoldersData?.Poaps?.Poap, recommendedUsers),
];
if (!poapHoldersHasNextPage) {
break;
} else {
poapHoldersDataResponse = await poapHoldersGetNextPage();
}
} else {
console.error("Error: ", poapHoldersError);
break;
}
}
if (!poapsHasNextPage) {
break;
} else {
poapsDataResponse = await poapsGetNextPage();
}
} else {
console.error("Error: ", poapsError);
break;
}
}
return recommendedUsers;
};
export default fetchPoapsData;
functions/poaps.py
from airstack.execute_query import AirstackClient
from utils.poaps import format_poaps_data
# get your API key at https://app.airstack.xyz/profile-settings/api-keys
api_client = AirstackClient(api_key="YOUR_AIRSTACK_API_KEY")
user_poaps_event_ids_query = """
query MyQuery($user: Identity!) {
Poaps(input: {filter: {owner: {_eq: $user}}, blockchain: ALL}) {
Poap {
eventId
poapEvent {
isVirtualEvent
}
}
}
}
"""
poaps_by_event_ids_query = """
query MyQuery($eventIds: [String!]) {
Poaps(input: {filter: {eventId: {_in: $eventIds}}, blockchain: ALL}) {
Poap {
eventId
poapEvent {
eventName
contentValue {
image {
extraSmall
}
}
}
attendee {
owner {
addresses
domains {
name
isPrimary
}
socials {
dappName
blockchain
profileName
profileImage
profileTokenId
profileTokenAddress
}
xmtp {
isXMTPEnabled
}
}
}
}
}
}
"""
async def fetch_poaps_data(address, existing_users=[]):
poaps_data_response = None
recommended_users = existing_users.copy()
while True:
if poaps_data_response is None:
execute_query_client = api_client.create_execute_query_object(
query=user_poaps_event_ids_query, variables={'user': address})
# Pagination #1: Fetch All POAPs
poaps_data_response = await execute_query_client.execute_paginated_query()
if poaps_data_response.error is None:
event_ids = [
poap.get('eventId')
for poap in poaps_data_response.data.get('Poaps', {}).get('Poap', [])
if not poap.get('poapEvent', {}).get('isVirtualEvent')
] if poaps_data_response.data and 'Poaps' in poaps_data_response.data and 'Poap' in poaps_data_response.data['Poaps'] else []
poap_holders_data_response = None
while True:
if poap_holders_data_response is None:
execute_query_client = api_client.create_execute_query_object(
query=poaps_by_event_ids_query, variables={'eventIds': event_ids})
# Pagination 2: Fetch all POAP Holders
poap_holders_data_response = await execute_query_client.execute_paginated_query()
if poap_holders_data_response.error is None:
recommended_users = format_poaps_data(
poap_holders_data_response.data.get(
'Poaps', {}).get('Poap', []),
recommended_users
)
if not poap_holders_data_response.has_next_page:
break
else:
poap_holders_data_response = await poap_holders_data_response.get_next_page
else:
print("Error: ", poap_holders_data_response.error)
break
if not poaps_data_response.has_next_page:
break
else:
poaps_data_response = await poaps_data_response.get_next_page
else:
print("Error: ", poaps_data_response.error)
break
return recommended_users

Step 1.2: Fetch Farcaster Followings Data

You can use Airstack to easily fetch all the users that is being followed on Farcaster by a given user, e.g. vitalik.eth, and get their 0x addresses, ENS domains, Lens, Farcaster, and XMTP:
Try Demo
https://app.airstack.xyz/query/B2gIbVbPXh
Show all Farcaster followings of vitalik.eth and check if they're mutual followings
Code
Query
Variables
Response
query MyQuery($user: Identity!) {
SocialFollowings(
input: {
filter: { identity: { _eq: $user }, dappName: { _eq: farcaster } }
blockchain: ALL
limit: 200
}
) {
Following {
followingAddress {
addresses
domains {
name
isPrimary
}
socials {
dappName
blockchain
profileName
profileImage
profileTokenId
profileTokenAddress
}
xmtp {
isXMTPEnabled
}
mutualFollower: socialFollowers(
input: {
filter: { identity: { _eq: $user }, dappName: { _eq: farcaster } }
}
) {
Follower {
followerAddress {
socials {
profileName
}
}
}
}
}
}
}
}
{
"user": "vitalik.eth"
}
// Some code
The response then can be formatted further with the following formatting function to extract all the recommended users that is being followed on Farcaster by the given user:
JavaScript
Python
utils/formatFarcasterFollowingsData.js
function formatFarcasterFollowingsData(followings, existingUser = []) {
const recommendedUsers = [...existingUser];
for (const following of followings) {
const existingUserIndex = recommendedUsers.findIndex(
({ addresses: recommendedUsersAddresses }) =>
recommendedUsersAddresses?.some?.((address) =>
following.addresses?.includes?.(address)
)
);
const followsBack = Boolean(following?.mutualFollower?.Follower?.[0]);
if (existingUserIndex !== -1) {
const follows = recommendedUsers?.[existingUserIndex]?.follows ?? {};
recommendedUsers[existingUserIndex] = {
...following,
...recommendedUsers[existingUserIndex],
follows: {
...follows,
followingOnFarcaster: true,
followedOnFarcaster: followsBack,
},
};
} else {
recommendedUsers.push({
...following,
follows: {
followingOnFarcaster: true,
followedOnFarcaster: followsBack,
},
});
}
}
return recommendedUsers;
}
export default formatFarcasterFollowingsData;
utils/farcaster_followings.py
def format_farcaster_followings_data(followings, existing_user=None):
if existing_user is None:
existing_user = []
recommended_users = existing_user.copy()
for following in followings:
existing_user_index = -1
for index, recommended_user in enumerate(recommended_users):
recommended_user_addresses = recommended_user.get('addresses', [])
if any(addr in recommended_user_addresses for addr in following.get('addresses', [])):
existing_user_index = index
break
mutual_follower = following.get('mutualFollower', {})
follower = mutual_follower.get(
'Follower') if mutual_follower is not None else []
follows_back = bool(follower[0]) if follower else False
if existing_user_index != -1:
follows = recommended_users[existing_user_index].get('follows', {})
recommended_users[existing_user_index] = {
**following,