diff --git a/server/src/routes/health.routes.ts b/server/src/routes/health.routes.ts index 79d002f9..4750bee9 100644 --- a/server/src/routes/health.routes.ts +++ b/server/src/routes/health.routes.ts @@ -274,6 +274,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 5013c08c..ca5b1849 100644 --- a/server/src/services/stellar-contract.service.ts +++ b/server/src/services/stellar-contract.service.ts @@ -152,6 +152,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( @@ -241,26 +402,43 @@ async function callVerifyMilestone( ) } - return withRetry( - async () => { - try { - // Enforce access control before doing anything - await ensureAdminRole() - // Dynamic import so the SDK is only loaded when actually needed - const { - Keypair, - Contract, - TransactionBuilder, - Networks, - BASE_FEE, - rpc, - xdr, - } = 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(() => withRetry(async () => { + try { + // Enforce access control before doing anything + await ensureAdminRole() + // Dynamic import so the SDK is only loaded when actually needed + const { + Keypair, + Contract, + TransactionBuilder, + Networks, + BASE_FEE, + rpc, + xdr, + } = await import("@stellar/stellar-sdk") + + const server = new rpc.Server( + STELLAR_NETWORK === "mainnet" + ? "https://soroban-rpc.stellar.org" + : "https://soroban-testnet.stellar.org", + ) + + const keypair = Keypair.fromSecret(STELLAR_SECRET_KEY) + const account = await server.getAccount(keypair.publicKey()) + const contract = new Contract(COURSE_MILESTONE_CONTRACT_ID) + + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: + STELLAR_NETWORK === "mainnet" ? Networks.PUBLIC : Networks.TESTNET, + }) + .addOperation( + contract.call( + "verify_milestone", + xdr.ScVal.scvString(scholarAddress), + xdr.ScVal.scvString(courseId), + xdr.ScVal.scvU32(milestoneId), + ), ) const keypair = Keypair.fromSecret(STELLAR_SECRET_KEY) @@ -300,10 +478,13 @@ async function callVerifyMilestone( (err instanceof Error ? err.message : String(err)), ) } - }, - 3, - "callVerifyMilestone", - ) + log.error({ err }, "Contract call failed") + throw new Error( + "Contract call failed: " + + (err instanceof Error ? err.message : String(err)), + ) + } + }, 3, "callVerifyMilestone")) } async function emitRejectionEvent( @@ -324,25 +505,43 @@ async function emitRejectionEvent( ) } - return withRetry( - async () => { - try { - // Enforce access control before doing anything - await ensureAdminRole() - const { - Keypair, - Contract, - TransactionBuilder, - Networks, - BASE_FEE, - rpc, - xdr, - } = 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(() => withRetry(async () => { + try { + // Enforce access control before doing anything + await ensureAdminRole() + const { + Keypair, + Contract, + TransactionBuilder, + Networks, + BASE_FEE, + rpc, + xdr, + } = await import("@stellar/stellar-sdk") + + const server = new rpc.Server( + STELLAR_NETWORK === "mainnet" + ? "https://soroban-rpc.stellar.org" + : "https://soroban-testnet.stellar.org", + ) + + const keypair = Keypair.fromSecret(STELLAR_SECRET_KEY) + const account = await server.getAccount(keypair.publicKey()) + const contract = new Contract(COURSE_MILESTONE_CONTRACT_ID) + + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: + STELLAR_NETWORK === "mainnet" ? Networks.PUBLIC : Networks.TESTNET, + }) + .addOperation( + contract.call( + "reject_milestone", + xdr.ScVal.scvString(scholarAddress), + xdr.ScVal.scvString(courseId), + xdr.ScVal.scvU32(milestoneId), + xdr.ScVal.scvString(reason), + ), ) const keypair = Keypair.fromSecret(STELLAR_SECRET_KEY) @@ -383,10 +582,13 @@ async function emitRejectionEvent( (err instanceof Error ? err.message : String(err)), ) } - }, - 3, - "emitRejectionEvent", - ) + log.error({ err }, "Rejection event failed") + throw new Error( + "Rejection event failed: " + + (err instanceof Error ? err.message : String(err)), + ) + } + }, 3, "emitRejectionEvent")) } async function callMintScholarNFT( @@ -404,59 +606,53 @@ async function callMintScholarNFT( ) } - return withRetry( - async () => { - try { - const { - Keypair, - Contract, - TransactionBuilder, - Networks, - BASE_FEE, - rpc, - xdr, - } = 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(() => withRetry(async () => { + try { + const { + Keypair, + Contract, + TransactionBuilder, + Networks, + BASE_FEE, + rpc, + xdr, + } = await import("@stellar/stellar-sdk") + + const server = new rpc.Server( + STELLAR_NETWORK === "mainnet" + ? "https://soroban-rpc.stellar.org" + : "https://soroban-testnet.stellar.org", + ) - const keypair = Keypair.fromSecret(STELLAR_SECRET_KEY) - const account = await server.getAccount(keypair.publicKey()) - const contract = new Contract(SCHOLAR_NFT_CONTRACT_ID) + const keypair = Keypair.fromSecret(STELLAR_SECRET_KEY) + const account = await server.getAccount(keypair.publicKey()) + const contract = new Contract(SCHOLAR_NFT_CONTRACT_ID) - const tx = new TransactionBuilder(account, { - fee: BASE_FEE, - networkPassphrase: - STELLAR_NETWORK === "mainnet" ? Networks.PUBLIC : Networks.TESTNET, - }) - .addOperation( - contract.call( - "mint", - xdr.ScVal.scvString(scholarAddress), - xdr.ScVal.scvString(metadataUri), - ), - ) - .setTimeout(30) - .build() + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: + STELLAR_NETWORK === "mainnet" ? Networks.PUBLIC : Networks.TESTNET, + }) + .addOperation( + contract.call( + "mint", + xdr.ScVal.scvString(scholarAddress), + xdr.ScVal.scvString(metadataUri), + ), + ) - const prepared = await server.prepareTransaction(tx) - prepared.sign(keypair) + const prepared = await server.prepareTransaction(tx) + prepared.sign(keypair) - const result = await server.sendTransaction(prepared) - return { txHash: result.hash, simulated: false } - } catch (err) { - log.error({ err }, "ScholarNFT mint failed") - throw new Error( - `ScholarNFT mint failed: ${err instanceof Error ? err.message : String(err)}`, - ) - } - }, - 3, - "callMintScholarNFT", - ) + const result = await server.sendTransaction(prepared) + return { txHash: result.hash, simulated: false, tokenId } + } catch (err) { + log.error({ err }, "ScholarNFT mint failed") + throw new Error( + `ScholarNFT mint failed: ${err instanceof Error ? err.message : String(err)}`, + ) + } + }, 3, "callMintScholarNFT")) } /** @@ -475,57 +671,62 @@ 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") + + const server = new rpc.Server( + STELLAR_NETWORK === "mainnet" + ? "https://soroban-rpc.stellar.org" + : "https://soroban-testnet.stellar.org", + ) - // Get a dummy account for simulation - const dummyKeypair = Keypair.random() - const dummyAccount = await server.getAccount(dummyKeypair.publicKey()) + // Get a dummy account for simulation + const dummyKeypair = Keypair.random() + const dummyAccount = await server.getAccount(dummyKeypair.publicKey()) - const contract = new Contract(COURSE_MILESTONE_CONTRACT_ID) + const contract = new Contract(COURSE_MILESTONE_CONTRACT_ID) - // Create address from learner address - const learnerScVal = xdr.ScVal.scvAddress( - new Address(learnerAddress).toScVal() as any, - ) - - 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)) { log.error({ err: simResult.error }, "is_enrolled simulation failed") return false } - 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) { log.error({ err }, "is_enrolled check failed") return false @@ -547,67 +748,56 @@ async function submitScholarshipProposal( ) } - return withRetry( - async () => { - try { - const { - Keypair, - Contract, - TransactionBuilder, - 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", - ) - - const keypair = Keypair.fromSecret(STELLAR_SECRET_KEY) - const account = await server.getAccount(keypair.publicKey()) - const contract = new Contract(SCHOLARSHIP_TREASURY_CONTRACT_ID) - - const tx = new TransactionBuilder(account, { - fee: BASE_FEE, - networkPassphrase: - STELLAR_NETWORK === "mainnet" ? Networks.PUBLIC : Networks.TESTNET, - }) - .addOperation( - contract.call( - "submit_proposal", - nativeToScVal(params.applicant, { type: "address" }), - nativeToScVal(params.amount, { type: "i128" }), - nativeToScVal(params.programName), - nativeToScVal(params.programUrl), - nativeToScVal(params.programDescription), - nativeToScVal(params.startDate), - nativeToScVal(params.milestoneTitles), - nativeToScVal(params.milestoneDates), - ), - ) - .setTimeout(30) - .build() - - const prepared = await server.prepareTransaction(tx) - prepared.sign(keypair) + return stellarRpcCircuitBreaker.call(() => withRetry(async () => { + try { + const { + Keypair, + Contract, + TransactionBuilder, + 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", + ) - const result = await server.sendTransaction(prepared) + const keypair = Keypair.fromSecret(STELLAR_SECRET_KEY) + const account = await server.getAccount(keypair.publicKey()) + const contract = new Contract(SCHOLARSHIP_TREASURY_CONTRACT_ID) - return { txHash: result.hash, proposalId: null, simulated: false } - } catch (err) { - log.error({ err }, "Scholarship proposal submission failed") - throw new Error( - "Scholarship proposal submission failed: " + - (err instanceof Error ? err.message : String(err)), + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: + STELLAR_NETWORK === "mainnet" ? Networks.PUBLIC : Networks.TESTNET, + }) + .addOperation( + contract.call( + "submit_proposal", + nativeToScVal(params.applicant, { type: "address" }), + nativeToScVal(params.amount, { type: "i128" }), + nativeToScVal(params.programName), + nativeToScVal(params.programUrl), + nativeToScVal(params.programDescription), + nativeToScVal(params.startDate), + nativeToScVal(params.milestoneTitles), + nativeToScVal(params.milestoneDates), + ), ) - } - }, - 3, - "submitScholarshipProposal", - ) + + return { txHash: result.hash, proposalId: null, simulated: false } + } catch (err) { + log.error({ err }, "Scholarship proposal submission failed") + throw new Error( + "Scholarship proposal submission failed: " + + (err instanceof Error ? err.message : String(err)), + ) + } + }, 3, "submitScholarshipProposal")) } async function castVote( @@ -625,54 +815,53 @@ 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", - ) - - const keypair = Keypair.fromSecret(STELLAR_SECRET_KEY) - const account = await server.getAccount(keypair.publicKey()) - const contract = new Contract(SCHOLARSHIP_TREASURY_CONTRACT_ID) + return stellarRpcCircuitBreaker.call(async () => { + 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", + ) - 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) { @@ -698,52 +887,53 @@ 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 server = new rpc.Server( + STELLAR_NETWORK === "mainnet" + ? "https://soroban-rpc.stellar.org" + : "https://soroban-testnet.stellar.org", + ) - const keypair = Keypair.fromSecret(STELLAR_SECRET_KEY) - const account = await server.getAccount(keypair.publicKey()) - const contract = new Contract(SCHOLARSHIP_TREASURY_CONTRACT_ID) + const keypair = Keypair.fromSecret(STELLAR_SECRET_KEY) + const account = await server.getAccount(keypair.publicKey()) + const contract = new Contract(SCHOLARSHIP_TREASURY_CONTRACT_ID) - 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 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 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) { @@ -770,50 +960,48 @@ 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", - ) - - const keypair = Keypair.fromSecret(STELLAR_SECRET_KEY) - const account = await server.getAccount(keypair.publicKey()) - const contract = new Contract(MILESTONE_ESCROW_CONTRACT_ID) + return stellarRpcCircuitBreaker.call(async () => { + 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", + ) - 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 } @@ -896,39 +1084,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) { log.error({ err }, "getLearnTokenBalance failed") return "0" @@ -941,39 +1119,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) { log.error({ err }, "getGovernanceTokenBalance failed") return "0" @@ -988,41 +1156,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) { log.error({ err }, "getGovernanceVotingPower failed") return "0" @@ -1034,43 +1192,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) { log.error({ err }, "getGovernanceDelegation failed") return null