Skip to content

Commit 5e4faf7

Browse files
authored
Merge pull request #324 from Samuel1-ona/fix/multi-issue-cleanup
Fix/multi issue cleanup
2 parents 0fb0054 + 013899a commit 5e4faf7

7 files changed

Lines changed: 264 additions & 20 deletions

File tree

soroban-client/lib/errors.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/**
2+
* Base class for all Soroban-related errors.
3+
*/
4+
export class SorobanError extends Error {
5+
constructor(
6+
message: string,
7+
public readonly category: SorobanErrorCategory,
8+
public readonly originalError?: any,
9+
) {
10+
super(message);
11+
this.name = "SorobanError";
12+
}
13+
}
14+
15+
export enum SorobanErrorCategory {
16+
NETWORK = "NETWORK",
17+
SIMULATION = "SIMULATION",
18+
TRANSACTION = "TRANSACTION",
19+
ACCOUNT_NOT_FOUND = "ACCOUNT_NOT_FOUND",
20+
INSUFFICIENT_FUNDS = "INSUFFICIENT_FUNDS",
21+
UNSUPPORTED_PROTOCOL = "UNSUPPORTED_PROTOCOL",
22+
UNKNOWN = "UNKNOWN",
23+
}
24+
25+
/**
26+
* Maps low-level RPC and transaction errors to consistent typed error categories.
27+
*/
28+
export function classifyError(error: any): SorobanError {
29+
if (error instanceof SorobanError) return error;
30+
31+
const message = error?.message || "An unknown error occurred";
32+
33+
if (message.includes("404") || message.includes("not found")) {
34+
return new SorobanError(
35+
"Source account not found on the network.",
36+
SorobanErrorCategory.ACCOUNT_NOT_FOUND,
37+
error,
38+
);
39+
}
40+
41+
if (message.includes("simulation failed") || message.includes("SimulationError")) {
42+
return new SorobanError(
43+
`Transaction simulation failed: ${message}`,
44+
SorobanErrorCategory.SIMULATION,
45+
error,
46+
);
47+
}
48+
49+
if (message.includes("insufficient") || message.includes("underfunded")) {
50+
return new SorobanError(
51+
"Insufficient funds to cover transaction fees.",
52+
SorobanErrorCategory.INSUFFICIENT_FUNDS,
53+
error,
54+
);
55+
}
56+
57+
if (message.includes("network") || message.includes("fetch") || message.includes("ENOTFOUND")) {
58+
return new SorobanError(
59+
"Network connectivity issue. Please check your internet connection.",
60+
SorobanErrorCategory.NETWORK,
61+
error,
62+
);
63+
}
64+
65+
return new SorobanError(message, SorobanErrorCategory.UNKNOWN, error);
66+
}
67+
68+
/**
69+
* Formats a SorobanError into a user-friendly message.
70+
*/
71+
export function getUserFriendlyMessage(error: any): string {
72+
const classified = classifyError(error);
73+
switch (classified.category) {
74+
case SorobanErrorCategory.ACCOUNT_NOT_FOUND:
75+
return "Your wallet account was not found on the network. Please fund it with some XLM.";
76+
case SorobanErrorCategory.INSUFFICIENT_FUNDS:
77+
return "You don't have enough XLM to pay for the transaction fees.";
78+
case SorobanErrorCategory.SIMULATION:
79+
return "The transaction would fail if submitted. This might be due to incorrect parameters or insufficient contract balance.";
80+
case SorobanErrorCategory.NETWORK:
81+
return "Could not connect to the Soroban network. Please try again later.";
82+
default:
83+
return classified.message;
84+
}
85+
}

soroban-client/lib/soroban.ts

Lines changed: 62 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { signTransaction } from "@stellar/freighter-api";
33

44
// Use require for the default export
55
const StellarSdk = require("@stellar/stellar-sdk");
6-
const { Server, TransactionBuilder, Operation, SorobanRpc } = StellarSdk;
6+
const { Server, TransactionBuilder, Operation, SorobanRpc, FeeBumpTransaction } = StellarSdk;
77

88
// Import Networks separately to avoid conflict
99
import { Networks } from "@stellar/stellar-sdk";
@@ -14,6 +14,7 @@ import {
1414
initializeRPCManager,
1515
DEFAULT_RPC_CONFIG,
1616
} from "./rpc-failover";
17+
import { classifyError, SorobanError } from "./errors";
1718

