diff --git a/backend/src/index.js b/backend/src/index.js index a613dca4..f59ed493 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -2560,10 +2560,10 @@ export async function createApp(options = {}) { app.get(`${prefix}/deprecations`, rateLimiter, (_req, res) => res.json({ deprecations: DEPRECATION_REGISTRY }), ); - app.get(`${prefix}/audit-logs`, rateLimiter, ...guard, listAuditLogs); + app.get(`${prefix}/audit-logs`, rateLimiter, ...guard, requireScope('campaigns:read'), listAuditLogs); app.get(`${prefix}/admin/audit/verify`, rateLimiter, requireMasterKey, verifyAuditChain); app.get(`${prefix}/indexer/cursor`, rateLimiter, getIndexerCursorState); - app.post(`${prefix}/indexer/cursor`, rateLimiter, ...guard, setIndexerCursorState); + app.post(`${prefix}/indexer/cursor`, rateLimiter, ...guard, requireScope('campaigns:write'), setIndexerCursorState); app.post( `${prefix}/campaigns`, rateLimiter, @@ -2608,57 +2608,29 @@ export async function createApp(options = {}) { contentModerationMiddleware, updateCampaign, ); - app.delete( - `${prefix}/campaigns/:id`, - rateLimiter, - ...guard, - requireScope('campaigns:write'), - deleteCampaign, - ); - app.put( - `${prefix}/campaigns/:id`, - rateLimiter, - idempotencyMiddleware, - requireApiKey, - updateCampaign, - ); app.put( `${prefix}/campaigns/:id/publish`, rateLimiter, idempotencyMiddleware, - requireApiKey, + ...guard, + requireScope('campaigns:write'), publishCampaign, ); app.put( `${prefix}/campaigns/:id/archive`, rateLimiter, idempotencyMiddleware, - requireApiKey, + ...guard, + requireScope('campaigns:write'), archiveCampaign, ); - app.delete(`${prefix}/campaigns/:id`, rateLimiter, requireApiKey, deleteCampaign); - app.put( + app.delete( `${prefix}/campaigns/:id`, rateLimiter, - idempotencyMiddleware, - ...guard, - updateCampaign, - ); - app.put( - `${prefix}/campaigns/:id/publish`, - rateLimiter, - idempotencyMiddleware, - ...guard, - publishCampaign, - ); - app.put( - `${prefix}/campaigns/:id/archive`, - rateLimiter, - idempotencyMiddleware, ...guard, - archiveCampaign, + requireScope('campaigns:write'), + deleteCampaign, ); - app.delete(`${prefix}/campaigns/:id`, rateLimiter, ...guard, deleteCampaign); // Soft-delete management routes app.post( @@ -2861,7 +2833,7 @@ export async function createApp(options = {}) { }); // Tenant usage metering (Issue #574) - app.get(`${prefix}/usage`, rateLimiter, ...guard, (req, res) => { + app.get(`${prefix}/usage`, rateLimiter, ...guard, requireScope('campaigns:read'), (req, res) => { const orgId = req.auth?.orgId; if (!orgId) { return res.status(403).json({ @@ -2909,12 +2881,13 @@ export async function createApp(options = {}) { }); // Job dead-letter inspection / requeue (Issue #286) - app.get(`${prefix}/jobs/failed`, rateLimiter, ...guard, listFailedJobsHandler); + app.get(`${prefix}/jobs/failed`, rateLimiter, ...guard, requireScope('campaigns:read'), listFailedJobsHandler); app.post( `${prefix}/jobs/retry/:id`, rateLimiter, idempotencyMiddleware, ...guard, + requireScope('campaigns:write'), retryFailedJobHandler, ); @@ -2945,7 +2918,7 @@ export async function createApp(options = {}) { ); // Webhook routes (Issue #287) - app.post(`${prefix}/webhooks`, rateLimiter, idempotencyMiddleware, ...guard, (req, res) => { + app.post(`${prefix}/webhooks`, rateLimiter, idempotencyMiddleware, ...guard, requireScope('campaigns:write'), (req, res) => { const { url, events, secret } = req.body; if (!url || !Array.isArray(events) || events.length === 0) { return res.status(400).json({ @@ -2964,12 +2937,12 @@ export async function createApp(options = {}) { return res.status(201).json(webhook); }); - app.get(`${prefix}/webhooks`, rateLimiter, ...guard, (req, res) => { + app.get(`${prefix}/webhooks`, rateLimiter, ...guard, requireScope('campaigns:read'), (req, res) => { const webhooks = webhookRepository.list(); return res.json(paginateItems(webhooks, req.query)); }); - app.get(`${prefix}/webhooks/:id`, rateLimiter, ...guard, (req, res) => { + app.get(`${prefix}/webhooks/:id`, rateLimiter, ...guard, requireScope('campaigns:read'), (req, res) => { const webhook = webhookRepository.getById(req.params.id); if (!webhook) { return res.status(404).json({ error: 'Webhook not found', code: 'WEBHOOK_NOT_FOUND' }); @@ -2977,7 +2950,7 @@ export async function createApp(options = {}) { return res.json(webhook); }); - app.put(`${prefix}/webhooks/:id`, rateLimiter, idempotencyMiddleware, ...guard, (req, res) => { + app.put(`${prefix}/webhooks/:id`, rateLimiter, idempotencyMiddleware, ...guard, requireScope('campaigns:write'), (req, res) => { const { url, events, active } = req.body; const before = webhookRepository.getById(req.params.id); if (!before) { @@ -2997,7 +2970,7 @@ export async function createApp(options = {}) { return res.json(webhook); }); - app.delete(`${prefix}/webhooks/:id`, rateLimiter, ...guard, (req, res) => { + app.delete(`${prefix}/webhooks/:id`, rateLimiter, ...guard, requireScope('campaigns:write'), (req, res) => { const before = webhookRepository.getById(req.params.id); const deleted = webhookRepository.delete(req.params.id); if (!deleted) { @@ -3012,7 +2985,7 @@ export async function createApp(options = {}) { return res.status(204).end(); }); - app.get(`${prefix}/webhooks/:id/deliveries`, rateLimiter, ...guard, (req, res) => { + app.get(`${prefix}/webhooks/:id/deliveries`, rateLimiter, ...guard, requireScope('campaigns:read'), (req, res) => { const webhook = webhookRepository.getById(req.params.id); if (!webhook) { return res.status(404).json({ error: 'Webhook not found', code: 'WEBHOOK_NOT_FOUND' }); @@ -3361,7 +3334,7 @@ export async function createApp(options = {}) { }, ); - app.get(`${prefix}/campaigns/:id/allowlist`, rateLimiter, ...guard, (req, res) => { + app.get(`${prefix}/campaigns/:id/allowlist`, rateLimiter, ...guard, requireScope('campaigns:read'), (req, res) => { const campaign = campaignRepository.getById(req.params.id); if (!campaign) { return res.status(404).json({ error: 'Campaign not found', code: 'CAMPAIGN_NOT_FOUND' }); diff --git a/contracts/campaign/src/fuzz_test.rs b/contracts/campaign/src/fuzz_test.rs index dd09027d..a8ab50d1 100644 --- a/contracts/campaign/src/fuzz_test.rs +++ b/contracts/campaign/src/fuzz_test.rs @@ -384,7 +384,9 @@ proptest! { let result = client.try_accept_admin(&impostor); assert_eq!(result, Err(Ok(Error::Unauthorized))); - // Pending admin accepts + // Pending admin accepts after the cooling period + env.ledger() + .with_mut(|li| li.timestamp = ADMIN_ROTATION_COOLDOWN_SECS); client.accept_admin(&new_admin); assert_eq!(client.admin(), new_admin); assert_eq!(client.pending_admin(), None); diff --git a/contracts/campaign/src/lib.rs b/contracts/campaign/src/lib.rs index 4c56908f..aa47ce64 100644 --- a/contracts/campaign/src/lib.rs +++ b/contracts/campaign/src/lib.rs @@ -105,6 +105,8 @@ pub enum Error { InAddressBlocklist = 126, /// Participation fee configuration is invalid (zero amount or missing token). InvalidFeeConfig = 127, + /// Admin rotation was requested before the 24-hour cooling period elapsed. + AdminCooldownActive = 128, } contractmeta!(key = "Description", val = "Trivela campaign configuration"); @@ -307,8 +309,11 @@ const PARTICIPANT_TTL_THRESHOLD: u32 = 100; const PARTICIPANT_TTL_EXTEND_TO: u32 = 500; // ── 2-step admin transfer (issue #281) ─────────────────────────────────────── const PENDING_ADMIN: Symbol = symbol_short!("padmin"); +const PENDING_ADMIN_AT: Symbol = symbol_short!("padminat"); const ADMIN_PROPOSED_EVENT: Symbol = symbol_short!("aproposed"); const ADMIN_ACCEPTED_EVENT: Symbol = symbol_short!("aaccepted"); +/// Mandatory cooling period before a proposed admin key takes effect (#1353). +pub const ADMIN_ROTATION_COOLDOWN_SECS: u64 = 86_400; // ── Privacy mode (issue #544) ──────────────────────────────────────────────── // Per-campaign registration mode: open, Merkle-gated, or ZK-gated. @@ -1863,7 +1868,8 @@ impl CampaignContract { } /// Propose a new admin (current admin only). The transfer does not take - /// effect until `accept_admin` is called by the new admin. + /// effect until `accept_admin` is called by the new admin, and only after + /// the 24-hour emergency-rotation cooling period has elapsed. pub fn propose_admin( env: Env, current_admin: Address, @@ -1875,6 +1881,9 @@ impl CampaignContract { return Err(Error::Unauthorized); } env.storage().instance().set(&PENDING_ADMIN, &new_admin); + env.storage() + .instance() + .set(&PENDING_ADMIN_AT, &env.ledger().timestamp()); env.events() .publish((ADMIN_PROPOSED_EVENT, current_admin), new_admin); env.storage() @@ -1884,8 +1893,8 @@ impl CampaignContract { } /// Accept admin role. Caller MUST be the address that the current admin - /// previously proposed via `propose_admin`. Clears the pending slot on - /// success. + /// previously proposed via `propose_admin`, and the 24-hour cooling + /// period must have elapsed. Clears the pending slot on success. pub fn accept_admin(env: Env, new_admin: Address) -> Result<(), Error> { new_admin.require_auth(); let pending: Address = env @@ -1896,8 +1905,17 @@ impl CampaignContract { if pending != new_admin { return Err(Error::Unauthorized); } + let proposed_at: u64 = env + .storage() + .instance() + .get(&PENDING_ADMIN_AT) + .unwrap_or(0); + if env.ledger().timestamp() < proposed_at + ADMIN_ROTATION_COOLDOWN_SECS { + return Err(Error::AdminCooldownActive); + } env.storage().instance().set(&ADMIN, &new_admin); env.storage().instance().remove(&PENDING_ADMIN); + env.storage().instance().remove(&PENDING_ADMIN_AT); env.events().publish((ADMIN_ACCEPTED_EVENT,), new_admin); env.storage() .instance() @@ -1913,6 +1931,7 @@ impl CampaignContract { return Err(Error::Unauthorized); } env.storage().instance().remove(&PENDING_ADMIN); + env.storage().instance().remove(&PENDING_ADMIN_AT); env.storage() .instance() .extend_ttl(TTL_THRESHOLD, TTL_EXTEND_TO); diff --git a/contracts/campaign/src/test.rs b/contracts/campaign/src/test.rs index 53dd0e7b..ded1b826 100644 --- a/contracts/campaign/src/test.rs +++ b/contracts/campaign/src/test.rs @@ -1113,7 +1113,7 @@ fn setup_admin_rotation_campaign() -> (Env, CampaignContractClient<'static>, Add #[test] fn test_campaign_propose_and_accept_admin_happy_path() { - let (_env, client, admin, new_admin) = setup_admin_rotation_campaign(); + let (env, client, admin, new_admin) = setup_admin_rotation_campaign(); assert_eq!(client.admin(), admin); assert_eq!(client.pending_admin(), None); @@ -1121,11 +1121,30 @@ fn test_campaign_propose_and_accept_admin_happy_path() { assert_eq!(client.pending_admin(), Some(new_admin.clone())); assert_eq!(client.admin(), admin); + env.ledger() + .with_mut(|li| li.timestamp = ADMIN_ROTATION_COOLDOWN_SECS); client.accept_admin(&new_admin); assert_eq!(client.admin(), new_admin); assert_eq!(client.pending_admin(), None); } +#[test] +fn test_campaign_accept_admin_before_cooldown_fails() { + let (env, client, admin, new_admin) = setup_admin_rotation_campaign(); + client.propose_admin(&admin, &new_admin); + + env.ledger() + .with_mut(|li| li.timestamp = ADMIN_ROTATION_COOLDOWN_SECS - 1); + let result = client.try_accept_admin(&new_admin); + assert_eq!(result, Err(Ok(Error::AdminCooldownActive))); + assert_eq!(client.admin(), admin); + + env.ledger() + .with_mut(|li| li.timestamp = ADMIN_ROTATION_COOLDOWN_SECS); + client.accept_admin(&new_admin); + assert_eq!(client.admin(), new_admin); +} + #[test] fn test_campaign_propose_without_accept_keeps_old_admin() { let (_env, client, admin, new_admin) = setup_admin_rotation_campaign(); @@ -1172,8 +1191,10 @@ fn test_campaign_cancel_admin_transfer_clears_pending() { #[test] fn test_campaign_new_admin_can_call_admin_operations() { // Once accepted, the new admin's signature is enough to perform admin-only ops. - let (_env, client, admin, new_admin) = setup_admin_rotation_campaign(); + let (env, client, admin, new_admin) = setup_admin_rotation_campaign(); client.propose_admin(&admin, &new_admin); + env.ledger() + .with_mut(|li| li.timestamp = ADMIN_ROTATION_COOLDOWN_SECS); client.accept_admin(&new_admin); // set_active is admin-only — was previously rejected for `admin`'s replacement diff --git a/contracts/rewards/src/lib.rs b/contracts/rewards/src/lib.rs index eaf98445..efd38d2f 100644 --- a/contracts/rewards/src/lib.rs +++ b/contracts/rewards/src/lib.rs @@ -201,6 +201,8 @@ pub enum Error { TimelockAlreadyQueued = 68, /// No matching timelock entry is queued. TimelockNotFound = 69, + /// Admin rotation was requested before the 24-hour cooling period elapsed. + AdminCooldownActive = 70, } // ── Issue #895: Operator delegation types ───────────────────────────────────── @@ -476,9 +478,14 @@ const ADMIN_NONCE: Symbol = symbol_short!("anonce"); // `PENDING_ADMIN` holds an in-flight proposed admin; the new admin must call // `accept_admin()` themselves to complete the rotation, eliminating the // "wrong address, key now lost" failure mode of a one-step transfer. +// `PENDING_ADMIN_AT` records the ledger timestamp of the proposal so the +// emergency rotation path can enforce a mandatory cooling period (#1353). const PENDING_ADMIN: Symbol = symbol_short!("padmin"); +const PENDING_ADMIN_AT: Symbol = symbol_short!("padminat"); const ADMIN_PROPOSED_EVENT: Symbol = symbol_short!("aproposed"); const ADMIN_ACCEPTED_EVENT: Symbol = symbol_short!("aaccepted"); +/// Mandatory cooling period before a proposed admin key takes effect. +pub const ADMIN_ROTATION_COOLDOWN_SECS: u64 = 86_400; // ── On-chain referral rewards (issue #656 / #603) ──────────────────────────── // The referral *graph* (who referred whom) is attributed by the campaign @@ -1551,7 +1558,8 @@ impl RewardsContract { } /// Propose a new admin (current admin only). The transfer does not take - /// effect until `accept_admin` is called by the new admin. + /// effect until `accept_admin` is called by the new admin, and only after + /// the 24-hour emergency-rotation cooling period has elapsed. /// /// Calling again overwrites the previous pending admin, so the current /// admin can cancel a proposal by calling `cancel_admin_transfer` or by @@ -1563,6 +1571,9 @@ impl RewardsContract { ) -> Result<(), Error> { require_admin(&env, ¤t_admin)?; env.storage().instance().set(&PENDING_ADMIN, &new_admin); + env.storage() + .instance() + .set(&PENDING_ADMIN_AT, &env.ledger().timestamp()); env.events() .publish((ADMIN_PROPOSED_EVENT, current_admin), new_admin); env.storage() @@ -1572,8 +1583,8 @@ impl RewardsContract { } /// Accept admin role. Caller MUST be the address that the current admin - /// previously proposed via `propose_admin`. Clears the pending slot on - /// success. + /// previously proposed via `propose_admin`, and the 24-hour cooling + /// period must have elapsed. Clears the pending slot on success. pub fn accept_admin(env: Env, new_admin: Address) -> Result<(), Error> { new_admin.require_auth(); let pending: Address = env @@ -1584,8 +1595,17 @@ impl RewardsContract { if pending != new_admin { return Err(Error::Unauthorized); } + let proposed_at: u64 = env + .storage() + .instance() + .get(&PENDING_ADMIN_AT) + .unwrap_or(0); + if env.ledger().timestamp() < proposed_at + ADMIN_ROTATION_COOLDOWN_SECS { + return Err(Error::AdminCooldownActive); + } env.storage().instance().set(&ADMIN, &new_admin); env.storage().instance().remove(&PENDING_ADMIN); + env.storage().instance().remove(&PENDING_ADMIN_AT); env.events().publish((ADMIN_ACCEPTED_EVENT,), new_admin); env.storage() .instance() @@ -1597,6 +1617,7 @@ impl RewardsContract { pub fn cancel_admin_transfer(env: Env, current_admin: Address) -> Result<(), Error> { require_admin(&env, ¤t_admin)?; env.storage().instance().remove(&PENDING_ADMIN); + env.storage().instance().remove(&PENDING_ADMIN_AT); env.storage() .instance() .extend_ttl(TTL_THRESHOLD, TTL_EXTEND_TO); diff --git a/contracts/rewards/src/test.rs b/contracts/rewards/src/test.rs index 35271758..a710e8bf 100644 --- a/contracts/rewards/src/test.rs +++ b/contracts/rewards/src/test.rs @@ -1238,7 +1238,7 @@ fn setup_admin_rotation() -> (Env, RewardsContractClient<'static>, Address, Addr #[test] fn test_propose_and_accept_admin_happy_path() { - let (_env, client, admin, new_admin) = setup_admin_rotation(); + let (env, client, admin, new_admin) = setup_admin_rotation(); assert_eq!(Some(client.admin()), Some(admin.clone())); assert_eq!(client.pending_admin(), None); @@ -1247,11 +1247,33 @@ fn test_propose_and_accept_admin_happy_path() { // Admin doesn't change until accepted. assert_eq!(Some(client.admin()), Some(admin.clone())); + // Accept only succeeds after the 24-hour cooling period. + env.ledger() + .with_mut(|li| li.timestamp = ADMIN_ROTATION_COOLDOWN_SECS); client.accept_admin(&new_admin); assert_eq!(Some(client.admin()), Some(new_admin.clone())); assert_eq!(client.pending_admin(), None); } +#[test] +fn test_accept_admin_before_cooldown_fails() { + let (env, client, admin, new_admin) = setup_admin_rotation(); + client.propose_admin(&admin, &new_admin); + + // One second short of the cooling period. + env.ledger() + .with_mut(|li| li.timestamp = ADMIN_ROTATION_COOLDOWN_SECS - 1); + let result = client.try_accept_admin(&new_admin); + assert_eq!(result, Err(Ok(Error::AdminCooldownActive))); + assert_eq!(client.admin(), admin); + + // Exactly at the cooling-period boundary the accept succeeds. + env.ledger() + .with_mut(|li| li.timestamp = ADMIN_ROTATION_COOLDOWN_SECS); + client.accept_admin(&new_admin); + assert_eq!(client.admin(), new_admin); +} + #[test] fn test_propose_admin_without_accept_keeps_old_admin() { let (_env, client, admin, new_admin) = setup_admin_rotation(); @@ -1306,6 +1328,8 @@ fn test_propose_overwrites_previous_proposal() { client.propose_admin(&admin, &later_admin); assert_eq!(client.pending_admin(), Some(later_admin.clone())); // Original proposed admin can no longer accept. + env.ledger() + .with_mut(|li| li.timestamp = ADMIN_ROTATION_COOLDOWN_SECS); let result = client.try_accept_admin(&new_admin); assert_eq!(result, Err(Ok(Error::Unauthorized))); // The later proposal still works. diff --git a/sdk/core/package.json b/sdk/core/package.json new file mode 100644 index 00000000..21d42dcf --- /dev/null +++ b/sdk/core/package.json @@ -0,0 +1,30 @@ +{ + "name": "@trivela/core", + "version": "0.1.0", + "description": "Lightweight vanilla JS/TS SDK for Trivela — zero UI framework dependencies", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./rpc": "./src/rpc.ts" + }, + "sideEffects": false, + "scripts": { + "check": "tsc --noEmit" + }, + "files": [ + "src" + ], + "engines": { + "node": ">=18" + }, + "keywords": [ + "trivela", + "stellar", + "soroban", + "sdk", + "vanilla" + ], + "license": "Apache-2.0" +} diff --git a/sdk/core/src/http.ts b/sdk/core/src/http.ts new file mode 100644 index 00000000..60668c56 --- /dev/null +++ b/sdk/core/src/http.ts @@ -0,0 +1,104 @@ +/** + * Minimal fetch-based HTTP helpers for the Trivela REST API. + * + * Deliberately framework-free: works in Node >= 18, browsers, workers, + * and any runtime that provides global `fetch`. + */ + +export interface TrivelaClientConfig { + /** Base URL of the Trivela API, e.g. `https://api.trivela.network`. */ + baseUrl: string; + /** Optional API key sent as `X-API-Key`. */ + apiKey?: string; + /** Optional custom fetch implementation (useful for tests). */ + fetchImpl?: typeof fetch; +} + +export interface RequestOptions { + method?: string; + body?: unknown; + headers?: Record; + signal?: AbortSignal; +} + +export class TrivelaHttpError extends Error { + readonly status: number; + readonly code: string | undefined; + + constructor(status: number, message: string, code?: string) { + super(message); + this.name = 'TrivelaHttpError'; + this.status = status; + this.code = code; + } +} + +/** + * Lightweight REST client for the Trivela API. + * + * No React, no bundler assumptions, no polyfills required beyond `fetch`. + */ +export function createClient(config: TrivelaClientConfig) { + const fetchImpl = config.fetchImpl ?? globalThis.fetch; + if (typeof fetchImpl !== 'function') { + throw new Error('fetch is not available in this runtime; pass config.fetchImpl'); + } + + const baseUrl = config.baseUrl.replace(/\/+$/, ''); + + async function request(path: string, options: RequestOptions = {}): Promise { + const { method = 'GET', body, headers = {}, signal } = options; + + const response = await fetchImpl(`${baseUrl}${path}`, { + method, + headers: { + accept: 'application/json', + ...(body !== undefined ? { 'content-type': 'application/json' } : {}), + ...(config.apiKey ? { 'x-api-key': config.apiKey } : {}), + ...headers, + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + signal, + }); + + const text = await response.text(); + const payload = text ? JSON.parse(text) : null; + + if (!response.ok) { + const message = + (payload && typeof payload === 'object' && 'error' in payload + ? String((payload as { error: unknown }).error) + : null) || `HTTP ${response.status}`; + const code = + payload && typeof payload === 'object' && 'code' in payload + ? String((payload as { code: unknown }).code) + : undefined; + throw new TrivelaHttpError(response.status, message, code); + } + + return payload as T; + } + + return { + /** Underlying request primitive. */ + request, + /** GET helper. */ + get(path: string, options?: Omit) { + return request(path, { ...options, method: 'GET' }); + }, + /** POST helper. */ + post(path: string, body?: unknown, options?: Omit) { + return request(path, { ...options, method: 'POST', body }); + }, + /** PUT helper. */ + put(path: string, body?: unknown, options?: Omit) { + return request(path, { ...options, method: 'PUT', body }); + }, + /** DELETE helper. */ + del(path: string, options?: Omit) { + return request(path, { ...options, method: 'DELETE' }); + }, + }; +} + +export type TrivelaClient = ReturnType; diff --git a/sdk/core/src/index.ts b/sdk/core/src/index.ts new file mode 100644 index 00000000..21e4cbe2 --- /dev/null +++ b/sdk/core/src/index.ts @@ -0,0 +1,22 @@ +/** + * `@trivela/core` — lightweight vanilla JS/TS SDK entry point. + * + * Zero UI framework dependencies (no React, no DOM assumptions). Suitable + * for Node services, vanilla browser apps, workers, and edge runtimes. + */ + +export { + createClient, + TrivelaHttpError, + type TrivelaClient, + type TrivelaClientConfig, + type RequestOptions, +} from './http.js'; + +export { + RpcNodeManager, + RpcCallError, + createRpcNodeManager, + type RpcNodeManagerOptions, + type RpcNodeStatus, +} from './rpc.js'; diff --git a/sdk/core/src/rpc.ts b/sdk/core/src/rpc.ts new file mode 100644 index 00000000..43635676 --- /dev/null +++ b/sdk/core/src/rpc.ts @@ -0,0 +1,228 @@ +/** + * Auto-reconnect fallback RPC node manager for Soroban JSON-RPC. + * + * Tries the primary node first; on HTTP 5xx or a transport failure it marks + * that node unhealthy and fails over to the next configured node. Unhealthy + * nodes are probed again after a backoff window so a recovered primary is + * automatically reconnected to. + */ + +export interface RpcNodeManagerOptions { + /** Milliseconds before an unhealthy node is retried. Default 30_000. */ + reconnectAfterMs?: number; + /** Maximum retries across all nodes per call. Default = number of nodes. */ + maxAttempts?: number; + /** Custom fetch implementation (useful for tests). */ + fetchImpl?: typeof fetch; + /** Extra headers sent with every JSON-RPC request. */ + headers?: Record; +} + +export interface RpcNodeStatus { + url: string; + healthy: boolean; + lastError: string | null; +} + +interface NodeState { + url: string; + healthy: boolean; + unhealthySince: number | null; + lastError: string | null; +} + +export class RpcCallError extends Error { + readonly code: string | undefined; + readonly url: string | undefined; + + constructor(message: string, code?: string, url?: string) { + super(message); + this.name = 'RpcCallError'; + this.code = code; + this.url = url; + } +} + +export class RpcNodeManager { + private readonly nodes: NodeState[]; + private readonly reconnectAfterMs: number; + private readonly maxAttempts: number; + private readonly fetchImpl: typeof fetch; + private readonly headers: Record; + private nextIndex = 0; + + constructor(urls: string[], options: RpcNodeManagerOptions = {}) { + if (!Array.isArray(urls) || urls.length === 0) { + throw new Error('RpcNodeManager requires at least one RPC URL'); + } + const fetchImpl = options.fetchImpl ?? globalThis.fetch; + if (typeof fetchImpl !== 'function') { + throw new Error('fetch is not available in this runtime; pass options.fetchImpl'); + } + + this.nodes = urls.map((url) => ({ + url, + healthy: true, + unhealthySince: null, + lastError: null, + })); + this.reconnectAfterMs = options.reconnectAfterMs ?? 30_000; + this.maxAttempts = options.maxAttempts ?? urls.length; + this.fetchImpl = fetchImpl; + this.headers = options.headers ?? {}; + } + + /** URLs in configured order. */ + getUrls(): string[] { + return this.nodes.map((n) => n.url); + } + + /** The URL that will be tried first for the next call. */ + getActiveUrl(): string { + this.recoverStale(); + return this.firstHealthy()?.url ?? this.nodes[this.nextIndex % this.nodes.length].url; + } + + /** Snapshot of node health for diagnostics. */ + getStatus(): RpcNodeStatus[] { + this.recoverStale(); + return this.nodes.map(({ url, healthy, lastError }) => ({ url, healthy, lastError })); + } + + /** Mark a node healthy again (e.g. after an external health check passes). */ + markHealthy(url: string): void { + const node = this.nodes.find((n) => n.url === url); + if (node) { + node.healthy = true; + node.unhealthySince = null; + node.lastError = null; + } + } + + /** + * Issue a JSON-RPC call against the preferred node, failing over to + * fallback nodes when the primary returns 5xx or the transport fails. + * + * JSON-RPC-level errors (non-2xx is transport; `error` in the 200 body + * is application-level) are returned to the caller without failover — + * only node/transport failures trigger the fallback path. + */ + async call(method: string, params?: unknown): Promise { + this.recoverStale(); + + const attempts = Math.max(1, this.maxAttempts); + let lastError: Error | null = null; + + for (let attempt = 0; attempt < attempts; attempt++) { + const node = this.nextHealthy(); + if (!node) break; + + try { + const payload = await this.postRpc(node.url, method, params); + node.healthy = true; + node.unhealthySince = null; + node.lastError = null; + return payload as T; + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + lastError = error; + + // Only transport / 5xx failures mark the node unhealthy. + if (error instanceof RpcCallError && error.code === 'RPC_ERROR') { + throw error; + } + this.markUnhealthy(node, error.message); + } + } + + throw lastError ?? new RpcCallError('No healthy RPC nodes available'); + } + + private async postRpc(url: string, method: string, params?: unknown): Promise { + let response: Response; + try { + response = await this.fetchImpl(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json', + ...this.headers, + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method, + ...(params !== undefined ? { params } : {}), + }), + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new RpcCallError(`RPC transport error: ${message}`, 'TRANSPORT_ERROR', url); + } + + if (response.status >= 500) { + throw new RpcCallError(`RPC node returned HTTP ${response.status}`, 'HTTP_5XX', url); + } + if (!response.ok) { + // 4xx is a client/request problem, not a node problem — surface without failover. + throw new RpcCallError(`RPC node returned HTTP ${response.status}`, 'RPC_ERROR', url); + } + + const body = (await response.json().catch(() => null)) as { + result?: unknown; + error?: { code?: number; message?: string }; + } | null; + + if (body && typeof body === 'object' && 'error' in body && body.error) { + throw new RpcCallError( + body.error.message || 'Soroban RPC returned an error', + 'RPC_ERROR', + url, + ); + } + + return body && typeof body === 'object' && 'result' in body ? body.result : body; + } + + private recoverStale(): void { + const now = Date.now(); + for (const node of this.nodes) { + if (!node.healthy && node.unhealthySince !== null && now - node.unhealthySince >= this.reconnectAfterMs) { + node.healthy = true; + node.unhealthySince = null; + } + } + } + + private firstHealthy(): NodeState | undefined { + for (let i = 0; i < this.nodes.length; i++) { + const node = this.nodes[(this.nextIndex + i) % this.nodes.length]; + if (node.healthy) return node; + } + return undefined; + } + + private nextHealthy(): NodeState | undefined { + const node = this.firstHealthy(); + if (node) { + this.nextIndex = (this.nodes.indexOf(node) + 1) % this.nodes.length; + } + return node; + } + + private markUnhealthy(node: NodeState, message: string): void { + node.healthy = false; + node.unhealthySince = Date.now(); + node.lastError = message; + } +} + +/** + * Convenience factory matching the `create*` style used across the codebase. + */ +export function createRpcNodeManager( + urls: string[], + options?: RpcNodeManagerOptions, +): RpcNodeManager { + return new RpcNodeManager(urls, options); +} diff --git a/sdk/core/test/rpc.test.ts b/sdk/core/test/rpc.test.ts new file mode 100644 index 00000000..67a71326 --- /dev/null +++ b/sdk/core/test/rpc.test.ts @@ -0,0 +1,127 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { RpcCallError, RpcNodeManager, createRpcNodeManager } from '../src/rpc.ts'; + +function okFetch(result: unknown) { + return async () => + new Response(JSON.stringify({ jsonrpc: '2.0', id: 1, result }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); +} + +function fetchSequence(responses: Array<() => Promise>) { + let i = 0; + return async () => { + const next = responses[Math.min(i, responses.length - 1)]; + i += 1; + return next(); + }; +} + +function http500() { + return async () => new Response('boom', { status: 500 }); +} + +function rpcErrorResponse() { + return async () => + new Response(JSON.stringify({ jsonrpc: '2.0', id: 1, error: { code: -32600, message: 'bad req' } }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); +} + +test('constructing with no URLs throws', () => { + assert.throws(() => new RpcNodeManager([]), /at least one RPC URL/); +}); + +test('call returns result from the primary node', async () => { + const mgr = createRpcNodeManager(['https://primary.example'], { + fetchImpl: okFetch({ network: 'testnet' }) as typeof fetch, + }); + const result = await mgr.call<{ network: string }>('getNetwork'); + assert.deepEqual(result, { network: 'testnet' }); + assert.equal(mgr.getActiveUrl(), 'https://primary.example'); +}); + +test('fails over to secondary when primary returns 5xx', async () => { + const mgr = createRpcNodeManager(['https://primary.example', 'https://secondary.example'], { + fetchImpl: fetchSequence([http500(), okFetch({ ok: true })]) as typeof fetch, + }); + const result = await mgr.call<{ ok: boolean }>('getNetwork'); + assert.deepEqual(result, { ok: true }); + + const status = mgr.getStatus(); + assert.equal(status[0].healthy, false); + assert.equal(status[1].healthy, true); +}); + +test('fails over on transport errors', async () => { + const mgr = createRpcNodeManager(['https://primary.example', 'https://secondary.example'], { + fetchImpl: fetchSequence([ + async () => { + throw new Error('ECONNREFUSED'); + }, + okFetch('pong'), + ]) as typeof fetch, + }); + const result = await rpcNodeResult(mgr); + assert.equal(result, 'pong'); +}); + +test('JSON-RPC application errors do not fail over', async () => { + const mgr = createRpcNodeManager(['https://primary.example', 'https://secondary.example'], { + fetchImpl: fetchSequence([rpcErrorResponse(), okFetch('unused')]) as typeof fetch, + }); + await assert.rejects(() => mgr.call('getNetwork'), (err: unknown) => { + assert.ok(err instanceof RpcCallError); + assert.equal(err.code, 'RPC_ERROR'); + return true; + }); +}); + +test('unhealthy node is retried after reconnect window', async () => { + let calls = 0; + const mgr = new RpcNodeManager(['https://primary.example', 'https://secondary.example'], { + reconnectAfterMs: 10, + fetchImpl: (async () => { + calls += 1; + if (calls === 1) return new Response('down', { status: 503 }); + return new Response(JSON.stringify({ jsonrpc: '2.0', id: 1, result: 'ok' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }) as typeof fetch, + }); + + // First call: primary fails, secondary serves. + assert.equal(await mgr.call('getNetwork'), 'ok'); + assert.equal(mgr.getStatus()[0].healthy, false); + + // After the reconnect window the primary is probed again and recovers. + await new Promise((r) => setTimeout(r, 20)); + assert.equal(mgr.getStatus()[0].healthy, true); + assert.equal(mgr.getActiveUrl(), 'https://primary.example'); +}); + +test('throws when every node is exhausted', async () => { + const mgr = createRpcNodeManager(['https://a.example', 'https://b.example'], { + fetchImpl: (async () => new Response('down', { status: 502 })) as typeof fetch, + }); + await assert.rejects(() => mgr.call('getNetwork'), /HTTP 502/); + assert.equal(mgr.getStatus().every((n) => !n.healthy), true); +}); + +test('markHealthy restores a node manually', async () => { + const mgr = createRpcNodeManager(['https://a.example'], { + fetchImpl: (async () => new Response('down', { status: 500 })) as typeof fetch, + }); + await assert.rejects(() => mgr.call('getNetwork')); + assert.equal(mgr.getStatus()[0].healthy, false); + mgr.markHealthy('https://a.example'); + assert.equal(mgr.getStatus()[0].healthy, true); +}); + +async function rpcNodeResult(mgr: RpcNodeManager): Promise { + return mgr.call('getHealth'); +}