πŸ“’Onchain Contacts

Learn how to use Airstack to build a onchain contacts using onchain graph data of users.

The Onchain Graph helps your construct address books and friends lists for your users. It enables you to solve cold start problems by pre-populating the user's onchain contacts immediately upon joining.

To do so, the onchain graph 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 friends, interactions & tokens in common across Farcaster, Lens, token transfers, POAP, and NFTs.

Table Of Contents

In this tutorial, you'll learn how to build an onchain contacts 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. fetch your users' onchain graph data periodically (e.g. once a day) as a cronjob

  2. store your user's onchain graph data into your preferred database

  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.

Here are quick links to get useful follows in common data between users of web3 social:

The algorithm for building a user's web3 address book will be as follows:

Pre-requisites

  • An Airstack account

  • Basic knowledge of GraphQL

Get Started

To get started, install the Airstack SDK:

React

npm install @airstack/airstack-react

Node

npm install @airstack/node

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, Base, and Zora NFT holders that are also held by the user

For token transfers and common NFT holders, you can include other Airstack-supported chains.

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

Show me all POAPs owned by vitalik.eth with their event IDs and whether they are virtual or not

Code

query MyQuery($user: Identity!) {
  Poaps(input: { filter: { owner: { _eq: $user } }, blockchain: ALL }) {
    Poap {
      eventId
      poapEvent {
        isVirtualEvent
      }
    }
  }
}

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:

const eventIds =
  data?.Poaps.Poap?.filter((poap) => !poap?.poapEvent?.isVirtualEvent).map(
    (poap) => poap?.eventId
  ) ?? [];

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

show me POAP holders of an array of POAP event IDs

Code

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
          }
        }
      }
    }
  }
}

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:

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;

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:

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;

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

Show all Farcaster followings of vitalik.eth and check if they're mutual followings

Code

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
              }
            }
          }
        }
      }
    }
  }
}

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:

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;

The formatted result will have a format as follows:

[
  {
    "addresses": [
      "0xf6fd7deec77d7b1061435585df1d7fdfd4682577",
      "0x925afeb19355e289ed1346ede709633ca8788b25",
      "0x18b7511938fbe2ee08adf3d4a24edb00a5c9b783"
    ],
    "domains": [
      { "name": "phil.brightmoments.eth", "isPrimary": true },
      { "name": "centerforonchainstudies.eth", "isPrimary": false },
      { "name": "purple.philm.eth", "isPrimary": true },
      { "name": "centerforonchainstructure.eth", "isPrimary": false },
      { "name": "lorensbags.eth", "isPrimary": false },
      { "name": "onchainstudies.eth", "isPrimary": false }
    ],
    "socials": [
      {
        "dappName": "farcaster",
        "blockchain": "optimism",
        "profileName": "phil",
        "profileImage": "https://i.imgur.com/sx6qqM7.jpg",
        "profileTokenId": "129",
        "profileTokenAddress": "0x00000000fcaf86937e41ba038b4fa40baa4b780a"
      }
    ],
    "xmtp": [{ "isXMTPEnabled": true }],
    "mutualFollower": { "Follower": null },
    // show vitalik.eth following this user on Faracster and being followed back
    "follows": { "followingOnFarcaster": true, "followedOnFarcaster": true }
  },
  // other onchain graph users
]

Iterate to fetch all users being followed on Farcaster

With the queries for fetching all the users being followed on Farcaster of a given user 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:

functions/fetchFarcasterFollowings.js
import { init, fetchQueryWithPagination } from "@airstack/node"; // or @airstack/airstack-react for frontend javascript
import formatFarcasterFollowingsData from "../utils/formatFarcasterFollowingsData";

// get your API key at https://app.airstack.xyz/profile-settings/api-keys
init("YOUR_AIRSTACK_API_KEY");

const socialFollowingsQuery = `
query MyQuery($user: Identity!) {
  Soci