1819
// Configuration helpers – prefer environment variables so they can be swapped
1920
// for different networks (testnet / preview / mainnet) without changing code.
@@ -151,25 +152,29 @@ export async function createEvent(
151152
args,
152153
});
153154

154-
const tx = new TransactionBuilder(sourceAccount, {
155-
fee: fee.toString(),
156-
networkPassphrase: NETWORK_PASSPHRASE,
157-
})
158-
.addOperation(operation)
159-
.setTimeout(30)
160-
.build();
161-
162-
const txXdr = tx.toXDR();
163-
164-
// ask configured wallet provider to sign
165-
const signedTxXdr = await signTransactionFn(txXdr, {
166-
networkPassphrase: NETWORK_PASSPHRASE,
167-
address: params.organizer,
168-
});
169-
170-
// submit to horizon and return the result
171-
const signedTx = TransactionBuilder.fromXDR(signedTxXdr, NETWORK_PASSPHRASE);
172-
return await server.submitTransaction(signedTx as any);
155+
try {
156+
const tx = new TransactionBuilder(sourceAccount, {
157+
fee: fee.toString(),
158+
networkPassphrase: NETWORK_PASSPHRASE,
159+
})
160+
.addOperation(operation)
161+
.setTimeout(30)
162+
.build();
163+
164+
const txXdr = tx.toXDR();
165+
166+
// ask configured wallet provider to sign
167+
const signedTxXdr = await signTransactionFn(txXdr, {
168+
networkPassphrase: NETWORK_PASSPHRASE,
169+
address: params.organizer,
170+
});
171+
172+
// submit to horizon and return the result
173+
const signedTx = TransactionBuilder.fromXDR(signedTxXdr, NETWORK_PASSPHRASE);
174+
return await server.submitTransaction(signedTx as any);
175+
} catch (error) {
176+
throw classifyError(error);
177+
}
173178
}
174179

175180
export async function buyTickets(
@@ -596,3 +601,40 @@ export async function getActiveListings(): Promise<any[]> {
596601
created_at: Number(l.created_at),
597602
}));
598603
}
604+
605+
/**
606+
* Builds a fee bump transaction for a given inner transaction.
607+
* @param innerTx The original transaction to be wrapped.
608+
* @param feeSource The account address that will pay the fees.
609+
* @param baseFee The fee to be paid for the fee bump transaction.
610+
*/
611+
export async function buildFeeBumpTransaction(
612+
innerTxXdr: string,
613+
feeSource: string,
614+
baseFee: string,
615+
) {
616+
const server = await getRPCManager().getHorizonServer();
617+
const feeSourceAccount = await server.loadAccount(feeSource);
618+
619+
const innerTx = TransactionBuilder.fromXDR(innerTxXdr, NETWORK_PASSPHRASE);
620+
621+
return TransactionBuilder.buildFeeBumpTransaction(
622+
feeSourceAccount,
623+
baseFee,
624+
innerTx,
625+
NETWORK_PASSPHRASE,
626+
);
627+
}
628+
629+
/**
630+
* Submits a fee bump transaction to the network.
631+
* @param feeBumpTx The fee bump transaction to submit.
632+
*/
633+
export async function submitFeeBumpTransaction(feeBumpTx: any) {
634+
const server = await getRPCManager().getHorizonServer();
635+
try {
636+
return await server.submitTransaction(feeBumpTx);
637+
} catch (error) {
638+
throw classifyError(error);
639+
}
640+
}

