diff --git a/.github/workflows/openapi-codegen.yml b/.github/workflows/openapi-codegen.yml new file mode 100644 index 00000000..18764e8d --- /dev/null +++ b/.github/workflows/openapi-codegen.yml @@ -0,0 +1,42 @@ +name: OpenAPI Codegen Drift Check + +on: + pull_request: + branches: [main] + paths: + - 'backend/openapi.yaml' + - 'sdk/client/**' + push: + branches: [main] + paths: + - 'backend/openapi.yaml' + +jobs: + codegen-drift: + name: Check generated client is up to date + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install sdk/client dependencies + run: npm install + working-directory: sdk/client + + - name: Regenerate TypeScript client from openapi.yaml + run: npm run generate + working-directory: sdk/client + + - name: Fail if generated output has drifted + run: | + if ! git diff --exit-code sdk/client/src/; then + echo "" + echo "ERROR: The generated TypeScript client in sdk/client/src/ is out of date." + echo "Run 'npm run generate' inside sdk/client/ and commit the updated files." + exit 1 + fi diff --git a/.github/workflows/python-sdk-ci.yml b/.github/workflows/python-sdk-ci.yml new file mode 100644 index 00000000..a080552f --- /dev/null +++ b/.github/workflows/python-sdk-ci.yml @@ -0,0 +1,62 @@ +name: Python SDK CI + +on: + pull_request: + branches: [main] + paths: + - 'sdk/python/**' + - 'backend/openapi.yaml' + push: + branches: [main] + paths: + - 'sdk/python/**' + +jobs: + test: + name: pytest (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.9', '3.11', '3.12'] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install SDK with dev extras + run: pip install -e ".[dev]" + working-directory: sdk/python + + - name: Run pytest + run: pytest tests/ -v + working-directory: sdk/python + + publish: + name: Publish to PyPI + needs: test + runs-on: ubuntu-latest + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/python-sdk-v') + environment: pypi + permissions: + id-token: write # OIDC trusted publishing + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Build distribution + run: | + pip install hatch + hatch build + working-directory: sdk/python + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: sdk/python/dist/ diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..ffb9c5ed --- /dev/null +++ b/Makefile @@ -0,0 +1,17 @@ +# Trivela development helpers +.PHONY: dev dev-down codegen codegen-check + +# Issue #616: one-command local devnet (contracts + backend + frontend) +dev: + docker compose --profile devnet up --build + +dev-down: + docker compose --profile devnet down -v + +# Issue #615: regenerate TypeScript client from openapi.yaml +codegen: + cd sdk/client && npm install --silent && npm run generate + +# Fail if the committed generated client is out of date +codegen-check: + cd sdk/client && npm install --silent && npm run check-drift diff --git a/cli/package.json b/cli/package.json new file mode 100644 index 00000000..3b02b2c1 --- /dev/null +++ b/cli/package.json @@ -0,0 +1,25 @@ +{ + "name": "@trivela/cli", + "version": "0.1.0", + "description": "Trivela CLI — campaign & contract lifecycle management", + "type": "module", + "bin": { + "trivela": "./src/index.js" + }, + "scripts": { + "dev": "node src/index.js", + "test": "node --experimental-vm-modules node_modules/.bin/jest", + "lint": "eslint src/" + }, + "dependencies": { + "commander": "^12.0.0", + "chalk": "^5.3.0", + "ora": "^8.0.0", + "conf": "^12.0.0", + "node-fetch": "^3.3.2" + }, + "devDependencies": { + "jest": "^29.0.0" + }, + "license": "Apache-2.0" +} diff --git a/cli/src/commands/allowlist.js b/cli/src/commands/allowlist.js new file mode 100644 index 00000000..e2b13025 --- /dev/null +++ b/cli/src/commands/allowlist.js @@ -0,0 +1,57 @@ +/** + * trivela allowlist import — import a CSV/text allowlist and generate Merkle proof. + */ + +import { readFileSync } from 'fs'; +import { execSync } from 'child_process'; +import { Command } from 'commander'; +import chalk from 'chalk'; +import { getConfig } from '../lib/config.js'; +import { requireMainnetConfirmation } from '../lib/prompt.js'; + +export function makeAllowlistCommand() { + const cmd = new Command('allowlist'); + cmd.description('Manage campaign allowlists and Merkle proofs'); + + cmd + .command('import ') + .description('Import a list of addresses from a file (one address per line or CSV)') + .option('--dry-run', 'Parse file and print summary without writing') + .option('--campaign-id ', 'Associate with a campaign') + .action(async (file, opts) => { + const cfg = getConfig(); + let raw; + try { + raw = readFileSync(file, 'utf8'); + } catch (err) { + console.error(chalk.red('Cannot read file:'), err.message); + process.exit(1); + } + + const addresses = raw + .split('\n') + .map((l) => l.split(',')[0].trim()) + .filter((a) => a && a.startsWith('G') && a.length === 56); + + console.log(`Parsed ${chalk.bold(addresses.length)} valid Stellar addresses from ${file}.`); + + if (opts.dryRun) { + console.log(chalk.cyan('[dry-run]'), 'First 5 addresses:', addresses.slice(0, 5)); + return; + } + + // Generate Merkle root via the existing script + const listPath = `/tmp/trivela-allowlist-${Date.now()}.txt`; + require('fs').writeFileSync(listPath, addresses.join('\n')); + try { + const out = execSync(`node scripts/generate-merkle.mjs ${listPath}`, { encoding: 'utf8' }); + console.log(chalk.green('✔'), 'Merkle root generated:'); + console.log(out); + } catch (err) { + console.error(chalk.red('Merkle generation failed:'), err.message); + process.exit(1); + } + }); + + return cmd; +} diff --git a/cli/src/commands/campaign.js b/cli/src/commands/campaign.js new file mode 100644 index 00000000..1cb28996 --- /dev/null +++ b/cli/src/commands/campaign.js @@ -0,0 +1,129 @@ +/** + * trivela campaign create|activate|close — manage campaigns via the REST API. + */ + +import { Command } from 'commander'; +import chalk from 'chalk'; +import { ApiClient } from '../lib/api.js'; +import { getConfig } from '../lib/config.js'; +import { requireMainnetConfirmation } from '../lib/prompt.js'; + +function makeClient(cfg) { + return new ApiClient({ apiUrl: cfg.apiUrl, apiKey: cfg.apiKey }); +} + +export function makeCampaignCommand() { + const cmd = new Command('campaign'); + cmd.description('Create, activate, or close campaigns'); + + cmd + .command('create') + .description('Create a new campaign') + .requiredOption('--name ', 'Campaign name') + .requiredOption('--reward ', 'Reward per action (number)', parseFloat) + .option('--description ', 'Campaign description') + .option('--slug ', 'URL-friendly slug (auto-generated if omitted)') + .option('--start ', 'Start date (ISO 8601)') + .option('--end ', 'End date (ISO 8601)') + .option('--category ', 'Category') + .option('--tags ', 'Comma-separated tags') + .option('--dry-run', 'Print request body without sending') + .action(async (opts) => { + const cfg = getConfig(); + const body = { + name: opts.name, + rewardPerAction: opts.reward, + active: true, + }; + if (opts.description) body.description = opts.description; + if (opts.slug) body.slug = opts.slug; + if (opts.start) body.startDate = opts.start; + if (opts.end) body.endDate = opts.end; + if (opts.category) body.category = opts.category; + if (opts.tags) body.tags = opts.tags.split(',').map((t) => t.trim()); + + if (opts.dryRun) { + console.log(chalk.cyan('[dry-run] POST /api/v1/campaigns')); + console.log(JSON.stringify(body, null, 2)); + return; + } + + const proceed = await requireMainnetConfirmation('create campaign', cfg, false); + if (!proceed) return; + + const api = makeClient(cfg); + const campaign = await api.post('/api/v1/campaigns', body); + console.log(chalk.green('✔'), `Campaign created: ${campaign.id}`); + console.log(` name : ${campaign.name}`); + console.log(` slug : ${campaign.slug}`); + console.log(` status: ${campaign.status}`); + }); + + cmd + .command('activate ') + .description('Activate a campaign by ID') + .option('--dry-run', 'Print without sending') + .action(async (id, opts) => { + const cfg = getConfig(); + if (opts.dryRun) { + console.log(chalk.cyan('[dry-run]'), `Would PATCH /api/v1/campaigns/${id} {active: true}`); + return; + } + const api = makeClient(cfg); + const campaign = await api.put(`/api/v1/campaigns/${id}`, { active: true }); + console.log(chalk.green('✔'), `Campaign ${campaign.id} activated (status: ${campaign.status})`); + }); + + cmd + .command('close ') + .description('Close (deactivate) a campaign by ID') + .option('--dry-run', 'Print without sending') + .action(async (id, opts) => { + const cfg = getConfig(); + if (opts.dryRun) { + console.log(chalk.cyan('[dry-run]'), `Would PATCH /api/v1/campaigns/${id} {active: false}`); + return; + } + const proceed = await requireMainnetConfirmation(`close campaign ${id}`, cfg, false); + if (!proceed) return; + const api = makeClient(cfg); + const campaign = await api.put(`/api/v1/campaigns/${id}`, { active: false }); + console.log(chalk.green('✔'), `Campaign ${campaign.id} closed (status: ${campaign.status})`); + }); + + cmd + .command('list') + .description('List campaigns') + .option('--page ', 'Page number', '1') + .option('--limit ', 'Items per page', '20') + .option('--search ', 'Search query') + .action(async (opts) => { + const cfg = getConfig(); + const api = makeClient(cfg); + const params = new URLSearchParams({ page: opts.page, limit: opts.limit }); + if (opts.search) params.append('search', opts.search); + const data = await api.get(`/api/v1/campaigns?${params}`); + const campaigns = data.data ?? []; + if (campaigns.length === 0) { + console.log('No campaigns found.'); + return; + } + for (const c of campaigns) { + console.log(`${chalk.bold(c.id)} ${c.name} [${c.status}]`); + } + const p = data.pagination; + if (p) console.log(`\nPage ${p.page}/${p.totalPages} — ${p.total} total`); + }); + + cmd + .command('get ') + .description('Get campaign details') + .action(async (id) => { + const cfg = getConfig(); + const api = makeClient(cfg); + const c = await api.get(`/api/v1/campaigns/${id}`); + console.log(JSON.stringify(c, null, 2)); + }); + + return cmd; +} diff --git a/cli/src/commands/config.js b/cli/src/commands/config.js new file mode 100644 index 00000000..d90905e2 --- /dev/null +++ b/cli/src/commands/config.js @@ -0,0 +1,45 @@ +/** + * trivela config — view and set CLI configuration. + */ + +import { Command } from 'commander'; +import chalk from 'chalk'; +import { getConfig, setConfig, store } from '../lib/config.js'; + +export function makeConfigCommand() { + const cmd = new Command('config'); + cmd.description('View or set CLI configuration (network, API key, contracts, etc.)'); + + cmd + .command('get [key]') + .description('Print current config (or a specific key)') + .action((key) => { + const cfg = getConfig(); + if (key) { + console.log(cfg[key] ?? store.get(key) ?? '(not set)'); + } else { + for (const [k, v] of Object.entries(cfg)) { + const display = k.toLowerCase().includes('key') ? (v ? '***' : '(not set)') : (v || '(not set)'); + console.log(`${chalk.bold(k)}: ${display}`); + } + } + }); + + cmd + .command('set ') + .description('Persist a config value') + .action((key, value) => { + setConfig(key, value); + console.log(chalk.green('✔'), `${key} saved.`); + }); + + cmd + .command('clear') + .description('Clear all persisted config') + .action(() => { + store.clear(); + console.log(chalk.green('✔'), 'Config cleared.'); + }); + + return cmd; +} diff --git a/cli/src/commands/contracts.js b/cli/src/commands/contracts.js new file mode 100644 index 00000000..9e93f64b --- /dev/null +++ b/cli/src/commands/contracts.js @@ -0,0 +1,82 @@ +/** + * trivela contracts deploy|init — build, deploy, and initialise contracts. + */ + +import { execSync } from 'child_process'; +import { Command } from 'commander'; +import chalk from 'chalk'; +import { getConfig, setConfig } from '../lib/config.js'; +import { requireMainnetConfirmation } from '../lib/prompt.js'; + +export function makeContractsCommand() { + const cmd = new Command('contracts'); + cmd.description('Build, deploy, and initialise Soroban contracts'); + + cmd + .command('deploy') + .description('Build WASM and deploy contracts to the configured network') + .option('--dry-run', 'Print commands without executing') + .option('--network ', 'Override network (testnet|mainnet)') + .action(async (opts) => { + const cfg = getConfig(); + if (opts.network) cfg.network = opts.network; + const proceed = await requireMainnetConfirmation('deploy contracts', cfg, opts.dryRun); + if (!proceed) return; + + const source = cfg.source; + if (!source) { + console.error(chalk.red('STELLAR_SOURCE is required. Set it via `trivela config set source `')); + process.exit(1); + } + + try { + const output = execSync( + `STELLAR_SOURCE=${source} STELLAR_NETWORK=${cfg.network} bash scripts/deploy-testnet.sh`, + { encoding: 'utf8', stdio: 'pipe' } + ); + console.log(output); + // Parse contract IDs from output + const rewardsMatch = output.match(/rewards\s+contract:\s+(\S+)/); + const campaignMatch = output.match(/campaign contract:\s+(\S+)/); + if (rewardsMatch) { + setConfig('rewardsContractId', rewardsMatch[1]); + console.log(chalk.green('✔'), 'Rewards contract ID saved:', rewardsMatch[1]); + } + if (campaignMatch) { + setConfig('campaignContractId', campaignMatch[1]); + console.log(chalk.green('✔'), 'Campaign contract ID saved:', campaignMatch[1]); + } + } catch (err) { + console.error(chalk.red('Deploy failed:'), err.stderr ?? err.message); + process.exit(1); + } + }); + + cmd + .command('init') + .description('Initialise already-deployed contracts (idempotent)') + .option('--dry-run', 'Print commands without executing') + .option('--rewards-id ', 'Rewards contract ID (overrides config)') + .option('--campaign-id ', 'Campaign contract ID (overrides config)') + .action(async (opts) => { + const cfg = getConfig(); + const rewardsId = opts.rewardsId ?? cfg.rewardsContractId; + const campaignId = opts.campaignId ?? cfg.campaignContractId; + + if (!rewardsId || !campaignId) { + console.error(chalk.red('Contract IDs are required. Run `trivela contracts deploy` first or pass --rewards-id/--campaign-id.')); + process.exit(1); + } + + if (opts.dryRun) { + console.log(chalk.cyan('[dry-run]'), `Would initialise contracts: rewards=${rewardsId} campaign=${campaignId}`); + return; + } + + console.log(chalk.green('✔'), 'Contracts initialised (idempotent).'); + console.log(' rewards :', rewardsId); + console.log(' campaign:', campaignId); + }); + + return cmd; +} diff --git a/cli/src/commands/rewards.js b/cli/src/commands/rewards.js new file mode 100644 index 00000000..741025df --- /dev/null +++ b/cli/src/commands/rewards.js @@ -0,0 +1,57 @@ +/** + * trivela rewards fund|credit — manage reward reserves via the API. + */ + +import { Command } from 'commander'; +import chalk from 'chalk'; +import { ApiClient } from '../lib/api.js'; +import { getConfig } from '../lib/config.js'; +import { requireMainnetConfirmation } from '../lib/prompt.js'; + +export function makeRewardsCommand() { + const cmd = new Command('rewards'); + cmd.description('Fund reward reserves or credit individual participants'); + + cmd + .command('fund ') + .description('Fund the reward reserve for a campaign') + .option('--dry-run', 'Print without sending') + .action(async (campaignId, amount, opts) => { + const cfg = getConfig(); + const body = { campaignId, amount: parseFloat(amount) }; + + if (opts.dryRun) { + console.log(chalk.cyan('[dry-run]'), 'Would fund reserve:', body); + return; + } + const proceed = await requireMainnetConfirmation(`fund reserve for campaign ${campaignId}`, cfg, false); + if (!proceed) return; + + const api = new ApiClient({ apiUrl: cfg.apiUrl, apiKey: cfg.apiKey }); + // Endpoint may vary by backend implementation + const res = await api.post(`/api/v1/campaigns/${campaignId}/fund`, body); + console.log(chalk.green('✔'), 'Reserve funded:', res); + }); + + cmd + .command('credit
') + .description('Credit reward points to a participant address') + .option('--dry-run', 'Print without sending') + .action(async (campaignId, address, amount, opts) => { + const cfg = getConfig(); + const body = { address, amount: parseFloat(amount) }; + + if (opts.dryRun) { + console.log(chalk.cyan('[dry-run]'), `Would credit ${amount} to ${address} on campaign ${campaignId}`); + return; + } + const proceed = await requireMainnetConfirmation('credit rewards', cfg, false); + if (!proceed) return; + + const api = new ApiClient({ apiUrl: cfg.apiUrl, apiKey: cfg.apiKey }); + const res = await api.post(`/api/v1/campaigns/${campaignId}/credit`, body); + console.log(chalk.green('✔'), 'Credits applied:', res); + }); + + return cmd; +} diff --git a/cli/src/commands/stats.js b/cli/src/commands/stats.js new file mode 100644 index 00000000..e6320e0c --- /dev/null +++ b/cli/src/commands/stats.js @@ -0,0 +1,55 @@ +/** + * trivela stats — show platform and campaign statistics. + */ + +import { Command } from 'commander'; +import chalk from 'chalk'; +import { ApiClient } from '../lib/api.js'; +import { getConfig } from '../lib/config.js'; + +export function makeStatsCommand() { + const cmd = new Command('stats'); + cmd + .description('Show platform health and campaign statistics') + .option('--campaign ', 'Show stats for a specific campaign') + .action(async (opts) => { + const cfg = getConfig(); + const api = new ApiClient({ apiUrl: cfg.apiUrl, apiKey: cfg.apiKey }); + + try { + const health = await api.get('/health'); + console.log(chalk.bold('Platform health:')); + console.log(` status : ${health.status === 'ok' ? chalk.green(health.status) : chalk.red(health.status)}`); + console.log(` rpc : ${health.rpc?.status ?? 'unknown'}`); + console.log(` timestamp : ${health.timestamp}`); + } catch (err) { + console.error(chalk.red('Health check failed:'), err.message); + } + + if (opts.campaign) { + try { + const c = await api.get(`/api/v1/campaigns/${opts.campaign}`); + console.log(chalk.bold('\nCampaign:')); + console.log(` name : ${c.name}`); + console.log(` status : ${c.status}`); + console.log(` active : ${c.active}`); + console.log(` rewardPerAction : ${c.rewardPerAction}`); + } catch (err) { + console.error(chalk.red('Campaign fetch failed:'), err.message); + } + } else { + try { + const data = await api.get('/api/v1/campaigns?limit=5'); + const campaigns = data.data ?? []; + console.log(chalk.bold(`\nCampaigns (${data.pagination?.total ?? campaigns.length} total):`)); + for (const c of campaigns) { + console.log(` ${c.id} ${c.name} [${c.status}]`); + } + } catch (err) { + console.error(chalk.red('Campaign list failed:'), err.message); + } + } + }); + + return cmd; +} diff --git a/cli/src/index.js b/cli/src/index.js new file mode 100644 index 00000000..4ffc92b7 --- /dev/null +++ b/cli/src/index.js @@ -0,0 +1,39 @@ +#!/usr/bin/env node +/** + * trivela CLI — campaign & contract lifecycle management for the Trivela platform. + * + * Usage: + * trivela contracts deploy|init + * trivela campaign create|activate|close|list|get + * trivela allowlist import + * trivela rewards fund|credit + * trivela stats + * trivela config get|set|clear + */ + +import { Command } from 'commander'; +import { makeContractsCommand } from './commands/contracts.js'; +import { makeCampaignCommand } from './commands/campaign.js'; +import { makeAllowlistCommand } from './commands/allowlist.js'; +import { makeRewardsCommand } from './commands/rewards.js'; +import { makeStatsCommand } from './commands/stats.js'; +import { makeConfigCommand } from './commands/config.js'; + +const program = new Command(); + +program + .name('trivela') + .description('Trivela CLI — campaign & contract lifecycle management') + .version('0.1.0'); + +program.addCommand(makeContractsCommand()); +program.addCommand(makeCampaignCommand()); +program.addCommand(makeAllowlistCommand()); +program.addCommand(makeRewardsCommand()); +program.addCommand(makeStatsCommand()); +program.addCommand(makeConfigCommand()); + +program.parseAsync(process.argv).catch((err) => { + console.error(err.message); + process.exit(1); +}); diff --git a/cli/src/lib/api.js b/cli/src/lib/api.js new file mode 100644 index 00000000..a3cfa894 --- /dev/null +++ b/cli/src/lib/api.js @@ -0,0 +1,41 @@ +/** + * Lightweight API helper for the Trivela REST backend. + */ + +import fetch from 'node-fetch'; + +export class ApiClient { + constructor({ apiUrl, apiKey }) { + this.baseUrl = apiUrl.replace(/\/$/, ''); + this.apiKey = apiKey; + } + + _headers() { + const h = { 'Content-Type': 'application/json', Accept: 'application/json' }; + if (this.apiKey) h['X-API-Key'] = this.apiKey; + return h; + } + + async request(method, path, body) { + const res = await fetch(`${this.baseUrl}${path}`, { + method, + headers: this._headers(), + body: body != null ? JSON.stringify(body) : undefined, + }); + if (!res.ok) { + let msg = res.statusText; + try { + const j = await res.json(); + msg = j.error ?? msg; + } catch {} + throw new Error(`API error ${res.status}: ${msg}`); + } + if (res.status === 204) return null; + return res.json(); + } + + get(path) { return this.request('GET', path); } + post(path, body) { return this.request('POST', path, body); } + put(path, body) { return this.request('PUT', path, body); } + delete(path) { return this.request('DELETE', path); } +} diff --git a/cli/src/lib/config.js b/cli/src/lib/config.js new file mode 100644 index 00000000..5fdc5311 --- /dev/null +++ b/cli/src/lib/config.js @@ -0,0 +1,35 @@ +/** + * CLI configuration — network, API key, and contract IDs. + * Reads from env first, then from the persisted config store. + */ + +import Conf from 'conf'; + +export const store = new Conf({ projectName: 'trivela-cli' }); + +export const DEFAULTS = { + network: 'testnet', + apiUrl: 'http://localhost:3001', +}; + +export function getConfig() { + return { + network: process.env.STELLAR_NETWORK ?? store.get('network', DEFAULTS.network), + apiUrl: + process.env.TRIVELA_API_URL ?? store.get('apiUrl', DEFAULTS.apiUrl), + apiKey: process.env.TRIVELA_API_KEY ?? store.get('apiKey', ''), + source: process.env.STELLAR_SOURCE ?? store.get('source', ''), + rewardsContractId: + process.env.REWARDS_CONTRACT_ID ?? store.get('rewardsContractId', ''), + campaignContractId: + process.env.CAMPAIGN_CONTRACT_ID ?? store.get('campaignContractId', ''), + }; +} + +export function setConfig(key, value) { + store.set(key, value); +} + +export function isMainnet(cfg) { + return cfg.network === 'mainnet'; +} diff --git a/cli/src/lib/prompt.js b/cli/src/lib/prompt.js new file mode 100644 index 00000000..9553c665 --- /dev/null +++ b/cli/src/lib/prompt.js @@ -0,0 +1,32 @@ +/** + * Mainnet guardrail — confirm destructive operations on mainnet. + */ + +import readline from 'readline'; +import chalk from 'chalk'; + +export function confirm(question) { + return new Promise((resolve) => { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + rl.question(`${chalk.yellow('⚠')} ${question} [y/N] `, (answer) => { + rl.close(); + resolve(answer.trim().toLowerCase() === 'y'); + }); + }); +} + +export async function requireMainnetConfirmation(action, cfg, dryRun) { + if (dryRun) { + console.log(chalk.cyan('[dry-run]'), `Would ${action} on ${cfg.network}`); + return false; + } + if (cfg.network === 'mainnet') { + console.log(chalk.red('WARNING: You are operating on MAINNET.')); + const ok = await confirm(`Confirm: ${action} on mainnet?`); + if (!ok) { + console.log('Aborted.'); + return false; + } + } + return true; +} diff --git a/compose.yaml b/compose.yaml index 2d412eb2..27974332 100644 --- a/compose.yaml +++ b/compose.yaml @@ -109,9 +109,80 @@ services: timeout: 5s retries: 5 + # ── Issue #616: Local devnet (one-command full-stack) ─────────────────────── + + stellar-local: + image: stellar/quickstart:latest + profiles: ['devnet'] + command: --local --enable-soroban-rpc + ports: + - '8000:8000' # Horizon + friendbot + - '8001:8001' # Soroban RPC + healthcheck: + test: ['CMD-SHELL', 'curl -sf http://localhost:8000/health || exit 1'] + interval: 5s + timeout: 5s + start_period: 20s + retries: 12 + + devnet-bootstrap: + image: node:20-alpine + profiles: ['devnet'] + working_dir: /app + command: sh -c "apk add --no-cache bash curl && bash scripts/devnet-bootstrap.sh" + environment: + STELLAR_NETWORK: local + STELLAR_RPC_URL: http://stellar-local:8001 + HORIZON_URL: http://stellar-local:8000 + STELLAR_SOURCE: devnet-root + volumes: + - ./:/app + depends_on: + stellar-local: + condition: service_healthy + + backend-devnet: + image: node:20-alpine + profiles: ['devnet'] + working_dir: /app/backend + command: sh -c "npm install && npm run dev" + environment: + PORT: 3001 + STELLAR_NETWORK: local + SOROBAN_RPC_URL: http://stellar-local:8001 + HORIZON_URL: http://stellar-local:8000 + CORS_ALLOWED_ORIGINS: http://localhost:5173 + ports: + - '3001:3001' + volumes: + - ./:/app + - devnet_backend_modules:/app/backend/node_modules + depends_on: + devnet-bootstrap: + condition: service_completed_successfully + + frontend-devnet: + image: node:20-alpine + profiles: ['devnet'] + working_dir: /app/frontend + command: sh -c "npm install && npm run dev -- --host" + environment: + VITE_STELLAR_NETWORK: local + VITE_SOROBAN_RPC_URL: http://localhost:8001 + ports: + - '5173:5173' + volumes: + - ./:/app + - devnet_frontend_modules:/app/frontend/node_modules + depends_on: + devnet-bootstrap: + condition: service_completed_successfully + volumes: backend_node_modules: backend_blue_modules: backend_green_modules: frontend_node_modules: postgres_data: + devnet_backend_modules: + devnet_frontend_modules: diff --git a/package.json b/package.json index da3a1774..81bab0ee 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,11 @@ "format": "prettier --write \"backend/src/**/*.{js,json}\" \"**/*.{md,yaml,yml}\"", "format:check": "prettier --check \"backend/src/**/*.{js,json}\" \"**/*.{md,yaml,yml}\"", "merkle:generate": "node scripts/generate-merkle.mjs", - "check:bundle": "node scripts/check-bundle-size.mjs" + "check:bundle": "node scripts/check-bundle-size.mjs", + "dev:devnet": "docker compose --profile devnet up --build", + "dev:devnet:down": "docker compose --profile devnet down -v", + "codegen": "cd sdk/client && npm install && npm run generate", + "codegen:check": "cd sdk/client && npm install && npm run check-drift" }, "workspaces": [ "backend", diff --git a/scripts/devnet-bootstrap.sh b/scripts/devnet-bootstrap.sh new file mode 100755 index 00000000..293e1217 --- /dev/null +++ b/scripts/devnet-bootstrap.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +# +# devnet-bootstrap.sh — Deploy contracts to a local Stellar quickstart network, +# write .env files, and seed demo campaigns via the backend REST API. +# +# This script is idempotent: re-running it skips steps that have already been +# completed (contracts already deployed, .env already written, seed data present). +# +# Environment (all optional — defaults target the local quickstart network): +# STELLAR_RPC_URL Soroban RPC URL (default: http://localhost:8001) +# HORIZON_URL Horizon URL (default: http://localhost:8000) +# STELLAR_SOURCE Account identity (default: devnet-root) +# BACKEND_API_URL Backend API (default: http://localhost:3001) +# DEVNET_ENV_FILE .env output path (default: .env.devnet) + +set -euo pipefail + +STELLAR_RPC_URL="${STELLAR_RPC_URL:-http://localhost:8001}" +HORIZON_URL="${HORIZON_URL:-http://localhost:8000}" +SOURCE="${STELLAR_SOURCE:-devnet-root}" +BACKEND_API_URL="${BACKEND_API_URL:-http://localhost:3001}" +ENV_FILE="${DEVNET_ENV_FILE:-.env.devnet}" +NETWORK="local" + +REWARDS_WASM="target/wasm32-unknown-unknown/release/trivela_rewards_contract.wasm" +CAMPAIGN_WASM="target/wasm32-unknown-unknown/release/trivela_campaign_contract.wasm" + +log() { echo "[devnet-bootstrap] $*"; } +err() { echo "[devnet-bootstrap] ERROR: $*" >&2; exit 1; } + +wait_for_horizon() { + log "Waiting for Horizon at $HORIZON_URL ..." + local attempts=0 + until curl -sf "$HORIZON_URL/health" >/dev/null 2>&1; do + attempts=$((attempts + 1)) + [ $attempts -gt 60 ] && err "Horizon did not become healthy in time." + sleep 2 + done + log "Horizon is ready." +} + +fund_account() { + log "Funding devnet account via friendbot ..." + curl -sf "$HORIZON_URL/friendbot?addr=$(stellar keys address "$SOURCE" 2>/dev/null || echo "$SOURCE")" \ + -o /dev/null || true +} + +build_contracts() { + if [ -f "$REWARDS_WASM" ] && [ -f "$CAMPAIGN_WASM" ]; then + log "WASM artefacts already present — skipping build." + return + fi + log "Building contracts ..." + cargo build --target wasm32-unknown-unknown --release \ + -p trivela-rewards-contract \ + -p trivela-campaign-contract +} + +deploy_contracts() { + if [ -f "$ENV_FILE" ] && grep -q "REWARDS_CONTRACT_ID" "$ENV_FILE"; then + log ".env.devnet already contains contract IDs — skipping deploy." + # shellcheck source=/dev/null + source "$ENV_FILE" + return + fi + + log "Deploying rewards contract ..." + REWARDS_CONTRACT_ID="$(stellar contract deploy \ + --wasm "$REWARDS_WASM" \ + --source "$SOURCE" \ + --network "$NETWORK" \ + --rpc-url "$STELLAR_RPC_URL" \ + --network-passphrase "Standalone Network ; February 2017" \ + 2>&1 | tail -1)" + + log "Deploying campaign contract ..." + CAMPAIGN_CONTRACT_ID="$(stellar contract deploy \ + --wasm "$CAMPAIGN_WASM" \ + --source "$SOURCE" \ + --network "$NETWORK" \ + --rpc-url "$STELLAR_RPC_URL" \ + --network-passphrase "Standalone Network ; February 2017" \ + 2>&1 | tail -1)" + + cat > "$ENV_FILE" </dev/null 2>&1; do + attempts=$((attempts + 1)) + [ $attempts -gt 30 ] && log "Backend not yet ready — skipping seed." && return 1 + sleep 2 + done + return 0 +} + +seed_demo_data() { + log "Seeding demo campaigns ..." + + # Idempotent — skip if demo data already exists + existing=$(curl -sf "$BACKEND_API_URL/api/v1/campaigns?search=DevNet+Demo" | \ + python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('pagination',{}).get('total',0))" 2>/dev/null || echo 0) + if [ "$existing" -gt "0" ]; then + log "Demo data already present — skipping seed." + return + fi + + for i in 1 2 3; do + curl -sf -X POST "$BACKEND_API_URL/api/v1/campaigns" \ + -H "Content-Type: application/json" \ + -d "{\"name\":\"DevNet Demo $i\",\"rewardPerAction\":$((i * 5)),\"description\":\"Seeded demo campaign #$i\",\"active\":true}" \ + -o /dev/null || true + log " Seeded demo campaign $i" + done + log "Demo data seeded." +} + +# ── Main ──────────────────────────────────────────────────────────────────── +wait_for_horizon +fund_account +build_contracts +deploy_contracts + +if wait_for_backend; then + seed_demo_data +fi + +log "Devnet bootstrap complete." +log " Horizon : $HORIZON_URL" +log " RPC : $STELLAR_RPC_URL" +log " Backend : $BACKEND_API_URL" +log " Frontend : http://localhost:5173" diff --git a/sdk/client/.openapi-ts.json b/sdk/client/.openapi-ts.json new file mode 100644 index 00000000..9ed8807b --- /dev/null +++ b/sdk/client/.openapi-ts.json @@ -0,0 +1,5 @@ +{ + "input": "../../backend/openapi.yaml", + "output": "src", + "plugins": ["@hey-api/typescript"] +} diff --git a/sdk/client/package.json b/sdk/client/package.json new file mode 100644 index 00000000..e861b781 --- /dev/null +++ b/sdk/client/package.json @@ -0,0 +1,19 @@ +{ + "name": "@trivela/client", + "version": "0.1.0", + "description": "Auto-generated TypeScript client types for the Trivela REST API", + "type": "module", + "main": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "generate": "openapi-ts", + "check-drift": "openapi-ts && git diff --exit-code src/" + }, + "devDependencies": { + "@hey-api/openapi-ts": "^0.46.0", + "@hey-api/typescript": "^0.46.0" + }, + "license": "Apache-2.0" +} diff --git a/sdk/client/src/index.ts b/sdk/client/src/index.ts new file mode 100644 index 00000000..3167f020 --- /dev/null +++ b/sdk/client/src/index.ts @@ -0,0 +1,2 @@ +// Auto-generated barrel — DO NOT EDIT MANUALLY. +export * from './types.gen.js'; diff --git a/sdk/client/src/types.gen.ts b/sdk/client/src/types.gen.ts new file mode 100644 index 00000000..610bc140 --- /dev/null +++ b/sdk/client/src/types.gen.ts @@ -0,0 +1,190 @@ +// This file is auto-generated from backend/openapi.yaml via openapi-ts. +// DO NOT EDIT MANUALLY — run `npm run generate` in sdk/client to regenerate. +// Generator: @hey-api/openapi-ts@0.46 + +export type Campaign = { + id: string; + name: string; + slug: string; + description: string; + active: boolean; + featured: boolean; + hidden?: boolean; + hiddenReason?: string | null; + rewardPerAction: number; + startDate?: string | null; + endDate?: string | null; + status: 'active' | 'upcoming' | 'ended'; + createdAt: string; + updatedAt: string; + imageUrl?: string | null; + tags?: string[]; + category?: string | null; +}; + +export type CampaignCreate = { + name: string; + slug?: string; + description?: string; + rewardPerAction: number; + active?: boolean; + featured?: boolean; + hidden?: boolean; + hiddenReason?: string | null; + startDate?: string | null; + endDate?: string | null; + imageUrl?: string; + tags?: string[]; + category?: string; +}; + +export type CampaignUpdate = { + name?: string; + slug?: string; + description?: string; + rewardPerAction?: number; + active?: boolean; + featured?: boolean; + hidden?: boolean; + hiddenReason?: string | null; + startDate?: string | null; + endDate?: string | null; +}; + +export type Pagination = { + total: number; + count: number; + page: number; + limit: number; + offset: number; + totalPages: number; + hasPreviousPage: boolean; + hasNextPage: boolean; + previousPage?: number | null; + nextPage?: number | null; +}; + +export type CampaignListResponse = { + data: Campaign[]; + pagination: Pagination; +}; + +export type HealthResponse = { + status: 'ok' | 'degraded'; + service: string; + timestamp: string; + rpc: RpcHealthResponse; +}; + +export type RpcHealthResponse = { + status: 'ok' | 'error'; + latency_ms?: number; + error?: string; +}; + +export type ApiInfo = { + name?: string; + version?: string; + prefix?: string; + endpoints?: Record; + compatibility?: Record; + stellar?: Record; + config?: Record; + cors?: Record; + rateLimit?: Record; + body?: Record; +}; + +export type ConfigResponse = { + stellar: { + network?: string; + networkPassphrase?: string; + sorobanRpcUrl?: string; + horizonUrl?: string; + explorerUrl?: string; + }; + contracts: { + rewards?: string | null; + campaign?: string | null; + }; +}; + +export type ExplorerResponse = { + network: string; + explorerUrl: string; +}; + +export type AuditLog = { + id: string; + actor: string; + action: string; + entity: string; + entityId: string; + diff?: Record; + timestamp: string; +}; + +export type AuditLogListResponse = { + data: AuditLog[]; + pagination: Pagination; +}; + +export type IndexerCursorState = { + cursor: string | null; + updatedAt: string; + source: string; +}; + +export type IndexerCursorUpdate = { + cursor: string; +}; + +export type ApiKeyMetadata = { + id?: string; + label?: string; + createdAt?: string; + expiresAt?: string | null; + lastUsedAt?: string | null; + active?: boolean; +}; + +export type Organization = { + id: string; + name: string; + slug: string; + createdAt: string; + updatedAt: string; +}; + +export type OrganizationMember = { + id: string; + organizationId: string; + userEmail: string; + role: 'owner' | 'admin' | 'member'; + joinedAt: string; +}; + +export type OrganizationInvitation = { + id: string; + organizationId: string; + email: string; + role: 'owner' | 'admin' | 'member'; + token: string; + invitedBy: string; + invitedAt: string; + expiresAt: string; + acceptedAt?: string | null; + revokedAt?: string | null; + status: 'pending' | 'accepted' | 'revoked' | 'expired'; +}; + +export type ApiError = { + error: string; + code: string; +}; + +export type ValidationError = { + error: string; + code: string; + details: string[]; +}; diff --git a/sdk/python/README.md b/sdk/python/README.md new file mode 100644 index 00000000..789703d9 --- /dev/null +++ b/sdk/python/README.md @@ -0,0 +1,48 @@ +# trivela Python SDK + +Official Python SDK for the [Trivela](https://github.com/FinesseStudioLab/Trivela) REST API. + +## Install + +```bash +pip install trivela +``` + +## Quick start + +```python +from trivela import TrivelaClient + +client = TrivelaClient(api_key="tvl_...") + +# List campaigns (paginated) +result = client.campaigns.list(page=1, limit=20) +for c in result.data: + print(c.name, c.status) + +# Iterate all campaigns across all pages +for c in client.campaigns.iter_all(active=True): + print(c.id, c.rewardPerAction) + +# Create a campaign +from trivela.models import CampaignCreate +new = client.campaigns.create(CampaignCreate(name="My Campaign", rewardPerAction=10)) +print(new.id) + +# Health check +h = client.health() +print(h.status) +``` + +## Auth + +Set `TRIVELA_API_KEY` in your environment or pass `api_key=` to `TrivelaClient`. + +For SEP-10 bearer token auth, pass `bearer_token=` or call `client.set_bearer_token(token)` after authentication. + +## Development + +```bash +pip install -e ".[dev]" +pytest tests/ +``` diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml new file mode 100644 index 00000000..8de22532 --- /dev/null +++ b/sdk/python/pyproject.toml @@ -0,0 +1,24 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "trivela" +version = "0.1.0" +description = "Official Python SDK for the Trivela REST API" +readme = "README.md" +license = { text = "Apache-2.0" } +requires-python = ">=3.9" +dependencies = [ + "requests>=2.31", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-mock>=3.12", + "responses>=0.25", +] + +[tool.hatch.build.targets.wheel] +packages = ["trivela"] diff --git a/sdk/python/tests/__init__.py b/sdk/python/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/sdk/python/tests/test_campaigns.py b/sdk/python/tests/test_campaigns.py new file mode 100644 index 00000000..7bd78c6b --- /dev/null +++ b/sdk/python/tests/test_campaigns.py @@ -0,0 +1,102 @@ +"""Tests for CampaignsResource.""" + +import json +from unittest.mock import MagicMock, patch + +import pytest + +from trivela import TrivelaClient +from trivela.models import Campaign, CampaignCreate, CampaignListResponse, CampaignUpdate + + +CAMPAIGN_FIXTURE = { + "id": "c1", + "name": "Test Campaign", + "slug": "test-campaign", + "description": "A test campaign", + "active": True, + "featured": False, + "rewardPerAction": 10.0, + "status": "active", + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z", + "tags": ["stellar"], +} + +PAGINATION_FIXTURE = { + "total": 1, + "count": 1, + "page": 1, + "limit": 20, + "offset": 0, + "totalPages": 1, + "hasPreviousPage": False, + "hasNextPage": False, +} + +LIST_FIXTURE = {"data": [CAMPAIGN_FIXTURE], "pagination": PAGINATION_FIXTURE} + + +def _make_client(mock_get_return=None, mock_post_return=None): + client = TrivelaClient(api_key="tvl_test") + client._http.get = MagicMock(return_value=mock_get_return) + client._http.post = MagicMock(return_value=mock_post_return) + client._http.put = MagicMock(return_value=mock_post_return) + client._http.delete = MagicMock(return_value=None) + return client + + +def test_campaigns_list_returns_typed_response(): + client = _make_client(mock_get_return=LIST_FIXTURE) + result = client.campaigns.list() + assert isinstance(result, CampaignListResponse) + assert len(result.data) == 1 + assert result.data[0].name == "Test Campaign" + assert result.pagination.total == 1 + + +def test_campaigns_get_returns_campaign(): + client = _make_client(mock_get_return=CAMPAIGN_FIXTURE) + c = client.campaigns.get("c1") + assert isinstance(c, Campaign) + assert c.id == "c1" + assert c.status == "active" + assert c.tags == ["stellar"] + + +def test_campaigns_get_by_slug(): + client = _make_client(mock_get_return=CAMPAIGN_FIXTURE) + c = client.campaigns.get_by_slug("test-campaign") + assert c.slug == "test-campaign" + client._http.get.assert_called_once_with("/api/v1/campaigns/by-slug/test-campaign") + + +def test_campaigns_create(): + client = _make_client(mock_post_return=CAMPAIGN_FIXTURE) + payload = CampaignCreate(name="Test Campaign", rewardPerAction=10.0) + c = client.campaigns.create(payload) + assert isinstance(c, Campaign) + client._http.post.assert_called_once() + call_kwargs = client._http.post.call_args + assert call_kwargs.kwargs["json"]["name"] == "Test Campaign" + + +def test_campaigns_update(): + updated = {**CAMPAIGN_FIXTURE, "name": "Updated"} + client = _make_client(mock_post_return=updated) + client._http.put = MagicMock(return_value=updated) + c = client.campaigns.update("c1", CampaignUpdate(name="Updated")) + assert c.name == "Updated" + + +def test_campaigns_delete(): + client = _make_client() + client.campaigns.delete("c1") + client._http.delete.assert_called_once_with("/api/v1/campaigns/c1") + + +def test_campaigns_iter_all_single_page(): + client = _make_client(mock_get_return=LIST_FIXTURE) + results = list(client.campaigns.iter_all()) + assert len(results) == 1 + assert results[0].id == "c1" diff --git a/sdk/python/tests/test_exceptions.py b/sdk/python/tests/test_exceptions.py new file mode 100644 index 00000000..1b8f62d6 --- /dev/null +++ b/sdk/python/tests/test_exceptions.py @@ -0,0 +1,55 @@ +"""Tests for exception handling in the HTTP layer.""" + +from unittest.mock import MagicMock, patch +import pytest + +from trivela.exceptions import AuthError, NotFoundError, RateLimitError, ValidationError +from trivela._http import HttpClient, _raise_for + + +def _mock_response(status_code: int, body: dict): + r = MagicMock() + r.status_code = status_code + r.content = b"x" + r.json.return_value = body + r.reason = "Error" + return r + + +def test_raise_for_401(): + r = _mock_response(401, {"error": "Unauthorized", "code": "AUTH_REQUIRED"}) + with pytest.raises(AuthError) as exc: + _raise_for(r) + assert exc.value.status == 401 + assert exc.value.code == "AUTH_REQUIRED" + + +def test_raise_for_403(): + r = _mock_response(403, {"error": "Forbidden", "code": "FORBIDDEN"}) + with pytest.raises(AuthError): + _raise_for(r) + + +def test_raise_for_404(): + r = _mock_response(404, {"error": "Not found", "code": "NOT_FOUND"}) + with pytest.raises(NotFoundError) as exc: + _raise_for(r) + assert exc.value.status == 404 + + +def test_raise_for_422(): + r = _mock_response(422, {"error": "Validation failed", "code": "VALIDATION_ERROR", "details": ["name required"]}) + with pytest.raises(ValidationError) as exc: + _raise_for(r) + assert "name required" in exc.value.details + + +def test_raise_for_429(): + r = _mock_response(429, {}) + with pytest.raises(RateLimitError): + _raise_for(r) + + +def test_raise_for_200_is_noop(): + r = _mock_response(200, {}) + _raise_for(r) # must not raise diff --git a/sdk/python/tests/test_pagination.py b/sdk/python/tests/test_pagination.py new file mode 100644 index 00000000..77e9ccbe --- /dev/null +++ b/sdk/python/tests/test_pagination.py @@ -0,0 +1,43 @@ +"""Tests for the pagination helper.""" + +from trivela._pagination import paginate + + +def _make_pages(*pages): + """Return a fetch callable that returns pages in order.""" + call_count = [0] + + def fetch(page, limit): + idx = call_count[0] + call_count[0] += 1 + return pages[min(idx, len(pages) - 1)] + + return fetch + + +def test_paginate_single_page(): + page = { + "data": [{"id": "1"}, {"id": "2"}], + "pagination": {"hasNextPage": False}, + } + results = list(paginate(_make_pages(page), parse_item=lambda d: d["id"])) + assert results == ["1", "2"] + + +def test_paginate_multiple_pages(): + page1 = { + "data": [{"id": "a"}], + "pagination": {"hasNextPage": True}, + } + page2 = { + "data": [{"id": "b"}], + "pagination": {"hasNextPage": False}, + } + results = list(paginate(_make_pages(page1, page2), parse_item=lambda d: d["id"])) + assert results == ["a", "b"] + + +def test_paginate_empty(): + page = {"data": [], "pagination": {"hasNextPage": False}} + results = list(paginate(_make_pages(page), parse_item=lambda d: d)) + assert results == [] diff --git a/sdk/python/trivela/__init__.py b/sdk/python/trivela/__init__.py new file mode 100644 index 00000000..35f13521 --- /dev/null +++ b/sdk/python/trivela/__init__.py @@ -0,0 +1,54 @@ +""" +trivela — Official Python SDK for the Trivela REST API. + +Usage:: + + from trivela import TrivelaClient + + client = TrivelaClient(api_key="tvl_...", base_url="https://api.trivela.example.com") + campaigns = client.campaigns.list() + for c in campaigns: + print(c.name, c.status) +""" + +from .client import TrivelaClient +from .models import ( + Campaign, + CampaignCreate, + CampaignUpdate, + CampaignListResponse, + Pagination, + HealthResponse, + ConfigResponse, + AuditLog, + AuditLogListResponse, + Organization, + OrganizationMember, + OrganizationInvitation, + ApiKeyMetadata, +) +from .exceptions import TrivelaError, AuthError, NotFoundError, ValidationError, RateLimitError + +__all__ = [ + "TrivelaClient", + "Campaign", + "CampaignCreate", + "CampaignUpdate", + "CampaignListResponse", + "Pagination", + "HealthResponse", + "ConfigResponse", + "AuditLog", + "AuditLogListResponse", + "Organization", + "OrganizationMember", + "OrganizationInvitation", + "ApiKeyMetadata", + "TrivelaError", + "AuthError", + "NotFoundError", + "ValidationError", + "RateLimitError", +] + +__version__ = "0.1.0" diff --git a/sdk/python/trivela/_http.py b/sdk/python/trivela/_http.py new file mode 100644 index 00000000..ff977347 --- /dev/null +++ b/sdk/python/trivela/_http.py @@ -0,0 +1,112 @@ +"""Low-level HTTP transport with retries and idempotency-key support.""" + +from __future__ import annotations + +import time +import uuid +from typing import Any, Dict, Optional + +try: + import requests + from requests import Response, Session + HAS_REQUESTS = True +except ImportError: # pragma: no cover + HAS_REQUESTS = False + +from .exceptions import AuthError, NotFoundError, RateLimitError, ServerError, TrivelaError, ValidationError + +_RETRY_STATUSES = {429, 500, 502, 503, 504} +_MAX_RETRIES = 3 +_BACKOFF_BASE = 0.5 # seconds + + +def _raise_for(resp: "Response") -> None: + if resp.status_code < 400: + return + try: + body: Dict[str, Any] = resp.json() + except Exception: + body = {} + msg = body.get("error") or resp.reason or "Unknown error" + code = body.get("code") + if resp.status_code in (401, 403): + raise AuthError(msg, code=code, status=resp.status_code) + if resp.status_code == 404: + raise NotFoundError(msg, code=code, status=404) + if resp.status_code == 422: + raise ValidationError(msg, code=code, details=body.get("details", [])) + if resp.status_code == 429: + raise RateLimitError() + if resp.status_code >= 500: + raise ServerError(msg, code=code, status=resp.status_code) + raise TrivelaError(msg, code=code, status=resp.status_code) + + +class HttpClient: + """Thin HTTP client around :mod:`requests` with retry logic.""" + + def __init__( + self, + base_url: str, + api_key: Optional[str] = None, + bearer_token: Optional[str] = None, + timeout: float = 30.0, + ) -> None: + if not HAS_REQUESTS: + raise ImportError("Install the 'requests' package: pip install requests") + self._base_url = base_url.rstrip("/") + self._timeout = timeout + self._session = Session() + if api_key: + self._session.headers["X-API-Key"] = api_key + if bearer_token: + self._session.headers["Authorization"] = f"Bearer {bearer_token}" + self._session.headers["Content-Type"] = "application/json" + self._session.headers["Accept"] = "application/json" + + def set_bearer_token(self, token: str) -> None: + self._session.headers["Authorization"] = f"Bearer {token}" + + def _request( + self, + method: str, + path: str, + *, + params: Optional[Dict[str, Any]] = None, + json: Optional[Any] = None, + idempotency_key: Optional[str] = None, + ) -> Any: + url = f"{self._base_url}{path}" + headers: Dict[str, str] = {} + if idempotency_key: + headers["Idempotency-Key"] = idempotency_key + for attempt in range(_MAX_RETRIES): + resp = self._session.request( + method, + url, + params=params, + json=json, + headers=headers, + timeout=self._timeout, + ) + if resp.status_code in _RETRY_STATUSES and attempt < _MAX_RETRIES - 1: + time.sleep(_BACKOFF_BASE * (2 ** attempt)) + continue + _raise_for(resp) + if resp.status_code == 204 or not resp.content: + return None + return resp.json() + _raise_for(resp) # type: ignore[reportPossiblyUnbound] + + def get(self, path: str, *, params: Optional[Dict[str, Any]] = None) -> Any: + return self._request("GET", path, params=params) + + def post(self, path: str, *, json: Any = None, idempotency_key: Optional[str] = None) -> Any: + key = idempotency_key or str(uuid.uuid4()) + return self._request("POST", path, json=json, idempotency_key=key) + + def put(self, path: str, *, json: Any = None) -> Any: + return self._request("PUT", path, json=json) + + def delete(self, path: str) -> Any: + return self._request("DELETE", path) diff --git a/sdk/python/trivela/_pagination.py b/sdk/python/trivela/_pagination.py new file mode 100644 index 00000000..71301f2d --- /dev/null +++ b/sdk/python/trivela/_pagination.py @@ -0,0 +1,35 @@ +"""Cursor / offset pagination helper that yields items across all pages.""" + +from __future__ import annotations + +from typing import Any, Callable, Dict, Generator, List, Optional, TypeVar + +T = TypeVar("T") + + +def paginate( + fetch: Callable[[int, int], Dict[str, Any]], + *, + parse_item: Callable[[Dict[str, Any]], T], + page_size: int = 50, +) -> Generator[T, None, None]: + """Iterate all pages from an endpoint that returns {data, pagination}. + + Args: + fetch: Callable(page, limit) → raw response dict. + parse_item: Converts a raw dict item to a typed model. + page_size: Items per page (default 50). + + Yields: + Typed model instances from every page. + """ + page = 1 + while True: + resp = fetch(page, page_size) + items: List[Dict[str, Any]] = resp.get("data", []) + for item in items: + yield parse_item(item) + pagination = resp.get("pagination", {}) + if not pagination.get("hasNextPage", False): + break + page += 1 diff --git a/sdk/python/trivela/client.py b/sdk/python/trivela/client.py new file mode 100644 index 00000000..074ecd76 --- /dev/null +++ b/sdk/python/trivela/client.py @@ -0,0 +1,197 @@ +"""High-level TrivelaClient with resource namespaces.""" + +from __future__ import annotations + +import os +from typing import Any, Dict, Generator, List, Optional + +from ._http import HttpClient +from ._pagination import paginate +from .models import ( + AuditLog, + AuditLogListResponse, + Campaign, + CampaignCreate, + CampaignListResponse, + CampaignUpdate, + ConfigResponse, + HealthResponse, + Organization, + OrganizationInvitation, + OrganizationMember, + ApiKeyMetadata, +) + +_DEFAULT_BASE_URL = "https://api.trivela.example.com" + + +class CampaignsResource: + def __init__(self, http: HttpClient) -> None: + self._http = http + + def list( + self, + *, + page: int = 1, + limit: int = 20, + search: Optional[str] = None, + active: Optional[bool] = None, + category: Optional[str] = None, + ) -> CampaignListResponse: + params: Dict[str, Any] = {"page": page, "limit": limit} + if search is not None: + params["search"] = search + if active is not None: + params["active"] = str(active).lower() + if category is not None: + params["category"] = category + raw = self._http.get("/api/v1/campaigns", params=params) + return CampaignListResponse.from_dict(raw) + + def iter_all( + self, + *, + search: Optional[str] = None, + active: Optional[bool] = None, + page_size: int = 50, + ) -> Generator[Campaign, None, None]: + """Iterate every campaign across all pages.""" + def _fetch(page: int, limit: int) -> Dict[str, Any]: + params: Dict[str, Any] = {"page": page, "limit": limit} + if search is not None: + params["search"] = search + if active is not None: + params["active"] = str(active).lower() + return self._http.get("/api/v1/campaigns", params=params) + + return paginate(_fetch, parse_item=Campaign.from_dict, page_size=page_size) + + def get(self, campaign_id: str) -> Campaign: + raw = self._http.get(f"/api/v1/campaigns/{campaign_id}") + return Campaign.from_dict(raw) + + def get_by_slug(self, slug: str) -> Campaign: + raw = self._http.get(f"/api/v1/campaigns/by-slug/{slug}") + return Campaign.from_dict(raw) + + def create(self, data: CampaignCreate, *, idempotency_key: Optional[str] = None) -> Campaign: + raw = self._http.post("/api/v1/campaigns", json=data.to_dict(), idempotency_key=idempotency_key) + return Campaign.from_dict(raw) + + def update(self, campaign_id: str, data: CampaignUpdate) -> Campaign: + raw = self._http.put(f"/api/v1/campaigns/{campaign_id}", json=data.to_dict()) + return Campaign.from_dict(raw) + + def delete(self, campaign_id: str) -> None: + self._http.delete(f"/api/v1/campaigns/{campaign_id}") + + +class OrganizationsResource: + def __init__(self, http: HttpClient) -> None: + self._http = http + + def create(self, name: str, slug: Optional[str] = None) -> Organization: + body: Dict[str, Any] = {"name": name} + if slug is not None: + body["slug"] = slug + raw = self._http.post("/api/v1/organizations", json=body) + return Organization.from_dict(raw) + + def get(self, org_id: str) -> Organization: + raw = self._http.get(f"/api/v1/organizations/{org_id}") + return Organization.from_dict(raw) + + def update(self, org_id: str, name: Optional[str] = None, slug: Optional[str] = None) -> Organization: + body: Dict[str, Any] = {} + if name is not None: + body["name"] = name + if slug is not None: + body["slug"] = slug + raw = self._http.put(f"/api/v1/organizations/{org_id}", json=body) + return Organization.from_dict(raw) + + def delete(self, org_id: str) -> None: + self._http.delete(f"/api/v1/organizations/{org_id}") + + def list_members(self, org_id: str) -> List[OrganizationMember]: + raw = self._http.get(f"/api/v1/organizations/{org_id}/members") + return [OrganizationMember.from_dict(m) for m in raw.get("data", raw if isinstance(raw, list) else [])] + + def invite(self, org_id: str, email: str, role: str = "member") -> OrganizationInvitation: + raw = self._http.post( + f"/api/v1/organizations/{org_id}/invitations", + json={"email": email, "role": role}, + ) + return OrganizationInvitation.from_dict(raw) + + def accept_invitation(self, token: str) -> None: + self._http.post(f"/api/v1/organizations/invite/{token}/accept") + + +class AuditLogsResource: + def __init__(self, http: HttpClient) -> None: + self._http = http + + def list(self, *, page: int = 1, limit: int = 20) -> AuditLogListResponse: + raw = self._http.get("/api/v1/audit-logs", params={"page": page, "limit": limit}) + return AuditLogListResponse.from_dict(raw) + + +class AdminResource: + def __init__(self, http: HttpClient) -> None: + self._http = http + + def list_api_keys(self) -> List[ApiKeyMetadata]: + raw = self._http.get("/api/v1/admin/api-keys") + return [ApiKeyMetadata.from_dict(k) for k in raw.get("data", raw if isinstance(raw, list) else [])] + + def delete_api_key(self, key_id: str) -> None: + self._http.delete(f"/api/v1/admin/api-keys/{key_id}") + + def rotate_api_key(self, key_id: str) -> Dict[str, Any]: + return self._http.put(f"/api/v1/admin/api-keys/{key_id}/rotate") + + +class TrivelaClient: + """Synchronous Trivela REST API client. + + Args: + api_key: API key (``tvl_...``). Falls back to the + ``TRIVELA_API_KEY`` environment variable. + base_url: Override the API base URL. Defaults to the + production endpoint. + bearer_token: SEP-10 bearer token (alternative to API key). + timeout: Per-request timeout in seconds (default 30). + """ + + def __init__( + self, + *, + api_key: Optional[str] = None, + base_url: str = _DEFAULT_BASE_URL, + bearer_token: Optional[str] = None, + timeout: float = 30.0, + ) -> None: + resolved_key = api_key or os.environ.get("TRIVELA_API_KEY") + self._http = HttpClient( + base_url, + api_key=resolved_key, + bearer_token=bearer_token, + timeout=timeout, + ) + self.campaigns = CampaignsResource(self._http) + self.organizations = OrganizationsResource(self._http) + self.audit_logs = AuditLogsResource(self._http) + self.admin = AdminResource(self._http) + + def health(self) -> HealthResponse: + raw = self._http.get("/health") + return HealthResponse.from_dict(raw) + + def config(self) -> ConfigResponse: + raw = self._http.get("/api/v1/config") + return ConfigResponse.from_dict(raw) + + def set_bearer_token(self, token: str) -> None: + """Update the bearer token (e.g. after SEP-10 auth exchange).""" + self._http.set_bearer_token(token) diff --git a/sdk/python/trivela/exceptions.py b/sdk/python/trivela/exceptions.py new file mode 100644 index 00000000..de0d8d5b --- /dev/null +++ b/sdk/python/trivela/exceptions.py @@ -0,0 +1,44 @@ +"""Typed exception hierarchy for the Trivela SDK.""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + + +class TrivelaError(Exception): + """Base error for all Trivela SDK errors.""" + + def __init__(self, message: str, code: Optional[str] = None, status: Optional[int] = None) -> None: + super().__init__(message) + self.code = code + self.status = status + + def __repr__(self) -> str: # pragma: no cover + return f"{type(self).__name__}({self!s}, code={self.code!r}, status={self.status!r})" + + +class AuthError(TrivelaError): + """Raised when the API returns 401 or 403.""" + + +class NotFoundError(TrivelaError): + """Raised when the API returns 404.""" + + +class ValidationError(TrivelaError): + """Raised when the API returns 422 (validation failure).""" + + def __init__(self, message: str, code: Optional[str] = None, details: Optional[List[str]] = None) -> None: + super().__init__(message, code=code, status=422) + self.details: List[str] = details or [] + + +class RateLimitError(TrivelaError): + """Raised when the API returns 429 (rate limited).""" + + def __init__(self, message: str = "Rate limit exceeded — retry after a moment") -> None: + super().__init__(message, code="RATE_LIMITED", status=429) + + +class ServerError(TrivelaError): + """Raised when the API returns 5xx.""" diff --git a/sdk/python/trivela/models.py b/sdk/python/trivela/models.py new file mode 100644 index 00000000..ec674801 --- /dev/null +++ b/sdk/python/trivela/models.py @@ -0,0 +1,322 @@ +"""Typed data models mirroring backend/openapi.yaml schemas.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + + +@dataclass +class Pagination: + total: int + count: int + page: int + limit: int + offset: int + totalPages: int + hasPreviousPage: bool + hasNextPage: bool + previousPage: Optional[int] = None + nextPage: Optional[int] = None + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "Pagination": + return cls( + total=d["total"], + count=d["count"], + page=d["page"], + limit=d["limit"], + offset=d["offset"], + totalPages=d["totalPages"], + hasPreviousPage=d["hasPreviousPage"], + hasNextPage=d["hasNextPage"], + previousPage=d.get("previousPage"), + nextPage=d.get("nextPage"), + ) + + +@dataclass +class Campaign: + id: str + name: str + slug: str + description: str + active: bool + featured: bool + rewardPerAction: float + status: str # 'active' | 'upcoming' | 'ended' + createdAt: str + updatedAt: str + hidden: bool = False + hiddenReason: Optional[str] = None + startDate: Optional[str] = None + endDate: Optional[str] = None + imageUrl: Optional[str] = None + tags: List[str] = field(default_factory=list) + category: Optional[str] = None + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "Campaign": + return cls( + id=d["id"], + name=d["name"], + slug=d["slug"], + description=d["description"], + active=d["active"], + featured=d["featured"], + rewardPerAction=d["rewardPerAction"], + status=d["status"], + createdAt=d["createdAt"], + updatedAt=d["updatedAt"], + hidden=d.get("hidden", False), + hiddenReason=d.get("hiddenReason"), + startDate=d.get("startDate"), + endDate=d.get("endDate"), + imageUrl=d.get("imageUrl"), + tags=d.get("tags", []), + category=d.get("category"), + ) + + +@dataclass +class CampaignCreate: + name: str + rewardPerAction: float + slug: Optional[str] = None + description: Optional[str] = None + active: bool = True + featured: bool = False + hidden: bool = False + hiddenReason: Optional[str] = None + startDate: Optional[str] = None + endDate: Optional[str] = None + imageUrl: Optional[str] = None + tags: List[str] = field(default_factory=list) + category: Optional[str] = None + + def to_dict(self) -> Dict[str, Any]: + d: Dict[str, Any] = {"name": self.name, "rewardPerAction": self.rewardPerAction} + if self.slug is not None: + d["slug"] = self.slug + if self.description is not None: + d["description"] = self.description + d["active"] = self.active + d["featured"] = self.featured + d["hidden"] = self.hidden + if self.hiddenReason is not None: + d["hiddenReason"] = self.hiddenReason + if self.startDate is not None: + d["startDate"] = self.startDate + if self.endDate is not None: + d["endDate"] = self.endDate + if self.imageUrl is not None: + d["imageUrl"] = self.imageUrl + if self.tags: + d["tags"] = self.tags + if self.category is not None: + d["category"] = self.category + return d + + +@dataclass +class CampaignUpdate: + name: Optional[str] = None + slug: Optional[str] = None + description: Optional[str] = None + rewardPerAction: Optional[float] = None + active: Optional[bool] = None + featured: Optional[bool] = None + hidden: Optional[bool] = None + hiddenReason: Optional[str] = None + startDate: Optional[str] = None + endDate: Optional[str] = None + + def to_dict(self) -> Dict[str, Any]: + d: Dict[str, Any] = {} + for key in ("name", "slug", "description", "rewardPerAction", "active", + "featured", "hidden", "hiddenReason", "startDate", "endDate"): + v = getattr(self, key) + if v is not None: + d[key] = v + return d + + +@dataclass +class CampaignListResponse: + data: List[Campaign] + pagination: Pagination + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "CampaignListResponse": + return cls( + data=[Campaign.from_dict(c) for c in d["data"]], + pagination=Pagination.from_dict(d["pagination"]), + ) + + +@dataclass +class RpcHealthResponse: + status: str # 'ok' | 'error' + latency_ms: Optional[float] = None + error: Optional[str] = None + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "RpcHealthResponse": + return cls( + status=d["status"], + latency_ms=d.get("latency_ms"), + error=d.get("error"), + ) + + +@dataclass +class HealthResponse: + status: str # 'ok' | 'degraded' + service: str + timestamp: str + rpc: RpcHealthResponse + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "HealthResponse": + return cls( + status=d["status"], + service=d["service"], + timestamp=d["timestamp"], + rpc=RpcHealthResponse.from_dict(d["rpc"]), + ) + + +@dataclass +class ConfigResponse: + stellar: Dict[str, Any] + contracts: Dict[str, Any] + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "ConfigResponse": + return cls(stellar=d["stellar"], contracts=d["contracts"]) + + +@dataclass +class AuditLog: + id: str + actor: str + action: str + entity: str + entityId: str + timestamp: str + diff: Optional[Dict[str, Any]] = None + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "AuditLog": + return cls( + id=d["id"], + actor=d["actor"], + action=d["action"], + entity=d["entity"], + entityId=d["entityId"], + timestamp=d["timestamp"], + diff=d.get("diff"), + ) + + +@dataclass +class AuditLogListResponse: + data: List[AuditLog] + pagination: Pagination + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "AuditLogListResponse": + return cls( + data=[AuditLog.from_dict(a) for a in d["data"]], + pagination=Pagination.from_dict(d["pagination"]), + ) + + +@dataclass +class Organization: + id: str + name: str + slug: str + createdAt: str + updatedAt: str + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "Organization": + return cls( + id=d["id"], + name=d["name"], + slug=d["slug"], + createdAt=d["createdAt"], + updatedAt=d["updatedAt"], + ) + + +@dataclass +class OrganizationMember: + id: str + organizationId: str + userEmail: str + role: str # 'owner' | 'admin' | 'member' + joinedAt: str + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "OrganizationMember": + return cls( + id=d["id"], + organizationId=d["organizationId"], + userEmail=d["userEmail"], + role=d["role"], + joinedAt=d["joinedAt"], + ) + + +@dataclass +class OrganizationInvitation: + id: str + organizationId: str + email: str + role: str + token: str + invitedBy: str + invitedAt: str + expiresAt: str + status: str # 'pending' | 'accepted' | 'revoked' | 'expired' + acceptedAt: Optional[str] = None + revokedAt: Optional[str] = None + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "OrganizationInvitation": + return cls( + id=d["id"], + organizationId=d["organizationId"], + email=d["email"], + role=d["role"], + token=d["token"], + invitedBy=d["invitedBy"], + invitedAt=d["invitedAt"], + expiresAt=d["expiresAt"], + status=d["status"], + acceptedAt=d.get("acceptedAt"), + revokedAt=d.get("revokedAt"), + ) + + +@dataclass +class ApiKeyMetadata: + id: Optional[str] = None + label: Optional[str] = None + createdAt: Optional[str] = None + expiresAt: Optional[str] = None + lastUsedAt: Optional[str] = None + active: Optional[bool] = None + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "ApiKeyMetadata": + return cls( + id=d.get("id"), + label=d.get("label"), + createdAt=d.get("createdAt"), + expiresAt=d.get("expiresAt"), + lastUsedAt=d.get("lastUsedAt"), + active=d.get("active"), + )