Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions soroban-client/lib/errors.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
82 changes: 62 additions & 20 deletions soroban-client/lib/soroban.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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.
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -596,3 +601,40 @@ export async function getActiveListings(): Promise<any[]> {
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);
}
}
5 changes: 5 additions & 0 deletions soroban-contract/contracts/tba_account/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<Val>) -> Result<Vec<Val>, Error> {
upg::require_not_paused(&env);
if !is_initialized(&env) {
return Err(Error::NotInitialized);
}
Expand Down Expand Up @@ -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);
}
Expand All @@ -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);
}
Expand All @@ -244,6 +248,7 @@ impl TbaAccount {
token_address: Address,
recipients: Vec<(Address, i128)>,
) -> Result<u32, Error> {
upg::require_not_paused(&env);
if !is_initialized(&env) {
return Err(Error::NotInitialized);
}
Expand Down
1 change: 1 addition & 0 deletions soroban-contract/contracts/tba_registry/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ impl TbaRegistry {
token_id: u128,
salt: BytesN<32>,
) -> Result<Address, Error> {
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(
Expand Down
55 changes: 55 additions & 0 deletions soroban-react/src/components/ConnectWallet.tsx
Original file line number Diff line number Diff line change
@@ -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<ConnectWalletProps> = ({ onConnect, className }) => {
const [address, setAddress] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className={`soroban-connect-wallet ${className || ''}`}>
{address ? (
<div className="flex items-center gap-2 px-4 py-2 bg-green-100 text-green-800 rounded-lg">
<span className="w-2 h-2 bg-green-500 rounded-full"></span>
<span className="font-mono text-sm">{address.slice(0, 6)}...{address.slice(-4)}</span>
</div>
) : (
<button
onClick={handleConnect}
disabled={loading}
className="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors disabled:opacity-50"
>
{loading ? 'Connecting...' : 'Connect Wallet'}
</button>
)}
{error && <p className="mt-2 text-xs text-red-600">{error}</p>}
</div>
);
};
54 changes: 54 additions & 0 deletions soroban-react/src/components/ContractCallButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import React, { useState } from 'react';

export interface ContractCallButtonProps {
onClick: () => Promise<any>;
label: string;
loadingLabel?: string;
className?: string;
onSuccess?: (result: any) => void;
onError?: (error: any) => void;
}

export const ContractCallButton: React.FC<ContractCallButtonProps> = ({
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 (
<button
onClick={handleClick}
disabled={loading}
className={`px-6 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors disabled:opacity-50 ${className}`}
>
{loading ? (
<span className="flex items-center gap-2">
<svg className="animate-spin h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{loadingLabel}
</span>
) : (
label
)}
</button>
);
};
2 changes: 2 additions & 0 deletions soroban-react/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './components/ConnectWallet';
export * from './components/ContractCallButton';
Loading