soroban-contract/contracts/tba_account/src/lib.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ impl TbaAccount {
104104
implementation_hash: BytesN<32>,
105105
salt: BytesN<32>,
106106
) -> Result<(), Error> {
107+
upg::require_not_paused(&env);
107108
// Prevent re-initialization
108109
if is_initialized(&env) {
109110
return Err(Error::AlreadyInitialized);
@@ -169,6 +170,7 @@ impl TbaAccount {
169170
/// Only the current NFT owner can execute transactions
170171
/// This function increments the nonce and emits an event
171172
pub fn execute(env: Env, to: Address, func: Symbol, args: Vec<Val>) -> Result<Vec<Val>, Error> {
173+
upg::require_not_paused(&env);
172174
if !is_initialized(&env) {
173175
return Err(Error::NotInitialized);
174176
}
@@ -203,6 +205,7 @@ impl TbaAccount {
203205
to: Address,
204206
amount: i128,
205207
) -> Result<(), Error> {
208+
upg::require_not_paused(&env);
206209
if !is_initialized(&env) {
207210
return Err(Error::NotInitialized);
208211
}
@@ -224,6 +227,7 @@ impl TbaAccount {
224227
to: Address,
225228
nft_token_id: u128,
226229
) -> Result<(), Error> {
230+
upg::require_not_paused(&env);
227231
if !is_initialized(&env) {
228232
return Err(Error::NotInitialized);
229233
}
@@ -244,6 +248,7 @@ impl TbaAccount {
244248
token_address: Address,
245249
recipients: Vec<(Address, i128)>,
246250
) -> Result<u32, Error> {
251+
upg::require_not_paused(&env);
247252
if !is_initialized(&env) {
248253
return Err(Error::NotInitialized);
249254
}

soroban-contract/contracts/tba_registry/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ impl TbaRegistry {
125125
token_id: u128,
126126
salt: BytesN<32>,
127127
) -> Result<Address, Error> {
128+
upg::require_not_paused(&env);
128129
// Verify that the caller owns the NFT (Issue #26)
129130
// This is a cross-contract call to the NFT contract
130131
let owner: Address = env.invoke_contract(
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import React, { useState } from 'react';
2+
import { isConnected, getAddress } from '@stellar/freighter-api';
3+
4+
export interface ConnectWalletProps {
5+
onConnect?: (address: string) => void;
6+
className?: string;
7+
}
8+
9+
export const ConnectWallet: React.FC<ConnectWalletProps> = ({ onConnect, className }) => {
10+
const [address, setAddress] = useState<string | null>(null);
11+
const [loading, setLoading] = useState(false);
12+
const [error, setError] = useState<string | null>(null);
13+
14+
const handleConnect = async () => {
15+
setLoading(true);
16+
setError(null);
17+
try {
18+
const connected = await isConnected();
19+
if (!connected) {
20+
throw new Error('Freighter wallet not found. Please install it.');
21+
}
22+
const { address } = await getAddress();
23+
if (address) {
24+
setAddress(address);
25+
onConnect?.(address);
26+
} else {
27+
throw new Error('No address returned from wallet.');
28+
}
29+
} catch (err: any) {
30+
setError(err.message || 'Failed to connect wallet');
31+
} finally {
32+
setLoading(false);
33+
}
34+
};
35+
36+
return (
37+
<div className={`soroban-connect-wallet ${className || ''}`}>
38+
{address ? (
39+
<div className="flex items-center gap-2 px-4 py-2 bg-green-100 text-green-800 rounded-lg">
40+
<span className="w-2 h-2 bg-green-500 rounded-full"></span>
41+
<span className="font-mono text-sm">{address.slice(0, 6)}...{address.slice(-4)}</span>
42+
</div>
43+
) : (
44+
<button
45+
onClick={handleConnect}
46+
disabled={loading}
47+
className="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors disabled:opacity-50"
48+
>
49+
{loading ? 'Connecting...' : 'Connect Wallet'}
50+
</button>
51+
)}
52+
{error && <p className="mt-2 text-xs text-red-600">{error}</p>}
53+
</div>
54+
);
55+
};
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import React, { useState } from 'react';
2+
3+
export interface ContractCallButtonProps {
4+
onClick: () => Promise<any>;
5+
label: string;
6+
loadingLabel?: string;
7+
className?: string;
8+
onSuccess?: (result: any) => void;
9+
onError?: (error: any) => void;
10+
}
11+
12+
export const ContractCallButton: React.FC<ContractCallButtonProps> = ({
13+
onClick,
14+
label,
15+
loadingLabel = 'Processing...',
16+
className = '',
17+
onSuccess,
18+
onError,
19+
}) => {
20+
const [loading, setLoading] = useState(false);
21+
22+
const handleClick = async () => {
23+
setLoading(true);
24+
try {
25+
const result = await onClick();
26+
onSuccess?.(result);
27+
} catch (err) {
28+
console.error('Contract call failed:', err);
29+
onError?.(err);
30+
} finally {
31+
setLoading(false);
32+
}
33+
};
34+
35+
return (
36+
<button
37+
onClick={handleClick}
38+
disabled={loading}
39+
className={`px-6 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors disabled:opacity-50 ${className}`}
40+
>
41+
{loading ? (
42+
<span className="flex items-center gap-2">
43+
<svg className="animate-spin h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
44+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
45+
<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>
46+
</svg>
47+
{loadingLabel}
48+
</span>
49+
) : (
50+
label
51+
)}
52+
</button>
53+
);
54+
};

soroban-react/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './components/ConnectWallet';
2+
export * from './components/ContractCallButton';

0 commit comments

Comments
 (0)