Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add total supply endpoint #7

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/bin/
/node_modules/
gcp_credentials.json
gcp_credentials*.json
.env
4 changes: 2 additions & 2 deletions Pulumi.dev.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
config:
gcp:project: coingecko-api-382821
gcp:project: coingecko-api-dev
gcp:region: us-east1
gcp:credentials: gcp_credentials.json
gcp:credentials: gcp_credentials_dev.json
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ When the function's trigger URL is hit, the following are performed:
1. If there is no valid cached value, an API query is performed. If successful, the cache is updated and the value is returned.
1. If no value is returned by the API query, HTTP status 500 is returned by the function.

## Service Account

A GCP service account is required for deployment. The name of the file should correspond to the name specified in the Pulumi config file (e.g. `Pulumi.prod.yaml`). The role of the service account should be set to "Owner" for the project.

TODO: determine if a more restrictive role can be used.

A key then needs to be created for the service account and saved to a JSON file. This can then be referenced in the Pulumi config file. The JSON file should NOT be committed to the repo.

## Deployment

Deployment is handled by Pulumi, with hosting on GCP.
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
"main": "src/index.ts",
"description": "Serves data on the Olympus protocol to the CoinGecko API",
"devDependencies": {
"@pulumi/gcp": "^6.45.0",
"@opentelemetry/api-metrics": "^0.33.0",
"@pulumi/gcp": "^8.15.0",
"@pulumi/pulumi": "^3.49.0",
"@types/node": "^18.16.7",
"@typescript-eslint/eslint-plugin": "^5.38.0",
Expand All @@ -26,6 +27,7 @@
},
"scripts": {
"build": "tsc",
"lint": "eslint --fix src/**/*.ts",
"start": "ts-node src/app/cli.ts"
}
}
4 changes: 3 additions & 1 deletion src/app/cache/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ const getFirestoreDocument = async (): Promise<firestore.DocumentSnapshot<CacheD
};

/**
* Obtains the value from the cache.
* Obtains the value from the cache.
*
* NOTE: if there are multiple cached values being stored in the database, they must be stored under different document names
*
* @returns [value, isCacheValid]
*/
Expand Down
4 changes: 2 additions & 2 deletions src/app/cli.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { getValue } from ".";
import { getCirculatingSupplyValue } from ".";

const main = async () => {
const value = await getValue("true");
const value = await getCirculatingSupplyValue("true");
console.log(`Value = ${value}`);
};

Expand Down
33 changes: 31 additions & 2 deletions src/app/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { getCachedValue, setCachedValue } from "./cache/cache";
import { getCirculatingSupply } from "./query";
import { getCirculatingSupply, getTotalSupply } from "./query";

