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

feat: replace server side metadata api with client side api #23

Merged
merged 6 commits into from
Feb 24, 2025
Merged
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
7 changes: 7 additions & 0 deletions .changeset/breezy-ligers-fry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@axis-finance/curator-api": patch
"@axis-finance/env": patch
"@axis-finance/sdk": patch
---

Migrate fireworks ipfs-api to this repo and refactor
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ jobs:
node-version: "21.6.0"
cache: "pnpm"

- name: Setup Playwright
run: pnpx playwright install chromium --with-deps --only-shell

- name: Install dependencies
run: pnpm i --frozen-lockfile

Expand Down
11 changes: 11 additions & 0 deletions apps/curator-api/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
FLEEK_PAT=
FLEEK_PROJECT_ID=
DAPP_URL=http://localhost:5173
SERVER_SESSION_SECRET=
TWITTER_CONSUMER_KEY=
TWITTER_CONSUMER_SECRET=
TWITTER_CALLBACK_URL=http://localhost:4000/auth/twitter-callback
TWITTER_RAPID_API_KEY=
SIGNER_KEY=
TWITTER_ACCOUNT=
FOLLOWING_CACHE_BUST_PWD=
3 changes: 3 additions & 0 deletions apps/curator-api/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": ["@axis-finance/eslint-config"]
}
2 changes: 2 additions & 0 deletions apps/curator-api/.npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# force package manager to run predev package.json script on dev
enable-pre-post-scripts=true
39 changes: 39 additions & 0 deletions apps/curator-api/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Curator API

Stateless middleware for authenticating curators via X (Twitter). This API can be used to prove a user owns a Twitter account. Axis uses this for verified curator onboarding.

## Background

