From 98f231458d63287cc7cc27623469e4855def91c5 Mon Sep 17 00:00:00 2001 From: trustosaretin Date: Tue, 20 Oct 2026 23:29:36 +0100 Subject: [PATCH 1/4] feat(contracts): enforce 24-hour cooling period on admin key rotation Emergency admin rotation now records the proposal timestamp and refuses accept_admin until ADMIN_ROTATION_COOLDOWN_SECS (86400) has elapsed, returning Error::AdminCooldownActive if called too early. Covers both the rewards and campaign contracts. --- contracts/campaign/src/fuzz_test.rs | 4 +++- contracts/campaign/src/lib.rs | 25 ++++++++++++++++++++++--- contracts/campaign/src/test.rs | 25 +++++++++++++++++++++++-- contracts/rewards/src/lib.rs | 27 ++++++++++++++++++++++++--- contracts/rewards/src/test.rs | 26 +++++++++++++++++++++++++- 5 files changed, 97 insertions(+), 10 deletions(-) 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. From dfcd53b02abe643b8cda328ef0266e1b40558061 Mon Sep 17 00:00:00 2001 From: trustosaretin Date: Tue, 20 Oct 2026 23:29:46 +0100 Subject: [PATCH 2/4] feat(security): enforce read/write API key scopes on operator REST endpoints Operator-facing routes now require campaigns:read for authenticated GETs and campaigns:write for mutations, so external integrations can be issued read-only or write-scoped keys. Removes duplicate unscoped registrations for campaign update/delete/publish/archive that previously bypassed scope checks. --- backend/src/index.js | 65 +++++++++++++------------------------------- 1 file changed, 19 insertions(+), 46 deletions(-) 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' }); From 7e3e5322e00bc5637cea21c8e22ff2303cd74276 Mon Sep 17 00:00:00 2001 From: trustosaretin Date: Tue, 20 Oct 2026 23:29:46 +0100 Subject: [PATCH 3/4] feat(sdk): add @trivela/core lightweight bundle without React dependency New vanilla JS/TS SDK entry point with zero UI framework dependencies. Provides a minimal fetch-based REST client for Node and browser runtimes and a framework-free package surface for external integrations. --- sdk/core/package.json | 30 ++++++++++++ sdk/core/src/http.ts | 104 ++++++++++++++++++++++++++++++++++++++++++ sdk/core/src/index.ts | 22 +++++++++ 3 files changed, 156 insertions(+) create mode 100644 sdk/core/package.json create mode 100644 sdk/core/src/http.ts create mode 100644 sdk/core/src/index.ts 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'; From edd38bde83e19b8e28c100b7ee0b88f99056aa37 Mon Sep 17 00:00:00 2001 From: trustosaretin Date: Tue, 20 Oct 2026 23:29:46 +0100 Subject: [PATCH 4/4] feat(sdk): add auto-reconnect fallback RPC node manager RpcNodeManager tries the primary Soroban RPC node first and fails over to secondary nodes on HTTP 5xx or transport errors. Unhealthy nodes are reconnected automatically after a backoff window. Includes unit tests covering failover, recovery, and error classification. --- sdk/core/src/rpc.ts | 228 ++++++++++++++++++++++++++++++++++++++ sdk/core/test/rpc.test.ts | 127 +++++++++++++++++++++ 2 files changed, 355 insertions(+) create mode 100644 sdk/core/src/rpc.ts create mode 100644 sdk/core/test/rpc.test.ts 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'); +}