From 8cf5fd4c5f00490170a1ce7eec5c3adecbd7c3f6 Mon Sep 17 00:00:00 2001 From: Bram Borggreve Date: Mon, 17 Jun 2024 01:34:33 +0300 Subject: [PATCH] feat: initial commit of PubKey Protocol Resolver --- .env.example | 1 + .gitignore | 1 + api/src/lib/features/profile.service.ts | 118 ++++++++++++++++++++++++ api/src/lib/features/profiles.routes.ts | 60 ++++++++++++ api/src/lib/features/uptime.route.ts | 9 -- api/src/lib/server-config.ts | 6 ++ api/src/lib/server-router.ts | 13 +-- api/src/lib/server.ts | 2 +- package.json | 2 + pnpm-lock.yaml | 58 ++++++++++++ 10 files changed, 254 insertions(+), 16 deletions(-) create mode 100644 .env.example create mode 100644 api/src/lib/features/profile.service.ts create mode 100644 api/src/lib/features/profiles.routes.ts delete mode 100644 api/src/lib/features/uptime.route.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8911ac9 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +SOLANA_RPC_ENDPOINT=https://mainnet.helius-rpc.com/?api-key= diff --git a/.gitignore b/.gitignore index 76431a8..355cc58 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,4 @@ Thumbs.db .nx/cache .nx/workspace-data nx-cloud.env +.env diff --git a/api/src/lib/features/profile.service.ts b/api/src/lib/features/profile.service.ts new file mode 100644 index 0000000..aca0e18 --- /dev/null +++ b/api/src/lib/features/profile.service.ts @@ -0,0 +1,118 @@ +import { AnchorProvider } from '@coral-xyz/anchor' +import { PUBKEY_PROFILE_PROGRAM_ID, PubKeyIdentityProvider, PubKeyProfile } from '@pubkey-program-library/anchor' +import { AnchorKeypairWallet, PubKeyProfileSdk } from '@pubkey-program-library/sdk' +import { Keypair, PublicKey } from '@solana/web3.js' +import { ServerConfig } from '../server-config' + +export class ProfileService { + private readonly sdk: PubKeyProfileSdk + private readonly validProviders: PubKeyIdentityProvider[] = [ + // Add more providers here once the protocol supports them + PubKeyIdentityProvider.Discord, + PubKeyIdentityProvider.Github, + PubKeyIdentityProvider.Google, + PubKeyIdentityProvider.Solana, + PubKeyIdentityProvider.Twitter, + ] + + constructor(private readonly config: ServerConfig) { + this.sdk = new PubKeyProfileSdk({ + connection: this.config.connection, + provider: this.getAnchorProvider(), + programId: PUBKEY_PROFILE_PROGRAM_ID, + }) + } + + getApiUrl(path: string) { + return `${this.config.apiUrl}/api${path}` + } + + getProviders() { + return this.validProviders + } + + async getUserProfileByUsername(username: string): Promise { + this.ensureValidUsername(username) + + try { + return this.sdk.getProfileByUsernameNullable({ username }) + } catch (e) { + throw new Error(`User profile not found for username ${username}`) + } + } + + async getUserProfileByProvider(provider: PubKeyIdentityProvider, providerId: string): Promise { + try { + this.ensureValidProvider(provider) + } catch (e) { + throw new Error(`Invalid provider, must be one of ${this.validProviders.join(', ')}`) + } + + try { + this.ensureValidProviderId(provider, providerId) + } catch (e) { + throw new Error(`Invalid provider ID for provider ${provider}`) + } + try { + return await this.sdk.getProfileByProviderNullable({ provider, providerId }) + } catch (e) { + throw new Error(`User profile not found for provider ${provider} and providerId ${providerId}`) + } + } + + async getUserProfiles(): Promise { + return this.sdk.getProfiles().then((res) => res.sort((a, b) => a.username.localeCompare(b.username))) + } + + private ensureValidProvider(provider: PubKeyIdentityProvider) { + if (!this.validProviders.includes(provider)) { + throw new Error(`Invalid provider: ${provider}`) + } + } + + private ensureValidProviderId(provider: PubKeyIdentityProvider, providerId: string) { + if (provider === PubKeyIdentityProvider.Solana && !isSolanaPublicKey(providerId)) { + throw new Error(`Invalid provider ID for ${provider}.`) + } + if (provider !== PubKeyIdentityProvider.Solana && !isNumericString(providerId)) { + throw new Error(`Invalid provider ID for ${provider}.`) + } + } + + private ensureValidUsername(username: string) { + if (!isValidUsername(username)) { + throw new Error(`Invalid username: ${username}`) + } + } + + private getAnchorProvider(keypair = Keypair.generate()) { + return new AnchorProvider(this.config.connection, new AnchorKeypairWallet(keypair), AnchorProvider.defaultOptions()) + } +} +function isNumericString(str: string): boolean { + return /^\d+$/.test(str) +} + +function isSolanaPublicKey(str: string): boolean { + return !!parseSolanaPublicKey(str) +} + +function parseSolanaPublicKey(publicKey: string): PublicKey | null { + try { + return new PublicKey(publicKey) + } catch (e) { + return null + } +} + +function isValidUsername(username: string): boolean { + if (username.length < 3 || username.length > 20) { + return false + } + + if (!username.split('').every((c) => /^[a-z0-9_]$/.test(c))) { + return false + } + + return true +} diff --git a/api/src/lib/features/profiles.routes.ts b/api/src/lib/features/profiles.routes.ts new file mode 100644 index 0000000..20a8002 --- /dev/null +++ b/api/src/lib/features/profiles.routes.ts @@ -0,0 +1,60 @@ +import { PubKeyIdentityProvider } from '@pubkey-program-library/anchor' +import express, { Request, Response } from 'express' +import { ServerConfig } from '../server-config' +import { ProfileService } from './profile.service' + +export function profilesRoutes(config: ServerConfig): express.Router { + const router = express.Router() + const service = new ProfileService(config) + + router.get('', (_: Request, res: Response) => { + return res.send( + [ + '/profiles/all', + '/profiles/providers', + '/profiles/provider/:provider/:providerId', + '/profiles/username/:username', + ].map((p) => service.getApiUrl(p)), + ) + }) + + router.get('/all', async (_: Request, res: Response) => { + return res.send(await service.getUserProfiles()) + }) + + router.get('/providers', (_: Request, res: Response) => { + return res.send(service.getProviders()) + }) + + router.get('/provider/:provider/:providerId', async (req: Request, res: Response) => { + const { provider, providerId } = req.params + + try { + const profile = await service.getUserProfileByProvider(provider as PubKeyIdentityProvider, providerId) + if (!profile) { + return res.status(404).send(`User profile not found for provider ${provider} and providerId ${providerId}`) + } + return res.send(profile) + } catch (e) { + return res.status(404).send(e.message) + } + }) + + router.get('/username/:username', async (req: Request, res: Response) => { + const { username } = req.params + + try { + const profile = await service.getUserProfileByUsername(username) + if (!profile) { + return res.status(404).send(`User profile not found for username ${username}`) + } + return res.send(profile) + } catch (e) { + return res.status(404).send(e.message) + } + }) + + router.use('*', (req: Request, res: Response) => res.status(404).send('Profile Not Found')) + + return router +} diff --git a/api/src/lib/features/uptime.route.ts b/api/src/lib/features/uptime.route.ts deleted file mode 100644 index 7eaff96..0000000 --- a/api/src/lib/features/uptime.route.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Request, Response } from 'express' - -export function uptimeRoute() { - return async (req: Request, res: Response) => { - const result = { uptime: process.uptime() } - - return res.json(result) - } -} diff --git a/api/src/lib/server-config.ts b/api/src/lib/server-config.ts index c069824..89f239a 100644 --- a/api/src/lib/server-config.ts +++ b/api/src/lib/server-config.ts @@ -1,5 +1,8 @@ +import { Connection } from '@solana/web3.js' + export interface ServerConfig { apiUrl: string + connection: Connection host: string port: string } @@ -7,6 +10,7 @@ export interface ServerConfig { export function getServerConfig(): ServerConfig { const requiredEnvVars = [ // Place any required environment variables here + 'SOLANA_RPC_ENDPOINT', ] const missingEnvVars = requiredEnvVars.filter((envVar) => !process.env[envVar]?.length) @@ -19,9 +23,11 @@ export function getServerConfig(): ServerConfig { const port = process.env.PORT || '3000' const apiUrl = process.env.API_URL || `http://${host}:${port}` + const connection = new Connection(process.env.SOLANA_RPC_ENDPOINT, 'confirmed') return { apiUrl, + connection, host, port, } diff --git a/api/src/lib/server-router.ts b/api/src/lib/server-router.ts index d4f97d0..2cc477c 100644 --- a/api/src/lib/server-router.ts +++ b/api/src/lib/server-router.ts @@ -1,13 +1,14 @@ import express, { Request, Response } from 'express' +import { profilesRoutes } from './features/profiles.routes' +import { ServerConfig } from './server-config' -import { uptimeRoute } from './features/uptime.route' - -export function serverRouter(): express.Router { +export function serverRouter(config: ServerConfig): express.Router { const router = express.Router() - router.use('/uptime', uptimeRoute()) - router.use('/', (req: Request, res: Response) => res.send('PubKey API')) - router.use('*', (req: Request, res: Response) => res.status(404).send('Not Found')) + router.use('/profiles', profilesRoutes(config)) + router.use('/uptime', (_: Request, res: Response) => res.json({ uptime: process.uptime() })) + router.use('/', (_: Request, res: Response) => res.send('PubKey API')) + router.use('*', (_: Request, res: Response) => res.status(404).send('Not Found')) return router } diff --git a/api/src/lib/server.ts b/api/src/lib/server.ts index 99881ec..9c9e067 100644 --- a/api/src/lib/server.ts +++ b/api/src/lib/server.ts @@ -15,7 +15,7 @@ export async function server(config: ServerConfig) { // Parse JSON app.use(express.json()) // Set base path to /api - app.use('/api', serverRouter()) + app.use('/api', serverRouter(config)) // Serve static files const staticPath = setupAssets(app, dir) diff --git a/package.json b/package.json index e1f8b31..7c1b4c4 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,8 @@ }, "dependencies": { "@coral-xyz/anchor": "^0.29.0", + "@pubkey-program-library/anchor": "^1.7.1", + "@pubkey-program-library/sdk": "^1.7.1", "@solana/spl-token": "^0.4.1", "@solana/web3.js": "^1.91.0", "clsx": "^2.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a3250e..6741c5b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,12 @@ importers: '@coral-xyz/anchor': specifier: ^0.29.0 version: 0.29.0(bufferutil@4.0.8)(utf-8-validate@5.0.10) + '@pubkey-program-library/anchor': + specifier: ^1.7.1 + version: 1.7.1(bufferutil@4.0.8)(utf-8-validate@5.0.10) + '@pubkey-program-library/sdk': + specifier: ^1.7.1 + version: 1.7.1(bufferutil@4.0.8)(utf-8-validate@5.0.10) '@solana/spl-token': specifier: ^0.4.1 version: 0.4.1(@solana/web3.js@1.91.0(bufferutil@4.0.8)(utf-8-validate@5.0.10))(bufferutil@4.0.8)(fastestsmallesttextencoderdecoder@1.0.22)(utf-8-validate@5.0.10) @@ -1287,6 +1293,12 @@ packages: peerDependencies: typescript: ^3 || ^4 || ^5 + '@pubkey-program-library/anchor@1.7.1': + resolution: {integrity: sha512-3GbBcr9+xggp0JHAEtHIN1ZjxAwIBVh66C+rjfX/lqkGFsmUHNNNVX81jB6C9P6S7WWbIvT84mPr3y6SoAZ41A==} + + '@pubkey-program-library/sdk@1.7.1': + resolution: {integrity: sha512-GEJEWxTVQ+C0xaZ9Yqvm3PeXHcB1uxaqDpGRqtoBAuXk5MHOsoWycQCXpLafnnfJtAaTiMG5e4fbWQVPj/wfoQ==} + '@sinclair/typebox@0.27.8': resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} @@ -1300,6 +1312,9 @@ packages: '@sinonjs/fake-timers@10.3.0': resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + '@solana-developers/helpers@2.3.0': + resolution: {integrity: sha512-OVdm/RJ9OMI23AnBYX/8UWuNtHRUxaXRUzhXo4WRtXYPHdQ+jTFS2TsjKSJ/F3a0kUZ6nN0b5TekFZvqYF0Qdg==} + '@solana/buffer-layout-utils@0.2.0': resolution: {integrity: sha512-szG4sxgJGktbuZYDg2FfNmkMi0DYQoVjN2h7ta1W1hPrwzarcFLBq9UpX1UjNXsNpT9dn+chgprtWGioUAr4/g==} engines: {node: '>= 10'} @@ -2135,6 +2150,9 @@ packages: base-x@3.0.9: resolution: {integrity: sha512-H7JU6iBHTal1gp56aKoaa//YUxEaAOUiydvrV/pILqIHXTtqxSkATOnDA2u+jZ/61sD+L/412+7kzXRtWukhpQ==} + base-x@4.0.0: + resolution: {integrity: sha512-FuwxlW4H5kh37X/oW59pwTzzTKRzfrrQwhmyspRM7swOEZcHtDZSCt45U6oKgtuFE+WYPblePMVIPR4RZrh/hw==} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -2215,6 +2233,9 @@ packages: bs58@4.0.1: resolution: {integrity: sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw==} + bs58@5.0.0: + resolution: {integrity: sha512-r+ihvQJvahgYT50JD05dyJNKlmmSlMoOGwn1lCcEzanPglg7TxYjioQUYehQ9mAR/+hOSd2jRc/Z2y5UxBymvQ==} + bser@2.1.1: resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} @@ -7345,6 +7366,27 @@ snapshots: esquery: 1.5.0 typescript: 5.4.5 + '@pubkey-program-library/anchor@1.7.1(bufferutil@4.0.8)(utf-8-validate@5.0.10)': + dependencies: + '@coral-xyz/anchor': 0.29.0(bufferutil@4.0.8)(utf-8-validate@5.0.10) + '@noble/hashes': 1.3.3 + '@solana/web3.js': 1.91.0(bufferutil@4.0.8)(utf-8-validate@5.0.10) + transitivePeerDependencies: + - bufferutil + - encoding + - utf-8-validate + + '@pubkey-program-library/sdk@1.7.1(bufferutil@4.0.8)(utf-8-validate@5.0.10)': + dependencies: + '@coral-xyz/anchor': 0.29.0(bufferutil@4.0.8)(utf-8-validate@5.0.10) + '@pubkey-program-library/anchor': 1.7.1(bufferutil@4.0.8)(utf-8-validate@5.0.10) + '@solana-developers/helpers': 2.3.0(bufferutil@4.0.8)(utf-8-validate@5.0.10) + '@solana/web3.js': 1.91.0(bufferutil@4.0.8)(utf-8-validate@5.0.10) + transitivePeerDependencies: + - bufferutil + - encoding + - utf-8-validate + '@sinclair/typebox@0.27.8': {} '@sindresorhus/is@4.6.0': {} @@ -7357,6 +7399,16 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 + '@solana-developers/helpers@2.3.0(bufferutil@4.0.8)(utf-8-validate@5.0.10)': + dependencies: + '@solana/web3.js': 1.91.0(bufferutil@4.0.8)(utf-8-validate@5.0.10) + bs58: 5.0.0 + dotenv: 16.4.5 + transitivePeerDependencies: + - bufferutil + - encoding + - utf-8-validate + '@solana/buffer-layout-utils@0.2.0(bufferutil@4.0.8)(utf-8-validate@5.0.10)': dependencies: '@solana/buffer-layout': 4.0.1 @@ -8374,6 +8426,8 @@ snapshots: dependencies: safe-buffer: 5.2.1 + base-x@4.0.0: {} + base64-js@1.5.1: {} basic-auth@2.0.1: @@ -8478,6 +8532,10 @@ snapshots: dependencies: base-x: 3.0.9 + bs58@5.0.0: + dependencies: + base-x: 4.0.0 + bser@2.1.1: dependencies: node-int64: 0.4.0