This API uses a private key `process.env.SIGNER_KEY` to sign a message associating a curator's twitter handle with their wallet address and their profile metadata IPFS CID. We have to handle this server side to prove the user owns the twitter account they're claiming is theirs. This signature is used by the [axis-registry](https://github.com/Axis-Fi/axis-registry/blob/main/src/MetadataRegistry.sol) contract to enable curators to create and update their curator profiles. A curator who can't sign into a given twitter account will not be able to create a curator profile for that twitter account. You can deploy your own instance of the registry contract and use it to store your own curators' profiles.

This server is stateless and does not store any user data. It only verifies that a user owns a given twitter account. Sessions are stored in memory only.

## Environment variables

Make sure you have a valid `.env` file in this package's root directory. See [.env.example](./.env.example) for an example.

Further reading on [twitter-passport environment variables](https://www.passportjs.org/packages/passport-twitter/).

## Setup

```bash
pnpm i
```

## Running the dev server

```bash
pnpm dev
```

## Running the build

```bash
pnpm build && pnpm start
```

# Notes

This package uses [twitter-passport](https://www.passportjs.org/packages/passport-twitter/) to allow curators to authenticate with X (Twitter).

It also uses a free [Twitter Rapid API service](https://rapidapi.com/twttrapi-twttrapi-default/api/twttrapi) to enable your dapp to filter registered curators by twitter accounts you're following
43 changes: 43 additions & 0 deletions apps/curator-api/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
{
"name": "@axis-finance/curator-api",
"type": "module",
"version": "0.0.0",
"publishConfig": {
"access": "public"
},
"description": "Service for verifying curator Twitter accounts",
"module": "dist/index.js",
"scripts": {
"build": "tsc",
"predev": "npm run build",
"dev": "tsx --watch ./src/index.ts",
"start": "node dist/index.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@axis-finance/sdk": "workspace:*",
"@trpc/server": "^10.45.1",
"cors": "^2.8.5",
"dotenv": "^16.4.3",
"express": "^4.18.2",
"express-session": "^1.18.1",
"oauth": "^0.10.0",
"passport": "^0.7.0",
"passport-twitter": "^1.0.4",
"viem": "^2.17.3",
"zod": "^3.22.4"
},
"devDependencies": {
"@axis-finance/tsup-config": "workspace:*",
"@axis-finance/typescript-config": "workspace:*",
"@axis-finance/eslint-config": "workspace:*",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.13",
"@types/express-session": "^1.18.1",
"@types/passport": "^1.0.17",
"@types/passport-twitter": "^1.0.40",
"tsx": "^4.7.1"
}
}
52 changes: 52 additions & 0 deletions apps/curator-api/src/curator/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { Router } from "express";
import passport from "passport";
import { resolveTwitterShortUrl } from "./utils";

const router = Router();

router.get("/verify-twitter-handle", passport.authenticate("twitter"));

router.get(
"/twitter-callback",
passport.authenticate("twitter", { failureRedirect: "/" }),
(_, res) => res.redirect(`${process.env.DAPP_URL}/#/curator-verified`),
);

router.get("/is-verified", async (req, res) => {
const website = await resolveTwitterShortUrl(req.user?._json.url);
res.send({
success: req.isAuthenticated && req.isAuthenticated(),
user: {
id: req.user?.id,
name: req.user?.displayName,
username: req.user?.username,
description: req.user?._json.description,
banner: req.user?._json.profile_banner_url,
website,
// we replace "normal" with "400x400" to use higher res image
avatar: req.user?._json.profile_image_url_https.replace(
"_normal",
"_400x400",
),
},
});
});

router.get("/sign-out", (req, res) => {
req.logout((err) => {
if (err) {
console.error("Sign out error:", err);
return res.status(500).send("Error logging out.");
}
req.session.destroy((err) => {
if (err) {
console.error("Session destruction error:", err);
return res.status(500).send("Error destroying session.");
}

res.redirect(process.env.DAPP_URL ?? "https://app.axis.finance");
});
});
});

export { router };
77 changes: 77 additions & 0 deletions apps/curator-api/src/curator/axis-following.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import * as dotenv from "dotenv";
import type { RapidTwitterApiResponse } from "./types";

dotenv.config();

if (process.env.TWITTER_RAPID_API_KEY == null) {
throw Error("process.env.TWITTER_RAPID_API_KEY is not set");
}

if (process.env.TWITTER_ACCOUNT == null) {
throw Error("process.env.TWITTER_ACCOUNT is not set");
}

const rapidTwitterApiKey = process.env.TWITTER_RAPID_API_KEY;
const twitterAccount = process.env.TWITTER_ACCOUNT;

const fetchAxisFollowing = async (): Promise<string[]> => {
const url = `https://twttrapi.p.rapidapi.com/user-following?username=${twitterAccount}`;
const headers = {
"x-rapidapi-host": "twttrapi.p.rapidapi.com",
"x-rapidapi-key": rapidTwitterApiKey,
};

const response = await fetch(url, { method: "GET", headers });

if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}

const data: RapidTwitterApiResponse = await response.json();

return (
data?.data?.user?.timeline_response?.timeline?.instructions
?.find((i) => i.__typename === "TimelineAddEntries")
?.entries?.map((e) => e?.content?.content?.userResult?.result)
?.filter((user) => user?.rest_id)
?.map((user) => user.rest_id) || []
);
};

let followingCache: string[];
let lastFetchTime = 0;

// cache axis followings for 24 hours
// (the free tier of the twitter rapid api is approx 50 requests per month)
const CACHE_TTL = 24 * 60 * 60 * 1000;

const getTwitterFollowing = async () => {
try {
// Use cached following if available and not expired
const now = Date.now();

if (followingCache && now - lastFetchTime < CACHE_TTL) {
return followingCache;
}

followingCache = await fetchAxisFollowing();
lastFetchTime = now;
return followingCache;
} catch (error: unknown) {
console.error("Error getting Axis Twitter following:", error);
return [];
}
};

const bustCache = async (pwd: string) => {
if (pwd !== process.env.FOLLOWING_CACHE_BUST_PWD) {
console.log("wrong password");
return "wrong password";
}
console.log("Busting cache", { lastFetchTime });
lastFetchTime = 0;

return "cache busted";
};

export { getTwitterFollowing, bustCache };
18 changes: 18 additions & 0 deletions apps/curator-api/src/curator/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { Address } from "viem";
import { baseSepolia, base } from "viem/chains";

const registry =
// TODO: pull this from deployments package once they're stored in there
process.env.NODE_ENV === "production"
? {
// base mainnet
address: "0xA12307d3cba3F0854cf92faDce07f7bff0B6a2BA" as Address,
chain: base,
}
: {
// base testnet
address: "0xc94404218178149ebebfc1f47f0df14b5fd881c5" as Address,
chain: baseSepolia,
};

export { registry };
32 changes: 32 additions & 0 deletions apps/curator-api/src/curator/passport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import * as dotenv from "dotenv";
import passport from "passport";
import { Strategy as TwitterStrategy } from "passport-twitter";

dotenv.config();

const getCredentials = () => {
if (
!process.env.TWITTER_CONSUMER_KEY ||
!process.env.TWITTER_CONSUMER_SECRET ||
!process.env.TWITTER_CALLBACK_URL
) {
throw new Error("Missing Twitter credentials in .env");
}

return {
consumerKey: process.env.TWITTER_CONSUMER_KEY!,
consumerSecret: process.env.TWITTER_CONSUMER_SECRET!,
callbackURL: process.env.TWITTER_CALLBACK_URL!,
userAuthorizationURL: "https://api.x.com/oauth/authorize",
requestTokenURL: "https://api.x.com/oauth/request_token",
};
};

passport.use(
new TwitterStrategy(getCredentials(), (_, __, profile, done) =>
done(null, profile),
),
);

passport.serializeUser((user, done) => done(null, user));
passport.deserializeUser((user: Express.User, done) => done(null, user));
30 changes: 30 additions & 0 deletions apps/curator-api/src/curator/signer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import * as dotenv from "dotenv";
import {
createPublicClient,
createWalletClient,
http,
type Address,
} from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { registry } from "./env";

dotenv.config();

if (process.env.SIGNER_KEY == null) {
throw Error("process.env.SIGNER_KEY is not set");
}

const signerKey = process.env.SIGNER_KEY! as Address;

const walletClient = createWalletClient({
account: privateKeyToAccount(signerKey),
transport: http(),
chain: registry.chain,
});

const publicClient = createPublicClient({
chain: registry.chain,
transport: http(),
});

export { walletClient, publicClient };
29 changes: 29 additions & 0 deletions apps/curator-api/src/curator/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
type RapidTwitterApiResponse = {
data: {
user: {
timeline_response: {
timeline: {
instructions: {
__typename: string;
entries?: {
content: {
content: {
userResult: {
result: {
legacy: {
screen_name: string;
};
rest_id: string;
};
};
};
};
}[];
}[];
};
};
};
};
};

export type { RapidTwitterApiResponse };
15 changes: 15 additions & 0 deletions apps/curator-api/src/curator/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
const resolveTwitterShortUrl = async (url?: string) => {
try {
console.log("resolveTwitterShortUrl", url);
if (url == null) return null;

const resolved = await fetch(url, { method: "HEAD", redirect: "follow" });

return resolved.url;
} catch (e) {
console.error("Error resolving Twitter short URL:", e);
return null;
}
};

export { resolveTwitterShortUrl };
Loading