From 3f389807f538281e2473d8577312f07d8e1b23d5 Mon Sep 17 00:00:00 2001 From: s6pa1rta3n-lab Date: Wed, 26 Aug 2026 08:19:58 -0400 Subject: [PATCH 1/7] refactor(frontend): extract shared @pactum/soroban-client workspace package (#231) From d64b71b87f18ab329dd3c277c0fc4c7a4f86075d Mon Sep 17 00:00:00 2001 From: s6pa1rta3n-lab Date: Wed, 26 Aug 2026 08:32:06 -0400 Subject: [PATCH 2/7] refactor(frontend): extract shared @pactum/soroban-client workspace package (#231) Fixes #231 Unified implementation of soroban client --- frontend-dashboard-remote/package.json | 3 +- frontend-dashboard-remote/src/lib/soroban.ts | 363 ----------- .../src/lib/verifiedReputation.ts | 7 +- frontend-dashboard-remote/src/lib/wallet.ts | 208 ------- frontend-wizard-remote/package.json | 3 +- .../src/CreateCommitmentWizard.tsx | 14 +- .../src/components/SimulationPreviewModal.tsx | 277 ++++++++- .../src/components/SorobanErrorModal.tsx | 4 +- frontend-wizard-remote/src/lib/soroban.ts | 574 ------------------ frontend-wizard-remote/src/lib/wallet.ts | 208 ------- frontend/package.json | 3 +- frontend/src/App.tsx | 2 +- frontend/src/components/SorobanErrorModal.tsx | 4 +- .../src/components/WalletConnectButton.tsx | 2 +- .../src/components/WalletConnectModal.tsx | 2 +- frontend/src/context/IndexerModeContext.tsx | 2 +- frontend/src/context/WalletContext.tsx | 2 +- frontend/src/lib/errors.ts | 196 ------ frontend/src/lib/verifiedReputation.ts | 7 +- package-lock.json | 219 ++++++- package.json | 5 +- packages/soroban-client/package.json | 27 + packages/soroban-client/src/env.d.ts | 11 + .../soroban-client/src}/errors.ts | 0 packages/soroban-client/src/index.ts | 9 + .../soroban-client/src}/soroban.ts | 6 +- .../src}/sorobanRpcPool.test.ts | 0 .../soroban-client/src}/sorobanRpcPool.ts | 0 .../soroban-client/src}/sorobanTxHelpers.ts | 0 .../soroban-client/src}/stellar.ts | 0 packages/soroban-client/src/types.ts | 7 + .../src}/wallet-adapters/ledger-adapter.ts | 10 +- .../soroban-client/src}/wallet.ts | 0 .../soroban-client/src}/web3auth.ts | 0 .../src}/web3authDerive.test.ts | 0 .../soroban-client/src}/web3authDerive.ts | 0 .../soroban-client/src}/xdrDecode.test.ts | 0 .../soroban-client/src}/xdrDecode.ts | 0 packages/soroban-client/tsconfig.json | 15 + 39 files changed, 559 insertions(+), 1631 deletions(-) delete mode 100644 frontend-dashboard-remote/src/lib/soroban.ts delete mode 100644 frontend-dashboard-remote/src/lib/wallet.ts delete mode 100644 frontend-wizard-remote/src/lib/soroban.ts delete mode 100644 frontend-wizard-remote/src/lib/wallet.ts delete mode 100644 frontend/src/lib/errors.ts create mode 100644 packages/soroban-client/package.json create mode 100644 packages/soroban-client/src/env.d.ts rename {frontend-wizard-remote/src/lib => packages/soroban-client/src}/errors.ts (100%) create mode 100644 packages/soroban-client/src/index.ts rename {frontend/src/lib => packages/soroban-client/src}/soroban.ts (99%) rename {frontend/src/lib => packages/soroban-client/src}/sorobanRpcPool.test.ts (100%) rename {frontend/src/lib => packages/soroban-client/src}/sorobanRpcPool.ts (100%) rename {frontend/src/lib => packages/soroban-client/src}/sorobanTxHelpers.ts (100%) rename {frontend/src/lib => packages/soroban-client/src}/stellar.ts (100%) create mode 100644 packages/soroban-client/src/types.ts rename {frontend/src/lib => packages/soroban-client/src}/wallet-adapters/ledger-adapter.ts (96%) rename {frontend/src/lib => packages/soroban-client/src}/wallet.ts (100%) rename {frontend/src/lib => packages/soroban-client/src}/web3auth.ts (100%) rename {frontend/src/lib => packages/soroban-client/src}/web3authDerive.test.ts (100%) rename {frontend/src/lib => packages/soroban-client/src}/web3authDerive.ts (100%) rename {frontend/src/lib => packages/soroban-client/src}/xdrDecode.test.ts (100%) rename {frontend/src/lib => packages/soroban-client/src}/xdrDecode.ts (100%) create mode 100644 packages/soroban-client/tsconfig.json diff --git a/frontend-dashboard-remote/package.json b/frontend-dashboard-remote/package.json index dfc9b71b..bad22bb1 100644 --- a/frontend-dashboard-remote/package.json +++ b/frontend-dashboard-remote/package.json @@ -22,7 +22,8 @@ "@tanstack/react-virtual": "^3.14.10", "lucide-react": "^1.32.0", "react": "^19.2.8", - "react-dom": "^19.2.8" + "react-dom": "^19.2.8", + "@pactum/soroban-client": "*" }, "devDependencies": { "@module-federation/vite": "^1.20.7", diff --git a/frontend-dashboard-remote/src/lib/soroban.ts b/frontend-dashboard-remote/src/lib/soroban.ts deleted file mode 100644 index f11cf869..00000000 --- a/frontend-dashboard-remote/src/lib/soroban.ts +++ /dev/null @@ -1,363 +0,0 @@ -import { - Account, - Contract, - rpc, - TransactionBuilder, - Networks, - BASE_FEE, - xdr, - Address, - Keypair, - nativeToScVal, - scValToNative, -} from '@stellar/stellar-sdk'; -import type { Reputation } from './api'; -import { signTransaction } from '@stellar/freighter-api'; -import { signTransactionWithLedger } from './wallet-adapters/ledger-adapter'; -import type { WalletProvider } from './wallet'; - -export const DEFAULT_SOROBAN_RPC_URL = 'https://soroban-testnet.stellar.org'; -export const DEFAULT_CONTRACT_ID = 'CBADTVTJ6IN332HIKZ7LWUYMYTLPZYCEBV3X2HS47VHR5UDBHQ3GAA7E'; -export const DEFAULT_NETWORK_PASSPHRASE = Networks.TESTNET; - -export interface CreateCommitmentParams { - issuerAddress: string; - counterpartyAddress: string; - termsHashHex: string; - dueAtSeconds: number; - rpcUrl?: string; - contractId?: string; - networkPassphrase?: string; - onStatusUpdate?: (statusMessage: string) => void; - walletProvider?: WalletProvider; -} - -export interface CreateCommitmentResult { - hash: string; - commitmentId?: number | bigint; - status: 'SUCCESS'; -} - -export interface TrustedLedgerAnchor { - hash: string; - sequence: number; -} - -export async function fetchLatestLedgerAnchor( - rpcUrl = import.meta.env.VITE_SOROBAN_RPC_URL || DEFAULT_SOROBAN_RPC_URL, -): Promise { - const server = new rpc.Server(rpcUrl, { allowHttp: true }); - const ledger = await server.getLatestLedger(); - - if (!ledger.id || !ledger.sequence) { - throw new Error('Soroban RPC returned an incomplete latest-ledger response'); - } - - return { hash: ledger.id, sequence: ledger.sequence }; -} - -/** - * Reads the registry's current arbitrator address. `create_commitment` requires a - * `resolver_address`, and this is the standard, no-custom-resolver value to pass for it: naming a - * current arbitrator routes disputes through the registry's committee majority vote instead of - * single-delegate resolution. Never default `resolver_address` to the issuer or counterparty -- - * `resolve_dispute`'s only guard is `caller == resolver_address`, so that would let a party - * unilaterally resolve their own dispute. - */ -export async function fetchArbitrator( - rpcUrl = import.meta.env.VITE_SOROBAN_RPC_URL || DEFAULT_SOROBAN_RPC_URL, - contractId = import.meta.env.VITE_PACTUM_CONTRACT_ID || DEFAULT_CONTRACT_ID, - networkPassphrase = import.meta.env.VITE_STELLAR_NETWORK_PASSPHRASE || DEFAULT_NETWORK_PASSPHRASE, -): Promise { - const server = new rpc.Server(rpcUrl, { allowHttp: true }); - const contract = new Contract(contractId); - const source = new Account(Keypair.random().publicKey(), '0'); - const transaction = new TransactionBuilder(source, { - fee: BASE_FEE, - networkPassphrase, - }) - .addOperation(contract.call('get_arbitrator')) - .setTimeout(30) - .build(); - - const simulation = await server.simulateTransaction(transaction); - if (rpc.Api.isSimulationError(simulation)) { - throw new Error(`Failed to read registry arbitrator: ${simulation.error}`); - } - if (!simulation.result) { - throw new Error('Direct Soroban query returned no arbitrator value'); - } - - return String(scValToNative(simulation.result.retval)); -} - -export async function fetchReputationFromRpc( - address: string, - rpcUrl = import.meta.env.VITE_SOROBAN_RPC_URL || DEFAULT_SOROBAN_RPC_URL, - contractId = import.meta.env.VITE_PACTUM_CONTRACT_ID || DEFAULT_CONTRACT_ID, - networkPassphrase = import.meta.env.VITE_STELLAR_NETWORK_PASSPHRASE || DEFAULT_NETWORK_PASSPHRASE, -): Promise { - const server = new rpc.Server(rpcUrl, { allowHttp: true }); - const contract = new Contract(contractId); - const source = new Account(Keypair.random().publicKey(), '0'); - const transaction = new TransactionBuilder(source, { - fee: BASE_FEE, - networkPassphrase, - }) - .addOperation(contract.call('get_reputation', nativeToScVal(address, { type: 'address' }))) - .setTimeout(30) - .build(); - - const simulation = await server.simulateTransaction(transaction); - if (rpc.Api.isSimulationError(simulation)) { - throw new Error(`Direct Soroban query failed: ${simulation.error}`); - } - if (!simulation.result) { - throw new Error('Direct Soroban query returned no reputation value'); - } - - const value = scValToNative(simulation.result.retval) as Record; - const fulfilled = Number(value.fulfilled_count ?? value.fulfilledCount ?? 0); - const late = Number(value.late_count ?? value.lateCount ?? 0); - const breached = Number(value.breached_count ?? value.breachedCount ?? 0); - - return { - address, - fulfilled, - late, - breached, - total: fulfilled + late + breached, - }; -} - -/** - * Converts a 64-character hex string (32 bytes SHA-256) into a Uint8Array - */ -export function hexToBytes(hexStr: string): Uint8Array { - const cleanHex = hexStr.replace(/^0x/i, ''); - if (cleanHex.length !== 64) { - throw new Error( - `Invalid terms hash hex length: expected 64 hex characters (32 bytes), got ${cleanHex.length}`, - ); - } - const bytes = new Uint8Array(32); - for (let i = 0; i < 32; i++) { - bytes[i] = parseInt(cleanHex.substring(i * 2, i * 2 + 2), 16); - } - return bytes; -} - -/** - * Helper to auto-fund a new unfunded Testnet account via Stellar Friendbot - */ -export async function fundTestnetAccount(address: string): Promise { - try { - const response = await fetch( - `https://friendbot.stellar.org/?addr=${encodeURIComponent(address)}`, - ); - return response.ok; - } catch (e) { - console.warn(`[Friendbot] Could not auto-fund ${address}:`, e); - return false; - } -} - -/** - * Builds, simulates, signs via Freighter, and submits a `create_commitment` Soroban transaction. - */ -export async function submitCreateCommitment({ - issuerAddress, - counterpartyAddress, - termsHashHex, - dueAtSeconds, - rpcUrl = import.meta.env.VITE_SOROBAN_RPC_URL || DEFAULT_SOROBAN_RPC_URL, - contractId = import.meta.env.VITE_PACTUM_CONTRACT_ID || DEFAULT_CONTRACT_ID, - networkPassphrase = import.meta.env.VITE_STELLAR_NETWORK_PASSPHRASE || DEFAULT_NETWORK_PASSPHRASE, - onStatusUpdate, - walletProvider = 'freighter', -}: CreateCommitmentParams): Promise { - // 1. Parameter Validation - if (!issuerAddress || !issuerAddress.startsWith('G')) { - throw new Error('Connected wallet issuer address must be a valid Stellar public key (G...)'); - } - if (!counterpartyAddress || !counterpartyAddress.startsWith('G')) { - throw new Error('Counterparty address must be a valid Stellar public key (G...)'); - } - if (issuerAddress.trim().toUpperCase() === counterpartyAddress.trim().toUpperCase()) { - throw new Error('Issuer and Counterparty addresses cannot be identical.'); - } - - const nowSeconds = Math.floor(Date.now() / 1000); - if (dueAtSeconds <= nowSeconds) { - throw new Error( - `Due date must be in the future. Selected timestamp (${dueAtSeconds}) is not > current timestamp (${nowSeconds}).`, - ); - } - - onStatusUpdate?.('Initializing Soroban RPC connection...'); - const server = new rpc.Server(rpcUrl, { allowHttp: true }); - - // 2. Convert Arguments to ScVal - onStatusUpdate?.('Encoding contract parameters...'); - const issuerScVal = Address.fromString(issuerAddress).toScVal(); - const counterpartyScVal = Address.fromString(counterpartyAddress).toScVal(); - const termsHashBytes = hexToBytes(termsHashHex); - // `scvBytes` accepts any Uint8Array at runtime — the `Buffer` param type is just its TS - // signature (same pattern as lib/crdt/signing.ts's verifyMessage cast in the host). - const termsHashScVal = xdr.ScVal.scvBytes(termsHashBytes as unknown as Buffer); - const dueAtScVal = xdr.ScVal.scvU64(xdr.Uint64.fromString(dueAtSeconds.toString())); - - // create_commitment requires a resolver_address; the wizard's UI has no concept of a custom - // dispute resolver yet, so read the registry's own arbitrator and use that (see - // fetchArbitrator's doc comment for why this -- not issuer/counterparty -- is the safe default). - onStatusUpdate?.('Fetching registry arbitrator...'); - const arbitratorAddress = await fetchArbitrator(rpcUrl, contractId, networkPassphrase); - const resolverScVal = Address.fromString(arbitratorAddress).toScVal(); - // oracle and schema_id are both genuinely optional (Option
/Option) with no - // downstream code assuming they're populated; the wizard doesn't collect either yet. - const oracleScVal = xdr.ScVal.scvVoid(); - const schemaIdScVal = xdr.ScVal.scvVoid(); - // Empty attestors + a 0 threshold is the contract's explicitly-designed "no voting panel, use - // the single-resolver dispute path" state (contracts/registry/src/commitments.rs::create). - const attestorsScVal = xdr.ScVal.scvVec([]); - const voteThresholdScVal = xdr.ScVal.scvU32(0); - - // 3. Build Transaction Envelope - onStatusUpdate?.('Fetching sequence number for issuer account...'); - let account: any = null; - try { - account = await server.getAccount(issuerAddress); - } catch (err: any) { - const errStr = String(err?.message || err).toLowerCase(); - if (errStr.includes('not found') || errStr.includes('404') || errStr.includes('account')) { - onStatusUpdate?.('Issuer account unfunded on Testnet. Auto-funding via Stellar Friendbot...'); - const funded = await fundTestnetAccount(issuerAddress); - if (funded) { - onStatusUpdate?.('Account funded! Re-fetching sequence number...'); - await new Promise((resolve) => setTimeout(resolve, 1500)); - try { - account = await server.getAccount(issuerAddress); - } catch (e2) { - console.warn('Re-fetch account error:', e2); - } - } - } - - if (!account) { - throw new Error( - `Connected account (${issuerAddress.substring(0, 8)}...) is not funded on Stellar Testnet yet. Please fund it with Testnet XLM in your Freighter extension or via Stellar Friendbot.`, - ); - } - } - - const contract = new Contract(contractId); - - const tx = new TransactionBuilder(account, { - fee: BASE_FEE, - networkPassphrase, - }) - .addOperation( - contract.call( - 'create_commitment', - issuerScVal, - counterpartyScVal, - termsHashScVal, - dueAtScVal, - resolverScVal, - oracleScVal, - schemaIdScVal, - attestorsScVal, - voteThresholdScVal, - ), - ) - .setTimeout(60) - .build(); - - // 4. Simulate & Prepare Transaction Envelope (Soroban footprint & fees) - onStatusUpdate?.('Simulating transaction on Soroban RPC...'); - const preparedTx = await server.prepareTransaction(tx); - - const unsignedXdr = preparedTx.toXDR(); - - // 5. Prompt the connected wallet for a signature - let signedXdr = ''; - - if (walletProvider === 'ledger') { - onStatusUpdate?.('Awaiting signature on Ledger device (confirm on-screen)...'); - signedXdr = await signTransactionWithLedger(unsignedXdr, networkPassphrase); - } else { - onStatusUpdate?.('Awaiting signature in Freighter wallet...'); - const signResult = await signTransaction(unsignedXdr, { - networkPassphrase, - address: issuerAddress, - }); - - if (typeof signResult === 'string') { - signedXdr = signResult; - } else if (signResult && typeof signResult === 'object') { - if ((signResult as any).error) { - throw new Error(`Freighter signing rejected: ${(signResult as any).error}`); - } - signedXdr = (signResult as any).signedTxXdr || (signResult as any).signedXdr || ''; - } - } - - if (!signedXdr) { - throw new Error('Transaction signing was cancelled or denied.'); - } - - // 6. Submit Signed Transaction Envelope to RPC - onStatusUpdate?.('Submitting transaction to Stellar Testnet...'); - const signedTx = TransactionBuilder.fromXDR(signedXdr, networkPassphrase); - const sendResult = await server.sendTransaction(signedTx); - - if (sendResult.status === 'ERROR' || sendResult.errorResult) { - throw new Error(`RPC submission error: ${sendResult.errorResult || sendResult.status}`); - } - - const txHash = sendResult.hash; - onStatusUpdate?.(`Transaction submitted! Confirming hash ${txHash.substring(0, 10)}...`); - - // 7. Poll RPC for Final On-Chain Ledger Status - let txStatus: rpc.Api.GetTransactionStatus = rpc.Api.GetTransactionStatus.NOT_FOUND; - let txResult: rpc.Api.GetTransactionResponse | null = null; - let attempts = 0; - - while (attempts < 25) { - attempts++; - await new Promise((resolve) => setTimeout(resolve, 1200)); - txResult = await server.getTransaction(txHash); - txStatus = txResult.status; - - if (txStatus === rpc.Api.GetTransactionStatus.SUCCESS) { - break; - } else if (txStatus === rpc.Api.GetTransactionStatus.FAILED) { - throw new Error(`Transaction execution failed on Stellar Testnet. Hash: ${txHash}`); - } - } - - if (txStatus !== rpc.Api.GetTransactionStatus.SUCCESS) { - throw new Error(`Transaction confirmation timed out. Hash: ${txHash}`); - } - - let commitmentId: number | bigint | undefined = undefined; - const successTx = txResult as any; - if (successTx && successTx.returnValue) { - try { - const nativeVal = scValToNative(successTx.returnValue); - if (typeof nativeVal === 'number' || typeof nativeVal === 'bigint') { - commitmentId = nativeVal; - } - } catch (e) { - console.warn('Could not parse commitmentId from retval:', e); - } - } - - onStatusUpdate?.('Transaction confirmed successfully on-chain!'); - - return { - hash: txHash, - commitmentId, - status: 'SUCCESS', - }; -} diff --git a/frontend-dashboard-remote/src/lib/verifiedReputation.ts b/frontend-dashboard-remote/src/lib/verifiedReputation.ts index e755167a..a070c5f3 100644 --- a/frontend-dashboard-remote/src/lib/verifiedReputation.ts +++ b/frontend-dashboard-remote/src/lib/verifiedReputation.ts @@ -5,7 +5,7 @@ import { DEFAULT_NETWORK_PASSPHRASE, fetchLatestLedgerAnchor, fetchReputationFromRpc, -} from './soroban'; +} from '@pactum/soroban-client'; export type ReputationIntegrity = 'verified' | 'rpc-fallback'; @@ -16,7 +16,10 @@ export interface VerifiedReputationResult { warning?: string; } -function reputationFromProof(address: string, proof: Awaited>) { +function reputationFromProof( + address: string, + proof: Awaited>, +) { const { fulfilledCount, lateCount, breachedCount } = proof.scoreData; return { address, diff --git a/frontend-dashboard-remote/src/lib/wallet.ts b/frontend-dashboard-remote/src/lib/wallet.ts deleted file mode 100644 index 2069d992..00000000 --- a/frontend-dashboard-remote/src/lib/wallet.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { Networks } from '@stellar/stellar-sdk'; -import { - isConnected as freighterIsConnected, - requestAccess as freighterRequestAccess, - getAddress as freighterGetAddress, - getNetwork as freighterGetNetwork, -} from '@stellar/freighter-api'; -import albedo from '@albedo-link/intent'; -import { isStellarAddress } from './stellar'; -import { LedgerAdapter } from './wallet-adapters/ledger-adapter'; - -export type WalletProvider = 'freighter' | 'albedo' | 'ledger'; - -export const PACTUM_NETWORK_PASSPHRASE = Networks.TESTNET; -export const PACTUM_NETWORK_NAME = 'TESTNET'; -export const FREIGHTER_HOMEPAGE = 'https://www.freighter.app/'; - -export type WalletErrorCode = - 'NOT_INSTALLED' | 'CONNECTION_REJECTED' | 'NETWORK_MISMATCH' | 'INVALID_ADDRESS' | 'UNKNOWN'; - -export class WalletConnectionError extends Error { - readonly code: WalletErrorCode; - - constructor(code: WalletErrorCode, message: string) { - super(message); - this.name = 'WalletConnectionError'; - this.code = code; - } -} - -export interface WalletConnectionResult { - address: string; - provider: WalletProvider; -} - -export { freighterGetAddress as getFreighterAddress, freighterGetNetwork as getFreighterNetwork }; - -export function isFreighterInstalled(): boolean { - return Boolean( - typeof window !== 'undefined' && (window as unknown as Record).freighter, - ); -} - -export async function isFreighterConnected(): Promise { - try { - const res = await freighterIsConnected(); - return Boolean(res && res.isConnected); - } catch (err) { - console.warn('[wallet] freighter isConnected check failed:', err); - return isFreighterInstalled(); - } -} - -function assertTestnetNetwork(network: string | undefined, passphrase: string | undefined): void { - if (!network) return; - const normalized = network.toUpperCase(); - const onTestnet = - normalized === PACTUM_NETWORK_NAME || - normalized === 'TEST' || - (passphrase != null && passphrase === PACTUM_NETWORK_PASSPHRASE); - - if (!onTestnet) { - throw new WalletConnectionError( - 'NETWORK_MISMATCH', - `Freighter is connected to ${network}. Pactum requires Stellar Testnet. ` + - 'Please switch your wallet network to Testnet (Settings → Network) and try again.', - ); - } -} - -/** - * Connects via the Freighter browser extension: - * 1. Verifies the extension is installed & unlocked - * 2. Requests access (prompts the extension pop-up) - * 3. Retrieves the public key - * 4. Verifies the wallet is on Stellar Testnet - */ -export async function connectWithFreighter(): Promise { - if (!isFreighterInstalled()) { - throw new WalletConnectionError( - 'NOT_INSTALLED', - 'Freighter browser extension was not detected. Please install Freighter from freighter.app.', - ); - } - - let address = ''; - - try { - const accessRes = await freighterRequestAccess(); - if (accessRes && accessRes.address) { - address = accessRes.address; - } else if (accessRes && accessRes.error) { - throw new WalletConnectionError( - 'CONNECTION_REJECTED', - String(accessRes.error) || 'Connection request denied in Freighter.', - ); - } - } catch (err) { - if (err instanceof WalletConnectionError) throw err; - // Fallback: connection may already be allowed - try { - const addrRes = await freighterGetAddress(); - if (addrRes && addrRes.address && !addrRes.error) { - address = addrRes.address; - } - } catch (e) { - console.warn('[wallet] freighter getAddress fallback failed:', e); - } - if (!address) { - throw new WalletConnectionError( - 'CONNECTION_REJECTED', - 'Connection request was rejected or cancelled in Freighter.', - ); - } - } - - if (!address) { - throw new WalletConnectionError( - 'CONNECTION_REJECTED', - 'Unable to retrieve account address from Freighter.', - ); - } - - if (!isStellarAddress(address)) { - throw new WalletConnectionError( - 'INVALID_ADDRESS', - `Freighter returned an invalid Stellar address: ${address}`, - ); - } - - // Enforce Testnet (best-effort: if the API is unavailable, log and continue) - try { - const netRes = await freighterGetNetwork(); - if (netRes && !netRes.error) { - assertTestnetNetwork(netRes.network, netRes.networkPassphrase); - } - } catch (err) { - if (err instanceof WalletConnectionError) throw err; - console.warn('[wallet] Unable to verify Freighter network; continuing:', err); - } - - return { address, provider: 'freighter' }; -} - -/** - * Connects via Albedo (web wallet) using the official intent SDK. - * Opens an Albedo pop-up where the user selects an account. - * Signing intents later enforce `network: 'testnet'`. - */ -export async function connectWithAlbedo(): Promise { - try { - const res = await albedo.publicKey({}); - if (!res || !res.pubkey) { - throw new WalletConnectionError( - 'CONNECTION_REJECTED', - 'Albedo connection was rejected or cancelled.', - ); - } - if (!isStellarAddress(res.pubkey)) { - throw new WalletConnectionError( - 'INVALID_ADDRESS', - `Albedo returned an invalid Stellar address: ${res.pubkey}`, - ); - } - return { address: res.pubkey, provider: 'albedo' }; - } catch (err) { - if (err instanceof WalletConnectionError) throw err; - throw new WalletConnectionError( - 'CONNECTION_REJECTED', - err instanceof Error ? err.message : 'Failed to connect with Albedo wallet.', - ); - } -} - -/** - * Connects directly to a Ledger Nano hardware wallet over WebUSB/WebBluetooth - * (no browser extension involved). Prompts the browser's native device picker, - * then reads the Stellar public key from the Ledger Stellar app. - */ -export async function connectWithLedger(): Promise { - try { - const address = await LedgerAdapter.connect(); - if (!address || !isStellarAddress(address)) { - throw new WalletConnectionError( - 'INVALID_ADDRESS', - 'Ledger returned an invalid Stellar address. Ensure the Stellar app is open on the device.', - ); - } - return { address, provider: 'ledger' }; - } catch (err) { - if (err instanceof WalletConnectionError) throw err; - throw new WalletConnectionError( - 'CONNECTION_REJECTED', - err instanceof Error ? err.message : 'Failed to connect to Ledger device.', - ); - } -} - -export function connectWallet(provider: WalletProvider): Promise { - if (provider === 'albedo') return connectWithAlbedo(); - if (provider === 'ledger') return connectWithLedger(); - return connectWithFreighter(); -} - -export function truncateAddress(address: string, start = 6, end = 4): string { - if (!address || address.length <= start + end) return address; - return `${address.substring(0, start)}...${address.substring(address.length - end)}`; -} diff --git a/frontend-wizard-remote/package.json b/frontend-wizard-remote/package.json index e425759f..10ed371e 100644 --- a/frontend-wizard-remote/package.json +++ b/frontend-wizard-remote/package.json @@ -24,7 +24,8 @@ "react": "^19.2.8", "react-dom": "^19.2.8", "react-hook-form": "^7.85.0", - "zod": "^4.4.3" + "zod": "^4.4.3", + "@pactum/soroban-client": "*" }, "devDependencies": { "@module-federation/vite": "^1.20.7", diff --git a/frontend-wizard-remote/src/CreateCommitmentWizard.tsx b/frontend-wizard-remote/src/CreateCommitmentWizard.tsx index d4ef65dc..12090efc 100644 --- a/frontend-wizard-remote/src/CreateCommitmentWizard.tsx +++ b/frontend-wizard-remote/src/CreateCommitmentWizard.tsx @@ -9,8 +9,8 @@ import { queryClient } from 'host/queryClient'; import { sha256Hex } from './lib/hash'; import { encryptCommitmentTerms, type EncryptResult } from './lib/crypto'; -import { stellarAddressSchema } from './lib/stellar'; -import { decodeRegistryContractError } from './lib/errors'; +import { stellarAddressSchema } from '@pactum/soroban-client'; +import { decodeRegistryContractError } from '@pactum/soroban-client'; import { createAstResolver, composeResolvers } from './lib/ast'; import { useValidationRules } from './hooks/useValidationRules'; import { useWallet } from 'host/WalletContext'; @@ -24,8 +24,8 @@ import { type CreateCommitmentResult, type SimulationPreview, SorobanSimulationError, -} from './lib/soroban'; -import { decodeSimulationError } from './lib/xdrDecode'; +} from '@pactum/soroban-client'; +import { decodeSimulationError } from '@pactum/soroban-client'; import { postEncryptedTerms, createCommitment } from './lib/api'; import UserProfile from './components/UserProfile'; import EncryptionConsentModal from './components/EncryptionConsentModal'; @@ -365,7 +365,11 @@ export default function CreateCommitmentWizard({ // rejection and a submit-time rejection look identical to the user. setShowSimModal(false); const diagBlobs = extractDiagnosticEventBlobs(preview.rawSimulation); - const decoded = decodeSimulationError(preview.error ?? '', diagBlobs, 'create_commitment'); + const decoded = decodeSimulationError( + preview.error ?? '', + diagBlobs, + 'create_commitment', + ); setXdrError( new SorobanSimulationError( decoded.message ?? `Transaction simulation failed: ${preview.error}`, diff --git a/frontend-wizard-remote/src/components/SimulationPreviewModal.tsx b/frontend-wizard-remote/src/components/SimulationPreviewModal.tsx index 3277e1b9..55674382 100644 --- a/frontend-wizard-remote/src/components/SimulationPreviewModal.tsx +++ b/frontend-wizard-remote/src/components/SimulationPreviewModal.tsx @@ -1,5 +1,5 @@ import { Loader2, X, AlertTriangle, CheckCircle2 } from 'lucide-react'; -import type { SimulationPreview } from '../lib/soroban'; +import type { SimulationPreview } from '@pactum/soroban-client'; interface SimulationPreviewModalProps { preview: SimulationPreview | null; @@ -8,7 +8,12 @@ interface SimulationPreviewModalProps { isOpen: boolean; } -export default function SimulationPreviewModal({ preview, onConfirm, onCancel, isOpen }: SimulationPreviewModalProps) { +export default function SimulationPreviewModal({ + preview, + onConfirm, + onCancel, + isOpen, +}: SimulationPreviewModalProps) { if (!isOpen) return null; const isLoading = preview === null; const isSuccess = preview?.success === true; @@ -19,57 +24,243 @@ export default function SimulationPreviewModal({ preview, onConfirm, onCancel, i role="dialog" aria-modal="true" aria-labelledby="sim-modal-title" - style={{ position: 'fixed', inset: 0, zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '16px', background: 'rgba(2, 6, 23, 0.72)', backdropFilter: 'blur(4px)' }} - onClick={(e) => { if (e.target === e.currentTarget) onCancel(); }} + style={{ + position: 'fixed', + inset: 0, + zIndex: 1000, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: '16px', + background: 'rgba(2, 6, 23, 0.72)', + backdropFilter: 'blur(4px)', + }} + onClick={(e) => { + if (e.target === e.currentTarget) onCancel(); + }} > -
-
- -
- {isLoading ? : isSuccess ? : } +
+ {isLoading ? ( + + ) : isSuccess ? ( + + ) : ( + + )}
-

