diff --git a/contracts/rewards/src/lib.rs b/contracts/rewards/src/lib.rs index d3bbee46..e20841ab 100644 --- a/contracts/rewards/src/lib.rs +++ b/contracts/rewards/src/lib.rs @@ -143,6 +143,26 @@ pub enum Error { AirdropInvalidProof = 47, /// The nullifier has already been used to claim from this airdrop. AirdropNullifierUsed = 48, + // ── Issue #900: Minimum claim threshold ────────────────────────────────── + /// Claim amount is below the configured minimum threshold. + BelowMinClaim = 49, + // ── Issue #903: Campaign supply cap ─────────────────────────────────────── + /// Credit would exceed the campaign's configured total supply cap. + CampaignSupplyCapExceeded = 50, + /// Campaign supply cap configuration is invalid. + InvalidSupplyCap = 51, + // ── Issue #898: Multi-level referral tree ──────────────────────────────── + /// Invalid referral depth configuration (must be > 0 and <= MAX_REFERRAL_DEPTH). + InvalidReferralDepth = 52, + /// Referral tier configuration is invalid. + InvalidReferralTierConfig = 53, + // ── Staking/Boost errors ────────────────────────────────────────────────── + /// Invalid boost curve configuration. + InvalidBoostCurve = 54, + /// Lock schedule configuration is invalid. + InvalidLockSchedule = 55, + /// Boost multiplier cannot be zero. + ZeroBoostMultiplier = 56, } /// Vesting schedule record stored per user per vest_id. @@ -378,6 +398,26 @@ const REF_BONUS_EVENT: Symbol = symbol_short!("refbonus"); // configuration and keep `qualifying_amount * rate_bps` comfortably in range. const MAX_REFERRAL_RATE_BPS: u32 = 100_000; +// ── Multi-level referral tree (issue #898) ─────────────────────────────────── +/// Maximum referral tree depth (prevents unbounded loops and gas attacks). +const MAX_REFERRAL_TREE_DEPTH: u32 = 10; +/// Configured referral tree depth: (REF_DEPTH) -> u32 +const REF_DEPTH: Symbol = symbol_short!("refdepth"); +/// Per-level rate configuration: (REF_TIER_RATE, level: u32) -> rate_bps: u32 +const REF_TIER_RATE: Symbol = symbol_short!("reftrate"); +/// Multi-level referral reward event: topics (ref_mlvl, referrer, referee, level), data (bonus, qualifying_amount) +const REF_MULTILEVEL_EVENT: Symbol = symbol_short!("refmlvl"); +/// Referral chain storage (mirrored from campaign contract): (REFERRAL, referee) -> referrer +const REFERRAL: Symbol = symbol_short!("referral"); + +// ── Campaign supply cap (issue #903) ───────────────────────────────────────── +/// Per-campaign total supply cap: (CAMPAIGN_CAP, campaign_id) -> u64 (0 = uncapped) +const CAMPAIGN_CAP: Symbol = symbol_short!("campcap"); +/// Per-campaign issued total: (CAMPAIGN_ISSUED, campaign_id) -> u64 +const CAMPAIGN_ISSUED: Symbol = symbol_short!("campiss"); +/// Campaign supply cap set event: topics (campcap, campaign_id), data (cap: u64) +const CAMPAIGN_CAP_EVENT: Symbol = symbol_short!("campcap"); + // ── Multi-sig constants (issue #733) ───────────────────────────────────────── const MULTISIG_CFG: Symbol = symbol_short!("mscfg"); const MULTISIG_PROP: Symbol = symbol_short!("msprop"); @@ -2379,6 +2419,258 @@ impl RewardsContract { env.storage().instance().get(&(REF_PAID, referee)) } + // ── Multi-level referral tree (issue #898) ─────────────────────────────── + + /// Configure multi-level referral tree parameters (admin only). + /// + /// `depth` defines how many levels up the referral chain receive rewards + /// (must be 1..=MAX_REFERRAL_TREE_DEPTH). `tier_rates` is a vector of + /// rate_bps for each level (level 1 = direct referrer, level 2 = referrer's + /// referrer, etc.). The vector length must equal `depth`. + /// + /// Example: depth=3, tier_rates=[5000, 2500, 1250] means: + /// - Level 1 (direct referrer): 50% of qualifying amount + /// - Level 2: 25% of qualifying amount + /// - Level 3: 12.5% of qualifying amount + pub fn set_multi_level_referral_config( + env: Env, + admin: Address, + depth: u32, + tier_rates: Vec, + ) -> Result<(), Error> { + require_admin(&env, &admin)?; + + if depth == 0 || depth > MAX_REFERRAL_TREE_DEPTH { + return Err(Error::InvalidReferralDepth); + } + if tier_rates.len() != depth { + return Err(Error::InvalidReferralTierConfig); + } + + // Validate all tier rates + for (level, rate_bps) in tier_rates.iter().enumerate() { + if rate_bps == 0 || rate_bps > MAX_REFERRAL_RATE_BPS { + return Err(Error::InvalidReferralTierConfig); + } + // Store per-level rate: level is 1-indexed for clarity (level 1 = direct referrer) + env.storage().instance().set(&(REF_TIER_RATE, (level as u32) + 1), &rate_bps); + } + + env.storage().instance().set(&REF_DEPTH, &depth); + env.events().publish((REF_CONFIG_EVENT,), (depth, tier_rates)); + env.storage() + .instance() + .extend_ttl(TTL_THRESHOLD, TTL_EXTEND_TO); + Ok(()) + } + + /// Get the configured referral tree depth (0 means multi-level not configured). + pub fn referral_tree_depth(env: Env) -> u32 { + env.storage().instance().get(&REF_DEPTH).unwrap_or(0) + } + + /// Get the rate for a specific referral level (1-indexed). + /// Returns 0 if level not configured. + pub fn referral_tier_rate(env: Env, level: u32) -> u32 { + env.storage().instance().get(&(REF_TIER_RATE, level)).unwrap_or(0) + } + + /// Pay multi-level referral bonuses up the referral chain (admin only). + /// + /// Starting from `referee`, walks up the referral chain (via campaign + /// contract's referral graph) and credits bonuses to each ancestor up to + /// the configured depth. Each level receives `qualifying_amount * tier_rate_bps / 10_000`. + /// + /// `campaign_contract`: address of the campaign contract that tracks the referral graph. + /// + /// Anti-abuse: uses campaign's referral graph (which enforces no-cycles, uniqueness). + /// Returns total bonus credited across all levels. + pub fn pay_multi_level_referral_bonus( + env: Env, + admin: Address, + campaign_contract: Address, + referee: Address, + qualifying_amount: u64, + ) -> Result { + require_admin(&env, &admin)?; + ensure_not_paused(&env)?; + + let depth: u32 = env.storage().instance().get(&REF_DEPTH).unwrap_or(0); + if depth == 0 { + return Err(Error::ReferralNotConfigured); + } + + let mut total_bonuses: u64 = 0; + let mut current_referee = referee.clone(); + + for level in 1..=depth { + // Get the rate for this level + let rate_bps: u32 = env + .storage() + .instance() + .get(&(REF_TIER_RATE, level)) + .unwrap_or(0); + if rate_bps == 0 { + break; // No more configured levels + } + + // Query campaign contract for the referrer of current_referee + // This requires the campaign contract to expose a `get_referrer(address) -> Option
` view + // For now, use internal storage as fallback (assumes campaign syncs referral graph here) + let referrer_opt: Option
= env + .storage() + .instance() + .get(&(REFERRAL, current_referee.clone())); + + let referrer = match referrer_opt { + Some(r) => r, + None => break, // No more referrers in chain + }; + + // Calculate bonus for this level + let bonus_u128 = (qualifying_amount as u128) + .checked_mul(rate_bps as u128) + .ok_or(Error::Overflow)? + / BPS_DENOMINATOR; + if bonus_u128 > u64::MAX as u128 { + return Err(Error::Overflow); + } + let bonus = bonus_u128 as u64; + + if bonus > 0 { + // Credit the referrer's balance + let balance_key = (BALANCE, referrer.clone()); + let current_balance: u64 = env.storage().instance().get(&balance_key).unwrap_or(0); + let new_balance = current_balance.checked_add(bonus).ok_or(Error::Overflow)?; + env.storage().instance().set(&balance_key, &new_balance); + + // Update total supply + let supply: u64 = env.storage().instance().get(&TOTAL_SUPPLY).unwrap_or(0); + env.storage().instance().set( + &TOTAL_SUPPLY, + &supply.checked_add(bonus).ok_or(Error::Overflow)?, + ); + + // Emit events + env.events() + .publish((CREDIT_EVENT, referrer.clone()), bonus); + env.events().publish( + (REF_MULTILEVEL_EVENT, referrer.clone(), current_referee.clone(), level), + (bonus, qualifying_amount), + ); + + total_bonuses = total_bonuses.checked_add(bonus).ok_or(Error::Overflow)?; + } + + // Move up the chain + current_referee = referrer; + } + + env.storage() + .instance() + .extend_ttl(TTL_THRESHOLD, TTL_EXTEND_TO); + Ok(total_bonuses) + } + + // ── Campaign supply cap (issue #903) ────────────────────────────────────── + + /// Set the total supply cap for a campaign (admin only). + /// + /// `cap` is the maximum total points that can be issued for this campaign_id. + /// Set to 0 for uncapped. Once set, all `credit_for_campaign` calls are + /// checked against the cap. + pub fn set_campaign_supply_cap( + env: Env, + admin: Address, + campaign_id: u64, + cap: u64, + ) -> Result<(), Error> { + require_admin(&env, &admin)?; + env.storage().instance().set(&(CAMPAIGN_CAP, campaign_id), &cap); + env.events().publish((CAMPAIGN_CAP_EVENT, campaign_id), cap); + env.storage() + .instance() + .extend_ttl(TTL_THRESHOLD, TTL_EXTEND_TO); + Ok(()) + } + + /// Get the total supply cap for a campaign (0 means uncapped). + pub fn campaign_supply_cap(env: Env, campaign_id: u64) -> u64 { + env.storage() + .instance() + .get(&(CAMPAIGN_CAP, campaign_id)) + .unwrap_or(0) + } + + /// Get the total issued amount for a campaign. + pub fn campaign_issued(env: Env, campaign_id: u64) -> u64 { + env.storage() + .instance() + .get(&(CAMPAIGN_ISSUED, campaign_id)) + .unwrap_or(0) + } + + /// Credit points using campaign multiplier with supply cap enforcement. + /// Extends `credit_for_campaign` to check against the campaign's supply cap. + pub fn credit_for_campaign_capped( + env: Env, + from: Address, + user: Address, + campaign_id: u64, + base_amount: u64, + ) -> Result { + let multiplier_bps: u32 = env + .storage() + .instance() + .get(&(CAMPAIGN_MULTIPLIER, campaign_id)) + .unwrap_or(10_000); + if multiplier_bps == 0 { + return Err(Error::InvalidMultiplier); + } + let adjusted_u128 = (base_amount as u128) + .checked_mul(multiplier_bps as u128) + .ok_or(Error::Overflow)? + / BPS_DENOMINATOR; + if adjusted_u128 > u64::MAX as u128 { + return Err(Error::Overflow); + } + let adjusted = adjusted_u128 as u64; + + // Check campaign supply cap + let cap: u64 = env + .storage() + .instance() + .get(&(CAMPAIGN_CAP, campaign_id)) + .unwrap_or(0); + if cap > 0 { + let issued: u64 = env + .storage() + .instance() + .get(&(CAMPAIGN_ISSUED, campaign_id)) + .unwrap_or(0); + let new_issued = issued.checked_add(adjusted).ok_or(Error::Overflow)?; + if new_issued > cap { + return Err(Error::CampaignSupplyCapExceeded); + } + env.storage() + .instance() + .set(&(CAMPAIGN_ISSUED, campaign_id), &new_issued); + } else { + // No cap, still track issued amount + let issued: u64 = env + .storage() + .instance() + .get(&(CAMPAIGN_ISSUED, campaign_id)) + .unwrap_or(0); + env.storage().instance().set( + &(CAMPAIGN_ISSUED, campaign_id), + &issued.checked_add(adjusted).ok_or(Error::Overflow)?, + ); + } + + Self::credit(env, from, user, adjusted) + } + // ── Multi-sig admin (issue #733) ────────────────────────────────────────── // // Privileged operations (upgrade, withdraw_reserve, rate/fee changes) are diff --git a/sdk/client/README.md b/sdk/client/README.md new file mode 100644 index 00000000..d51628d4 --- /dev/null +++ b/sdk/client/README.md @@ -0,0 +1,103 @@ +# @trivela/client + +Official TypeScript SDK for Trivela - A Stellar-based campaign rewards platform. + +## Features + +- Type-safe API client (auto-generated from OpenAPI) +- Smart contract bindings for Soroban contracts +- Zero-knowledge proof utilities +- Full TypeScript support + +## Installation + +```bash +npm install @trivela/client @stellar/stellar-sdk +``` + +## Usage + +### REST API Client + +```typescript +import type { Campaign } from '@trivela/client'; +``` + +### Smart Contract Bindings + +```typescript +import { RewardsContract } from '@trivela/client/contracts'; + +const rewards = new RewardsContract({ + contractId: 'CC...', + rpcUrl: 'https://soroban-testnet.stellar.org', +}); + +const balance = await rewards.balance({ user: 'GABC...' }); +``` + +### Issue #903: Campaign Supply Cap + +```typescript +// Set campaign supply cap +await rewards.set_campaign_supply_cap({ + admin: adminAddress, + campaign_id: 1n, + cap: 1000000n, +}); + +// Check remaining supply +const cap = await rewards.campaign_supply_cap({ campaign_id: 1n }); +const issued = await rewards.campaign_issued({ campaign_id: 1n }); +console.log(`Remaining: ${cap - issued}`); +``` + +### Issue #898: Multi-Level Referrals + +```typescript +// Configure 3-level referral tree +await rewards.set_multi_level_referral_config({ + admin: adminAddress, + depth: 3, + tier_rates: [5000, 2500, 1250], // 50%, 25%, 12.5% +}); + +// Pay bonuses up the chain +await rewards.pay_multi_level_referral_bonus({ + admin: adminAddress, + campaign_contract: campaignAddress, + referee: refereeAddress, + qualifying_amount: 1000n, +}); +``` + +### Issue #900: Minimum Claim Threshold + +```typescript +// Set minimum claim amount +await rewards.set_min_claim({ + admin: adminAddress, + min_amount: 100n, +}); + +// Claims below minimum will fail +const minClaim = await rewards.min_claim(); +``` + +## Development + +### Generate Bindings + +```bash +npm run generate:contracts +``` + +### Build + +```bash +npm run build +``` + +## License + +Apache-2.0 diff --git a/sdk/client/contracts/index.ts b/sdk/client/contracts/index.ts new file mode 100644 index 00000000..96ff88ee --- /dev/null +++ b/sdk/client/contracts/index.ts @@ -0,0 +1,27 @@ +/** + * Trivela Smart Contract TypeScript Bindings + * + * This module provides TypeScript bindings for Trivela's Soroban smart contracts. + * Bindings are generated from compiled WASM files using stellar-cli. + * + * To generate bindings, run: npm run generate:contracts + * + * Note: Contract bindings will be auto-generated when WASM files are available. + * Build contracts first: cargo build --release --target wasm32-unknown-unknown + * + * Issue #878: TypeScript SDK with contract bindings + */ + +// Placeholder types until bindings are generated +export interface ContractClient { + contractId: string; + rpcUrl: string; +} + +// Export contract clients (will be replaced by generated code) +// export * from './rewards'; +// export * from './campaign'; + +console.warn( + '@trivela/client/contracts: Bindings not yet generated. Run `npm run generate:contracts` after building the Soroban contracts.' +); diff --git a/sdk/client/package.json b/sdk/client/package.json index a59183ca..fce694ee 100644 --- a/sdk/client/package.json +++ b/sdk/client/package.json @@ -1,19 +1,62 @@ { "name": "@trivela/client", "version": "0.1.0", - "description": "Auto-generated TypeScript client types for the Trivela REST API", + "description": "TypeScript SDK for Trivela - REST API client and Stellar contract bindings", "type": "module", - "main": "./src/index.ts", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", "exports": { - ".": "./src/index.ts", - "./zk": "./zk/index.ts" + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./zk": { + "types": "./dist/zk/index.d.ts", + "import": "./dist/zk/index.js" + }, + "./contracts": { + "types": "./dist/contracts/index.d.ts", + "import": "./dist/contracts/index.js" + } }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], "scripts": { - "generate": "openapi-ts --input ../../backend/openapi.yaml --output src --plugins @hey-api/typescript", + "generate": "npm run generate:api && npm run generate:contracts", + "generate:api": "openapi-ts --input ../../backend/openapi.yaml --output src --plugins @hey-api/typescript", + "generate:contracts": "node scripts/generate-bindings.mjs", + "build": "tsc", + "prepublishOnly": "npm run generate && npm run build", "check-drift": "npm run generate && git diff --exit-code src/" }, "devDependencies": { - "@hey-api/openapi-ts": "0.99.0" + "@hey-api/openapi-ts": "0.99.0", + "@stellar/stellar-sdk": "^13.0.0", + "typescript": "^5.6.0" + }, + "peerDependencies": { + "@stellar/stellar-sdk": "^13.0.0" + }, + "keywords": [ + "trivela", + "stellar", + "soroban", + "smart-contracts", + "typescript", + "sdk", + "rewards", + "campaign" + ], + "repository": { + "type": "git", + "url": "https://github.com/FinesseStudioLab/Trivela.git", + "directory": "sdk/client" + }, + "publishConfig": { + "access": "public" }, "license": "Apache-2.0" } diff --git a/sdk/client/scripts/generate-bindings.mjs b/sdk/client/scripts/generate-bindings.mjs new file mode 100644 index 00000000..8ad0f5b8 --- /dev/null +++ b/sdk/client/scripts/generate-bindings.mjs @@ -0,0 +1,122 @@ +#!/usr/bin/env node +/** + * Generate TypeScript bindings from Soroban contract WASM files. + * + * This script uses `stellar contract bindings typescript` to generate + * TypeScript client code from compiled Soroban smart contracts. + * + * Issue #878: TypeScript SDK with contract bindings + */ + +import { execSync } from 'child_process'; +import { existsSync, mkdirSync, writeFileSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const SDK_ROOT = join(__dirname, '..'); +const CONTRACTS_ROOT = join(SDK_ROOT, '..', '..', 'contracts'); +const OUTPUT_DIR = join(SDK_ROOT, 'contracts'); + +// Contract configurations +const CONTRACTS = [ + { + name: 'rewards', + wasmPath: join( + CONTRACTS_ROOT, + 'rewards', + 'target', + 'wasm32-unknown-unknown', + 'release', + 'rewards_contract.wasm', + ), + outputFile: 'rewards.ts', + }, + { + name: 'campaign', + wasmPath: join( + CONTRACTS_ROOT, + 'campaign', + 'target', + 'wasm32-unknown-unknown', + 'release', + 'campaign_contract.wasm', + ), + outputFile: 'campaign.ts', + }, +]; + +console.log('🔧 Generating TypeScript contract bindings...\n'); + +// Ensure output directory exists +if (!existsSync(OUTPUT_DIR)) { + mkdirSync(OUTPUT_DIR, { recursive: true }); +} + +let hasErrors = false; + +for (const contract of CONTRACTS) { + console.log(`📦 Processing ${contract.name} contract...`); + + if (!existsSync(contract.wasmPath)) { + console.warn(`⚠️ WASM file not found: ${contract.wasmPath}`); + console.warn( + ` Skipping ${contract.name}. Build the contracts first with: cargo build --release --target wasm32-unknown-unknown\n`, + ); + hasErrors = true; + continue; + } + + try { + const outputPath = join(OUTPUT_DIR, contract.outputFile); + + // Generate bindings using stellar CLI + // Note: This requires `stellar` CLI to be installed + // Install with: cargo install --locked stellar-cli + execSync( + `stellar contract bindings typescript --wasm ${contract.wasmPath} --output-dir ${OUTPUT_DIR} --overwrite`, + { stdio: 'inherit' }, + ); + + console.log(`✅ Generated bindings for ${contract.name}\n`); + } catch (error) { + console.error(`❌ Failed to generate bindings for ${contract.name}:`, error.message); + hasErrors = true; + } +} + +// Generate index file that exports all contracts +const indexContent = `/** + * Trivela Smart Contract TypeScript Bindings + * + * Auto-generated bindings for Soroban smart contracts. + * + * Usage: + * \`\`\`typescript + * import { RewardsContract, CampaignContract } from '@trivela/client/contracts'; + * + * const rewards = new RewardsContract({ contractId: 'C...', rpcUrl: 'https://...' }); + * const balance = await rewards.balance({ user: 'G...' }); + * \`\`\` + */ + +// Export contract clients +export * from './rewards'; +export * from './campaign'; +`; + +writeFileSync(join(OUTPUT_DIR, 'index.ts'), indexContent); + +console.log('📝 Generated contracts index file'); + +if (hasErrors) { + console.log('\n⚠️ Some bindings could not be generated. Build the contracts first:\n'); + console.log(' cd contracts/rewards && cargo build --release --target wasm32-unknown-unknown'); + console.log( + ' cd contracts/campaign && cargo build --release --target wasm32-unknown-unknown\n', + ); + process.exit(0); // Don't fail - allow partial generation for development +} + +console.log('\n✨ Contract bindings generation complete!\n'); diff --git a/sdk/client/tsconfig.json b/sdk/client/tsconfig.json new file mode 100644 index 00000000..aa04f954 --- /dev/null +++ b/sdk/client/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "lib": ["ES2020"], + "moduleResolution": "bundler", + "declaration": true, + "declarationMap": true, + "outDir": "./dist", + "rootDir": "./", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src/**/*", "zk/**/*", "contracts/**/*"], + "exclude": ["node_modules", "dist", "scripts"] +}