export const getValue = async (cache: string | undefined): Promise<string | null> => {
export const getCirculatingSupplyValue = async (cache: string | undefined): Promise<string | null> => {
const skipCache = cache === "false";

// Return the cached value if still valid
Expand Down Expand Up @@ -29,3 +29,32 @@ export const getValue = async (cache: string | undefined): Promise<string | null

return newValue;
};

export const getTotalSupplyValue = async (cache: string | undefined): Promise<string | null> => {
const skipCache = cache === "false";

// Return the cached value if still valid
const [cachedValue, isCacheValid] = await getCachedValue();
if (cachedValue && isCacheValid && !skipCache) {
return cachedValue;
}

if (skipCache) {
console.log("Cache was skipped due to override");
}

// If cache is empty, fetch from GraphQL API
const newValue: string | null = await getTotalSupply();

// If newValue is not set, return the cached value
if (!newValue) {
console.log(`Unable to fetch live value, returning cached value: ${cachedValue}`);
return cachedValue;
}

if (newValue) {
await setCachedValue(newValue);
}

return newValue;
};
53 changes: 53 additions & 0 deletions src/app/query.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,59 @@
import { createClient } from "@olympusdao/treasury-subgraph-client";
import { fetch } from "cross-fetch";

export const getTotalSupply = async (): Promise<string | null> => {
const apiEndpointOverride = process.env.API_ENDPOINT;
if (apiEndpointOverride) {
console.log(`Overriding API endpoint with ${apiEndpointOverride}`);
}

const client = createClient({
...(apiEndpointOverride ? { baseURL: apiEndpointOverride } : {}),
customFetch: fetch,
});

let returnValue: string | null;
try {
const response = await client.query({
operationName: "latest/metrics",
});

// No data - return null
if (!response.data) {
console.error(`No data returned from API`);
returnValue = null;
}
// Has data
else {
// Check that the timestamps for Ethereum and Arbitrum within the past 8 hours
const now = new Date().getTime();
const eightHoursAgoMilliseconds = now - 8 * 60 * 60 * 1000;
const isEthereumTimestampValid = response.data.timestamps.Ethereum * 1000 > eightHoursAgoMilliseconds;
const isArbitrumTimestampValid = response.data.timestamps.Arbitrum * 1000 > eightHoursAgoMilliseconds;

console.log(`Arbitrum timestamp: ${response.data.timestamps.Arbitrum}`);
console.log(`Ethereum timestamp: ${response.data.timestamps.Ethereum}`);

// If either timestamp is invalid, return null
if (!isEthereumTimestampValid || !isArbitrumTimestampValid) {
console.error(`Arbitrum or Ethereum timestamps were out of range`);
returnValue = null;
}
// Return the total supply
else {
console.log(`Arbitrum and Ethereum timestamps are within range`);
returnValue = response.data.ohmTotalSupply.toString();
}
}
} catch (error) {
console.error(`Error fetching total supply: ${error}`);

returnValue = null;
}

return returnValue;
};

export const getCirculatingSupply = async (): Promise<string | null> => {
const apiEndpointOverride = process.env.API_ENDPOINT;
if (apiEndpointOverride) {
Expand Down
170 changes: 112 additions & 58 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import * as gcp from "@pulumi/gcp";
import * as pulumi from "@pulumi/pulumi";

import { getValue } from "./app";
import { getCirculatingSupplyValue, getTotalSupplyValue } from "./app";

const pulumiConfig = new pulumi.Config();
const gcpConfig = new pulumi.Config("gcp");
const PROJECT_NAME = "coingecko-api";
const PROJECT_NAME = "metrics";

// Enable required APIs
const serviceCloudFunctions = new gcp.projects.Service("cloudfunctions", {
Expand Down Expand Up @@ -34,30 +34,31 @@ const firestoreDatabase = new gcp.firestore.Database(
},
{
protect: true,
dependsOn: [serviceFirestore],
},
);

// Create a document in Cloud Datastore to use for caching
const projectStackName = `${PROJECT_NAME}-${pulumi.getStack()}`;
const firestoreDocumentName = "cache";
const firestoreDocument = new gcp.firestore.Document(
projectStackName,
const circulatingSupplyFirestoreDocumentName = "cache";
const circulatingSupplyFirestoreDocument = new gcp.firestore.Document(
`${projectStackName}-circulating-supply`,
{
collection: projectStackName,
documentId: firestoreDocumentName,
documentId: circulatingSupplyFirestoreDocumentName,
fields: "",
},
{ dependsOn: [serviceFirestore, firestoreDatabase] },
);

// Deploy Cloud HttpCallback Function
const cloudFunction = new gcp.cloudfunctions.HttpCallbackFunction(
projectStackName,
const circulatingSupplyCloudFunction = new gcp.cloudfunctions.HttpCallbackFunction(
`${projectStackName}-circulating-supply`,
{
callback: async (req: any, res: any) => {
console.log("Received request");
const cache: string | undefined = req.query.cache;
const value = await getValue(cache);
const value = await getCirculatingSupplyValue(cache);

if (!value) {
res.status(500).send("Error fetching circulating supply");
Expand All @@ -69,11 +70,49 @@ const cloudFunction = new gcp.cloudfunctions.HttpCallbackFunction(
environmentVariables: {
API_ENDPOINT: pulumiConfig.get("apiEndpoint"), // Optional
FIRESTORE_COLLECTION: projectStackName,
FIRESTORE_DOCUMENT: firestoreDocumentName,
FIRESTORE_DOCUMENT: circulatingSupplyFirestoreDocumentName,
},
runtime: "nodejs16",
runtime: "nodejs20",
},
{ dependsOn: [serviceCloudFunctions, serviceCloudBuild, firestoreDocument] },
{ dependsOn: [serviceCloudFunctions, serviceCloudBuild, circulatingSupplyFirestoreDocument] },
);

// Create a document in Firestore for the total supply
const totalSupplyFirestoreDocumentName = "total-supply";
const totalSupplyFirestoreDocument = new gcp.firestore.Document(
`${projectStackName}-total-supply`,
{
collection: projectStackName,
documentId: totalSupplyFirestoreDocumentName,
fields: "",
},
{ dependsOn: [serviceFirestore, firestoreDatabase] },
);

// Create a Cloud Function for the total supply
const totalSupplyCloudFunction = new gcp.cloudfunctions.HttpCallbackFunction(
`${projectStackName}-total-supply`,
{
callback: async (req: any, res: any) => {
console.log("Received request");
const cache: string | undefined = req.query.cache;
const value = await getTotalSupplyValue(cache);

if (!value) {
res.status(500).send("Error fetching total supply");
return;
}

res.send(value).end();
},
environmentVariables: {
API_ENDPOINT: pulumiConfig.get("apiEndpoint"), // Optional
FIRESTORE_COLLECTION: projectStackName,
FIRESTORE_DOCUMENT: totalSupplyFirestoreDocumentName,
},
runtime: "nodejs20",
},
{ dependsOn: [serviceCloudFunctions, serviceCloudBuild, totalSupplyFirestoreDocument] },
);

/**
Expand All @@ -93,55 +132,70 @@ const firebaseProject = new gcp.firebase.Project(
},
);

const firebaseHostingSite = new gcp.firebase.HostingSite(
"coingecko-api",
{
project: firebaseProject.project,
siteId: `olympusdao-${projectStackName}`, // Will end up as olympusdao-coingecko-api-<stack>.web.app
},
{
dependsOn: [firebaseProject],
},
);
const createFirebaseDeployment = (firebaseProject: gcp.firebase.Project, cloudFunction: gcp.cloudfunctions.HttpCallbackFunction, siteId: string): [gcp.firebase.HostingSite] => {
const firebaseHostingSite = new gcp.firebase.HostingSite(
siteId,
{
project: firebaseProject.project,
siteId: siteId, // Will end up as siteId.web.app
},
{
dependsOn: [firebaseProject],
},
);

const firebaseSiteId = firebaseHostingSite.siteId;
if (!firebaseSiteId) {
throw new Error("Firebase Hosting site ID is undefined");
}

const firebaseSiteIdInput: pulumi.Input<string> = firebaseSiteId.apply(str => `${str}`);

// Create a rewrite rule to redirect all requests to the Cloud Function
const firebaseHostingVersion = new gcp.firebase.HostingVersion(
siteId,
{
siteId: firebaseSiteIdInput,
config: {
redirects: [
{
glob: "/",
location: cloudFunction.httpsTriggerUrl,
statusCode: 302,
},
],
},
},
{
dependsOn: [firebaseHostingSite, cloudFunction],
},
);

const firebaseHostingRelease = new gcp.firebase.HostingRelease(
siteId,
{
siteId: firebaseSiteIdInput,
versionName: firebaseHostingVersion.name,
message: "Cloud Functions integration",
},
{
dependsOn: [firebaseHostingVersion],
},
);

const firebaseSiteId = firebaseHostingSite.siteId;
if (!firebaseSiteId) {
throw new Error("Firebase Hosting site ID is undefined");
return [firebaseHostingSite];
}

const firebaseSiteIdInput: pulumi.Input<string> = firebaseSiteId.apply(str => `${str}`);
// Create an endpoint for the circulating supply
// siteId will end up as olympus-metrics-<stack>-circulating-supply.web.app
const [circulatingSupplyFirebaseHostingSite] = createFirebaseDeployment(firebaseProject, circulatingSupplyCloudFunction, `olympus-${projectStackName}-circulating-supply`);

// Create a rewrite rule to redirect all requests to the Cloud Function
const firebaseHostingVersion = new gcp.firebase.HostingVersion(
projectStackName,
{
siteId: firebaseSiteIdInput,
config: {
redirects: [
{
glob: "/",
location: cloudFunction.httpsTriggerUrl,
statusCode: 302,
},
],
},
},
{
dependsOn: [firebaseHostingSite, cloudFunction],
},
);
// Create an endpoint for the total supply
// siteId will end up as olympus-metrics-<stack>-total-supply.web.app
const [totalSupplyFirebaseHostingSite] = createFirebaseDeployment(firebaseProject, totalSupplyCloudFunction, `olympus-${projectStackName}-total-supply`);

const firebaseHostingRelease = new gcp.firebase.HostingRelease(
projectStackName,
{
siteId: firebaseSiteIdInput,
versionName: firebaseHostingVersion.name,
message: "Cloud Functions integration",
},
{
dependsOn: [firebaseHostingVersion],
},
);
export const circulatingSupplyCloudFunctionTriggerUrl = circulatingSupplyCloudFunction.httpsTriggerUrl;
export const circulatingSupplyFirebaseHostingUrl = circulatingSupplyFirebaseHostingSite.defaultUrl;

export const cloudFunctionTriggerUrl = cloudFunction.httpsTriggerUrl;
export const firebaseHostingUrl = firebaseHostingSite.defaultUrl;
export const totalSupplyCloudFunctionTriggerUrl = totalSupplyCloudFunction.httpsTriggerUrl;
export const totalSupplyFirebaseHostingUrl = totalSupplyFirebaseHostingSite.defaultUrl;
Loading