- {isLoading ? 'Simulating Transaction' : isSuccess ? 'Transaction Preview' : 'Transaction Would Fail'} +

+ {isLoading + ? 'Simulating Transaction' + : isSuccess + ? 'Transaction Preview' + : 'Transaction Would Fail'}

- {isLoading ? 'Evaluating contract execution on Soroban RPC...' : isSuccess ? 'Preflight simulation succeeded. Review estimated costs below.' : 'Simulation encountered an error. The transaction cannot be executed.'} + {isLoading + ? 'Evaluating contract execution on Soroban RPC...' + : isSuccess + ? 'Preflight simulation succeeded. Review estimated costs below.' + : 'Simulation encountered an error. The transaction cannot be executed.'}

{isLoading && ( -
- Simulating transaction... +
+ Simulating + transaction...
)} {!isLoading && !isSuccess && (
-
Error Details:
- +
+ Error Details: +
+ {preview?.error || 'Simulation failed with unknown error.'}
)} {!isLoading && isSuccess && cost && ( -
-
-
Fee
-
Estimated Fee: {cost.feeXlm} XLM ({cost.feeStroops} stroops)
+
+
+
+ Fee +
+
+ Estimated Fee: {cost.feeXlm} XLM ({cost.feeStroops} stroops) +
-
-
Resources
-
CPU: {cost.cpuInsns} instructions | Memory: {cost.memBytes} bytes
+
+
+ Resources +
+
+ CPU: {cost.cpuInsns} instructions | Memory: {cost.memBytes} bytes +
-
-
Required Authorizations
+
+
+ Required Authorizations +
{preview.requiredAuths.length > 0 ? (
{preview.requiredAuths.map((auth, idx) => ( -
+
{auth}
))} @@ -82,11 +273,41 @@ export default function SimulationPreviewModal({ preview, onConfirm, onCancel, i )}
- {!isLoading && isSuccess && ( - )} diff --git a/frontend-wizard-remote/src/components/SorobanErrorModal.tsx b/frontend-wizard-remote/src/components/SorobanErrorModal.tsx index 8c3a7f0f..eef9d6f8 100644 --- a/frontend-wizard-remote/src/components/SorobanErrorModal.tsx +++ b/frontend-wizard-remote/src/components/SorobanErrorModal.tsx @@ -9,8 +9,8 @@ import { Terminal, Bug, } from 'lucide-react'; -import type { DecodedXdrError, DecodedDiagnosticEvent } from '../lib/errors'; -import { decodeSorobanError, sanitizeErrorMessage } from '../lib/errors'; +import type { DecodedXdrError, DecodedDiagnosticEvent } from '@pactum/soroban-client'; +import { decodeSorobanError, sanitizeErrorMessage } from '@pactum/soroban-client'; // --------------------------------------------------------------------------- // Types diff --git a/frontend-wizard-remote/src/lib/soroban.ts b/frontend-wizard-remote/src/lib/soroban.ts deleted file mode 100644 index e88e0740..00000000 --- a/frontend-wizard-remote/src/lib/soroban.ts +++ /dev/null @@ -1,574 +0,0 @@ -import { - Account, - Contract, - rpc, - TransactionBuilder, - Networks, - BASE_FEE, - xdr, - Address, - Keypair, - nativeToScVal, - scValToNative, -} from '@stellar/stellar-sdk'; -import type { Reputation } from './api'; -import { signTransaction } from '@stellar/freighter-api'; -import { signTransactionWithLedger } from './wallet-adapters/ledger-adapter'; -import type { WalletProvider } from './wallet'; -import { decodeSimulationError, isSorobanXdrError } from './xdrDecode'; -import type { DecodedXdrError } from './xdrDecode'; - -export const DEFAULT_SOROBAN_RPC_URL = 'https://soroban-testnet.stellar.org'; -export const DEFAULT_CONTRACT_ID = 'CBADTVTJ6IN332HIKZ7LWUYMYTLPZYCEBV3X2HS47VHR5UDBHQ3GAA7E'; -export const DEFAULT_NETWORK_PASSPHRASE = Networks.TESTNET; - -/** - * Enhanced error class that carries decoded Soroban XDR information. - * - * When a simulation fails, this error wraps the raw RPC response alongside - * decoded diagnostic events, the attempted operation, and resolution guidance. - * UI components can extract these fields to render a rich error modal. - */ -export class SorobanSimulationError extends Error { - public readonly diagnosticEventBlobs: string[]; - public readonly attemptedFunction: string | null; - public readonly decodedXdrError: DecodedXdrError; - - constructor( - message: string, - rawError: string, - diagnosticEventBlobs: string[] = [], - attemptedFunction: string | null = null, - ) { - super(message); - this.name = 'SorobanSimulationError'; - this.diagnosticEventBlobs = diagnosticEventBlobs; - this.attemptedFunction = attemptedFunction; - this.decodedXdrError = decodeSimulationError(rawError, diagnosticEventBlobs, attemptedFunction); - } -} - -/** - * Extract base64-encoded diagnostic event XDR blobs from a raw simulation - * error response. These are often embedded in the error string or returned - * as a separate `events` array. - */ -export function extractDiagnosticEventBlobs( - simulationResult: rpc.Api.SimulateTransactionErrorResponse | any, -): string[] { - const blobs: string[] = []; - - if (!simulationResult || typeof simulationResult !== 'object') { - return blobs; - } - - // Path 1: `events` array on the simulation response (parsed DiagnosticEvent[]) - if (Array.isArray(simulationResult.events)) { - for (const event of simulationResult.events) { - try { - const base64 = (event as any).toXDR?.('base64'); - if (base64) blobs.push(base64); - } catch { - // skip - } - } - } - - // Path 2: Base64 strings embedded in the error message - if (typeof simulationResult.error === 'string') { - const matches = simulationResult.error.match(/[A-Za-z0-9+/]{40,}={0,2}/g); - if (matches) { - for (const m of matches) { - if (isSorobanXdrError(m)) { - blobs.push(m); - } - } - } - } - - return blobs; -} - -export interface CreateCommitmentParams { - issuerAddress: string; - counterpartyAddress: string; - termsHashHex: string; - dueAtSeconds: number; - rpcUrl?: string; - contractId?: string; - networkPassphrase?: string; - onStatusUpdate?: (statusMessage: string) => void; - walletProvider?: WalletProvider; -} - -export interface CreateCommitmentResult { - hash: string; - commitmentId?: number | bigint; - status: 'SUCCESS'; -} - -export interface TrustedLedgerAnchor { - hash: string; - sequence: number; -} - -export interface SimulationCost { - /** Estimated fee in stroops (1 XLM = 10,000,000 stroops). */ - feeStroops: string; - /** Estimated fee formatted as XLM string for display. */ - feeXlm: string; - /** CPU instructions consumed. */ - cpuInsns: string; - /** Memory bytes consumed. */ - memBytes: string; -} - -export interface SimulationPreview { - /** True if simulation succeeded. */ - success: boolean; - /** Decoded error message if simulation failed. */ - error?: string; - /** Cost metrics if simulation succeeded. */ - cost?: SimulationCost; - /** List of required authorizations as human-readable strings. */ - requiredAuths: string[]; - /** Raw simulation result for downstream use (prepareTransaction). */ - rawSimulation: rpc.Api.SimulateTransactionResponse; -} - -export async function fetchLatestLedgerAnchor( - rpcUrl = import.meta.env.VITE_SOROBAN_RPC_URL || DEFAULT_SOROBAN_RPC_URL, -): Promise { - const server = new rpc.Server(rpcUrl, { allowHttp: true }); - const ledger = await server.getLatestLedger(); - - if (!ledger.id || !ledger.sequence) { - throw new Error('Soroban RPC returned an incomplete latest-ledger response'); - } - - return { hash: ledger.id, sequence: ledger.sequence }; -} - -/** - * Runs simulateTransaction against the Soroban RPC and returns a parsed - * SimulationPreview without modifying any state or prompting the wallet. - */ -export async function preflightSimulate( - tx: ReturnType, - rpcUrl = import.meta.env.VITE_SOROBAN_RPC_URL || DEFAULT_SOROBAN_RPC_URL, -): Promise { - const server = new rpc.Server(rpcUrl, { allowHttp: true }); - const simulation = await server.simulateTransaction(tx); - - if (rpc.Api.isSimulationError(simulation)) { - return { - success: false, - error: simulation.error ?? 'Simulation failed with unknown error.', - requiredAuths: [], - rawSimulation: simulation, - }; - } - - // Parse cost metrics - const feeStroops = simulation.minResourceFee ?? '0'; - const feeXlm = (Number(feeStroops) / 10_000_000).toFixed(7); - - const cost: SimulationCost = { - feeStroops, - feeXlm, - cpuInsns: (simulation as any).cost?.cpuInsns ?? '0', - memBytes: (simulation as any).cost?.memBytes ?? '0', - }; - - // Parse required auths as readable strings - const requiredAuths: string[] = []; - if (simulation.result?.auth) { - for (const auth of simulation.result.auth) { - try { - const decoded: xdr.SorobanAuthorizationEntry = - typeof auth === 'string' - ? xdr.SorobanAuthorizationEntry.fromXDR(auth, 'base64') - : (auth as xdr.SorobanAuthorizationEntry); - const credentials = decoded.credentials(); - if (credentials.switch().name === 'sorobanCredentialsAddress') { - const addrCreds = credentials.address(); - requiredAuths.push( - addrCreds.address().accountId().ed25519().toString('hex').slice(0, 8) + '...', - ); - } else { - requiredAuths.push('Source account authorization'); - } - } catch { - requiredAuths.push('Unknown authorization'); - } - } - } - - return { - success: true, - cost, - requiredAuths, - rawSimulation: simulation, - }; -} - -/** - * Reads the registry's current arbitrator address. `create_commitment` requires a - * `resolver_address`, and this is the standard, no-custom-resolver value to pass for it: naming a - * current arbitrator routes disputes through the registry's committee majority vote instead of - * single-delegate resolution. Never default `resolver_address` to the issuer or counterparty -- - * `resolve_dispute`'s only guard is `caller == resolver_address`, so that would let a party - * unilaterally resolve their own dispute. - */ -export async function fetchArbitrator( - rpcUrl = import.meta.env.VITE_SOROBAN_RPC_URL || DEFAULT_SOROBAN_RPC_URL, - contractId = import.meta.env.VITE_PACTUM_CONTRACT_ID || DEFAULT_CONTRACT_ID, - networkPassphrase = import.meta.env.VITE_STELLAR_NETWORK_PASSPHRASE || DEFAULT_NETWORK_PASSPHRASE, -): Promise { - const server = new rpc.Server(rpcUrl, { allowHttp: true }); - const contract = new Contract(contractId); - const source = new Account(Keypair.random().publicKey(), '0'); - const transaction = new TransactionBuilder(source, { - fee: BASE_FEE, - networkPassphrase, - }) - .addOperation(contract.call('get_arbitrator')) - .setTimeout(30) - .build(); - - const simulation = await server.simulateTransaction(transaction); - if (rpc.Api.isSimulationError(simulation)) { - const diagBlobs = extractDiagnosticEventBlobs(simulation); - const decoded = decodeSimulationError(simulation.error, diagBlobs, 'get_arbitrator'); - throw new SorobanSimulationError( - decoded.message ?? `Failed to read registry arbitrator: ${simulation.error}`, - simulation.error, - diagBlobs, - 'get_arbitrator', - ); - } - if (!simulation.result) { - throw new Error('Direct Soroban query returned no arbitrator value'); - } - - return String(scValToNative(simulation.result.retval)); -} - -export async function fetchReputationFromRpc( - address: string, - rpcUrl = import.meta.env.VITE_SOROBAN_RPC_URL || DEFAULT_SOROBAN_RPC_URL, - contractId = import.meta.env.VITE_PACTUM_CONTRACT_ID || DEFAULT_CONTRACT_ID, - networkPassphrase = import.meta.env.VITE_STELLAR_NETWORK_PASSPHRASE || DEFAULT_NETWORK_PASSPHRASE, -): Promise { - const server = new rpc.Server(rpcUrl, { allowHttp: true }); - const contract = new Contract(contractId); - const source = new Account(Keypair.random().publicKey(), '0'); - const transaction = new TransactionBuilder(source, { - fee: BASE_FEE, - networkPassphrase, - }) - .addOperation(contract.call('get_reputation', nativeToScVal(address, { type: 'address' }))) - .setTimeout(30) - .build(); - - const simulation = await server.simulateTransaction(transaction); - if (rpc.Api.isSimulationError(simulation)) { - const diagnosticBlobs = extractDiagnosticEventBlobs(simulation); - const decoded = decodeSimulationError(simulation.error, diagnosticBlobs, 'get_reputation'); - throw new SorobanSimulationError( - decoded.message ?? `Direct Soroban query failed: ${simulation.error}`, - simulation.error, - diagnosticBlobs, - 'get_reputation', - ); - } - if (!simulation.result) { - throw new Error('Direct Soroban query returned no reputation value'); - } - - const value = scValToNative(simulation.result.retval) as Record; - const fulfilled = Number(value.fulfilled_count ?? value.fulfilledCount ?? 0); - const late = Number(value.late_count ?? value.lateCount ?? 0); - const breached = Number(value.breached_count ?? value.breachedCount ?? 0); - - return { - address, - fulfilled, - late, - breached, - total: fulfilled + late + breached, - }; -} - -/** - * Converts a 64-character hex string (32 bytes SHA-256) into a Uint8Array - */ -export function hexToBytes(hexStr: string): Uint8Array { - const cleanHex = hexStr.replace(/^0x/i, ''); - if (cleanHex.length !== 64) { - throw new Error( - `Invalid terms hash hex length: expected 64 hex characters (32 bytes), got ${cleanHex.length}`, - ); - } - const bytes = new Uint8Array(32); - for (let i = 0; i < 32; i++) { - bytes[i] = parseInt(cleanHex.substring(i * 2, i * 2 + 2), 16); - } - return bytes; -} - -/** - * Helper to auto-fund a new unfunded Testnet account via Stellar Friendbot - */ -export async function fundTestnetAccount(address: string): Promise { - try { - const response = await fetch( - `https://friendbot.stellar.org/?addr=${encodeURIComponent(address)}`, - ); - return response.ok; - } catch (e) { - console.warn(`[Friendbot] Could not auto-fund ${address}:`, e); - return false; - } -} - -/** - * Builds, simulates, signs via Freighter, and submits a `create_commitment` Soroban transaction. - */ -export async function submitCreateCommitment({ - issuerAddress, - counterpartyAddress, - termsHashHex, - dueAtSeconds, - rpcUrl = import.meta.env.VITE_SOROBAN_RPC_URL || DEFAULT_SOROBAN_RPC_URL, - contractId = import.meta.env.VITE_PACTUM_CONTRACT_ID || DEFAULT_CONTRACT_ID, - networkPassphrase = import.meta.env.VITE_STELLAR_NETWORK_PASSPHRASE || DEFAULT_NETWORK_PASSPHRASE, - onStatusUpdate, - walletProvider = 'freighter', -}: CreateCommitmentParams): Promise { - // 1. Parameter Validation - if (!issuerAddress || !issuerAddress.startsWith('G')) { - throw new Error('Connected wallet issuer address must be a valid Stellar public key (G...)'); - } - if (!counterpartyAddress || !counterpartyAddress.startsWith('G')) { - throw new Error('Counterparty address must be a valid Stellar public key (G...)'); - } - if (issuerAddress.trim().toUpperCase() === counterpartyAddress.trim().toUpperCase()) { - throw new Error('Issuer and Counterparty addresses cannot be identical.'); - } - - const nowSeconds = Math.floor(Date.now() / 1000); - if (dueAtSeconds <= nowSeconds) { - throw new Error( - `Due date must be in the future. Selected timestamp (${dueAtSeconds}) is not > current timestamp (${nowSeconds}).`, - ); - } - - onStatusUpdate?.('Initializing Soroban RPC connection...'); - const server = new rpc.Server(rpcUrl, { allowHttp: true }); - - // 2. Convert Arguments to ScVal - onStatusUpdate?.('Encoding contract parameters...'); - const issuerScVal = Address.fromString(issuerAddress).toScVal(); - const counterpartyScVal = Address.fromString(counterpartyAddress).toScVal(); - const termsHashBytes = hexToBytes(termsHashHex); - // `scvBytes` accepts any Uint8Array at runtime — the `Buffer` param type is just its TS - // signature (same pattern as lib/crdt/signing.ts's verifyMessage cast in the host). - const termsHashScVal = xdr.ScVal.scvBytes(termsHashBytes as unknown as Buffer); - const dueAtScVal = xdr.ScVal.scvU64(xdr.Uint64.fromString(dueAtSeconds.toString())); - - // create_commitment requires a resolver_address; the wizard's UI has no concept of a custom - // dispute resolver yet, so read the registry's own arbitrator and use that (see - // fetchArbitrator's doc comment for why this -- not issuer/counterparty -- is the safe default). - onStatusUpdate?.('Fetching registry arbitrator...'); - const arbitratorAddress = await fetchArbitrator(rpcUrl, contractId, networkPassphrase); - const resolverScVal = Address.fromString(arbitratorAddress).toScVal(); - // oracle and schema_id are both genuinely optional (Option
/Option) with no - // downstream code assuming they're populated; the wizard doesn't collect either yet. - const oracleScVal = xdr.ScVal.scvVoid(); - const schemaIdScVal = xdr.ScVal.scvVoid(); - // Empty attestors + a 0 threshold is the contract's explicitly-designed "no voting panel, use - // the single-resolver dispute path" state (contracts/registry/src/commitments.rs::create). - const attestorsScVal = xdr.ScVal.scvVec([]); - const voteThresholdScVal = xdr.ScVal.scvU32(0); - - // 3. Build Transaction Envelope - onStatusUpdate?.('Fetching sequence number for issuer account...'); - let account: any = null; - try { - account = await server.getAccount(issuerAddress); - } catch (err: any) { - const errStr = String(err?.message || err).toLowerCase(); - if (errStr.includes('not found') || errStr.includes('404') || errStr.includes('account')) { - onStatusUpdate?.('Issuer account unfunded on Testnet. Auto-funding via Stellar Friendbot...'); - const funded = await fundTestnetAccount(issuerAddress); - if (funded) { - onStatusUpdate?.('Account funded! Re-fetching sequence number...'); - await new Promise((resolve) => setTimeout(resolve, 1500)); - try { - account = await server.getAccount(issuerAddress); - } catch (e2) { - console.warn('Re-fetch account error:', e2); - } - } - } - - if (!account) { - throw new Error( - `Connected account (${issuerAddress.substring(0, 8)}...) is not funded on Stellar Testnet yet. Please fund it with Testnet XLM in your Freighter extension or via Stellar Friendbot.`, - ); - } - } - - const contract = new Contract(contractId); - - const tx = new TransactionBuilder(account, { - fee: BASE_FEE, - networkPassphrase, - }) - .addOperation( - contract.call( - 'create_commitment', - issuerScVal, - counterpartyScVal, - termsHashScVal, - dueAtScVal, - resolverScVal, - oracleScVal, - schemaIdScVal, - attestorsScVal, - voteThresholdScVal, - ), - ) - .setTimeout(60) - .build(); - - // 4. Simulate & Prepare Transaction Envelope (Soroban footprint & fees) - onStatusUpdate?.('Simulating transaction on Soroban RPC...'); - let preparedTx: Awaited>; - try { - preparedTx = await server.prepareTransaction(tx); - } catch (prepareErr: unknown) { - const errMsg = prepareErr instanceof Error ? prepareErr.message : String(prepareErr); - const diagBlobs = extractDiagnosticEventBlobs({ error: errMsg }); - const decoded = decodeSimulationError(errMsg, diagBlobs, 'create_commitment'); - throw new SorobanSimulationError( - decoded.message ?? `Transaction simulation failed: ${errMsg}`, - errMsg, - diagBlobs, - 'create_commitment', - ); - } - - const unsignedXdr = preparedTx.toXDR(); - - // 5. Prompt the connected wallet for a signature - let signedXdr = ''; - - if (walletProvider === 'ledger') { - onStatusUpdate?.('Awaiting signature on Ledger device (confirm on-screen)...'); - signedXdr = await signTransactionWithLedger(unsignedXdr, networkPassphrase); - } else { - onStatusUpdate?.('Awaiting signature in Freighter wallet...'); - const signResult = await signTransaction(unsignedXdr, { - networkPassphrase, - address: issuerAddress, - }); - - if (typeof signResult === 'string') { - signedXdr = signResult; - } else if (signResult && typeof signResult === 'object') { - if ((signResult as any).error) { - throw new Error(`Freighter signing rejected: ${(signResult as any).error}`); - } - signedXdr = - (signResult as any).signedTxXdr || - (signResult as any).signedXdr || - (signResult as any).signedTransaction || - ''; - } - } - - if (!signedXdr) { - throw new Error('Transaction signing was cancelled or denied.'); - } - - // 6. Submit Signed Transaction Envelope to RPC - onStatusUpdate?.('Submitting transaction to Stellar Testnet...'); - const signedTx = TransactionBuilder.fromXDR(signedXdr, networkPassphrase); - const sendResult = await server.sendTransaction(signedTx); - - if (sendResult.status === 'ERROR' || sendResult.errorResult) { - throw new Error(`RPC submission error: ${sendResult.errorResult || sendResult.status}`); - } - - const txHash = sendResult.hash; - onStatusUpdate?.(`Transaction submitted! Confirming hash ${txHash.substring(0, 10)}...`); - - // 7. Poll RPC for Final On-Chain Ledger Status - let txStatus: rpc.Api.GetTransactionStatus = rpc.Api.GetTransactionStatus.NOT_FOUND; - let txResult: rpc.Api.GetTransactionResponse | null = null; - let attempts = 0; - - // 25 attempts (30s) was too tight against a freshly-booted local sandbox - // under CI load, where ledger close + RPC round-trip time can eat most of - // that budget before the tx is even included -- bumped to give real - // confirmation latency enough headroom. - while (attempts < 45) { - attempts++; - await new Promise((resolve) => setTimeout(resolve, 1200)); - txResult = await server.getTransaction(txHash); - txStatus = txResult.status; - - if (txStatus === rpc.Api.GetTransactionStatus.SUCCESS) { - break; - } else if (txStatus === rpc.Api.GetTransactionStatus.FAILED) { - // Enrich the FAILED result with XDR decoding if available - const failedTx = txResult as rpc.Api.GetFailedTransactionResponse | null; - let diagBlobs: string[] = []; - if (failedTx?.diagnosticEventsXdr) { - diagBlobs = failedTx.diagnosticEventsXdr - .map((e: any) => { - try { - return (e as any).toXDR?.('base64') ?? String(e); - } catch { - return null; - } - }) - .filter((b: string | null): b is string => b !== null); - } - const enrichedMessage = `Transaction execution failed on Stellar Testnet. Hash: ${txHash}`; - throw new SorobanSimulationError( - enrichedMessage, - enrichedMessage, - diagBlobs, - 'create_commitment', - ); - } - } - - if (txStatus !== rpc.Api.GetTransactionStatus.SUCCESS) { - throw new Error(`Transaction confirmation timed out. Hash: ${txHash}`); - } - - let commitmentId: number | bigint | undefined = undefined; - const successTx = txResult as any; - if (successTx && successTx.returnValue) { - try { - const nativeVal = scValToNative(successTx.returnValue); - if (typeof nativeVal === 'number' || typeof nativeVal === 'bigint') { - commitmentId = nativeVal; - } - } catch (e) { - console.warn('Could not parse commitmentId from retval:', e); - } - } - - onStatusUpdate?.('Transaction confirmed successfully on-chain!'); - - return { - hash: txHash, - commitmentId, - status: 'SUCCESS', - }; -} diff --git a/frontend-wizard-remote/src/lib/wallet.ts b/frontend-wizard-remote/src/lib/wallet.ts deleted file mode 100644 index 2069d992..00000000 --- a/frontend-wizard-remote/src/lib/wallet.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { Networks } from '@stellar/stellar-sdk'; -import { - isConnected as freighterIsConnected, - requestAccess as freighterRequestAccess, - getAddress as freighterGetAddress, - getNetwork as freighterGetNetwork, -} from '@stellar/freighter-api'; -import albedo from '@albedo-link/intent'; -import { isStellarAddress } from './stellar'; -import { LedgerAdapter } from './wallet-adapters/ledger-adapter'; - -export type WalletProvider = 'freighter' | 'albedo' | 'ledger'; - -export const PACTUM_NETWORK_PASSPHRASE = Networks.TESTNET; -export const PACTUM_NETWORK_NAME = 'TESTNET'; -export const FREIGHTER_HOMEPAGE = 'https://www.freighter.app/'; - -export type WalletErrorCode = - 'NOT_INSTALLED' | 'CONNECTION_REJECTED' | 'NETWORK_MISMATCH' | 'INVALID_ADDRESS' | 'UNKNOWN'; - -export class WalletConnectionError extends Error { - readonly code: WalletErrorCode; - - constructor(code: WalletErrorCode, message: string) { - super(message); - this.name = 'WalletConnectionError'; - this.code = code; - } -} - -export interface WalletConnectionResult { - address: string; - provider: WalletProvider; -} - -export { freighterGetAddress as getFreighterAddress, freighterGetNetwork as getFreighterNetwork }; - -export function isFreighterInstalled(): boolean { - return Boolean( - typeof window !== 'undefined' && (window as unknown as Record).freighter, - ); -} - -export async function isFreighterConnected(): Promise { - try { - const res = await freighterIsConnected(); - return Boolean(res && res.isConnected); - } catch (err) { - console.warn('[wallet] freighter isConnected check failed:', err); - return isFreighterInstalled(); - } -} - -function assertTestnetNetwork(network: string | undefined, passphrase: string | undefined): void { - if (!network) return; - const normalized = network.toUpperCase(); - const onTestnet = - normalized === PACTUM_NETWORK_NAME || - normalized === 'TEST' || - (passphrase != null && passphrase === PACTUM_NETWORK_PASSPHRASE); - - if (!onTestnet) { - throw new WalletConnectionError( - 'NETWORK_MISMATCH', - `Freighter is connected to ${network}. Pactum requires Stellar Testnet. ` + - 'Please switch your wallet network to Testnet (Settings → Network) and try again.', - ); - } -} - -/** - * Connects via the Freighter browser extension: - * 1. Verifies the extension is installed & unlocked - * 2. Requests access (prompts the extension pop-up) - * 3. Retrieves the public key - * 4. Verifies the wallet is on Stellar Testnet - */ -export async function connectWithFreighter(): Promise { - if (!isFreighterInstalled()) { - throw new WalletConnectionError( - 'NOT_INSTALLED', - 'Freighter browser extension was not detected. Please install Freighter from freighter.app.', - ); - } - - let address = ''; - - try { - const accessRes = await freighterRequestAccess(); - if (accessRes && accessRes.address) { - address = accessRes.address; - } else if (accessRes && accessRes.error) { - throw new WalletConnectionError( - 'CONNECTION_REJECTED', - String(accessRes.error) || 'Connection request denied in Freighter.', - ); - } - } catch (err) { - if (err instanceof WalletConnectionError) throw err; - // Fallback: connection may already be allowed - try { - const addrRes = await freighterGetAddress(); - if (addrRes && addrRes.address && !addrRes.error) { - address = addrRes.address; - } - } catch (e) { - console.warn('[wallet] freighter getAddress fallback failed:', e); - } - if (!address) { - throw new WalletConnectionError( - 'CONNECTION_REJECTED', - 'Connection request was rejected or cancelled in Freighter.', - ); - } - } - - if (!address) { - throw new WalletConnectionError( - 'CONNECTION_REJECTED', - 'Unable to retrieve account address from Freighter.', - ); - } - - if (!isStellarAddress(address)) { - throw new WalletConnectionError( - 'INVALID_ADDRESS', - `Freighter returned an invalid Stellar address: ${address}`, - ); - } - - // Enforce Testnet (best-effort: if the API is unavailable, log and continue) - try { - const netRes = await freighterGetNetwork(); - if (netRes && !netRes.error) { - assertTestnetNetwork(netRes.network, netRes.networkPassphrase); - } - } catch (err) { - if (err instanceof WalletConnectionError) throw err; - console.warn('[wallet] Unable to verify Freighter network; continuing:', err); - } - - return { address, provider: 'freighter' }; -} - -/** - * Connects via Albedo (web wallet) using the official intent SDK. - * Opens an Albedo pop-up where the user selects an account. - * Signing intents later enforce `network: 'testnet'`. - */ -export async function connectWithAlbedo(): Promise { - try { - const res = await albedo.publicKey({}); - if (!res || !res.pubkey) { - throw new WalletConnectionError( - 'CONNECTION_REJECTED', - 'Albedo connection was rejected or cancelled.', - ); - } - if (!isStellarAddress(res.pubkey)) { - throw new WalletConnectionError( - 'INVALID_ADDRESS', - `Albedo returned an invalid Stellar address: ${res.pubkey}`, - ); - } - return { address: res.pubkey, provider: 'albedo' }; - } catch (err) { - if (err instanceof WalletConnectionError) throw err; - throw new WalletConnectionError( - 'CONNECTION_REJECTED', - err instanceof Error ? err.message : 'Failed to connect with Albedo wallet.', - ); - } -} - -/** - * Connects directly to a Ledger Nano hardware wallet over WebUSB/WebBluetooth - * (no browser extension involved). Prompts the browser's native device picker, - * then reads the Stellar public key from the Ledger Stellar app. - */ -export async function connectWithLedger(): Promise { - try { - const address = await LedgerAdapter.connect(); - if (!address || !isStellarAddress(address)) { - throw new WalletConnectionError( - 'INVALID_ADDRESS', - 'Ledger returned an invalid Stellar address. Ensure the Stellar app is open on the device.', - ); - } - return { address, provider: 'ledger' }; - } catch (err) { - if (err instanceof WalletConnectionError) throw err; - throw new WalletConnectionError( - 'CONNECTION_REJECTED', - err instanceof Error ? err.message : 'Failed to connect to Ledger device.', - ); - } -} - -export function connectWallet(provider: WalletProvider): Promise { - if (provider === 'albedo') return connectWithAlbedo(); - if (provider === 'ledger') return connectWithLedger(); - return connectWithFreighter(); -} - -export function truncateAddress(address: string, start = 6, end = 4): string { - if (!address || address.length <= start + end) return address; - return `${address.substring(0, start)}...${address.substring(address.length - end)}`; -} diff --git a/frontend/package.json b/frontend/package.json index f2e3ed41..3541cbf3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -47,7 +47,8 @@ "y-protocols": "^1.0.7", "yjs": "^13.6.32", "zod": "^4.4.3", - "zustand": "^5.0.15" + "zustand": "^5.0.15", + "@pactum/soroban-client": "*" }, "devDependencies": { "@module-federation/vite": "^1.20.7", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7f1debe1..324cf562 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,7 +14,7 @@ import { fetchEncryptedTerms } from './lib/api'; import type { Commitment, CommitmentStatus } from './lib/api'; import { useWallet } from './context/WalletContext'; import { wsClient } from './lib/wsClient'; -import type { WalletProvider } from './lib/wallet'; +import type { WalletProvider } from '@pactum/soroban-client'; import { submitAttest, submitDispute, diff --git a/frontend/src/components/SorobanErrorModal.tsx b/frontend/src/components/SorobanErrorModal.tsx index 8c3a7f0f..eef9d6f8 100644 --- a/frontend/src/components/SorobanErrorModal.tsx +++ b/frontend/src/components/SorobanErrorModal.tsx @@ -9,8 +9,8 @@ import { Terminal, Bug, } from 'lucide-react'; -import type { DecodedXdrError, DecodedDiagnosticEvent } from '../lib/errors'; -import { decodeSorobanError, sanitizeErrorMessage } from '../lib/errors'; +import type { DecodedXdrError, DecodedDiagnosticEvent } from '@pactum/soroban-client'; +import { decodeSorobanError, sanitizeErrorMessage } from '@pactum/soroban-client'; // --------------------------------------------------------------------------- // Types diff --git a/frontend/src/components/WalletConnectButton.tsx b/frontend/src/components/WalletConnectButton.tsx index 418dbc1c..435183c8 100644 --- a/frontend/src/components/WalletConnectButton.tsx +++ b/frontend/src/components/WalletConnectButton.tsx @@ -2,7 +2,7 @@ import React, { useState } from 'react'; import { Wallet, CheckCircle2 } from 'lucide-react'; import { useWallet } from '../context/WalletContext'; import { useTheme } from '../context/ThemeContext'; -import { truncateAddress } from '../lib/wallet'; +import { truncateAddress } from '@pactum/soroban-client'; import WalletConnectModal from './WalletConnectModal'; export interface WalletConnectButtonProps { diff --git a/frontend/src/components/WalletConnectModal.tsx b/frontend/src/components/WalletConnectModal.tsx index e137590b..3b7a9579 100644 --- a/frontend/src/components/WalletConnectModal.tsx +++ b/frontend/src/components/WalletConnectModal.tsx @@ -12,7 +12,7 @@ import { Usb, Mail, } from 'lucide-react'; -import { truncateAddress, FREIGHTER_HOMEPAGE, type WalletProvider } from '../lib/wallet'; +import { truncateAddress, FREIGHTER_HOMEPAGE, type WalletProvider } from '@pactum/soroban-client'; export interface WalletConnectModalProps { isOpen: boolean; diff --git a/frontend/src/context/IndexerModeContext.tsx b/frontend/src/context/IndexerModeContext.tsx index 41dfc616..22ca26ab 100644 --- a/frontend/src/context/IndexerModeContext.tsx +++ b/frontend/src/context/IndexerModeContext.tsx @@ -7,7 +7,7 @@ import { DEFAULT_CONTRACT_ID, DEFAULT_NETWORK_PASSPHRASE, DEFAULT_SOROBAN_RPC_URL, -} from '../lib/soroban'; +} from '@pactum/soroban-client'; import type { IndexerWorkerStatus } from '../workers/indexer.worker.ts'; export type IndexerMode = 'cloud' | 'local'; diff --git a/frontend/src/context/WalletContext.tsx b/frontend/src/context/WalletContext.tsx index 50aba83d..ca83c6ab 100644 --- a/frontend/src/context/WalletContext.tsx +++ b/frontend/src/context/WalletContext.tsx @@ -14,7 +14,7 @@ import { WalletConnectionError, type WalletErrorCode, type WalletProvider as WalletProviderName, -} from '../lib/wallet'; +} from '@pactum/soroban-client'; export interface WalletContextType { address: string | null; diff --git a/frontend/src/lib/errors.ts b/frontend/src/lib/errors.ts deleted file mode 100644 index 98b87539..00000000 --- a/frontend/src/lib/errors.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { decodeSimulationError, isSorobanXdrError } from './xdrDecode'; -import type { DecodedXdrError, DecodedDiagnosticEvent } from './xdrDecode'; - -export type { DecodedXdrError, DecodedDiagnosticEvent }; - -export const TRANSACTION_FAILED_MESSAGE = 'Transaction Failed' as const; - -const REGISTRY_CONTRACT_ERROR_MESSAGES: Readonly> = Object.freeze({ - 1: 'Due date must be in the future', - 2: 'Commitment not found', - 3: 'Commitment already resolved', - 4: 'Unauthorized', - 5: 'Invalid outcome', - 6: 'Contract already initialized', - 7: 'Not arbitrator', - 8: 'Dispute window expired', - 9: 'Invalid transition', - 10: 'Contract not initialized', - 11: 'Not authorized', - 12: 'Overflow', - 13: 'Reentrant call', - 14: 'Upgrade admin not set', - 15: 'Upgrade admin already set', - 16: 'Schema downgrade', - 17: 'Unsupported schema version', - 18: 'Migration not enabled', - 19: 'Batch too large', - 20: 'Invalid milestone count', - 21: 'Invalid milestone index', - 22: 'Milestone already attested', - 23: 'Milestone out of order', - 24: 'Empty arbitrator set', - 25: 'Already voted', - 26: 'Insufficient stake', - 27: 'Unbonding pending', - 28: 'Unbonding not elapsed', - 29: 'Dispute active', - 30: 'Staking token not set', - 31: 'Zero amount', - 32: 'Not attestor', - 33: 'Attestor already voted', - 34: 'Threshold invalid', - 35: 'Voting closed', - 36: 'Votes not met', - 37: 'Use voting resolution', - 38: 'Protocol paused', -}); - -const CONTRACT_ERROR_PATTERN = /Error\(Contract,\s*#(\d+)\)/i; -const GENERIC_ERROR_CODE_PATTERN = /Error\s+Code\s+(\d+)/i; - -function extractRegistryErrorCode(source: string): number | null { - const contractMatch = CONTRACT_ERROR_PATTERN.exec(source); - if (contractMatch?.[1] !== undefined) { - return Number.parseInt(contractMatch[1], 10); - } - - const genericMatch = GENERIC_ERROR_CODE_PATTERN.exec(source); - if (genericMatch?.[1] !== undefined) { - return Number.parseInt(genericMatch[1], 10); - } - - return null; -} - -function errorToSource(error: unknown): string | null { - if (typeof error === 'string') { - return error; - } - - if (error instanceof Error) { - return error.message; - } - - return null; -} - -/** - * Primary error decoder for contract invocation failures. - * - * This function is the main entry point used by UI components to translate - * any Soroban-related error into a human-readable message. It tries several - * strategies in order: - * - * 1. Registry contract error codes (e.g. `Error(Contract, #1)`) - * 2. XDR-based decoding of diagnostic events and footprints - * 3. Generic error pattern matching - * 4. Fallback to a generic "Transaction Failed" message - * - * @param error - The error to decode (string, Error, or unknown) - * @returns A human-readable error string for display in toasts, modals, etc. - */ -export function decodeRegistryContractError(error: unknown): string { - const source = errorToSource(error); - if (source === null) { - return TRANSACTION_FAILED_MESSAGE; - } - - // Strategy 1: Direct contract error code match - const code = extractRegistryErrorCode(source); - if (code !== null && Number.isInteger(code)) { - if (Object.hasOwn(REGISTRY_CONTRACT_ERROR_MESSAGES, code)) { - return REGISTRY_CONTRACT_ERROR_MESSAGES[code]; - } - } - - // Strategy 2: Try XDR-based decoding for errors that contain - // either base64 blobs OR known Soroban trap patterns (HostError, etc.). - if ( - isSorobanXdrError(source) || - /HostError|WasmVmError|InvalidAction|InternalError|InsufficientRefundableFee|StaleFootprint/i.test( - source, - ) - ) { - try { - const decoded = decodeSimulationError(source); - if (decoded && decoded.message && decoded.message !== source) { - return decoded.message; - } - } catch { - // XDR decode failed, fall through to generic handling - } - } - - // Strategy 3: If we matched a contract code but it's unknown, fall through - // to generic "Transaction Failed" rather than exposing raw error details. - if (code !== null) { - return TRANSACTION_FAILED_MESSAGE; - } - - return TRANSACTION_FAILED_MESSAGE; -} - -/** - * Enhanced version that returns structured error information including - * diagnostic events, attempted operation details, and resolution guidance. - * - * Use this when you need richer error data for display in a modal/dialog - * rather than a simple toast message. - * - * @param error - The error to decode - * @param diagnosticEventBlobs - Optional base64-encoded diagnostic events from simulation - * @param attemptedFunction - Optional hint about which function was invoked - * @returns A structured error object with details suitable for modal display - */ -export function decodeSorobanError( - error: unknown, - diagnosticEventBlobs: string[] = [], - attemptedFunction: string | null = null, -): DecodedXdrError { - const source = errorToSource(error); - - // If we have a simple contract error code, build a concise XDR error - if (source) { - const code = extractRegistryErrorCode(source); - if (code !== null && Number.isInteger(code)) { - const message = Object.hasOwn(REGISTRY_CONTRACT_ERROR_MESSAGES, code) - ? REGISTRY_CONTRACT_ERROR_MESSAGES[code] - : TRANSACTION_FAILED_MESSAGE; - - return { - message, - rawError: source, - diagnosticEvents: [], - attemptedOperation: attemptedFunction - ? { - operation: attemptedFunction, - arguments: {}, - failedAt: `${attemptedFunction} execution`, - trapReason: `The contract returned error code #${code}: ${message}`, - } - : null, - resolution: 'Check your transaction parameters and try again.', - rawXdrBlobs: [], - }; - } - } - - // Full XDR-based decoding - const errorMessage = source ?? TRANSACTION_FAILED_MESSAGE; - return decodeSimulationError(errorMessage, diagnosticEventBlobs, attemptedFunction); -} - -/** - * Sanitize an error message to ensure no sensitive data (tokens, keys, etc.) - * is exposed to the UI. - */ -export function sanitizeErrorMessage(message: string): string { - // Strip potential base64-encoded sensitive tokens - let sanitized = message; - // Remove any Stellar secret keys (S-prefixed base58) - sanitized = sanitized.replace(/S[A-Za-z0-9]{55}/g, '[REDACTED_SECRET]'); - // Remove long hex strings (potential API keys / hashes of secrets) - sanitized = sanitized.replace(/[0-9a-fA-F]{64,}/g, '[REDACTED_HEX]'); - return sanitized; -} diff --git a/frontend/src/lib/verifiedReputation.ts b/frontend/src/lib/verifiedReputation.ts index e755167a..a070c5f3 100644 --- a/frontend/src/lib/verifiedReputation.ts +++ b/frontend/src/lib/verifiedReputation.ts @@ -5,7 +5,7 @@ import { DEFAULT_NETWORK_PASSPHRASE, fetchLatestLedgerAnchor, fetchReputationFromRpc, -} from './soroban'; +} from '@pactum/soroban-client'; export type ReputationIntegrity = 'verified' | 'rpc-fallback'; @@ -16,7 +16,10 @@ export interface VerifiedReputationResult { warning?: string; } -function reputationFromProof(address: string, proof: Awaited>) { +function reputationFromProof( + address: string, + proof: Awaited>, +) { const { fulfilledCount, lateCount, breachedCount } = proof.scoreData; return { address, diff --git a/package-lock.json b/package-lock.json index 74e4b39f..fb587958 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,10 @@ "sdk/js", "packages/cli", "evm", - "zk" + "zk", + "packages/soroban-client", + "frontend-wizard-remote", + "frontend-dashboard-remote" ], "devDependencies": { "@typescript-eslint/eslint-plugin": "^8.0.0", @@ -217,6 +220,7 @@ "@ledgerhq/hw-app-str": "^7.7.7", "@ledgerhq/hw-transport-web-ble": "^6.35.0", "@ledgerhq/hw-transport-webusb": "^6.35.0", + "@pactum/soroban-client": "*", "@sentry/react": "^10.71.0", "@stellar/freighter-api": "^6.0.1", "@stellar/stellar-sdk": "^16.2.0", @@ -266,6 +270,125 @@ "node": ">=22.18.0" } }, + "frontend-dashboard-remote": { + "name": "@pactum/dashboard-remote", + "version": "0.0.0", + "dependencies": { + "@albedo-link/intent": "^0.13.0", + "@ledgerhq/hw-app-str": "^7.7.7", + "@ledgerhq/hw-transport-web-ble": "^6.35.0", + "@ledgerhq/hw-transport-webusb": "^6.35.0", + "@pactum/soroban-client": "*", + "@stellar/freighter-api": "^6.0.1", + "@stellar/stellar-sdk": "^16.2.0", + "@tanstack/react-query": "^5.101.4", + "@tanstack/react-virtual": "^3.14.10", + "lucide-react": "^1.32.0", + "react": "^19.2.8", + "react-dom": "^19.2.8" + }, + "devDependencies": { + "@module-federation/vite": "^1.20.7", + "@types/node": "^24.13.3", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.4", + "typescript": "~6.0.2", + "vite": "^8.2.0" + }, + "engines": { + "node": ">=22.18.0" + } + }, + "frontend-dashboard-remote/node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "frontend-dashboard-remote/node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "frontend-wizard-remote": { + "name": "@pactum/wizard-remote", + "version": "0.0.0", + "dependencies": { + "@albedo-link/intent": "^0.13.0", + "@hookform/resolvers": "^5.9.0", + "@ledgerhq/hw-app-str": "^7.7.7", + "@ledgerhq/hw-transport-web-ble": "^6.35.0", + "@ledgerhq/hw-transport-webusb": "^6.35.0", + "@pactum/soroban-client": "*", + "@stellar/freighter-api": "^6.0.1", + "@stellar/stellar-sdk": "^16.2.0", + "@tanstack/react-query": "^5.101.4", + "lucide-react": "^1.32.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-hook-form": "^7.85.0", + "zod": "^4.4.3" + }, + "devDependencies": { + "@module-federation/vite": "^1.20.7", + "@types/node": "^24.13.3", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.4", + "typescript": "~6.0.2", + "vite": "^8.2.0" + }, + "engines": { + "node": ">=22.18.0" + } + }, + "frontend-wizard-remote/node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "frontend-wizard-remote/node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "frontend-wizard-remote/node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "frontend/node_modules/@types/node": { "version": "24.13.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", @@ -750,12 +873,12 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "aix" ], + "peer": true, "engines": { "node": ">=18" } @@ -767,12 +890,12 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -784,12 +907,12 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -801,12 +924,12 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -818,12 +941,12 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -835,12 +958,12 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -852,12 +975,12 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -869,12 +992,12 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -886,12 +1009,12 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -903,12 +1026,12 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -920,12 +1043,12 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -937,12 +1060,12 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -954,12 +1077,12 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -971,12 +1094,12 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -988,12 +1111,12 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1005,12 +1128,12 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1022,12 +1145,12 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1039,12 +1162,12 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -1056,12 +1179,12 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -1073,12 +1196,12 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -1090,12 +1213,12 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -1107,12 +1230,12 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "openharmony" ], + "peer": true, "engines": { "node": ">=18" } @@ -1124,12 +1247,12 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "sunos" ], + "peer": true, "engines": { "node": ">=18" } @@ -1141,12 +1264,12 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -1158,12 +1281,12 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -1175,12 +1298,12 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -3663,6 +3786,10 @@ "resolved": "packages/cli", "link": true }, + "node_modules/@pactum/dashboard-remote": { + "resolved": "frontend-dashboard-remote", + "link": true + }, "node_modules/@pactum/evm-oracle": { "resolved": "evm", "link": true @@ -3671,6 +3798,14 @@ "resolved": "sdk/js", "link": true }, + "node_modules/@pactum/soroban-client": { + "resolved": "packages/soroban-client", + "link": true + }, + "node_modules/@pactum/wizard-remote": { + "resolved": "frontend-wizard-remote", + "link": true + }, "node_modules/@pactum/zk-reputation": { "resolved": "zk", "link": true @@ -21842,6 +21977,36 @@ "dev": true, "license": "MIT" }, + "packages/soroban-client": { + "name": "@pactum/soroban-client", + "version": "1.0.0", + "dependencies": { + "@albedo-link/intent": "^0.13.0", + "@ledgerhq/hw-app-str": "^7.7.7", + "@ledgerhq/hw-transport-web-ble": "^6.35.0", + "@ledgerhq/hw-transport-webusb": "^6.35.0", + "@stellar/freighter-api": "^6.0.1", + "@stellar/stellar-sdk": "^16.2.0", + "@toruslabs/openlogin-ed25519": "^8.1.0", + "@web3auth/base": "^9.7.0", + "@web3auth/base-provider": "^9.7.0", + "@web3auth/modal": "^9.7.0", + "buffer": "^6.0.3", + "zod": "^4.4.3" + }, + "devDependencies": { + "typescript": "^5.0.0" + } + }, + "packages/soroban-client/node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "sdk/js": { "name": "@pactum/sdk", "version": "0.1.0", diff --git a/package.json b/package.json index 19e1a77e..7d83468b 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,10 @@ "sdk/js", "packages/cli", "evm", - "zk" + "zk", + "packages/soroban-client", + "frontend-wizard-remote", + "frontend-dashboard-remote" ], "scripts": { "lint": "eslint \"**/*.{ts,tsx,js,jsx}\" --ignore-path .eslintignore", diff --git a/packages/soroban-client/package.json b/packages/soroban-client/package.json new file mode 100644 index 00000000..e1d8cd99 --- /dev/null +++ b/packages/soroban-client/package.json @@ -0,0 +1,27 @@ +{ + "name": "@pactum/soroban-client", + "version": "1.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "scripts": { + "build": "tsc", + "test": "vitest run" + }, + "dependencies": { + "@stellar/freighter-api": "^6.0.1", + "@stellar/stellar-sdk": "^16.2.0", + "buffer": "^6.0.3", + "@albedo-link/intent": "^0.13.0", + "@ledgerhq/hw-app-str": "^7.7.7", + "@ledgerhq/hw-transport-web-ble": "^6.35.0", + "@ledgerhq/hw-transport-webusb": "^6.35.0", + "@toruslabs/openlogin-ed25519": "^8.1.0", + "@web3auth/base": "^9.7.0", + "@web3auth/base-provider": "^9.7.0", + "@web3auth/modal": "^9.7.0", + "zod": "^4.4.3" + }, + "devDependencies": { + "typescript": "^5.0.0" + } +} diff --git a/packages/soroban-client/src/env.d.ts b/packages/soroban-client/src/env.d.ts new file mode 100644 index 00000000..f61d8b2a --- /dev/null +++ b/packages/soroban-client/src/env.d.ts @@ -0,0 +1,11 @@ +/// +interface ImportMetaEnv { + readonly VITE_SOROBAN_RPC_URL?: string; + readonly VITE_PACTUM_CONTRACT_ID?: string; + readonly VITE_STELLAR_NETWORK_PASSPHRASE?: string; + readonly VITE_ALBEDO_NETWORK?: string; + readonly VITE_WEB3AUTH_CLIENT_ID?: string; +} +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/frontend-wizard-remote/src/lib/errors.ts b/packages/soroban-client/src/errors.ts similarity index 100% rename from frontend-wizard-remote/src/lib/errors.ts rename to packages/soroban-client/src/errors.ts diff --git a/packages/soroban-client/src/index.ts b/packages/soroban-client/src/index.ts new file mode 100644 index 00000000..732c0eb3 --- /dev/null +++ b/packages/soroban-client/src/index.ts @@ -0,0 +1,9 @@ +export * from './soroban'; +export * from './sorobanRpcPool'; +export * from './xdrDecode'; +export * from './web3auth'; +export * from './wallet'; +export * from './stellar'; +export * from './errors'; +export * from './sorobanTxHelpers'; +export * from './types'; diff --git a/frontend/src/lib/soroban.ts b/packages/soroban-client/src/soroban.ts similarity index 99% rename from frontend/src/lib/soroban.ts rename to packages/soroban-client/src/soroban.ts index 2492caca..cf3fb8a0 100644 --- a/frontend/src/lib/soroban.ts +++ b/packages/soroban-client/src/soroban.ts @@ -11,7 +11,7 @@ import { nativeToScVal, scValToNative, } from '@stellar/stellar-sdk'; -import type { Reputation } from './api'; +import type { Reputation } from './types'; import { Buffer } from 'buffer'; import { signTransaction } from '@stellar/freighter-api'; import { signTransactionWithLedger } from './wallet-adapters/ledger-adapter'; @@ -377,7 +377,9 @@ export async function preflightSimulate( const credentials = decoded.credentials(); if (credentials.switch().name === 'sorobanCredentialsAddress') { const addrCreds = credentials.address(); - requiredAuths.push(addrCreds.address().accountId().ed25519().toString('hex').slice(0, 8) + '...'); + requiredAuths.push( + addrCreds.address().accountId().ed25519().toString('hex').slice(0, 8) + '...', + ); } else { requiredAuths.push('Source account authorization'); } diff --git a/frontend/src/lib/sorobanRpcPool.test.ts b/packages/soroban-client/src/sorobanRpcPool.test.ts similarity index 100% rename from frontend/src/lib/sorobanRpcPool.test.ts rename to packages/soroban-client/src/sorobanRpcPool.test.ts diff --git a/frontend/src/lib/sorobanRpcPool.ts b/packages/soroban-client/src/sorobanRpcPool.ts similarity index 100% rename from frontend/src/lib/sorobanRpcPool.ts rename to packages/soroban-client/src/sorobanRpcPool.ts diff --git a/frontend/src/lib/sorobanTxHelpers.ts b/packages/soroban-client/src/sorobanTxHelpers.ts similarity index 100% rename from frontend/src/lib/sorobanTxHelpers.ts rename to packages/soroban-client/src/sorobanTxHelpers.ts diff --git a/frontend/src/lib/stellar.ts b/packages/soroban-client/src/stellar.ts similarity index 100% rename from frontend/src/lib/stellar.ts rename to packages/soroban-client/src/stellar.ts diff --git a/packages/soroban-client/src/types.ts b/packages/soroban-client/src/types.ts new file mode 100644 index 00000000..bde2bbbf --- /dev/null +++ b/packages/soroban-client/src/types.ts @@ -0,0 +1,7 @@ +export interface Reputation { + address: string; + fulfilled: number; + late: number; + breached: number; + total: number; +} diff --git a/frontend/src/lib/wallet-adapters/ledger-adapter.ts b/packages/soroban-client/src/wallet-adapters/ledger-adapter.ts similarity index 96% rename from frontend/src/lib/wallet-adapters/ledger-adapter.ts rename to packages/soroban-client/src/wallet-adapters/ledger-adapter.ts index 6c4f27e3..fbf4daed 100644 --- a/frontend/src/lib/wallet-adapters/ledger-adapter.ts +++ b/packages/soroban-client/src/wallet-adapters/ledger-adapter.ts @@ -1,4 +1,9 @@ -import { StrKey, TransactionBuilder, type Transaction, type FeeBumpTransaction } from '@stellar/stellar-sdk'; +import { + StrKey, + TransactionBuilder, + type Transaction, + type FeeBumpTransaction, +} from '@stellar/stellar-sdk'; interface WalletAdapter { name: string; @@ -112,8 +117,7 @@ export async function signTransactionWithLedger( const publicKey = StrKey.encodeEd25519PublicKey(rawPublicKey); const tx = TransactionBuilder.fromXDR(unsignedXdr, networkPassphrase) as - | Transaction - | FeeBumpTransaction; + Transaction | FeeBumpTransaction; const { signature } = await app.signTransaction(LEDGER_STELLAR_PATH, tx.signatureBase()); tx.addSignature(publicKey, signature.toString('base64')); diff --git a/frontend/src/lib/wallet.ts b/packages/soroban-client/src/wallet.ts similarity index 100% rename from frontend/src/lib/wallet.ts rename to packages/soroban-client/src/wallet.ts diff --git a/frontend/src/lib/web3auth.ts b/packages/soroban-client/src/web3auth.ts similarity index 100% rename from frontend/src/lib/web3auth.ts rename to packages/soroban-client/src/web3auth.ts diff --git a/frontend/src/lib/web3authDerive.test.ts b/packages/soroban-client/src/web3authDerive.test.ts similarity index 100% rename from frontend/src/lib/web3authDerive.test.ts rename to packages/soroban-client/src/web3authDerive.test.ts diff --git a/frontend/src/lib/web3authDerive.ts b/packages/soroban-client/src/web3authDerive.ts similarity index 100% rename from frontend/src/lib/web3authDerive.ts rename to packages/soroban-client/src/web3authDerive.ts diff --git a/frontend/src/lib/xdrDecode.test.ts b/packages/soroban-client/src/xdrDecode.test.ts similarity index 100% rename from frontend/src/lib/xdrDecode.test.ts rename to packages/soroban-client/src/xdrDecode.test.ts diff --git a/frontend/src/lib/xdrDecode.ts b/packages/soroban-client/src/xdrDecode.ts similarity index 100% rename from frontend/src/lib/xdrDecode.ts rename to packages/soroban-client/src/xdrDecode.ts diff --git a/packages/soroban-client/tsconfig.json b/packages/soroban-client/tsconfig.json new file mode 100644 index 00000000..d84c6dcb --- /dev/null +++ b/packages/soroban-client/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "lib": ["ES2022", "DOM"], + "moduleResolution": "bundler", + "strict": true, + "declaration": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "dist" + }, + "include": ["src"] +} From 95c679c57966fc9eda7c80ee88204ce3d6bae779 Mon Sep 17 00:00:00 2001 From: s6pa1rta3n-lab Date: Wed, 26 Aug 2026 09:15:17 -0400 Subject: [PATCH 3/7] fix(soroban-client): address actionable review feedback --- .../src/CreateCommitmentWizard.tsx | 16 +- .../src/components/SorobanErrorModal.tsx | 639 ------------------ frontend/src/context/WalletContext.tsx | 4 +- frontend/vite.config.ts | 1 + .../soroban-client/src/sorobanTxHelpers.ts | 17 +- packages/soroban-client/src/stellar.ts | 5 +- packages/soroban-client/src/wallet.ts | 17 +- packages/soroban-client/src/web3auth.ts | 5 +- packages/soroban-client/src/xdrDecode.ts | 6 +- 9 files changed, 45 insertions(+), 665 deletions(-) delete mode 100644 frontend-wizard-remote/src/components/SorobanErrorModal.tsx diff --git a/frontend-wizard-remote/src/CreateCommitmentWizard.tsx b/frontend-wizard-remote/src/CreateCommitmentWizard.tsx index 12090efc..4adb49e0 100644 --- a/frontend-wizard-remote/src/CreateCommitmentWizard.tsx +++ b/frontend-wizard-remote/src/CreateCommitmentWizard.tsx @@ -9,13 +9,9 @@ import { queryClient } from 'host/queryClient'; import { sha256Hex } from './lib/hash'; import { encryptCommitmentTerms, type EncryptResult } from './lib/crypto'; -import { stellarAddressSchema } from '@pactum/soroban-client'; -import { decodeRegistryContractError } from '@pactum/soroban-client'; -import { createAstResolver, composeResolvers } from './lib/ast'; -import { useValidationRules } from './hooks/useValidationRules'; -import { useWallet } from 'host/WalletContext'; -import { useWasmValidation } from './hooks/useWasmValidation'; import { + stellarAddressSchema, + decodeRegistryContractError, submitCreateCommitment, fundTestnetAccount, preflightSimulate, @@ -24,12 +20,16 @@ import { type CreateCommitmentResult, type SimulationPreview, SorobanSimulationError, + decodeSimulationError, } from '@pactum/soroban-client'; -import { decodeSimulationError } from '@pactum/soroban-client'; +import { createAstResolver, composeResolvers } from './lib/ast'; +import { useValidationRules } from './hooks/useValidationRules'; +import { useWallet } from 'host/WalletContext'; +import { useWasmValidation } from './hooks/useWasmValidation'; import { postEncryptedTerms, createCommitment } from './lib/api'; import UserProfile from './components/UserProfile'; import EncryptionConsentModal from './components/EncryptionConsentModal'; -import { SorobanErrorModal } from './components/SorobanErrorModal'; +import { SorobanErrorModal } from 'host/SorobanErrorModal'; import SimulationPreviewModal from './components/SimulationPreviewModal'; import { CheckCircle2, diff --git a/frontend-wizard-remote/src/components/SorobanErrorModal.tsx b/frontend-wizard-remote/src/components/SorobanErrorModal.tsx deleted file mode 100644 index eef9d6f8..00000000 --- a/frontend-wizard-remote/src/components/SorobanErrorModal.tsx +++ /dev/null @@ -1,639 +0,0 @@ -import React, { useState } from 'react'; -import { - X, - AlertTriangle, - Info, - ChevronDown, - ChevronRight, - ExternalLink, - Terminal, - Bug, -} from 'lucide-react'; -import type { DecodedXdrError, DecodedDiagnosticEvent } from '@pactum/soroban-client'; -import { decodeSorobanError, sanitizeErrorMessage } from '@pactum/soroban-client'; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export interface SorobanErrorModalProps { - /** The raw error that was thrown */ - error: unknown; - /** Optional diagnostic event blobs (base64-encoded XDR) from the simulation */ - diagnosticEventBlobs?: string[]; - /** Optional hint about which contract function was called */ - attemptedFunction?: string; - /** Called when the user dismisses the modal */ - onDismiss: () => void; - /** Optional callback to retry the transaction */ - onRetry?: () => void; -} - -// --------------------------------------------------------------------------- -// Sub-Components -// --------------------------------------------------------------------------- - -/** - * Renders a single diagnostic event row with expandable details. - */ -const DiagnosticEventRow: React.FC<{ event: DecodedDiagnosticEvent }> = ({ event }) => { - const [expanded, setExpanded] = useState(false); - - return ( -
- - {expanded && ( -
-
- Type: - {event.type} -
- {event.topics.length > 0 && ( -
- Topics: -
- {event.topics.map((topic, idx) => ( - - [{idx}] {typeof topic === 'string' ? topic : JSON.stringify(topic)} - - ))} -
-
- )} - {event.data !== null && event.data !== undefined && ( -
- Data: - - {JSON.stringify(event.data, null, 2)} - -
- )} -
- )} -
- ); -}; - -/** - * Renders the "attempted operation diff" section — shows what the contract - * tried to do and where it failed. - */ -const AttemptedOperationDiff: React.FC<{ - op: NonNullable; -}> = ({ op }) => { - return ( -
-
- - - Attempted Operation - -
- -
-
- Function: - - {op.operation} - -
- -
- Failed at: - {op.failedAt} -
- -
- Reason: - {op.trapReason} -
- - {Object.keys(op.arguments).length > 0 && ( -
-
- Arguments: -
- {Object.entries(op.arguments).map(([key, value]) => ( -
- {key}:{' '} - - {typeof value === 'string' ? value : JSON.stringify(value)} - -
- ))} -
- )} -
-
- ); -}; - -// --------------------------------------------------------------------------- -// Main Component -// --------------------------------------------------------------------------- - -export const SorobanErrorModal: React.FC = ({ - error, - diagnosticEventBlobs = [], - attemptedFunction = null, - onDismiss, - onRetry, -}) => { - const [showRaw, setShowRaw] = useState(false); - - if (!error) return null; - - const decoded: DecodedXdrError = decodeSorobanError( - error, - diagnosticEventBlobs, - attemptedFunction, - ); - - return ( -
- {/* ── Modal Card ── */} -
- {/* ── Header ── */} -
-
-
- -
-
-

- Transaction Simulation Failed -

-

- {decoded.message} -

-
-
- - -
- - {/* ── Scrollable Body ── */} -
- {/* Attempted Operation Diff */} - {decoded.attemptedOperation && } - - {/* Resolution Guidance */} - {decoded.resolution && ( -
-
- - - Suggested Resolution - -
-

- {decoded.resolution} -

-
- )} - - {/* Diagnostic Events */} - {decoded.diagnosticEvents.length > 0 && ( -
-
- - - Diagnostic Events ({decoded.diagnosticEvents.length}) - -
- {decoded.diagnosticEvents.map((event, idx) => ( - - ))} -
- )} - - {/* Raw Error (collapsible) */} -
- - {showRaw && ( -
-
-                  {sanitizeErrorMessage(decoded.rawError)}
-                
- - {decoded.rawXdrBlobs.length > 0 && ( -
-
- Decoded XDR Blobs ({decoded.rawXdrBlobs.length}) -
- {decoded.rawXdrBlobs.map((blob, idx) => ( - - {blob.substring(0, 80)} - {blob.length > 80 ? '...' : ''} - - ))} -
- )} -
- )} -
-
- - {/* ── Footer ── */} -
- - - {onRetry && ( - - )} - - - - Soroban Docs - -
-
-
- ); -}; - -export default SorobanErrorModal; diff --git a/frontend/src/context/WalletContext.tsx b/frontend/src/context/WalletContext.tsx index ca83c6ab..b4aee7f4 100644 --- a/frontend/src/context/WalletContext.tsx +++ b/frontend/src/context/WalletContext.tsx @@ -150,7 +150,7 @@ export const WalletProvider: React.FC<{ children: ReactNode }> = ({ children }) return; } - const installed = isFreighterInstalled(); + const installed = await isFreighterInstalled(); setIsInstalled(installed); if (!installed) return; @@ -183,7 +183,7 @@ export const WalletProvider: React.FC<{ children: ReactNode }> = ({ children }) try { if (walletProvider === 'freighter') { - setIsInstalled(isFreighterInstalled()); + setIsInstalled(await isFreighterInstalled()); } const result = await connectWithProvider(walletProvider); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 3b019933..dc0da3c1 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -44,6 +44,7 @@ export default defineConfig(({ mode }) => { exposes: { './WalletContext': './src/context/WalletContext.tsx', './queryClient': './src/lib/queryClient.ts', + './SorobanErrorModal': './src/components/SorobanErrorModal.tsx', }, remotes: { dashboard: { diff --git a/packages/soroban-client/src/sorobanTxHelpers.ts b/packages/soroban-client/src/sorobanTxHelpers.ts index e9ff0158..43e04978 100644 --- a/packages/soroban-client/src/sorobanTxHelpers.ts +++ b/packages/soroban-client/src/sorobanTxHelpers.ts @@ -11,6 +11,7 @@ import { getOrCreatePool, } from './soroban'; import { decodeSimulationError } from './xdrDecode'; +import { RpcPoolExhaustedError } from './sorobanRpcPool'; const BASE_FEE = '100000'; @@ -61,6 +62,7 @@ export async function submitGenericSorobanTx({ try { preparedTx = await pool.prepareTransaction(tx); } catch (prepareErr: unknown) { + if (prepareErr instanceof RpcPoolExhaustedError) throw prepareErr; const errMsg = prepareErr instanceof Error ? prepareErr.message : String(prepareErr); const diagBlobs = extractDiagnosticEventBlobs({ error: errMsg }); const decoded = decodeSimulationError(errMsg, diagBlobs, methodName); @@ -101,7 +103,13 @@ export async function submitGenericSorobanTx({ const signedTx = TransactionBuilder.fromXDR(signedXdr, networkPassphrase); const sendResult = await pool.sendTransaction(signedTx); if (sendResult.status === 'ERROR' || sendResult.errorResult) { - throw new Error(`RPC submission error: ${sendResult.errorResult || sendResult.status}`); + let formattedErr = sendResult.errorResult; + if (formattedErr && typeof (formattedErr as any).toXDR === 'function') { + formattedErr = (formattedErr as any).toXDR('base64'); + } + throw new Error( + `RPC submission error: ${formattedErr || sendResult.errorResultXdr || sendResult.status}`, + ); } const txHash = sendResult.hash; @@ -113,7 +121,12 @@ export async function submitGenericSorobanTx({ while (attempts < 25) { attempts++; await new Promise((resolve) => setTimeout(resolve, 1200)); - txResult = await pool.getTransaction(txHash); + try { + txResult = await pool.getTransaction(txHash); + } catch (err) { + if (err instanceof RpcPoolExhaustedError) continue; + throw err; + } txStatus = txResult.status; if (txStatus === rpc.Api.GetTransactionStatus.SUCCESS) break; else if (txStatus === rpc.Api.GetTransactionStatus.FAILED) { diff --git a/packages/soroban-client/src/stellar.ts b/packages/soroban-client/src/stellar.ts index 9370766b..1785bc6c 100644 --- a/packages/soroban-client/src/stellar.ts +++ b/packages/soroban-client/src/stellar.ts @@ -1,9 +1,8 @@ import { z } from 'zod'; - -const STELLAR_ADDRESS_RE = /^G[A-Z2-7]{55}$/; +import { StrKey } from '@stellar/stellar-sdk'; export function isStellarAddress(value: string): boolean { - return STELLAR_ADDRESS_RE.test(value); + return StrKey.isValidEd25519PublicKey(value); } export const stellarAddressSchema = z diff --git a/packages/soroban-client/src/wallet.ts b/packages/soroban-client/src/wallet.ts index ab50c28f..f8796eba 100644 --- a/packages/soroban-client/src/wallet.ts +++ b/packages/soroban-client/src/wallet.ts @@ -37,10 +37,14 @@ export interface WalletConnectionResult { export { freighterGetAddress as getFreighterAddress, freighterGetNetwork as getFreighterNetwork }; -export function isFreighterInstalled(): boolean { - return Boolean( - typeof window !== 'undefined' && (window as unknown as Record).freighter, - ); +export async function isFreighterInstalled(): Promise { + try { + const res = await freighterIsConnected(); + return Boolean(res && res.isConnected); + } catch (err) { + console.warn('[wallet] freighter isInstalled check failed:', err); + return false; + } } export async function isFreighterConnected(): Promise { @@ -49,7 +53,7 @@ export async function isFreighterConnected(): Promise { return Boolean(res && res.isConnected); } catch (err) { console.warn('[wallet] freighter isConnected check failed:', err); - return isFreighterInstalled(); + return await isFreighterInstalled(); } } @@ -85,7 +89,8 @@ function assertTestnetNetwork(network: string | undefined, passphrase: string | * 4. Verifies the wallet is on Stellar Testnet */ export async function connectWithFreighter(): Promise { - if (!isFreighterInstalled()) { + const installed = await isFreighterInstalled(); + if (!installed) { throw new WalletConnectionError( 'NOT_INSTALLED', 'Freighter browser extension was not detected. Please install Freighter from freighter.app.', diff --git a/packages/soroban-client/src/web3auth.ts b/packages/soroban-client/src/web3auth.ts index 8c2d8a81..db26733b 100644 --- a/packages/soroban-client/src/web3auth.ts +++ b/packages/soroban-client/src/web3auth.ts @@ -64,7 +64,7 @@ async function ensureClient(): Promise { config: { chainConfig: stellarChainConfig }, }); - web3auth = new Web3Auth({ + const client = new Web3Auth({ clientId: CLIENT_ID, web3AuthNetwork: WEB3AUTH_NETWORK_NAME, privateKeyProvider, @@ -76,7 +76,8 @@ async function ensureClient(): Promise { }, }); - await web3auth.init(); + await client.init(); + web3auth = client; return web3auth; } diff --git a/packages/soroban-client/src/xdrDecode.ts b/packages/soroban-client/src/xdrDecode.ts index 035f3120..04eb8938 100644 --- a/packages/soroban-client/src/xdrDecode.ts +++ b/packages/soroban-client/src/xdrDecode.ts @@ -65,7 +65,7 @@ export interface AttemptedOperation { * When decoding the invocation footprint, we can label each arg. */ const KNOWN_FUNCTION_ARGS: Record = { - create_commitment: ['issuer', 'counterparty', 'termsHash', 'dueAt'], + create_commitment: ['issuer', 'counterparty', 'termsHash', 'dueAt', 'resolver'], attest: ['commitmentId', 'outcome'], dispute: ['commitmentId', 'reason'], resolve_dispute: ['commitmentId', 'outcome'], @@ -249,7 +249,7 @@ function summarizeDiagnosticEvent(event: xdr.DiagnosticEvent): { // Extract data value let data: unknown = null; try { - const dataVal = contractEvent.body?.()?.v0?.()?.data; + const dataVal = contractEvent.body?.()?.v0?.()?.data?.(); if (dataVal) { data = safeScValToNative(dataVal); } @@ -316,7 +316,7 @@ function extractAttemptedOperation(events: xdr.DiagnosticEvent[]): AttemptedOper // Known Soroban functions often emit their name as the first topic if (functionName in KNOWN_FUNCTION_ARGS) { - const dataVal = contractEvent.body?.()?.v0?.()?.data; + const dataVal = contractEvent.body?.()?.v0?.()?.data?.(); invocationTopics.set(functionName, { topics: nativeTopics.slice(1), // remaining topics after function name data: dataVal ? safeScValToNative(dataVal) : null, From f6203dd33b9ae8d2918fbb7638d6563d2a006747 Mon Sep 17 00:00:00 2001 From: s6pa1rta3n-lab Date: Wed, 26 Aug 2026 10:05:56 -0400 Subject: [PATCH 4/7] fix(ci): resolve TS errors and web3auth init promise --- frontend/src/App.tsx | 2 +- frontend/src/context/WalletContext.tsx | 4 +- .../soroban-client/src/sorobanTxHelpers.ts | 2 +- packages/soroban-client/src/web3auth.ts | 52 ++++++++++++------- 4 files changed, 37 insertions(+), 23 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 324cf562..1eb1fcb1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -20,7 +20,7 @@ import { submitDispute, submitResolve, submitInitRegistry, -} from './lib/sorobanTxHelpers'; +} from '@pactum/soroban-client'; import { ThemeToggle } from './components/ThemeToggle'; import { IndexerModeToggle, useIndexerMode } from './context/IndexerModeContext'; import { Menu, X, User, Lock } from 'lucide-react'; diff --git a/frontend/src/context/WalletContext.tsx b/frontend/src/context/WalletContext.tsx index b4aee7f4..14b762e4 100644 --- a/frontend/src/context/WalletContext.tsx +++ b/frontend/src/context/WalletContext.tsx @@ -136,7 +136,7 @@ export const WalletProvider: React.FC<{ children: ReactNode }> = ({ children }) if (persisted.provider === 'web3auth') { try { - const { restoreWeb3AuthSession } = await import('../lib/web3auth'); + const { restoreWeb3AuthSession } = await import('@pactum/soroban-client'); const restored = await restoreWeb3AuthSession(persisted.address); if (isMounted && restored) { setAddress(restored.address); @@ -207,7 +207,7 @@ export const WalletProvider: React.FC<{ children: ReactNode }> = ({ children }) setProvider(null); clearPersistedState(); if (wasSocial) { - void import('../lib/web3auth').then(({ logoutWeb3Auth }) => logoutWeb3Auth()); + void import('@pactum/soroban-client').then(({ logoutWeb3Auth }) => logoutWeb3Auth()); } }, [provider]); diff --git a/packages/soroban-client/src/sorobanTxHelpers.ts b/packages/soroban-client/src/sorobanTxHelpers.ts index 43e04978..31b6d5e3 100644 --- a/packages/soroban-client/src/sorobanTxHelpers.ts +++ b/packages/soroban-client/src/sorobanTxHelpers.ts @@ -108,7 +108,7 @@ export async function submitGenericSorobanTx({ formattedErr = (formattedErr as any).toXDR('base64'); } throw new Error( - `RPC submission error: ${formattedErr || sendResult.errorResultXdr || sendResult.status}`, + `RPC submission error: ${formattedErr || sendResult.errorResult || sendResult.status}`, ); } diff --git a/packages/soroban-client/src/web3auth.ts b/packages/soroban-client/src/web3auth.ts index db26733b..cafa2c93 100644 --- a/packages/soroban-client/src/web3auth.ts +++ b/packages/soroban-client/src/web3auth.ts @@ -51,6 +51,8 @@ export function isWeb3AuthConfigured(): boolean { return CLIENT_ID.length > 0; } +let web3authInitPromise: Promise | null = null; + async function ensureClient(): Promise { if (!isWeb3AuthConfigured()) { throw new WalletConnectionError( @@ -60,25 +62,36 @@ async function ensureClient(): Promise { } if (web3auth) return web3auth; - const privateKeyProvider = new CommonPrivateKeyProvider({ - config: { chainConfig: stellarChainConfig }, - }); - - const client = new Web3Auth({ - clientId: CLIENT_ID, - web3AuthNetwork: WEB3AUTH_NETWORK_NAME, - privateKeyProvider, - uiConfig: { - appName: 'Pactum', - mode: 'light', - loginGridCol: 3, - primaryButton: 'socialLogin', - }, - }); - - await client.init(); - web3auth = client; - return web3auth; + if (!web3authInitPromise) { + web3authInitPromise = (async () => { + const privateKeyProvider = new CommonPrivateKeyProvider({ + config: { chainConfig: stellarChainConfig }, + }); + + const client = new Web3Auth({ + clientId: CLIENT_ID, + web3AuthNetwork: WEB3AUTH_NETWORK_NAME, + privateKeyProvider, + uiConfig: { + appName: 'Pactum', + mode: 'light', + loginGridCol: 3, + primaryButton: 'socialLogin', + }, + }); + + await client.init(); + web3auth = client; + return web3auth; + })(); + } + + try { + return await web3authInitPromise; + } catch (err) { + web3authInitPromise = null; + throw err; + } } async function keypairFromProvider(provider: IProvider): Promise { @@ -185,4 +198,5 @@ export function signTransactionWithWeb3Auth( export function __resetWeb3AuthForTests(): void { activeKeypair = null; web3auth = null; + web3authInitPromise = null; } From 57bca14e0463c12d75725818710cf6aca8785d3f Mon Sep 17 00:00:00 2001 From: s6pa1rta3n-lab Date: Thu, 27 Aug 2026 16:15:47 -0400 Subject: [PATCH 5/7] fix(ci): fix package-lock.json and backend test key lengths --- backend/tests/integration/api.test.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/backend/tests/integration/api.test.ts b/backend/tests/integration/api.test.ts index c5928437..00076a4d 100644 --- a/backend/tests/integration/api.test.ts +++ b/backend/tests/integration/api.test.ts @@ -21,7 +21,7 @@ describe('Commitments API Integration', () => { before(async () => { db = await startIntegrationDatabase(); - + // Override the pool.query used by the router with our integration DB pool originalQuery = pool.query; pool.query = db.pool.query.bind(db.pool); @@ -48,8 +48,8 @@ describe('Commitments API Integration', () => { it('POST /commitments should insert an optimistic commitment into commitment_outcomes', async () => { const payload = { - issuer: 'GBLDEY4S2X2WFTX6FYX4M4YZ276M2E4N5J5QO2E3B5Z5O5N5P5R5S', - counterparty: 'GCLDEY4S2X2WFTX6FYX4M4YZ276M2E4N5J5QO2E3B5Z5O5N5P5R5S', + issuer: 'GBLDEY4S2X2WFTX6FYX4M4YZ276M2E4N5J5QO2E3B5Z5O5N5P5R5S222', + counterparty: 'GCLDEY4S2X2WFTX6FYX4M4YZ276M2E4N5J5QO2E3B5Z5O5N5P5R5S222', terms_hash: 'abc123def456', due_at: Math.floor(Date.now() / 1000) + 86400, // tomorrow }; @@ -57,19 +57,22 @@ describe('Commitments API Integration', () => { const res = await fetch(`http://localhost:${port}/commitments`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload) + body: JSON.stringify(payload), }); assert.equal(res.status, 201); - const body = await res.json() as { id: number; status: string }; + const body = (await res.json()) as { id: number; status: string }; assert.ok(typeof body.id === 'number'); assert.equal(body.status, 'Pending'); // Assert a row landed in commitment_outcomes - const { rows } = await db.pool.query('SELECT * FROM commitment_outcomes WHERE commitment_id = $1', [body.id.toString()]); + const { rows } = await db.pool.query( + 'SELECT * FROM commitment_outcomes WHERE commitment_id = $1', + [body.id.toString()], + ); assert.equal(rows.length, 1); const row = rows[0]; - + assert.equal(row.party_a, payload.issuer); assert.equal(row.party_b, payload.counterparty); assert.equal(row.status, 'pending'); From 86344271c76edcf1d2d7d087ea9af6fe81b248ec Mon Sep 17 00:00:00 2001 From: s6pa1rta3n-lab Date: Fri, 28 Aug 2026 08:58:09 -0400 Subject: [PATCH 6/7] fix(ci): run npm ci at root to fix workspace resolution --- .github/workflows/ci.yml | 26 +--------- .github/workflows/e2e-sandbox.yml | 12 ----- .../src/CreateCommitmentWizard.tsx | 2 +- frontend-wizard-remote/src/remotes.d.ts | 47 ++++++++++++------- packages/soroban-client/src/sorobanRpcPool.ts | 10 ++-- .../soroban-client/src/sorobanTxHelpers.ts | 8 +++- packages/soroban-client/tsconfig.json | 3 +- 7 files changed, 47 insertions(+), 61 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 86711467..b0467ce1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -270,9 +270,7 @@ jobs: ${{ runner.os }}-frontend-node- - name: Install frontend dependencies - working-directory: frontend run: | - rm -rf node_modules npm ci --include=optional - name: Run frontend linter @@ -296,17 +294,7 @@ jobs: # Federation remotes (see docs/module-federation.md) since the host now lazy-loads them # instead of bundling them directly -- each is an independent npm project needing its own # install. - - name: Install dashboard remote dependencies - working-directory: frontend-dashboard-remote - run: | - rm -rf node_modules - npm ci --include=optional - - name: Install wizard remote dependencies - working-directory: frontend-wizard-remote - run: | - rm -rf node_modules - npm ci --include=optional - name: Run frontend e2e tests working-directory: frontend @@ -365,23 +353,11 @@ jobs: # The host and the two Module Federation remotes it loads at runtime (see # docs/module-federation.md) are independent npm projects, each installed separately. - - name: Install host dependencies - working-directory: frontend + - name: Install monorepo dependencies run: | - rm -rf node_modules npm ci --include=optional - - name: Install dashboard remote dependencies - working-directory: frontend-dashboard-remote - run: | - rm -rf node_modules - npm ci --include=optional - - name: Install wizard remote dependencies - working-directory: frontend-wizard-remote - run: | - rm -rf node_modules - npm ci --include=optional - name: Typecheck and build dashboard remote working-directory: frontend-dashboard-remote diff --git a/.github/workflows/e2e-sandbox.yml b/.github/workflows/e2e-sandbox.yml index 6a0b5e50..47da92da 100644 --- a/.github/workflows/e2e-sandbox.yml +++ b/.github/workflows/e2e-sandbox.yml @@ -74,26 +74,14 @@ jobs: # the Frontend Checks job and fall back to `npm install`, which resolves # workspace packages from the repo and succeeds. - name: Install frontend dependencies - working-directory: frontend run: | - rm -rf node_modules npm ci --include=optional # playwright.config.ts's webServer also builds+previews the dashboard/wizard Module # Federation remotes (see docs/module-federation.md) since the host now lazy-loads them # instead of bundling them directly -- each is an independent npm project needing its own # install (mirrors ci.yml's frontend-checks job, which builds the same three apps). - - name: Install dashboard remote dependencies - working-directory: frontend-dashboard-remote - run: | - rm -rf node_modules - npm ci --include=optional - - name: Install wizard remote dependencies - working-directory: frontend-wizard-remote - run: | - rm -rf node_modules - npm ci --include=optional - name: Install Playwright browsers working-directory: frontend diff --git a/frontend-wizard-remote/src/CreateCommitmentWizard.tsx b/frontend-wizard-remote/src/CreateCommitmentWizard.tsx index 4adb49e0..11c486a5 100644 --- a/frontend-wizard-remote/src/CreateCommitmentWizard.tsx +++ b/frontend-wizard-remote/src/CreateCommitmentWizard.tsx @@ -21,6 +21,7 @@ import { type SimulationPreview, SorobanSimulationError, decodeSimulationError, + hexToBytes, } from '@pactum/soroban-client'; import { createAstResolver, composeResolvers } from './lib/ast'; import { useValidationRules } from './hooks/useValidationRules'; @@ -331,7 +332,6 @@ export default function CreateCommitmentWizard({ const contract = new Contract(contractId); const networkPassphrase = import.meta.env.VITE_STELLAR_NETWORK_PASSPHRASE || Networks.TESTNET; - const { hexToBytes } = await import('./lib/soroban'); const termsHashBytes = hexToBytes(termsHashHex); // Mirror submitCreateCommitment's own argument list exactly (see soroban.ts) -- // create_commitment takes 9 parameters, and a preflight built with only the first diff --git a/frontend-wizard-remote/src/remotes.d.ts b/frontend-wizard-remote/src/remotes.d.ts index 291c525e..ef8bd781 100644 --- a/frontend-wizard-remote/src/remotes.d.ts +++ b/frontend-wizard-remote/src/remotes.d.ts @@ -10,28 +10,41 @@ // under this project's compiler options (confirmed empirically). Use inline `import('pkg').Type` // instead. declare module 'host/WalletContext' { - type WalletProviderName = 'freighter' | 'albedo' | 'ledger' + type WalletProviderName = 'freighter' | 'albedo' | 'ledger'; type WalletErrorCode = - 'NOT_INSTALLED' | 'CONNECTION_REJECTED' | 'NETWORK_MISMATCH' | 'INVALID_ADDRESS' | 'UNKNOWN' + 'NOT_INSTALLED' | 'CONNECTION_REJECTED' | 'NETWORK_MISMATCH' | 'INVALID_ADDRESS' | 'UNKNOWN'; export interface WalletContextType { - address: string | null - provider: WalletProviderName | null - isConnected: boolean - isInstalled: boolean - isConnecting: boolean - error: string | null - errorCode: WalletErrorCode | null - connectWallet: (provider?: WalletProviderName) => Promise - disconnectWallet: () => void - clearError: () => void - contextModuleId: string + address: string | null; + provider: WalletProviderName | null; + isConnected: boolean; + isInstalled: boolean; + isConnecting: boolean; + error: string | null; + errorCode: WalletErrorCode | null; + connectWallet: (provider?: WalletProviderName) => Promise; + disconnectWallet: () => void; + clearError: () => void; + contextModuleId: string; } - export function useWallet(): WalletContextType - export function WalletProvider(props: { children: import('react').ReactNode }): import('react').ReactElement + export function useWallet(): WalletContextType; + export function WalletProvider(props: { + children: import('react').ReactNode; + }): import('react').ReactElement; } declare module 'host/queryClient' { - import type { QueryClient } from '@tanstack/react-query' - export const queryClient: QueryClient + import type { QueryClient } from '@tanstack/react-query'; + export const queryClient: QueryClient; +} + +declare module 'host/SorobanErrorModal' { + export interface SorobanErrorModalProps { + error: unknown; + diagnosticEventBlobs?: string[]; + attemptedFunction?: string; + onDismiss: () => void; + onRetry?: () => void; + } + export function SorobanErrorModal(props: SorobanErrorModalProps): import('react').ReactElement; } diff --git a/packages/soroban-client/src/sorobanRpcPool.ts b/packages/soroban-client/src/sorobanRpcPool.ts index b185f7d1..b82c732f 100644 --- a/packages/soroban-client/src/sorobanRpcPool.ts +++ b/packages/soroban-client/src/sorobanRpcPool.ts @@ -1,5 +1,4 @@ import { Account, FeeBumpTransaction, Keypair, Transaction, rpc, xdr } from '@stellar/stellar-sdk'; -import { captureSorobanRpcError } from './sentry'; /** * A single Soroban RPC endpoint that the pool can route traffic to. @@ -59,6 +58,10 @@ export interface SorobanRpcPoolOptions { onNodeSuccess?: (stats: SorobanRpcNodeStats) => void; /** Called after a node fails a call (retryable or not). */ onNodeFailure?: (stats: SorobanRpcNodeStats, error: unknown) => void; + /** + * Optional callback invoked when all nodes are exhausted on retryable failures. + */ + onError?: (error: unknown, context?: Record) => void; /** * Injectable {@link rpc.Server} factory — primarily used by tests to stub * out the network layer. @@ -354,9 +357,8 @@ export class SorobanRpcPool { this.options.onFallback?.({ url: node.url, error, attempt: attempt + 1, remaining }); } else if (retryable) { // Every node failed on a retryable (timeout / network / node overload) - // error. Report it to Sentry with non-sensitive context so production - // RPC timeouts are visible (Issue #210). No-op when Sentry is off. - captureSorobanRpcError(error, { + // error. Report it with non-sensitive context if an onError callback is provided. + this.options.onError?.(error, { rpcNode: node.url, attempts: maxAttempts, errorType: error instanceof Error ? error.name : typeof error, diff --git a/packages/soroban-client/src/sorobanTxHelpers.ts b/packages/soroban-client/src/sorobanTxHelpers.ts index 31b6d5e3..a54457eb 100644 --- a/packages/soroban-client/src/sorobanTxHelpers.ts +++ b/packages/soroban-client/src/sorobanTxHelpers.ts @@ -117,14 +117,19 @@ export async function submitGenericSorobanTx({ let txStatus: rpc.Api.GetTransactionStatus = rpc.Api.GetTransactionStatus.NOT_FOUND; let txResult: rpc.Api.GetTransactionResponse | null = null; + let lastPollError: RpcPoolExhaustedError | null = null; let attempts = 0; while (attempts < 25) { attempts++; await new Promise((resolve) => setTimeout(resolve, 1200)); try { txResult = await pool.getTransaction(txHash); + lastPollError = null; } catch (err) { - if (err instanceof RpcPoolExhaustedError) continue; + if (err instanceof RpcPoolExhaustedError) { + lastPollError = err; + continue; + } throw err; } txStatus = txResult.status; @@ -153,6 +158,7 @@ export async function submitGenericSorobanTx({ } if (txStatus !== rpc.Api.GetTransactionStatus.SUCCESS) { + if (lastPollError) throw lastPollError; throw new Error(`Transaction confirmation timed out. Hash: ${txHash}`); } diff --git a/packages/soroban-client/tsconfig.json b/packages/soroban-client/tsconfig.json index d84c6dcb..b30d87cd 100644 --- a/packages/soroban-client/tsconfig.json +++ b/packages/soroban-client/tsconfig.json @@ -11,5 +11,6 @@ "forceConsistentCasingInFileNames": true, "outDir": "dist" }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/**/*.test.ts"] } From 25fe3711a90c8d02f9e338bb16f06103ee09798a Mon Sep 17 00:00:00 2001 From: Aman koli <2025.amana@isu.ac.in> Date: Sat, 29 Aug 2026 22:03:14 +0530 Subject: [PATCH 7/7] fix: preflight simulation calls the new fetchArbitrator with the old, incompatible signature @pactum/soroban-client's fetchArbitrator (extracted from the host's connection-pooled soroban.ts) is fetchArbitrator(rpcUrls?: string[], rpcUrl?: string, contractId, ...) -- the wizard-remote-specific version this call site was originally written against was fetchArbitrator(rpcUrl: string, contractId, networkPassphrase), 3 plain strings. The call wasn't updated for the new signature, so `rpcUrl` (a string) landed positionally in the `rpcUrls` slot. resolveSorobanRpcUrls does `rpcUrls && rpcUrls.length > 0` -- a string has `.length` too, so this passed the truthy check and got iterated character-by- character as if it were an array of single-character RPC URLs, silently producing a garbage connection pool. Every preflight simulation call then failed against the real sandbox (and the mocked e2e suite, whose route interception no longer matched the mangled requests either) -- both e2e-sandbox and Frontend Checks timed out waiting for #sim-modal-confirm, since the modal never reaches its success state. Fixed by passing `undefined` for the new rpcUrls slot and shifting the existing rpcUrl into its new second position. Verified against the full local e2e suite (30/31 passing, the one failure is the pre-existing "loading spinners" flake unrelated to this change). Co-Authored-By: Claude Sonnet 5 --- .../src/CreateCommitmentWizard.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/frontend-wizard-remote/src/CreateCommitmentWizard.tsx b/frontend-wizard-remote/src/CreateCommitmentWizard.tsx index 11c486a5..b705f29d 100644 --- a/frontend-wizard-remote/src/CreateCommitmentWizard.tsx +++ b/frontend-wizard-remote/src/CreateCommitmentWizard.tsx @@ -337,7 +337,20 @@ export default function CreateCommitmentWizard({ // create_commitment takes 9 parameters, and a preflight built with only the first // 4 fails simulation with a MismatchingParameterLen host error regardless of // whether the real submission would have succeeded. - const arbitratorAddress = await fetchArbitrator(rpcUrl, contractId, networkPassphrase); + // + // fetchArbitrator's signature is (rpcUrls?: string[], rpcUrl?: string, contractId, ...) + // since @pactum/soroban-client's extraction -- it now supports the host's multi-URL + // connection pool. `undefined` in the first slot skips that and falls through to the + // single rpcUrl below. Passing rpcUrl positionally as `rpcUrls` (a string, not an + // array) used to silently pass resolveSorobanRpcUrls' `rpcUrls.length > 0` truthy + // check and get iterated character-by-character as if it were an array of one-char + // RPC URLs, which is exactly why this failed against the real sandbox in CI. + const arbitratorAddress = await fetchArbitrator( + undefined, + rpcUrl, + contractId, + networkPassphrase, + ); const simTx = new TransactionBuilder(account, { fee: BASE_FEE, networkPassphrase }) .addOperation( contract.call(