Skip to content

Commit

Permalink
feat(iam): add redis for chaching (passportxyz#1797)
Browse files Browse the repository at this point in the history
* feat(iam): add redis and use it to store eth price

* feat(iam): swap redis out for mem cache

* feat(infra): add redis cache

* feat(infra): setup redis across environments

* feat(infra): enable auto scaling

* chore(iam): update tests to use redis cache

* chore(iam): unit test cache

* fix(iam): use cached value if error is thrown from moralis

* chore(platforms): rename cache mem-cache

* chore(platforms, iam): move redis cache to platforms

* feat(platforms): use redis to cache auth flows Co-authored-by: Lucian Hymer <[email protected]>

* fix(platforms): typo

* fix(iam, app, platforms): fix build after redis install

* chore(platforms): use globally mocked redis instance and disconnect

* fix(iam): mock passport cache for integration test

* fix(infra): correct vpcId

* fix(platforms): remove unnecesary timeout

* fix async procedures and hSet redis command

* chore(platforms): update tests and functionality to work with updated hSet implementation

---------

Co-authored-by: Lucian Hymer <[email protected]>
  • Loading branch information
tim-schultz and lucianHymer authored Oct 23, 2023
1 parent 41d9c22 commit 0e004bc
Show file tree
Hide file tree
Showing 29 changed files with 1,070 additions and 479 deletions.
2 changes: 1 addition & 1 deletion app/__tests__/components/GenericPlatform.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { Drawer, DrawerOverlay } from "@chakra-ui/react";
import { closeAllToasts } from "../../__test-fixtures__/toastTestHelpers";
import { PlatformScoreSpec } from "../../context/scorerContext";
import { getPlatformSpec } from "../../config/platforms";
import { PlatformSpec } from "@gitcoin/passport-platforms/src/types";
import { PlatformSpec } from "@gitcoin/passport-platforms";

jest.mock("@didtools/cacao", () => ({
Cacao: {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { RefreshMyStampsModalContentCard } from "../../components/RefreshMyStampsModalContentCard";
import { PlatformSpec } from "@gitcoin/passport-platforms/src/types";
import { PlatformSpec } from "@gitcoin/passport-platforms";
import { PLATFORM_ID, PROVIDER_ID } from "@gitcoin/passport-types";
import { ValidatedProviderGroup } from "../../signer/utils";

Expand Down
4 changes: 2 additions & 2 deletions app/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ module.exports = {
};
},
reactStrictMode: true,
webpack: function (config, options) {
webpack: function (config, { _isServer }) {
config.experiments = { asyncWebAssembly: true };
config.resolve.fallback = { fs: false };
config.resolve.fallback = { fs: false, net: false, tls: false };
return config;
},
};
4 changes: 3 additions & 1 deletion iam/.env-example.env
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,6 @@ CGRANTS_API_TOKEN=abc
CGRANTS_API_URL=http://localhost:8002/cgrants


IAM_JWK_EIP712='{"kty":"EC","crv":"secp256k1","x":"PdB2nS-knyAxc6KPuxBr65vRpW-duAXwpeXlwGJ03eU","y":"MwoGZ08hF5uv-_UEC9BKsYdJVSbJNHcFhR1BZWer5RQ","d":"z9VrSNNZXf9ywUx3v_8cLDhSw8-pvAT9qu_WZmqqfWM"}'
IAM_JWK_EIP712='{"kty":"EC","crv":"secp256k1","x":"PdB2nS-knyAxc6KPuxBr65vRpW-duAXwpeXlwGJ03eU","y":"MwoGZ08hF5uv-_UEC9BKsYdJVSbJNHcFhR1BZWer5RQ","d":"z9VrSNNZXf9ywUx3v_8cLDhSw8-pvAT9qu_WZmqqfWM"}'

REDIS_URL=redis://localhost:6379
5 changes: 5 additions & 0 deletions iam/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,8 @@ There are a few options for adding the variable into the build process:
- (If the value can be public) Hardcode the value in plaintext into the Github Actions script and feed it into the
Pulumi file as described above. Alternatively, hardcode the value into the Pulumi file directly. Also note that it can
be added to `environment` array in the `iam` container definition instead of `secrets`, since the value can be public.

## Cache

Passport uses redis to handle caching. For local development you can spin up a redis instance using docker:
`docker run -d -p 6379:6379 redis` or using whatever other method you prefer. The redis instance should be available at `localhost:6379` by default.
63 changes: 57 additions & 6 deletions iam/__tests__/easFees.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { getEASFeeAmount } from "../src/utils/easFees";
import { utils } from "ethers";
import Moralis from "moralis";
import { PassportCache } from "@gitcoin/passport-platforms";

jest.mock("moralis", () => ({
EvmApi: {
Expand All @@ -12,6 +13,8 @@ jest.mock("moralis", () => ({
},
}));

jest.spyOn(PassportCache.prototype, "init").mockImplementation(() => Promise.resolve());

describe("EthPriceLoader", () => {
beforeAll(() => {
jest.useFakeTimers();
Expand All @@ -24,6 +27,13 @@ describe("EthPriceLoader", () => {

describe("getEASFeeAmount", () => {
it("should calculate the correct EAS Fee amount based on the current ETH price", async () => {
jest.spyOn(PassportCache.prototype, "get").mockImplementation((key) => {
if (key === "ethPrice") {
return Promise.resolve("3000");
} else if (key === "ethPriceLastUpdate") {
return Promise.resolve(null);
}
});
const usdFeeAmount = 2;
const result = await getEASFeeAmount(usdFeeAmount);

Expand All @@ -34,13 +44,47 @@ describe("EthPriceLoader", () => {
});

it("should handle Moralis errors gracefully", async () => {
(Moralis.EvmApi.token.getTokenPrice as jest.Mock).mockRejectedValueOnce(new Error("Failed fetching price"));
const consoleSpy = jest.spyOn(console, "error");
let count = 0;
jest.spyOn(PassportCache.prototype, "get").mockImplementation((key) => {
count += 1;
if (key === "ethPrice") {
if (count === 1) {
return Promise.resolve(null);
}
return Promise.resolve("3000");
} else if (key === "ethPriceLastUpdate") {
return Promise.resolve((Date.now() - 1000 * 60 * 6).toString());
}
});

jest.spyOn(PassportCache.prototype, "set").mockImplementation(() => Promise.resolve());

await expect(getEASFeeAmount(2)).rejects.toThrow("Failed to get ETH price");
(Moralis.EvmApi.token.getTokenPrice as jest.Mock).mockRejectedValueOnce(new Error("Failed fetching price"));
await getEASFeeAmount(2);
expect(consoleSpy).toHaveBeenCalledWith(
"REDIS CONNECTION ERROR: Failed to get ETH price, Error: Failed fetching price"
);
});
});

it("should call Moralis API only once if getEASFeeAmount is called multiple times in succession before cachePeriod is reached", async () => {
let calls = 0;
jest.spyOn(PassportCache.prototype, "get").mockImplementation((key) => {
calls += 1;
if (key === "ethPrice") {
return Promise.resolve("3000");
} else if (key === "ethPriceLastUpdate") {
if (calls === 1) {
return Promise.resolve((Date.now() - 1000 * 60 * 6).toString());
} else {
return Promise.resolve((Date.now() - 1000).toString());
}
}
});

jest.spyOn(PassportCache.prototype, "set").mockImplementation(() => Promise.resolve());

await getEASFeeAmount(2);
await getEASFeeAmount(3);
await getEASFeeAmount(4);
Expand All @@ -49,13 +93,20 @@ describe("EthPriceLoader", () => {
});

it("should call Moralis API again if cachePeriod is exceeded", async () => {
// We're making the first call
let calls = 0;
jest.spyOn(PassportCache.prototype, "get").mockImplementation((key) => {
calls += 1;
if (key === "ethPrice") {
return Promise.resolve("3000");
} else if (key === "ethPriceLastUpdate") {
return Promise.resolve((Date.now() - 1000 * 60 * 6).toString());
}
});

await getEASFeeAmount(2);

// Fast-forwarding time to exceed the cache period of 5 minutes
jest.advanceTimersByTime(1000 * 60 * 6); // Advance by 6 minutes
jest.advanceTimersByTime(1000 * 60 * 6);

// Making the second call after the cache period
await getEASFeeAmount(2);

expect(Moralis.EvmApi.token.getTokenPrice).toHaveBeenCalledTimes(2);
Expand Down
10 changes: 10 additions & 0 deletions iam/__tests__/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// ---- Testing libraries
import request from "supertest";
import * as DIDKit from "@spruceid/didkit-wasm-node";
import { PassportCache } from "@gitcoin/passport-platforms";

// --- Mocks - test configuration

Expand Down Expand Up @@ -1243,6 +1244,15 @@ describe("POST /eas/passport", () => {
});

it("successfully verifies and formats passport", async () => {
jest.spyOn(PassportCache.prototype, "init").mockImplementation(() => Promise.resolve());
jest.spyOn(PassportCache.prototype, "set").mockImplementation(() => Promise.resolve());
jest.spyOn(PassportCache.prototype, "get").mockImplementation((key) => {
if (key === "ethPrice") {
return Promise.resolve("3000");
} else if (key === "ethPriceLastUpdate") {
return Promise.resolve((Date.now() - 1000 * 60 * 6).toString());
}
});
const nonce = 0;
const credentials = [
{
Expand Down
32 changes: 16 additions & 16 deletions iam/src/utils/easFees.ts
Original file line number Diff line number Diff line change
@@ -1,52 +1,52 @@
import { utils } from "ethers";
import { BigNumber } from "@ethersproject/bignumber";
import Moralis from "moralis";
import { IAMError } from "./scorerService";
import { PassportCache } from "@gitcoin/passport-platforms";

const FIVE_MINUTES = 1000 * 60 * 5;
const WETH_CONTRACT = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";

class EthPriceLoader {
cachedPrice: number;
lastUpdated: number;
cachePeriod = FIVE_MINUTES;
cache: PassportCache = new PassportCache();

constructor() {
this.cachedPrice = 0;
this.lastUpdated = 0;
async init(): Promise<void> {
await this.cache.init();
}

async getPrice(): Promise<number> {
if (this.#needsUpdate()) {
this.cachedPrice = await this.#requestCurrentPrice();
this.lastUpdated = Date.now();
if ((await this.#needsUpdate()) || (await this.cache.get("ethPrice")) === null) {
await this.#requestCurrentPrice();
}
return this.cachedPrice;
return Number(await this.cache.get("ethPrice"));
}

#needsUpdate(): boolean {
return Date.now() - this.lastUpdated > this.cachePeriod;
async #needsUpdate(): Promise<boolean> {
const lastUpdate = await this.cache.get("ethPriceLastUpdate");
const lastUpdateTimestamp = Date.now() - Number(lastUpdate || Date.now());
return lastUpdateTimestamp > this.cachePeriod;
}

async #requestCurrentPrice(): Promise<number> {
async #requestCurrentPrice(): Promise<void> {
try {
const { result } = await Moralis.EvmApi.token.getTokenPrice({
chain: "0x1",
address: WETH_CONTRACT,
});

return result.usdPrice;
await this.cache.set("ethPrice", result.usdPrice.toString());
await this.cache.set("ethPriceLastUpdate", Date.now().toString());
} catch (e) {
let message = "Failed to get ETH price";
if (e instanceof Error) message += `, ${e.name}: ${e.message}`;
throw new IAMError(message);
console.error(`REDIS CONNECTION ERROR: ${message}`);
}
}
}

const ethPriceLoader = new EthPriceLoader();

export async function getEASFeeAmount(usdFeeAmount: number): Promise<BigNumber> {
await ethPriceLoader.init();
const ethPrice = await ethPriceLoader.getPrice();
const ethFeeAmount = usdFeeAmount / ethPrice;
return utils.parseEther(ethFeeAmount.toFixed(18));
Expand Down
76 changes: 76 additions & 0 deletions infra/lib/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,5 +86,81 @@ export function createIAMLogGroup({ alertTopic }: { alertTopic: aws.sns.Topic })
treatMissingData: "notBreaching",
});

new aws.cloudwatch.LogMetricFilter("redisConnectionErrors", {
logGroupName: logGroup.name,
metricTransformation: {
defaultValue: "0",
name: "redisConnectionError",
namespace: "/iam/errors/redis",
unit: "Count",
value: "1",
},
name: "Redis Connection Error",
pattern: '"REDIS CONNECTION ERROR:"',
});

new aws.cloudwatch.MetricAlarm("redisConnectionErrorsAlarm", {
alarmActions: [alertTopic.arn],
comparisonOperator: "GreaterThanOrEqualToThreshold",
datapointsToAlarm: 1,
evaluationPeriods: 1,
insufficientDataActions: [],
metricName: "redisConnectionError",
name: "Redis Connection Error",
namespace: "/iam/errors/redis",
okActions: [],
period: 21600,
statistic: "Sum",
threshold: 1,
treatMissingData: "notBreaching",
});

return logGroup;
}

export function setupRedis(vpcPrivateSubnetIds: any, vpcID: pulumi.Output<string>) {
//////////////////////////////////////////////////////////////
// Set up Redis
//////////////////////////////////////////////////////////////

const redisSubnetGroup = new aws.elasticache.SubnetGroup("passport-redis-subnet", {
subnetIds: vpcPrivateSubnetIds,
});

const secgrp_redis = new aws.ec2.SecurityGroup("passport-redis-secgrp", {
description: "passport-redis-secgrp",
vpcId: vpcID,
ingress: [
{
protocol: "tcp",
fromPort: 6379,
toPort: 6379,
cidrBlocks: ["0.0.0.0/0"],
},
],
egress: [
{
protocol: "-1",
fromPort: 0,
toPort: 0,
cidrBlocks: ["0.0.0.0/0"],
},
],
});

const redis = new aws.elasticache.Cluster("passport-redis", {
engine: "redis",
engineVersion: "4.0.10",
nodeType: "cache.t2.small",
numCacheNodes: 1,
port: 6379,
subnetGroupName: redisSubnetGroup.name,
securityGroupIds: [secgrp_redis.id],
});

const redisPrimaryNode = redis.cacheNodes[0];

const redisCacheOpsConnectionUrl = pulumi.interpolate`redis://${redisPrimaryNode.address}:${redisPrimaryNode.port}/0`;

return redisCacheOpsConnectionUrl;
}
Loading

0 comments on commit 0e004bc

Please sign in to comment.