From 5269f0faed7d646e5e509002a73938860324cfc5 Mon Sep 17 00:00:00 2001 From: Obiajulu-gif Date: Mon, 27 Apr 2026 05:06:50 -0700 Subject: [PATCH] feat(stellar): implement circuit breaker for all Stellar RPC calls - Add CircuitBreaker class with CLOSED/OPEN/HALF_OPEN state machine - Open circuit after N consecutive failures (default: 5, env: STELLAR_CB_FAILURE_THRESHOLD) - Transition to HALF_OPEN after reset timeout (default: 30s, env: STELLAR_CB_RESET_TIMEOUT_MS) - Close circuit on first successful probe in HALF_OPEN state - Log all state transitions (CLOSED -> OPEN -> HALF_OPEN -> CLOSED) - Wrap all 12 Stellar RPC functions with stellarRpcCircuitBreaker.call(): callVerifyMilestone, emitRejectionEvent, callMintScholarNFT, isEnrolled, submitScholarshipProposal, castVote, cancelProposal, reclaimInactiveEscrow, getLearnTokenBalance, getGovernanceTokenBalance, getGovernanceVotingPower, getGovernanceDelegation - Expose circuit state (state, consecutiveFailures, openedAt) in GET /api/health under the 'stellarRpc' key Closes # --- server/src/routes/health.routes.ts | 2 + .../src/services/stellar-contract.service.ts | 789 ++++++++++-------- 2 files changed, 461 insertions(+), 330 deletions(-) diff --git a/server/src/routes/health.routes.ts b/server/src/routes/health.routes.ts index 2f5fa302..ec05aa0d 100644 --- a/server/src/routes/health.routes.ts +++ b/server/src/routes/health.routes.ts @@ -10,6 +10,7 @@ import { resetPoolAlerts, } from "../controllers/metrics.controller" import { pool } from "../db" +import { stellarRpcCircuitBreaker } from "../services/stellar-contract.service" export const healthRouter = Router() @@ -271,6 +272,7 @@ healthRouter.get("/health", async (req, res) => { redis, stellarHorizon, }, + stellarRpc: stellarRpcCircuitBreaker.getStatus(), }) }) diff --git a/server/src/services/stellar-contract.service.ts b/server/src/services/stellar-contract.service.ts index a509b1c0..2041c2c4 100644 --- a/server/src/services/stellar-contract.service.ts +++ b/server/src/services/stellar-contract.service.ts @@ -148,6 +148,167 @@ async function withRetry( throw wrapped } +// --------------------------------------------------------------------------- +// Circuit Breaker +// --------------------------------------------------------------------------- + +/** + * Circuit breaker states: + * CLOSED – calls pass through normally (healthy) + * OPEN – calls are rejected immediately (endpoint assumed failed) + * HALF_OPEN – a single probe call is allowed to test recovery + */ +export type CircuitState = "CLOSED" | "OPEN" | "HALF_OPEN" + +interface CircuitBreakerOptions { + /** Number of consecutive failures before the circuit opens. Default: 5 */ + failureThreshold?: number + /** Milliseconds to wait before moving from OPEN → HALF_OPEN. Default: 30 000 */ + resetTimeoutMs?: number + /** Label used in log messages. Default: "stellar-rpc" */ + label?: string +} + +export class CircuitBreaker { + private state: CircuitState = "CLOSED" + private consecutiveFailures = 0 + private openedAt: number | null = null + + private readonly failureThreshold: number + private readonly resetTimeoutMs: number + private readonly label: string + + constructor(options: CircuitBreakerOptions = {}) { + this.failureThreshold = options.failureThreshold ?? 5 + this.resetTimeoutMs = options.resetTimeoutMs ?? 30_000 + this.label = options.label ?? "stellar-rpc" + } + + /** Returns a snapshot of the current circuit state for health checks. */ + getStatus(): { + state: CircuitState + consecutiveFailures: number + openedAt: string | null + } { + return { + state: this.state, + consecutiveFailures: this.consecutiveFailures, + openedAt: this.openedAt ? new Date(this.openedAt).toISOString() : null, + } + } + + /** + * Wrap an async operation with circuit-breaker protection. + * Throws `CircuitOpenError` when the circuit is OPEN and the probe window + * has not yet elapsed. + */ + async call(operation: () => Promise): Promise { + this.maybeTransitionToHalfOpen() + + if (this.state === "OPEN") { + throw new CircuitOpenError( + `[circuit:${this.label}] Circuit is OPEN – Stellar RPC calls are suspended`, + ) + } + + try { + const result = await operation() + this.onSuccess() + return result + } catch (err) { + this.onFailure(err) + throw err + } + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + private maybeTransitionToHalfOpen(): void { + if ( + this.state === "OPEN" && + this.openedAt !== null && + Date.now() - this.openedAt >= this.resetTimeoutMs + ) { + this.transition("HALF_OPEN") + } + } + + private onSuccess(): void { + if (this.state === "HALF_OPEN") { + this.transition("CLOSED") + } + this.consecutiveFailures = 0 + } + + private onFailure(err: unknown): void { + this.consecutiveFailures++ + + if (this.state === "HALF_OPEN") { + // Probe failed – reopen immediately + this.transition("OPEN") + return + } + + if ( + this.state === "CLOSED" && + this.consecutiveFailures >= this.failureThreshold + ) { + this.transition("OPEN") + } else { + const msg = err instanceof Error ? err.message : String(err) + console.warn( + `[circuit:${this.label}] Failure recorded (${this.consecutiveFailures}/${this.failureThreshold}): ${msg}`, + ) + } + } + + private transition(next: CircuitState): void { + const prev = this.state + this.state = next + + if (next === "OPEN") { + this.openedAt = Date.now() + } else if (next === "CLOSED") { + this.openedAt = null + this.consecutiveFailures = 0 + } + + console.warn( + `[circuit:${this.label}] State transition: ${prev} → ${next}` + + (next === "OPEN" + ? ` (will probe after ${this.resetTimeoutMs}ms)` + : ""), + ) + } +} + +/** Error thrown when a call is rejected because the circuit is open. */ +export class CircuitOpenError extends Error { + readonly isCircuitOpen = true + + constructor(message: string) { + super(message) + this.name = "CircuitOpenError" + } +} + +/** + * Shared circuit breaker instance for all Stellar RPC calls. + * + * Configuration via environment variables: + * STELLAR_CB_FAILURE_THRESHOLD – consecutive failures before opening (default 5) + * STELLAR_CB_RESET_TIMEOUT_MS – ms before probing recovery (default 30 000) + */ +export const stellarRpcCircuitBreaker = new CircuitBreaker({ + failureThreshold: Number(process.env.STELLAR_CB_FAILURE_THRESHOLD ?? 5), + resetTimeoutMs: Number(process.env.STELLAR_CB_RESET_TIMEOUT_MS ?? 30_000), + label: "stellar-rpc", +}) + +// --------------------------------------------------------------------------- + async function ensureAdminRole(): Promise { if (!STELLAR_SECRET_KEY) { throw new Error( @@ -237,7 +398,7 @@ async function callVerifyMilestone( ) } - return withRetry(async () => { + return stellarRpcCircuitBreaker.call(() => withRetry(async () => { try { // Enforce access control before doing anything await ensureAdminRole() @@ -295,7 +456,7 @@ async function callVerifyMilestone( (err instanceof Error ? err.message : String(err)), ) } - }, 3, "callVerifyMilestone") + }, 3, "callVerifyMilestone")) } async function emitRejectionEvent( @@ -316,7 +477,7 @@ async function emitRejectionEvent( ) } - return withRetry(async () => { + return stellarRpcCircuitBreaker.call(() => withRetry(async () => { try { // Enforce access control before doing anything await ensureAdminRole() @@ -374,7 +535,7 @@ async function emitRejectionEvent( (err instanceof Error ? err.message : String(err)), ) } - }, 3, "emitRejectionEvent") + }, 3, "emitRejectionEvent")) } async function callMintScholarNFT( @@ -392,7 +553,7 @@ async function callMintScholarNFT( ) } - return withRetry(async () => { + return stellarRpcCircuitBreaker.call(() => withRetry(async () => { try { const { Keypair, @@ -441,7 +602,7 @@ async function callMintScholarNFT( (err instanceof Error ? err.message : String(err)), ) } - }, 3, "callMintScholarNFT") + }, 3, "callMintScholarNFT")) } /** @@ -460,57 +621,59 @@ async function isEnrolled( } try { - const { - Contract, - rpc, - xdr, - Address, - Networks, - TransactionBuilder, - Keypair, - } = await import("@stellar/stellar-sdk") - - const server = new rpc.Server( - STELLAR_NETWORK === "mainnet" - ? "https://soroban-rpc.stellar.org" - : "https://soroban-testnet.stellar.org", - ) + return await stellarRpcCircuitBreaker.call(async () => { + const { + Contract, + rpc, + xdr, + Address, + Networks, + TransactionBuilder, + Keypair, + } = await import("@stellar/stellar-sdk") - // Get a dummy account for simulation - const dummyKeypair = Keypair.random() - const dummyAccount = await server.getAccount(dummyKeypair.publicKey()) + const server = new rpc.Server( + STELLAR_NETWORK === "mainnet" + ? "https://soroban-rpc.stellar.org" + : "https://soroban-testnet.stellar.org", + ) - const contract = new Contract(COURSE_MILESTONE_CONTRACT_ID) + // Get a dummy account for simulation + const dummyKeypair = Keypair.random() + const dummyAccount = await server.getAccount(dummyKeypair.publicKey()) - // Create address from learner address - const learnerScVal = xdr.ScVal.scvAddress( - new Address(learnerAddress).toScVal() as any, - ) + const contract = new Contract(COURSE_MILESTONE_CONTRACT_ID) - const tx = new TransactionBuilder(dummyAccount, { - fee: "100", - networkPassphrase: - STELLAR_NETWORK === "mainnet" ? Networks.PUBLIC : Networks.TESTNET, - }) - .addOperation( - contract.call("is_enrolled", learnerScVal, xdr.ScVal.scvU32(courseId)), + // Create address from learner address + const learnerScVal = xdr.ScVal.scvAddress( + new Address(learnerAddress).toScVal() as any, ) - .setTimeout(30) - .build() - const simResult = await server.simulateTransaction(tx) + const tx = new TransactionBuilder(dummyAccount, { + fee: "100", + networkPassphrase: + STELLAR_NETWORK === "mainnet" ? Networks.PUBLIC : Networks.TESTNET, + }) + .addOperation( + contract.call("is_enrolled", learnerScVal, xdr.ScVal.scvU32(courseId)), + ) + .setTimeout(30) + .build() - if (rpc.Api.isSimulationError(simResult)) { - console.error("[stellar] is_enrolled simulation failed:", simResult.error) - return false - } + const simResult = await server.simulateTransaction(tx) - if (simResult.result) { - const { scValToNative } = await import("@stellar/stellar-sdk") - return scValToNative(simResult.result.retval) as boolean - } + if (rpc.Api.isSimulationError(simResult)) { + console.error("[stellar] is_enrolled simulation failed:", simResult.error) + return false + } - return false + if (simResult.result) { + const { scValToNative } = await import("@stellar/stellar-sdk") + return scValToNative(simResult.result.retval) as boolean + } + + return false + }) } catch (err) { console.error("[stellar] is_enrolled check failed:", err) return false @@ -532,7 +695,7 @@ async function submitScholarshipProposal( ) } - return withRetry(async () => { + return stellarRpcCircuitBreaker.call(() => withRetry(async () => { try { const { Keypair, @@ -588,7 +751,7 @@ async function submitScholarshipProposal( (err instanceof Error ? err.message : String(err)), ) } - }, 3, "submitScholarshipProposal") + }, 3, "submitScholarshipProposal")) } async function castVote( @@ -606,62 +769,64 @@ async function castVote( ) } - try { - const { - Keypair, - Contract, - TransactionBuilder, - Memo, - Networks, - BASE_FEE, - rpc, - nativeToScVal, - } = await import("@stellar/stellar-sdk") - - const server = new rpc.Server( - STELLAR_NETWORK === "mainnet" - ? "https://soroban-rpc.stellar.org" - : "https://soroban-testnet.stellar.org", - ) + return stellarRpcCircuitBreaker.call(async () => { + try { + const { + Keypair, + Contract, + TransactionBuilder, + Memo, + Networks, + BASE_FEE, + rpc, + nativeToScVal, + } = await import("@stellar/stellar-sdk") - const keypair = Keypair.fromSecret(STELLAR_SECRET_KEY) - const account = await server.getAccount(keypair.publicKey()) - const contract = new Contract(SCHOLARSHIP_TREASURY_CONTRACT_ID) + const server = new rpc.Server( + STELLAR_NETWORK === "mainnet" + ? "https://soroban-rpc.stellar.org" + : "https://soroban-testnet.stellar.org", + ) - const txBuilder = new TransactionBuilder(account, { - fee: BASE_FEE, - networkPassphrase: - STELLAR_NETWORK === "mainnet" ? Networks.PUBLIC : Networks.TESTNET, - }) - const requestMemoValue = buildRequestMemoValue(resolveRequestId(options)) - if (requestMemoValue) { - txBuilder.addMemo(Memo.text(requestMemoValue)) - } + const keypair = Keypair.fromSecret(STELLAR_SECRET_KEY) + const account = await server.getAccount(keypair.publicKey()) + const contract = new Contract(SCHOLARSHIP_TREASURY_CONTRACT_ID) - const tx = txBuilder - .addOperation( - contract.call( - "vote", - nativeToScVal(params.voter, { type: "address" }), - nativeToScVal(params.proposalId, { type: "u32" }), - nativeToScVal(params.support, { type: "bool" }), - ), - ) - .setTimeout(30) - .build() + const txBuilder = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: + STELLAR_NETWORK === "mainnet" ? Networks.PUBLIC : Networks.TESTNET, + }) + const requestMemoValue = buildRequestMemoValue(resolveRequestId(options)) + if (requestMemoValue) { + txBuilder.addMemo(Memo.text(requestMemoValue)) + } - const prepared = await server.prepareTransaction(tx) - prepared.sign(keypair) + const tx = txBuilder + .addOperation( + contract.call( + "vote", + nativeToScVal(params.voter, { type: "address" }), + nativeToScVal(params.proposalId, { type: "u32" }), + nativeToScVal(params.support, { type: "bool" }), + ), + ) + .setTimeout(30) + .build() - const result = await server.sendTransaction(prepared) + const prepared = await server.prepareTransaction(tx) + prepared.sign(keypair) - return { txHash: result.hash, simulated: false } - } catch (err) { - console.error("[stellar] Cast vote failed:", err) - throw new Error( - "Cast vote failed: " + (err instanceof Error ? err.message : String(err)), - ) - } + const result = await server.sendTransaction(prepared) + + return { txHash: result.hash, simulated: false } + } catch (err) { + console.error("[stellar] Cast vote failed:", err) + throw new Error( + "Cast vote failed: " + (err instanceof Error ? err.message : String(err)), + ) + } + }) } async function cancelProposal( @@ -679,61 +844,63 @@ async function cancelProposal( ) } - try { - const { - Keypair, - Contract, - TransactionBuilder, - Memo, - Networks, - BASE_FEE, - rpc, - nativeToScVal, - } = await import("@stellar/stellar-sdk") - - const server = new rpc.Server( - STELLAR_NETWORK === "mainnet" - ? "https://soroban-rpc.stellar.org" - : "https://soroban-testnet.stellar.org", - ) + return stellarRpcCircuitBreaker.call(async () => { + try { + const { + Keypair, + Contract, + TransactionBuilder, + Memo, + Networks, + BASE_FEE, + rpc, + nativeToScVal, + } = await import("@stellar/stellar-sdk") - const keypair = Keypair.fromSecret(STELLAR_SECRET_KEY) - const account = await server.getAccount(keypair.publicKey()) - const contract = new Contract(SCHOLARSHIP_TREASURY_CONTRACT_ID) + const server = new rpc.Server( + STELLAR_NETWORK === "mainnet" + ? "https://soroban-rpc.stellar.org" + : "https://soroban-testnet.stellar.org", + ) - const txBuilder = new TransactionBuilder(account, { - fee: BASE_FEE, - networkPassphrase: - STELLAR_NETWORK === "mainnet" ? Networks.PUBLIC : Networks.TESTNET, - }) - const requestMemoValue = buildRequestMemoValue(resolveRequestId(options)) - if (requestMemoValue) { - txBuilder.addMemo(Memo.text(requestMemoValue)) - } + const keypair = Keypair.fromSecret(STELLAR_SECRET_KEY) + const account = await server.getAccount(keypair.publicKey()) + const contract = new Contract(SCHOLARSHIP_TREASURY_CONTRACT_ID) - const tx = txBuilder - .addOperation( - contract.call( - "cancel_proposal", - nativeToScVal(params.proposalId, { type: "u32" }), - ), - ) - .setTimeout(30) - .build() + const txBuilder = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: + STELLAR_NETWORK === "mainnet" ? Networks.PUBLIC : Networks.TESTNET, + }) + const requestMemoValue = buildRequestMemoValue(resolveRequestId(options)) + if (requestMemoValue) { + txBuilder.addMemo(Memo.text(requestMemoValue)) + } + + const tx = txBuilder + .addOperation( + contract.call( + "cancel_proposal", + nativeToScVal(params.proposalId, { type: "u32" }), + ), + ) + .setTimeout(30) + .build() - const prepared = await server.prepareTransaction(tx) - prepared.sign(keypair) + const prepared = await server.prepareTransaction(tx) + prepared.sign(keypair) - const result = await server.sendTransaction(prepared) + const result = await server.sendTransaction(prepared) - return { txHash: result.hash, simulated: false } - } catch (err) { - console.error("[stellar] Cancel proposal failed:", err) - throw new Error( - "Cancel proposal failed: " + - (err instanceof Error ? err.message : String(err)), - ) - } + return { txHash: result.hash, simulated: false } + } catch (err) { + console.error("[stellar] Cancel proposal failed:", err) + throw new Error( + "Cancel proposal failed: " + + (err instanceof Error ? err.message : String(err)), + ) + } + }) } async function reclaimInactiveEscrow( @@ -751,60 +918,62 @@ async function reclaimInactiveEscrow( ) } - try { - const { - Keypair, - Contract, - TransactionBuilder, - Memo, - Networks, - BASE_FEE, - rpc, - nativeToScVal, - } = await import("@stellar/stellar-sdk") - - const server = new rpc.Server( - STELLAR_NETWORK === "mainnet" - ? "https://soroban-rpc.stellar.org" - : "https://soroban-testnet.stellar.org", - ) + return stellarRpcCircuitBreaker.call(async () => { + try { + const { + Keypair, + Contract, + TransactionBuilder, + Memo, + Networks, + BASE_FEE, + rpc, + nativeToScVal, + } = await import("@stellar/stellar-sdk") - const keypair = Keypair.fromSecret(STELLAR_SECRET_KEY) - const account = await server.getAccount(keypair.publicKey()) - const contract = new Contract(MILESTONE_ESCROW_CONTRACT_ID) + const server = new rpc.Server( + STELLAR_NETWORK === "mainnet" + ? "https://soroban-rpc.stellar.org" + : "https://soroban-testnet.stellar.org", + ) - const txBuilder = new TransactionBuilder(account, { - fee: BASE_FEE, - networkPassphrase: - STELLAR_NETWORK === "mainnet" ? Networks.PUBLIC : Networks.TESTNET, - }) - const requestMemoValue = buildRequestMemoValue(resolveRequestId(options)) - if (requestMemoValue) { - txBuilder.addMemo(Memo.text(requestMemoValue)) - } + const keypair = Keypair.fromSecret(STELLAR_SECRET_KEY) + const account = await server.getAccount(keypair.publicKey()) + const contract = new Contract(MILESTONE_ESCROW_CONTRACT_ID) - const tx = txBuilder - .addOperation( - contract.call( - "reclaim_inactive", - nativeToScVal(proposalId, { type: "u32" }), - ), - ) - .setTimeout(30) - .build() + const txBuilder = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: + STELLAR_NETWORK === "mainnet" ? Networks.PUBLIC : Networks.TESTNET, + }) + const requestMemoValue = buildRequestMemoValue(resolveRequestId(options)) + if (requestMemoValue) { + txBuilder.addMemo(Memo.text(requestMemoValue)) + } - const prepared = await server.prepareTransaction(tx) - prepared.sign(keypair) + const tx = txBuilder + .addOperation( + contract.call( + "reclaim_inactive", + nativeToScVal(proposalId, { type: "u32" }), + ), + ) + .setTimeout(30) + .build() - const result = await server.sendTransaction(prepared) - return { txHash: result.hash, simulated: false } - } catch (err) { - console.error("[stellar] reclaim_inactive failed:", err) - throw new Error( - "reclaim_inactive failed: " + - (err instanceof Error ? err.message : String(err)), - ) - } + const prepared = await server.prepareTransaction(tx) + prepared.sign(keypair) + + const result = await server.sendTransaction(prepared) + return { txHash: result.hash, simulated: false } + } catch (err) { + console.error("[stellar] reclaim_inactive failed:", err) + throw new Error( + "reclaim_inactive failed: " + + (err instanceof Error ? err.message : String(err)), + ) + } + }) } async function getLearnTokenBalance(address: string): Promise { @@ -815,39 +984,29 @@ async function getLearnTokenBalance(address: string): Promise { return "10000000000" // 1000 LRN } try { - const { Contract, Address } = await import("@stellar/stellar-sdk") - const server = new (await import("@stellar/stellar-sdk")).rpc.Server( - STELLAR_NETWORK === "mainnet" - ? "https://soroban-rpc.stellar.org" - : "https://soroban-testnet.stellar.org", - ) - const contract = new Contract(LEARN_TOKEN_CONTRACT_ID) - const tx = new (await import("@stellar/stellar-sdk")).TransactionBuilder( - new (await import("@stellar/stellar-sdk")).Account( - "GDGQVOKHW4VEJRU2TETD6DBRKEO5ERCNF353LW5JBF3UKJQ2K5RQDD", - "0", - ), - { - fee: "100", - networkPassphrase: - STELLAR_NETWORK === "mainnet" - ? (await import("@stellar/stellar-sdk")).Networks.PUBLIC - : (await import("@stellar/stellar-sdk")).Networks.TESTNET, - }, - ) - .addOperation(contract.call("balance", new Address(address).toScVal())) - .setTimeout(30) - .build() - - const simResult = await server.simulateTransaction(tx) - if ( - (await import("@stellar/stellar-sdk")).rpc.Api.isSimulationError( - simResult, + return await stellarRpcCircuitBreaker.call(async () => { + const { Contract, Address, rpc, TransactionBuilder, Account, Networks } = await import("@stellar/stellar-sdk") + const server = new rpc.Server( + STELLAR_NETWORK === "mainnet" + ? "https://soroban-rpc.stellar.org" + : "https://soroban-testnet.stellar.org", ) - ) - return "0" - const { scValToNative } = await import("@stellar/stellar-sdk") - return scValToNative(simResult.result?.retval!).toString() + const contract = new Contract(LEARN_TOKEN_CONTRACT_ID) + const tx = new TransactionBuilder( + new Account("GDGQVOKHW4VEJRU2TETD6DBRKEO5ERCNF353LW5JBF3UKJQ2K5RQDD", "0"), + { + fee: "100", + networkPassphrase: STELLAR_NETWORK === "mainnet" ? Networks.PUBLIC : Networks.TESTNET, + }, + ) + .addOperation(contract.call("balance", new Address(address).toScVal())) + .setTimeout(30) + .build() + const simResult = await server.simulateTransaction(tx) + if (rpc.Api.isSimulationError(simResult)) return "0" + const { scValToNative } = await import("@stellar/stellar-sdk") + return scValToNative(simResult.result?.retval!).toString() + }) } catch (err) { console.error("[stellar] getLearnTokenBalance failed:", err) return "0" @@ -862,39 +1021,29 @@ async function getGovernanceTokenBalance(address: string): Promise { return "1250000000" } try { - const { Contract, Address } = await import("@stellar/stellar-sdk") - const server = new (await import("@stellar/stellar-sdk")).rpc.Server( - STELLAR_NETWORK === "mainnet" - ? "https://soroban-rpc.stellar.org" - : "https://soroban-testnet.stellar.org", - ) - const contract = new Contract(GOVERNANCE_TOKEN_CONTRACT_ID) - const tx = new (await import("@stellar/stellar-sdk")).TransactionBuilder( - new (await import("@stellar/stellar-sdk")).Account( - "GDGQVOKHW4VEJRU2TETD6DBRKEO5ERCNF353LW5JBF3UKJQ2K5RQDD", - "0", - ), - { - fee: "100", - networkPassphrase: - STELLAR_NETWORK === "mainnet" - ? (await import("@stellar/stellar-sdk")).Networks.PUBLIC - : (await import("@stellar/stellar-sdk")).Networks.TESTNET, - }, - ) - .addOperation(contract.call("balance", new Address(address).toScVal())) - .setTimeout(30) - .build() - - const simResult = await server.simulateTransaction(tx) - if ( - (await import("@stellar/stellar-sdk")).rpc.Api.isSimulationError( - simResult, + return await stellarRpcCircuitBreaker.call(async () => { + const { Contract, Address, rpc, TransactionBuilder, Account, Networks } = await import("@stellar/stellar-sdk") + const server = new rpc.Server( + STELLAR_NETWORK === "mainnet" + ? "https://soroban-rpc.stellar.org" + : "https://soroban-testnet.stellar.org", ) - ) - return "0" - const { scValToNative } = await import("@stellar/stellar-sdk") - return scValToNative(simResult.result?.retval!).toString() + const contract = new Contract(GOVERNANCE_TOKEN_CONTRACT_ID) + const tx = new TransactionBuilder( + new Account("GDGQVOKHW4VEJRU2TETD6DBRKEO5ERCNF353LW5JBF3UKJQ2K5RQDD", "0"), + { + fee: "100", + networkPassphrase: STELLAR_NETWORK === "mainnet" ? Networks.PUBLIC : Networks.TESTNET, + }, + ) + .addOperation(contract.call("balance", new Address(address).toScVal())) + .setTimeout(30) + .build() + const simResult = await server.simulateTransaction(tx) + if (rpc.Api.isSimulationError(simResult)) return "0" + const { scValToNative } = await import("@stellar/stellar-sdk") + return scValToNative(simResult.result?.retval!).toString() + }) } catch (err) { console.error("[stellar] getGovernanceTokenBalance failed:", err) return "0" @@ -909,41 +1058,31 @@ async function getGovernanceVotingPower(address: string): Promise { return "1250000000" } try { - const { Contract, Address } = await import("@stellar/stellar-sdk") - const server = new (await import("@stellar/stellar-sdk")).rpc.Server( - STELLAR_NETWORK === "mainnet" - ? "https://soroban-rpc.stellar.org" - : "https://soroban-testnet.stellar.org", - ) - const contract = new Contract(GOVERNANCE_TOKEN_CONTRACT_ID) - const tx = new (await import("@stellar/stellar-sdk")).TransactionBuilder( - new (await import("@stellar/stellar-sdk")).Account( - "GDGQVOKHW4VEJRU2TETD6DBRKEO5ERCNF353LW5JBF3UKJQ2K5RQDD", - "0", - ), - { - fee: "100", - networkPassphrase: - STELLAR_NETWORK === "mainnet" - ? (await import("@stellar/stellar-sdk")).Networks.PUBLIC - : (await import("@stellar/stellar-sdk")).Networks.TESTNET, - }, - ) - .addOperation( - contract.call("get_voting_power", new Address(address).toScVal()), + return await stellarRpcCircuitBreaker.call(async () => { + const { Contract, Address, rpc, TransactionBuilder, Account, Networks } = await import("@stellar/stellar-sdk") + const server = new rpc.Server( + STELLAR_NETWORK === "mainnet" + ? "https://soroban-rpc.stellar.org" + : "https://soroban-testnet.stellar.org", ) - .setTimeout(30) - .build() - - const simResult = await server.simulateTransaction(tx) - if ( - (await import("@stellar/stellar-sdk")).rpc.Api.isSimulationError( - simResult, + const contract = new Contract(GOVERNANCE_TOKEN_CONTRACT_ID) + const tx = new TransactionBuilder( + new Account("GDGQVOKHW4VEJRU2TETD6DBRKEO5ERCNF353LW5JBF3UKJQ2K5RQDD", "0"), + { + fee: "100", + networkPassphrase: STELLAR_NETWORK === "mainnet" ? Networks.PUBLIC : Networks.TESTNET, + }, ) - ) - return "0" - const { scValToNative } = await import("@stellar/stellar-sdk") - return scValToNative(simResult.result?.retval!).toString() + .addOperation( + contract.call("get_voting_power", new Address(address).toScVal()), + ) + .setTimeout(30) + .build() + const simResult = await server.simulateTransaction(tx) + if (rpc.Api.isSimulationError(simResult)) return "0" + const { scValToNative } = await import("@stellar/stellar-sdk") + return scValToNative(simResult.result?.retval!).toString() + }) } catch (err) { console.error("[stellar] getGovernanceVotingPower failed:", err) return "0" @@ -955,43 +1094,33 @@ async function getGovernanceDelegation( ): Promise { if (!GOVERNANCE_TOKEN_CONTRACT_ID) return null try { - const { Contract, Address } = await import("@stellar/stellar-sdk") - const server = new (await import("@stellar/stellar-sdk")).rpc.Server( - STELLAR_NETWORK === "mainnet" - ? "https://soroban-rpc.stellar.org" - : "https://soroban-testnet.stellar.org", - ) - const contract = new Contract(GOVERNANCE_TOKEN_CONTRACT_ID) - const tx = new (await import("@stellar/stellar-sdk")).TransactionBuilder( - new (await import("@stellar/stellar-sdk")).Account( - "GDGQVOKHW4VEJRU2TETD6DBRKEO5ERCNF353LW5JBF3UKJQ2K5RQDD", - "0", - ), - { - fee: "100", - networkPassphrase: - STELLAR_NETWORK === "mainnet" - ? (await import("@stellar/stellar-sdk")).Networks.PUBLIC - : (await import("@stellar/stellar-sdk")).Networks.TESTNET, - }, - ) - .addOperation( - contract.call("get_delegate", new Address(address).toScVal()), + return await stellarRpcCircuitBreaker.call(async () => { + const { Contract, Address, rpc, TransactionBuilder, Account, Networks } = await import("@stellar/stellar-sdk") + const server = new rpc.Server( + STELLAR_NETWORK === "mainnet" + ? "https://soroban-rpc.stellar.org" + : "https://soroban-testnet.stellar.org", ) - .setTimeout(30) - .build() - - const simResult = await server.simulateTransaction(tx) - if ( - (await import("@stellar/stellar-sdk")).rpc.Api.isSimulationError( - simResult, + const contract = new Contract(GOVERNANCE_TOKEN_CONTRACT_ID) + const tx = new TransactionBuilder( + new Account("GDGQVOKHW4VEJRU2TETD6DBRKEO5ERCNF353LW5JBF3UKJQ2K5RQDD", "0"), + { + fee: "100", + networkPassphrase: STELLAR_NETWORK === "mainnet" ? Networks.PUBLIC : Networks.TESTNET, + }, ) - ) - return null - const { scValToNative } = await import("@stellar/stellar-sdk") - const raw = scValToNative(simResult.result?.retval!) - // Option
→ null (None) or an Address string (Some) - return typeof raw === "string" ? raw : null + .addOperation( + contract.call("get_delegate", new Address(address).toScVal()), + ) + .setTimeout(30) + .build() + const simResult = await server.simulateTransaction(tx) + if (rpc.Api.isSimulationError(simResult)) return null + const { scValToNative } = await import("@stellar/stellar-sdk") + const raw = scValToNative(simResult.result?.retval!) + // Option
→ null (None) or an Address string (Some) + return typeof raw === "string" ? raw : null + }) } catch (err) { console.error("[stellar] getGovernanceDelegation failed:", err) return null