diff --git a/sdk/client/CHANGELOG.md b/sdk/client/CHANGELOG.md new file mode 100644 index 000000000..0a4f88480 --- /dev/null +++ b/sdk/client/CHANGELOG.md @@ -0,0 +1,23 @@ +# Changelog + +All notable changes to `@solfoundry/sdk` will be documented in this file. + +## [1.0.0] - 2025-04-09 + +### Added +- Full API coverage for SolFoundry platform + - Auth: GitHub OAuth flow, token refresh, user profile + - Bounties: list, get, create, filter by status/tier/skill/token + - Submissions: list, create with links + - Treasury/Escrow: deposit info, verify deposit + - Review Fees: get fee, verify payment + - Leaderboard: rankings by time period + - Stats: platform-wide statistics +- TypeScript types with full JSDoc documentation +- Auto token refresh on 401 responses +- Rate limiting with token bucket algorithm +- Retry with exponential backoff and jitter +- Request timeout support +- Custom `SolFoundryError` with status codes and error details +- Tree-shakeable ESM/CJS dual output +- Comprehensive README with examples for every API diff --git a/sdk/client/README.md b/sdk/client/README.md new file mode 100644 index 000000000..bfd43abf4 --- /dev/null +++ b/sdk/client/README.md @@ -0,0 +1,274 @@ +# @solfoundry/sdk + +> Comprehensive TypeScript SDK for the [SolFoundry](https://solfoundry.com) bounty platform. + +## Installation + +```bash +npm install @solfoundry/sdk +# or +yarn add @solfoundry/sdk +# or +pnpm add @solfoundry/sdk +``` + +## Quick Start + +```typescript +import { SolFoundryClient } from '@solfoundry/sdk'; + +// Create a client (no auth needed for public endpoints) +const client = new SolFoundryClient(); + +// List open bounties +const { data: bounties } = await client.bounties.list({ + status: 'open', + tier: 'T3', + limit: 10, +}); + +for (const bounty of bounties) { + console.log(`🎯 ${bounty.title}`); + console.log(` Reward: ${bounty.reward.amount} ${bounty.reward.token}`); + console.log(` Skills: ${bounty.skills.join(', ')}`); +} +``` + +## Authentication + +### GitHub OAuth Flow + +The SDK supports the full GitHub OAuth flow: + +```typescript +import { SolFoundryClient } from '@solfoundry/sdk'; + +const client = new SolFoundryClient(); + +// Step 1: Get the GitHub authorize URL +const authorizeUrl = await client.auth.getGitHubAuthorizeUrl(); +// Redirect your user to authorizeUrl... + +// Step 2: Exchange the code from the callback +const tokens = await client.auth.exchangeGitHubCode('code-from-callback'); +console.log('Access token:', tokens.accessToken); + +// Step 3: Create an authenticated client +const authClient = new SolFoundryClient({ + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + onTokensRefreshed: (newTokens) => { + // Save the refreshed tokens + console.log('Token refreshed!'); + }, +}); + +// Tokens auto-refresh on 401 responses +const me = await authClient.auth.getMe(); +console.log(`Logged in as ${me.githubUsername}`); +``` + +### Manual Token + +If you already have an access token: + +```typescript +const client = new SolFoundryClient({ + accessToken: 'your-jwt-token', +}); +``` + +## Configuration + +```typescript +const client = new SolFoundryClient({ + // Base URL (default: https://solfoundry.com) + baseUrl: 'https://solfoundry.com', + + // Auth tokens + accessToken: 'your-jwt', + refreshToken: 'your-refresh-token', + + // Called when tokens are auto-refreshed + onTokensRefreshed: (tokens) => { /* persist tokens */ }, + + // Rate limiting (default: 10 req/s) + maxRequestsPerSecond: 5, + + // Retry (default: 3 retries) + maxRetries: 5, + + // Request timeout in ms (default: 30000) + timeout: 15000, +}); +``` + +## API Reference + +### Bounties + +#### List Bounties + +```typescript +const result = await client.bounties.list({ + status: 'open', // 'open' | 'in_progress' | 'in_review' | 'completed' | 'cancelled' | 'expired' + tier: 'T3', // 'T1' | 'T2' | 'T3' | 'T4' + reward_token: 'FNDRY', // 'SOL' | 'USDC' | 'FNDRY' + skill: 'typescript', + limit: 20, + offset: 0, +}); + +console.log(`Total: ${result.total}`); +for (const bounty of result.data) { + console.log(bounty.title, bounty.reward); +} +``` + +#### Get a Bounty + +```typescript +const bounty = await client.bounties.get('bounty-id'); +console.log(bounty.title, bounty.description, bounty.deadline); +``` + +#### Create a Bounty *(requires auth)* + +```typescript +const bounty = await client.bounties.create({ + title: 'Build a TypeScript SDK', + description: 'Create a comprehensive SDK for SolFoundry API...', + tier: 'T3', + rewardToken: 'FNDRY', + rewardAmount: '900000', + skills: ['typescript', 'sdk', 'api-design'], + deadline: '2025-12-31T23:59:59Z', +}); +``` + +### Submissions + +#### List Submissions + +```typescript +const { data: submissions } = await client.bounties.listSubmissions('bounty-id'); +for (const sub of submissions) { + console.log(`${sub.title} — ${sub.status}`); +} +``` + +#### Create a Submission *(requires auth)* + +```typescript +const submission = await client.bounties.createSubmission('bounty-id', { + title: 'My Implementation', + description: 'Full SDK with TypeScript types, retry logic, and auth management.', + links: [ + 'https://github.com/user/repo/pull/42', + 'https://npmjs.com/package/@solfoundry/sdk', + ], +}); +``` + +### Treasury & Escrow + +#### Get Deposit Info + +```typescript +const depositInfo = await client.treasury.getTreasuryDepositInfo('bounty-id'); +// Send depositInfo.amount of depositInfo.token to depositInfo.walletAddress +// with depositInfo.memo as the transaction memo +console.log(`Send ${depositInfo.amount} ${depositInfo.token} to ${depositInfo.walletAddress}`); +``` + +#### Verify Escrow Deposit + +```typescript +const result = await client.treasury.verifyEscrowDeposit({ + bountyId: 'bounty-id', + transactionSignature: '5Kt7n...signature', +}); +console.log(result.verified ? '✅ Deposit confirmed' : '❌ Not found'); +``` + +### Review Fees + +#### Get Review Fee + +```typescript +const fee = await client.reviewFee.getReviewFee('bounty-id'); +console.log(`Review fee: ${fee.amount} ${fee.token} to ${fee.walletAddress}`); +``` + +#### Verify Review Fee Payment + +```typescript +const result = await client.reviewFee.verify({ + bountyId: 'bounty-id', + transactionSignature: '5Kt7n...signature', +}); +``` + +### Leaderboard + +```typescript +const { data: leaders } = await client.leaderboard.get('monthly'); +for (const entry of leaders) { + console.log(`#${entry.rank} ${entry.name} — ${entry.bountiesCompleted} bounties, $${entry.totalEarnings}`); +} +``` + +### Platform Stats + +```typescript +const stats = await client.stats.get(); +console.log(`${stats.openBounties} open bounties`); +console.log(`$${stats.totalDistributed} distributed`); +console.log(`${stats.totalUsers} users`); +``` + +## Error Handling + +```typescript +import { SolFoundryClient, SolFoundryError } from '@solfoundry/sdk'; + +try { + const bounty = await client.bounties.get('invalid-id'); +} catch (error) { + if (error instanceof SolFoundryError) { + console.error(`API Error [${error.code}]: ${error.message}`); + console.error(`Status: ${error.statusCode}`); + + if (error.isClientError) { + // 4xx - check your request + } else if (error.isServerError) { + // 5xx - server issue, will be retried automatically + } + + if (error.details) { + console.error('Details:', error.details); + } + } else { + throw error; + } +} +``` + +## Tree-Shaking + +The SDK supports tree-shaking via named exports: + +```typescript +// Import only what you need +import { SolFoundryClient } from '@solfoundry/sdk'; +import type { Bounty, BountyStatus } from '@solfoundry/sdk'; +``` + +## Requirements + +- Node.js >= 18.0.0 (uses native `fetch`) +- TypeScript >= 5.0 (for type checking) + +## License + +MIT diff --git a/sdk/client/package-lock.json b/sdk/client/package-lock.json new file mode 100644 index 000000000..7464239e4 --- /dev/null +++ b/sdk/client/package-lock.json @@ -0,0 +1,33 @@ +{ + "name": "@solfoundry/sdk", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@solfoundry/sdk", + "version": "1.0.0", + "license": "MIT", + "devDependencies": { + "typescript": "^5.3.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/sdk/client/package.json b/sdk/client/package.json new file mode 100644 index 000000000..c47999ec4 --- /dev/null +++ b/sdk/client/package.json @@ -0,0 +1,51 @@ +{ + "name": "@solfoundry/sdk", + "version": "1.0.0", + "description": "Comprehensive TypeScript SDK for the SolFoundry bounty platform", + "main": "./dist/cjs/index.js", + "module": "./dist/esm/index.js", + "types": "./dist/types/index.d.ts", + "exports": { + ".": { + "import": "./dist/esm/index.js", + "require": "./dist/cjs/index.js", + "types": "./dist/types/index.d.ts" + }, + "./types": { + "import": "./dist/esm/types/index.js", + "require": "./dist/cjs/types/index.js", + "types": "./dist/types/types/index.d.ts" + }, + "./api": { + "import": "./dist/esm/api/index.js", + "require": "./dist/cjs/api/index.js", + "types": "./dist/types/api/index.d.ts" + } + }, + "files": [ + "dist", + "README.md", + "CHANGELOG.md" + ], + "scripts": { + "build": "tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json", + "build:clean": "rm -rf dist && npm run build", + "check": "tsc --noEmit", + "prepublishOnly": "npm run build:clean" + }, + "keywords": [ + "solfoundry", + "bounty", + "solana", + "sdk", + "typescript" + ], + "license": "MIT", + "devDependencies": { + "typescript": "^5.3.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "sideEffects": false +} diff --git a/sdk/client/src/api/index.ts b/sdk/client/src/api/index.ts new file mode 100644 index 000000000..768a11a3a --- /dev/null +++ b/sdk/client/src/api/index.ts @@ -0,0 +1,560 @@ +import type { + AuthTokens, + User, + Bounty, + ListBountiesParams, + BountyCreatePayload, + Submission, + SubmissionCreatePayload, + PaginatedResponse, + TreasuryDepositInfo, + EscrowVerifyPayload, + EscrowVerifyResult, + ReviewFeeInfo, + ReviewFeeVerifyPayload, + ReviewFeeVerifyResult, + LeaderboardEntry, + TimePeriod, + PlatformStats, +} from '../types/index'; +import { SolFoundryError } from '../utils/error'; +import { RateLimiter, retryWithBackoff } from '../utils/rate-limiter'; + +/** Configuration options for the SolFoundry client */ +export interface SolFoundryConfig { + /** Base URL of the SolFoundry API (default: https://solfoundry.com) */ + baseUrl?: string; + /** JWT access token for authenticated requests */ + accessToken?: string; + /** Refresh token for automatic token renewal */ + refreshToken?: string; + /** Callback invoked when tokens are refreshed */ + onTokensRefreshed?: (tokens: AuthTokens) => void; + /** Maximum requests per second (default: 10) */ + maxRequestsPerSecond?: number; + /** Maximum retry attempts for retryable errors (default: 3) */ + maxRetries?: number; + /** Request timeout in milliseconds (default: 30000) */ + timeout?: number; +} + +interface ApiResponse { + data: T; +} + +/** Internal token state */ +interface TokenState { + accessToken?: string; + refreshToken?: string; + expiresAt?: number; +} + +/** + * SolFoundry API Client + * + * Provides full access to the SolFoundry bounty platform API including + * authentication, bounty management, submissions, treasury/escrow, + * review fees, leaderboard, and platform statistics. + * + * @example + * ```typescript + * import { SolFoundryClient } from '@solfoundry/sdk'; + * + * const client = new SolFoundryClient({ + * accessToken: 'your-jwt-token', + * }); + * + * const bounties = await client.bounties.list({ status: 'open', limit: 10 }); + * ``` + */ +export class SolFoundryClient { + private readonly baseUrl: string; + private readonly rateLimiter: RateLimiter; + private readonly maxRetries: number; + private readonly timeout: number; + private readonly onTokensRefreshed?: (tokens: AuthTokens) => void; + private tokenState: TokenState; + + /** Auth API methods */ + public readonly auth: AuthAPI; + /** Bounties API methods */ + public readonly bounties: BountiesAPI; + /** Treasury/Escrow API methods */ + public readonly treasury: TreasuryAPI; + /** Review Fee API methods */ + public readonly reviewFee: ReviewFeeAPI; + /** Leaderboard API methods */ + public readonly leaderboard: LeaderboardAPI; + /** Platform Stats API methods */ + public readonly stats: StatsAPI; + + constructor(config: SolFoundryConfig = {}) { + this.baseUrl = (config.baseUrl || 'https://solfoundry.com').replace(/\/+$/, ''); + this.maxRetries = config.maxRetries ?? 3; + this.timeout = config.timeout ?? 30000; + this.onTokensRefreshed = config.onTokensRefreshed; + this.rateLimiter = new RateLimiter(config.maxRequestsPerSecond ?? 10); + this.tokenState = { + accessToken: config.accessToken, + refreshToken: config.refreshToken, + }; + + // Initialize API namespaces + this.auth = new AuthAPI(this); + this.bounties = new BountiesAPI(this); + this.treasury = new TreasuryAPI(this); + this.reviewFee = new ReviewFeeAPI(this); + this.leaderboard = new LeaderboardAPI(this); + this.stats = new StatsAPI(this); + } + + /** Update the access token (e.g., after manual refresh) */ + setAccessToken(token: string): void { + this.tokenState.accessToken = token; + } + + /** Get the current access token */ + getAccessToken(): string | undefined { + return this.tokenState.accessToken; + } + + /** + * Core request method with rate limiting, retry, and auto-refresh. + * @internal + */ + async request( + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', + path: string, + options: { + body?: unknown; + params?: Record; + auth?: boolean; + noRetry?: boolean; + } = {}, + ): Promise { + const { body, params, auth = true, noRetry = false } = options; + + return retryWithBackoff( + async () => { + await this.rateLimiter.acquire(); + + const url = new URL(`${this.baseUrl}${path}`); + if (params) { + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) { + url.searchParams.set(key, String(value)); + } + } + } + + const headers: Record = { + 'Content-Type': 'application/json', + Accept: 'application/json', + }; + + if (auth && this.tokenState.accessToken) { + headers['Authorization'] = `Bearer ${this.tokenState.accessToken}`; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeout); + + try { + const response = await fetch(url.toString(), { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + // Auto-refresh on 401 + if (response.status === 401 && this.tokenState.refreshToken && !noRetry) { + try { + const tokens = await this.auth.refreshTokens(this.tokenState.refreshToken); + this.tokenState.accessToken = tokens.accessToken; + this.tokenState.refreshToken = tokens.refreshToken; + this.onTokensRefreshed?.(tokens); + // Retry the request with new token (noRetry to avoid infinite loop) + return this.request(method, path, { ...options, noRetry: true }); + } catch { + // Refresh failed, throw original 401 + } + } + + if (!response.ok) { + let errorBody: Record = {}; + try { + errorBody = await response.json(); + } catch { + // ignore json parse error + } + throw new SolFoundryError( + (errorBody.message as string) || `HTTP ${response.status}`, + response.status, + (errorBody.code as string) || 'UNKNOWN_ERROR', + errorBody.details as Record | undefined, + ); + } + + // Handle 204 No Content + if (response.status === 204) { + return undefined as T; + } + + const json = await response.json(); + return (json.data !== undefined ? json.data : json) as T; + } catch (error) { + clearTimeout(timeoutId); + if (error instanceof SolFoundryError) throw error; + if ((error as Error).name === 'AbortError') { + throw new SolFoundryError('Request timed out', 0, 'TIMEOUT'); + } + throw new SolFoundryError( + (error as Error).message || 'Network error', + 0, + 'NETWORK_ERROR', + ); + } + }, + { + maxRetries: noRetry ? 0 : this.maxRetries, + retryableCheck: (error) => + error instanceof SolFoundryError && error.isRetryable, + }, + ); + } +} + +// ─── Auth API ──────────────────────────────────────────────────────────────── + +/** Authentication API */ +export class AuthAPI { + constructor(private readonly client: SolFoundryClient) {} + + /** + * Get the GitHub OAuth authorize URL. + * Redirect users to this URL to start the GitHub OAuth flow. + * + * @example + * ```typescript + * const url = client.auth.getGitHubAuthorizeUrl(); + * // Redirect user to url + * ``` + */ + async getGitHubAuthorizeUrl(): Promise { + const result = await this.client.request<{ url: string }>( + 'GET', + '/api/auth/github/authorize', + { auth: false }, + ); + return result.url; + } + + /** + * Exchange a GitHub OAuth code for access tokens. + * + * @param code - The authorization code from GitHub OAuth callback + * @param state - Optional state parameter for CSRF protection + * + * @example + * ```typescript + * const tokens = await client.auth.exchangeGitHubCode('oauth-code-from-callback'); + * console.log(tokens.accessToken); + * ``` + */ + async exchangeGitHubCode(code: string, state?: string): Promise { + return this.client.request('POST', '/api/auth/github', { + body: { code, state }, + auth: false, + }); + } + + /** + * Get the authenticated user's profile. + * + * @example + * ```typescript + * const me = await client.auth.getMe(); + * console.log(me.githubUsername); + * ``` + */ + async getMe(): Promise { + return this.client.request('GET', '/api/auth/me'); + } + + /** + * Refresh an expired access token using a refresh token. + * + * @param refreshToken - The refresh token to exchange + * + * @example + * ```typescript + * const newTokens = await client.auth.refreshTokens('your-refresh-token'); + * ``` + */ + async refreshTokens(refreshToken: string): Promise { + return this.client.request('POST', '/api/auth/refresh', { + body: { refreshToken }, + auth: false, + noRetry: true, + }); + } +} + +// ─── Bounties API ──────────────────────────────────────────────────────────── + +/** Bounties API */ +export class BountiesAPI { + constructor(private readonly client: SolFoundryClient) {} + + /** + * List bounties with optional filters and pagination. + * + * @param params - Filter and pagination options + * @returns Paginated list of bounties + * + * @example + * ```typescript + * // Get open T3 bounties + * const result = await client.bounties.list({ + * status: 'open', + * tier: 'T3', + * limit: 10, + * }); + * for (const bounty of result.data) { + * console.log(`${bounty.title} - ${bounty.reward.amount} ${bounty.reward.token}`); + * } + * ``` + */ + async list(params?: ListBountiesParams): Promise> { + const queryParams: Record = {}; + if (params?.status) queryParams.status = params.status; + if (params?.limit) queryParams.limit = params.limit; + if (params?.offset) queryParams.offset = params.offset; + if (params?.skill) queryParams.skill = params.skill; + if (params?.tier) queryParams.tier = params.tier; + if (params?.reward_token) queryParams.reward_token = params.reward_token; + + return this.client.request>('GET', '/api/bounties', { + params: queryParams, + auth: false, + }); + } + + /** + * Get a single bounty by ID. + * + * @param id - Bounty ID + * + * @example + * ```typescript + * const bounty = await client.bounties.get('bounty-123'); + * console.log(bounty.title, bounty.status); + * ``` + */ + async get(id: string): Promise { + return this.client.request('GET', `/api/bounties/${id}`, { auth: false }); + } + + /** + * Create a new bounty. Requires authentication. + * + * @param payload - Bounty creation data + * + * @example + * ```typescript + * const bounty = await client.bounties.create({ + * title: 'Build a TypeScript SDK', + * description: 'Create a comprehensive SDK...', + * tier: 'T3', + * rewardToken: 'FNDRY', + * rewardAmount: '900000', + * skills: ['typescript', 'sdk', 'api-design'], + * deadline: '2025-12-31T23:59:59Z', + * }); + * ``` + */ + async create(payload: BountyCreatePayload): Promise { + return this.client.request('POST', '/api/bounties', { body: payload }); + } + + /** + * List submissions for a bounty. + * + * @param bountyId - The bounty ID + * + * @example + * ```typescript + * const submissions = await client.bounties.listSubmissions('bounty-123'); + * for (const sub of submissions.data) { + * console.log(`${sub.title} - ${sub.status}`); + * } + * ``` + */ + async listSubmissions(bountyId: string): Promise> { + return this.client.request>( + 'GET', + `/api/bounties/${bountyId}/submissions`, + { auth: false }, + ); + } + + /** + * Submit work to a bounty. Requires authentication. + * + * @param bountyId - The bounty ID + * @param payload - Submission data + * + * @example + * ```typescript + * const submission = await client.bounties.createSubmission('bounty-123', { + * title: 'My Implementation', + * description: 'I implemented the SDK with full API coverage...', + * links: ['https://github.com/user/repo/pull/1'], + * }); + * ``` + */ + async createSubmission(bountyId: string, payload: SubmissionCreatePayload): Promise { + return this.client.request( + 'POST', + `/api/bounties/${bountyId}/submissions`, + { body: payload }, + ); + } +} + +// ─── Treasury/Escrow API ──────────────────────────────────────────────────── + +/** Treasury and Escrow API */ +export class TreasuryAPI { + constructor(private readonly client: SolFoundryClient) {} + + /** + * Get treasury deposit information for funding a bounty escrow. + * + * @param bountyId - The bounty ID to fund + * + * @example + * ```typescript + * const depositInfo = await client.treasury.getTreasuryDepositInfo('bounty-123'); + * // Send `depositInfo.amount` of `depositInfo.token` to `depositInfo.walletAddress` + * // with `depositInfo.memo` as the transaction memo + * ``` + */ + async getTreasuryDepositInfo(bountyId: string): Promise { + return this.client.request('GET', '/api/treasury/deposit-info', { + params: { bountyId }, + }); + } + + /** + * Verify that an escrow deposit has been received on-chain. + * + * @param payload - Escrow verification data with transaction signature + * + * @example + * ```typescript + * const result = await client.treasury.verifyEscrowDeposit({ + * bountyId: 'bounty-123', + * transactionSignature: '5Kt...sig', + * }); + * if (result.verified) console.log('Deposit confirmed!'); + * ``` + */ + async verifyEscrowDeposit(payload: EscrowVerifyPayload): Promise { + return this.client.request('POST', '/api/escrow/verify-deposit', { + body: payload, + }); + } +} + +// ─── Review Fee API ────────────────────────────────────────────────────────── + +/** Review Fee API */ +export class ReviewFeeAPI { + constructor(private readonly client: SolFoundryClient) {} + + /** + * Get the review fee details for a bounty. + * + * @param bountyId - The bounty ID + * + * @example + * ```typescript + * const fee = await client.reviewFee.getReviewFee('bounty-123'); + * console.log(`Review fee: ${fee.amount} ${fee.token}`); + * ``` + */ + async getReviewFee(bountyId: string): Promise { + return this.client.request('GET', `/api/review-fee/${bountyId}`); + } + + /** + * Verify that a review fee payment has been made on-chain. + * + * @param payload - Verification data with transaction signature + * + * @example + * ```typescript + * const result = await client.reviewFee.verify({ + * bountyId: 'bounty-123', + * transactionSignature: '5Kt...sig', + * }); + * ``` + */ + async verify(payload: ReviewFeeVerifyPayload): Promise { + return this.client.request('POST', '/api/review-fee/verify', { + body: payload, + }); + } +} + +// ─── Leaderboard API ───────────────────────────────────────────────────────── + +/** Leaderboard API */ +export class LeaderboardAPI { + constructor(private readonly client: SolFoundryClient) {} + + /** + * Get the leaderboard rankings. + * + * @param period - Time period filter (default: 'all_time') + * + * @example + * ```typescript + * const topHunters = await client.leaderboard.get('monthly'); + * for (const entry of topHunters.data) { + * console.log(`#${entry.rank} ${entry.name} - $${entry.totalEarnings}`); + * } + * ``` + */ + async get(period?: TimePeriod): Promise> { + return this.client.request>( + 'GET', + '/api/leaderboard', + { params: { period }, auth: false }, + ); + } +} + +// ─── Stats API ─────────────────────────────────────────────────────────────── + +/** Platform Statistics API */ +export class StatsAPI { + constructor(private readonly client: SolFoundryClient) {} + + /** + * Get platform-wide statistics. + * + * @example + * ```typescript + * const stats = await client.stats.get(); + * console.log(`${stats.openBounties} open bounties worth $${stats.totalDistributed}`); + * ``` + */ + async get(): Promise { + return this.client.request('GET', '/api/stats', { auth: false }); + } +} diff --git a/sdk/client/src/index.ts b/sdk/client/src/index.ts new file mode 100644 index 000000000..1e57725df --- /dev/null +++ b/sdk/client/src/index.ts @@ -0,0 +1,33 @@ +// Main SDK entry point - tree-shakeable exports +export { SolFoundryClient } from './api/index'; +export type { SolFoundryConfig } from './api/index'; + +// Re-export all types for convenience +export type { + BountyStatus, + BountyTier, + RewardToken, + SubmissionStatus, + TimePeriod, + AuthTokens, + User, + ListBountiesParams, + BountyReward, + Bounty, + BountyCreatePayload, + Submission, + SubmissionCreatePayload, + TreasuryDepositInfo, + EscrowVerifyPayload, + EscrowVerifyResult, + ReviewFeeInfo, + ReviewFeeVerifyPayload, + ReviewFeeVerifyResult, + LeaderboardEntry, + PlatformStats, + PaginatedResponse, +} from './types/index'; + +// Re-export error class and utilities +export { SolFoundryError } from './utils/error'; +export { RateLimiter, retryWithBackoff } from './utils/rate-limiter'; diff --git a/sdk/client/src/types/index.ts b/sdk/client/src/types/index.ts new file mode 100644 index 000000000..5fcf365f0 --- /dev/null +++ b/sdk/client/src/types/index.ts @@ -0,0 +1,275 @@ +/** Bounty status lifecycle */ +export type BountyStatus = + | 'open' + | 'in_progress' + | 'in_review' + | 'completed' + | 'cancelled' + | 'expired'; + +/** Bounty difficulty tier */ +export type BountyTier = 'T1' | 'T2' | 'T3' | 'T4'; + +/** Supported reward tokens */ +export type RewardToken = 'SOL' | 'USDC' | 'FNDRY'; + +/** Submission review status */ +export type SubmissionStatus = + | 'pending' + | 'under_review' + | 'approved' + | 'rejected' + | 'revision_requested'; + +/** Leaderboard time period */ +export type TimePeriod = 'daily' | 'weekly' | 'monthly' | 'all_time'; + +/** Authentication tokens returned after OAuth exchange */ +export interface AuthTokens { + /** JWT access token */ + accessToken: string; + /** Long-lived refresh token */ + refreshToken: string; + /** Token expiration time in seconds from now */ + expiresIn: number; + /** Token type, typically "Bearer" */ + tokenType: string; +} + +/** SolFoundry user profile */ +export interface User { + /** Unique user identifier */ + id: string; + /** Display name */ + name: string | null; + /** GitHub username */ + githubUsername: string; + /** Avatar URL */ + avatarUrl: string | null; + /** Email address */ + email: string | null; + /** Wallet public key */ + wallet: string | null; + /** Account creation timestamp */ + createdAt: string; +} + +/** Bounty listing filters */ +export interface ListBountiesParams { + /** Filter by bounty status */ + status?: BountyStatus; + /** Maximum number of results (default 20, max 100) */ + limit?: number; + /** Pagination offset */ + offset?: number; + /** Filter by required skill */ + skill?: string; + /** Filter by tier */ + tier?: BountyTier; + /** Filter by reward token */ + reward_token?: RewardToken; +} + +/** Bounty reward details */ +export interface BountyReward { + /** Reward amount (in smallest unit) */ + amount: string; + /** Reward token */ + token: RewardToken; + /** USD equivalent if available */ + usdEquivalent?: number; +} + +/** A SolFoundry bounty */ +export interface Bounty { + /** Unique bounty identifier */ + id: string; + /** Bounty title */ + title: string; + /** Detailed description (markdown) */ + description: string; + /** Current status */ + status: BountyStatus; + /** Difficulty tier */ + tier: BountyTier; + /** Reward details */ + reward: BountyReward; + /** Required skills */ + skills: string[]; + /** Deadline for submissions */ + deadline: string | null; + /** Bounty creator user ID */ + creatorId: string; + /** Assigned reviewer user ID */ + reviewerId: string | null; + /** Number of submissions received */ + submissionCount: number; + /** Creation timestamp */ + createdAt: string; + /** Last update timestamp */ + updatedAt: string; +} + +/** Payload for creating a new bounty */ +export interface BountyCreatePayload { + /** Bounty title */ + title: string; + /** Detailed description (markdown) */ + description: string; + /** Difficulty tier */ + tier: BountyTier; + /** Reward token */ + rewardToken: RewardToken; + /** Reward amount (in smallest unit or whole number) */ + rewardAmount: string; + /** Required skills */ + skills: string[]; + /** Submission deadline (ISO 8601) */ + deadline?: string; + /** Reviewer wallet or user ID (optional) */ + reviewerId?: string; +} + +/** A submission to a bounty */ +export interface Submission { + /** Unique submission identifier */ + id: string; + /** Parent bounty ID */ + bountyId: string; + /** Submitter user ID */ + userId: string; + /** Submission title */ + title: string; + /** Submission description (markdown) */ + description: string; + /** URLs to supporting materials (PRs, demos, etc.) */ + links: string[]; + /** Current review status */ + status: SubmissionStatus; + /** Reviewer feedback (if reviewed) */ + feedback: string | null; + /** Creation timestamp */ + createdAt: string; + /** Last update timestamp */ + updatedAt: string; +} + +/** Payload for creating a submission */ +export interface SubmissionCreatePayload { + /** Submission title */ + title: string; + /** Submission description (markdown) */ + description: string; + /** URLs to supporting materials */ + links: string[]; +} + +/** Treasury deposit information for escrow */ +export interface TreasuryDepositInfo { + /** Deposit destination wallet address */ + walletAddress: string; + /** Required deposit amount */ + amount: string; + /** Token to deposit */ + token: RewardToken; + /** Memo/reference ID for the deposit */ + memo: string; + /** Minimum required confirmations */ + minConfirmations: number; +} + +/** Payload for verifying an escrow deposit */ +export interface EscrowVerifyPayload { + /** Bounty ID */ + bountyId: string; + /** Transaction signature on Solana */ + transactionSignature: string; +} + +/** Result of escrow deposit verification */ +export interface EscrowVerifyResult { + /** Whether the deposit was verified */ + verified: boolean; + /** Verification message */ + message: string; + /** Escrow balance after verification */ + escrowBalance?: string; +} + +/** Review fee information */ +export interface ReviewFeeInfo { + /** Fee amount */ + amount: string; + /** Fee token */ + token: RewardToken; + /** Fee wallet address */ + walletAddress: string; + /** Payment memo */ + memo: string; + /** Whether the fee has been paid */ + paid: boolean; +} + +/** Payload for verifying a review fee payment */ +export interface ReviewFeeVerifyPayload { + /** Bounty ID */ + bountyId: string; + /** Transaction signature */ + transactionSignature: string; +} + +/** Review fee verification result */ +export interface ReviewFeeVerifyResult { + /** Whether the fee was verified */ + verified: boolean; + /** Verification message */ + message: string; +} + +/** Leaderboard entry for a user */ +export interface LeaderboardEntry { + /** Rank position */ + rank: number; + /** User ID */ + userId: string; + /** Display name */ + name: string | null; + /** GitHub username */ + githubUsername: string; + /** Avatar URL */ + avatarUrl: string | null; + /** Total earnings in USD */ + totalEarnings: number; + /** Number of bounties completed */ + bountiesCompleted: number; + /** Points earned this period */ + points: number; +} + +/** Platform-wide statistics */ +export interface PlatformStats { + /** Total bounties created */ + totalBounties: number; + /** Currently open bounties */ + openBounties: number; + /** Total completed bounties */ + completedBounties: number; + /** Total value distributed (USD) */ + totalDistributed: number; + /** Total registered users */ + totalUsers: number; + /** Total submissions received */ + totalSubmissions: number; +} + +/** Generic paginated response */ +export interface PaginatedResponse { + /** Result items */ + data: T[]; + /** Total count of matching items */ + total: number; + /** Current offset */ + offset: number; + /** Items per page */ + limit: number; +} diff --git a/sdk/client/src/utils/error.ts b/sdk/client/src/utils/error.ts new file mode 100644 index 000000000..9543f143a --- /dev/null +++ b/sdk/client/src/utils/error.ts @@ -0,0 +1,46 @@ +/** + * Custom error class for SolFoundry API errors. + * + * Provides structured error information including HTTP status code, + * API error code, and optional details. + */ +export class SolFoundryError extends Error { + /** HTTP status code */ + public readonly statusCode: number; + /** API-specific error code */ + public readonly code: string; + /** Additional error details */ + public readonly details?: Record; + + constructor( + message: string, + statusCode: number, + code: string, + details?: Record, + ) { + super(message); + this.name = 'SolFoundryError'; + this.statusCode = statusCode; + this.code = code; + this.details = details; + } + + /** Returns true if this is a client error (4xx) */ + get isClientError(): boolean { + return this.statusCode >= 400 && this.statusCode < 500; + } + + /** Returns true if this is a server error (5xx) */ + get isServerError(): boolean { + return this.statusCode >= 500; + } + + /** Returns true if the error is retryable */ + get isRetryable(): boolean { + return this.statusCode === 429 || this.statusCode >= 500; + } + + override toString(): string { + return `SolFoundryError [${this.code}] (${this.statusCode}): ${this.message}`; + } +} diff --git a/sdk/client/src/utils/index.ts b/sdk/client/src/utils/index.ts new file mode 100644 index 000000000..38e404e69 --- /dev/null +++ b/sdk/client/src/utils/index.ts @@ -0,0 +1,2 @@ +export { SolFoundryError } from './error'; +export { RateLimiter, retryWithBackoff, sleep } from './rate-limiter'; diff --git a/sdk/client/src/utils/rate-limiter.ts b/sdk/client/src/utils/rate-limiter.ts new file mode 100644 index 000000000..0898fcb92 --- /dev/null +++ b/sdk/client/src/utils/rate-limiter.ts @@ -0,0 +1,80 @@ +/** Simple rate limiter using token bucket algorithm */ +export class RateLimiter { + private tokens: number; + private lastRefill: number; + + constructor( + private readonly maxRequestsPerSecond: number, + ) { + this.tokens = maxRequestsPerSecond; + this.lastRefill = Date.now(); + } + + /** Wait until a token is available, then consume it */ + async acquire(): Promise { + this.refill(); + if (this.tokens >= 1) { + this.tokens -= 1; + return; + } + // Wait until next token available + const waitMs = Math.ceil(1000 / this.maxRequestsPerSecond); + await new Promise((resolve) => setTimeout(resolve, waitMs)); + this.refill(); + this.tokens -= 1; + } + + private refill(): void { + const now = Date.now(); + const elapsed = (now - this.lastRefill) / 1000; + this.tokens = Math.min( + this.maxRequestsPerSecond, + this.tokens + elapsed * this.maxRequestsPerSecond, + ); + this.lastRefill = now; + } +} + +/** Retry with exponential backoff */ +export async function retryWithBackoff( + fn: () => Promise, + options: { + maxRetries?: number; + baseDelayMs?: number; + maxDelayMs?: number; + retryableCheck?: (error: unknown) => boolean; + } = {}, +): Promise { + const { + maxRetries = 3, + baseDelayMs = 1000, + maxDelayMs = 30000, + retryableCheck, + } = options; + + let lastError: unknown; + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await fn(); + } catch (error: unknown) { + lastError = error; + if (attempt === maxRetries) break; + + // Check if retryable + if (retryableCheck && !retryableCheck(error)) break; + + // Calculate delay with jitter + const delay = Math.min( + baseDelayMs * Math.pow(2, attempt) + Math.random() * 1000, + maxDelayMs, + ); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + throw lastError; +} + +/** Sleep for a given number of milliseconds */ +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/sdk/client/tsconfig.cjs.json b/sdk/client/tsconfig.cjs.json new file mode 100644 index 000000000..149000efb --- /dev/null +++ b/sdk/client/tsconfig.cjs.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020", "DOM"], + "outDir": "./dist/cjs", + "declarationDir": "./dist/types", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "moduleResolution": "node" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "**/__tests__/**"] +} diff --git a/sdk/client/tsconfig.esm.json b/sdk/client/tsconfig.esm.json new file mode 100644 index 000000000..063055a88 --- /dev/null +++ b/sdk/client/tsconfig.esm.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "lib": ["ES2020", "DOM"], + "outDir": "./dist/esm", + "rootDir": "./src", + "declaration": false, + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "moduleResolution": "bundler" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "**/__tests__/**"] +}