From baa24c186a2e1df3e4fadfd422858636fe8ab7f4 Mon Sep 17 00:00:00 2001 From: samuel1-ona Date: Wed, 29 Apr 2026 11:43:24 +0100 Subject: [PATCH 1/3] feat(soroban-client): implement fee bump helpers and error classification (#226, #224) --- soroban-client/lib/errors.ts | 85 +++++++++++++++++++++++++++++++++++ soroban-client/lib/soroban.ts | 82 ++++++++++++++++++++++++--------- 2 files changed, 147 insertions(+), 20 deletions(-) create mode 100644 soroban-client/lib/errors.ts diff --git a/soroban-client/lib/errors.ts b/soroban-client/lib/errors.ts new file mode 100644 index 00000000..3eaff3fc --- /dev/null +++ b/soroban-client/lib/errors.ts @@ -0,0 +1,85 @@ +/** + * Base class for all Soroban-related errors. + */ +export class SorobanError extends Error { + constructor( + message: string, + public readonly category: SorobanErrorCategory, + public readonly originalError?: any, + ) { + super(message); + this.name = "SorobanError"; + } +} + +export enum SorobanErrorCategory { + NETWORK = "NETWORK", + SIMULATION = "SIMULATION", + TRANSACTION = "TRANSACTION", + ACCOUNT_NOT_FOUND = "ACCOUNT_NOT_FOUND", + INSUFFICIENT_FUNDS = "INSUFFICIENT_FUNDS", + UNSUPPORTED_PROTOCOL = "UNSUPPORTED_PROTOCOL", + UNKNOWN = "UNKNOWN", +} + +/** + * Maps low-level RPC and transaction errors to consistent typed error categories. + */ +export function classifyError(error: any): SorobanError { + if (error instanceof SorobanError) return error; + + const message = error?.message || "An unknown error occurred"; + + if (message.includes("404") || message.includes("not found")) { + return new SorobanError( + "Source account not found on the network.", + SorobanErrorCategory.ACCOUNT_NOT_FOUND, + error, + ); + } + + if (message.includes("simulation failed") || message.includes("SimulationError")) { + return new SorobanError( + `Transaction simulation failed: ${message}`, + SorobanErrorCategory.SIMULATION, + error, + ); + } + + if (message.includes("insufficient") || message.includes("underfunded")) { + return new SorobanError( + "Insufficient funds to cover transaction fees.", + SorobanErrorCategory.INSUFFICIENT_FUNDS, + error, + ); + } + + if (message.includes("network") || message.includes("fetch") || message.includes("ENOTFOUND")) { + return new SorobanError( + "Network connectivity issue. Please check your internet connection.", + SorobanErrorCategory.NETWORK, + error, + ); + } + + return new SorobanError(message, SorobanErrorCategory.UNKNOWN, error); +} + +/** + * Formats a SorobanError into a user-friendly message. + */ +export function getUserFriendlyMessage(error: any): string { + const classified = classifyError(error); + switch (classified.category) { + case SorobanErrorCategory.ACCOUNT_NOT_FOUND: + return "Your wallet account was not found on the network. Please fund it with some XLM."; + case SorobanErrorCategory.INSUFFICIENT_FUNDS: + return "You don't have enough XLM to pay for the transaction fees."; + case SorobanErrorCategory.SIMULATION: + return "The transaction would fail if submitted. This might be due to incorrect parameters or insufficient contract balance."; + case SorobanErrorCategory.NETWORK: + return "Could not connect to the Soroban network. Please try again later."; + default: + return classified.message; + } +} diff --git a/soroban-client/lib/soroban.ts b/soroban-client/lib/soroban.ts index 1a7e3b66..5d8b6a28 100644 --- a/soroban-client/lib/soroban.ts +++ b/soroban-client/lib/soroban.ts @@ -3,7 +3,7 @@ import { signTransaction } from "@stellar/freighter-api"; // Use require for the default export const StellarSdk = require("@stellar/stellar-sdk"); -const { Server, TransactionBuilder, Operation, SorobanRpc } = StellarSdk; +const { Server, TransactionBuilder, Operation, SorobanRpc, FeeBumpTransaction } = StellarSdk; // Import Networks separately to avoid conflict import { Networks } from "@stellar/stellar-sdk"; @@ -14,6 +14,7 @@ import { initializeRPCManager, DEFAULT_RPC_CONFIG, } from "./rpc-failover"; +import { classifyError, SorobanError } from "./errors"; // Configuration helpers – prefer environment variables so they can be swapped // for different networks (testnet / preview / mainnet) without changing code. @@ -151,25 +152,29 @@ export async function createEvent( args, }); - const tx = new TransactionBuilder(sourceAccount, { - fee: fee.toString(), - networkPassphrase: NETWORK_PASSPHRASE, - }) - .addOperation(operation) - .setTimeout(30) - .build(); - - const txXdr = tx.toXDR(); - - // ask configured wallet provider to sign - const signedTxXdr = await signTransactionFn(txXdr, { - networkPassphrase: NETWORK_PASSPHRASE, - address: params.organizer, - }); - - // submit to horizon and return the result - const signedTx = TransactionBuilder.fromXDR(signedTxXdr, NETWORK_PASSPHRASE); - return await server.submitTransaction(signedTx as any); + try { + const tx = new TransactionBuilder(sourceAccount, { + fee: fee.toString(), + networkPassphrase: NETWORK_PASSPHRASE, + }) + .addOperation(operation) + .setTimeout(30) + .build(); + + const txXdr = tx.toXDR(); + + // ask configured wallet provider to sign + const signedTxXdr = await signTransactionFn(txXdr, { + networkPassphrase: NETWORK_PASSPHRASE, + address: params.organizer, + }); + + // submit to horizon and return the result + const signedTx = TransactionBuilder.fromXDR(signedTxXdr, NETWORK_PASSPHRASE); + return await server.submitTransaction(signedTx as any); + } catch (error) { + throw classifyError(error); + } } export async function buyTickets( @@ -596,3 +601,40 @@ export async function getActiveListings(): Promise { created_at: Number(l.created_at), })); } + +/** + * Builds a fee bump transaction for a given inner transaction. + * @param innerTx The original transaction to be wrapped. + * @param feeSource The account address that will pay the fees. + * @param baseFee The fee to be paid for the fee bump transaction. + */ +export async function buildFeeBumpTransaction( + innerTxXdr: string, + feeSource: string, + baseFee: string, +) { + const server = await getRPCManager().getHorizonServer(); + const feeSourceAccount = await server.loadAccount(feeSource); + + const innerTx = TransactionBuilder.fromXDR(innerTxXdr, NETWORK_PASSPHRASE); + + return TransactionBuilder.buildFeeBumpTransaction( + feeSourceAccount, + baseFee, + innerTx, + NETWORK_PASSPHRASE, + ); +} + +/** + * Submits a fee bump transaction to the network. + * @param feeBumpTx The fee bump transaction to submit. + */ +export async function submitFeeBumpTransaction(feeBumpTx: any) { + const server = await getRPCManager().getHorizonServer(); + try { + return await server.submitTransaction(feeBumpTx); + } catch (error) { + throw classifyError(error); + } +} From 6bdea00c7f5edad92ec6421adf14d6cf6e46a016 Mon Sep 17 00:00:00 2001 From: samuel1-ona Date: Wed, 29 Apr 2026 11:43:49 +0100 Subject: [PATCH 2/3] feat(soroban-react): create initial React component library (#185) --- .../src/components/ConnectWallet.tsx | 55 +++++++++++++++++++ .../src/components/ContractCallButton.tsx | 54 ++++++++++++++++++ soroban-react/src/index.ts | 2 + 3 files changed, 111 insertions(+) create mode 100644 soroban-react/src/components/ConnectWallet.tsx create mode 100644 soroban-react/src/components/ContractCallButton.tsx create mode 100644 soroban-react/src/index.ts diff --git a/soroban-react/src/components/ConnectWallet.tsx b/soroban-react/src/components/ConnectWallet.tsx new file mode 100644 index 00000000..c7e00a59 --- /dev/null +++ b/soroban-react/src/components/ConnectWallet.tsx @@ -0,0 +1,55 @@ +import React, { useState } from 'react'; +import { isConnected, getAddress } from '@stellar/freighter-api'; + +export interface ConnectWalletProps { + onConnect?: (address: string) => void; + className?: string; +} + +export const ConnectWallet: React.FC = ({ onConnect, className }) => { + const [address, setAddress] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const handleConnect = async () => { + setLoading(true); + setError(null); + try { + const connected = await isConnected(); + if (!connected) { + throw new Error('Freighter wallet not found. Please install it.'); + } + const { address } = await getAddress(); + if (address) { + setAddress(address); + onConnect?.(address); + } else { + throw new Error('No address returned from wallet.'); + } + } catch (err: any) { + setError(err.message || 'Failed to connect wallet'); + } finally { + setLoading(false); + } + }; + + return ( +
+ {address ? ( +
+ + {address.slice(0, 6)}...{address.slice(-4)} +
+ ) : ( + + )} + {error &&

{error}

} +
+ ); +}; diff --git a/soroban-react/src/components/ContractCallButton.tsx b/soroban-react/src/components/ContractCallButton.tsx new file mode 100644 index 00000000..0f05bcbb --- /dev/null +++ b/soroban-react/src/components/ContractCallButton.tsx @@ -0,0 +1,54 @@ +import React, { useState } from 'react'; + +export interface ContractCallButtonProps { + onClick: () => Promise; + label: string; + loadingLabel?: string; + className?: string; + onSuccess?: (result: any) => void; + onError?: (error: any) => void; +} + +export const ContractCallButton: React.FC = ({ + onClick, + label, + loadingLabel = 'Processing...', + className = '', + onSuccess, + onError, +}) => { + const [loading, setLoading] = useState(false); + + const handleClick = async () => { + setLoading(true); + try { + const result = await onClick(); + onSuccess?.(result); + } catch (err) { + console.error('Contract call failed:', err); + onError?.(err); + } finally { + setLoading(false); + } + }; + + return ( + + ); +}; diff --git a/soroban-react/src/index.ts b/soroban-react/src/index.ts new file mode 100644 index 00000000..156a39d3 --- /dev/null +++ b/soroban-react/src/index.ts @@ -0,0 +1,2 @@ +export * from './components/ConnectWallet'; +export * from './components/ContractCallButton'; From 013899a0d473b69f683ba501dec714f94ca6fac5 Mon Sep 17 00:00:00 2001 From: samuel1-ona Date: Wed, 29 Apr 2026 11:44:32 +0100 Subject: [PATCH 3/3] feat(soroban-contract): implement circuit breaker pattern in TBA contracts (#179) --- soroban-contract/contracts/tba_account/src/lib.rs | 5 +++++ soroban-contract/contracts/tba_registry/src/lib.rs | 1 + 2 files changed, 6 insertions(+) diff --git a/soroban-contract/contracts/tba_account/src/lib.rs b/soroban-contract/contracts/tba_account/src/lib.rs index 74efa8cf..fc7b354d 100644 --- a/soroban-contract/contracts/tba_account/src/lib.rs +++ b/soroban-contract/contracts/tba_account/src/lib.rs @@ -104,6 +104,7 @@ impl TbaAccount { implementation_hash: BytesN<32>, salt: BytesN<32>, ) -> Result<(), Error> { + upg::require_not_paused(&env); // Prevent re-initialization if is_initialized(&env) { return Err(Error::AlreadyInitialized); @@ -169,6 +170,7 @@ impl TbaAccount { /// Only the current NFT owner can execute transactions /// This function increments the nonce and emits an event pub fn execute(env: Env, to: Address, func: Symbol, args: Vec) -> Result, Error> { + upg::require_not_paused(&env); if !is_initialized(&env) { return Err(Error::NotInitialized); } @@ -203,6 +205,7 @@ impl TbaAccount { to: Address, amount: i128, ) -> Result<(), Error> { + upg::require_not_paused(&env); if !is_initialized(&env) { return Err(Error::NotInitialized); } @@ -224,6 +227,7 @@ impl TbaAccount { to: Address, nft_token_id: u128, ) -> Result<(), Error> { + upg::require_not_paused(&env); if !is_initialized(&env) { return Err(Error::NotInitialized); } @@ -244,6 +248,7 @@ impl TbaAccount { token_address: Address, recipients: Vec<(Address, i128)>, ) -> Result { + upg::require_not_paused(&env); if !is_initialized(&env) { return Err(Error::NotInitialized); } diff --git a/soroban-contract/contracts/tba_registry/src/lib.rs b/soroban-contract/contracts/tba_registry/src/lib.rs index d0a3ba17..0d660aa1 100644 --- a/soroban-contract/contracts/tba_registry/src/lib.rs +++ b/soroban-contract/contracts/tba_registry/src/lib.rs @@ -125,6 +125,7 @@ impl TbaRegistry { token_id: u128, salt: BytesN<32>, ) -> Result { + upg::require_not_paused(&env); // Verify that the caller owns the NFT (Issue #26) // This is a cross-contract call to the NFT contract let owner: Address = env.invoke_contract(