diff --git a/apps/web/index.html b/apps/web/index.html index e74a71b..191aff0 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -2,6 +2,7 @@ + Escrow Program diff --git a/apps/web/public/solana-logo.svg b/apps/web/public/solana-logo.svg new file mode 100644 index 0000000..ed6f34d --- /dev/null +++ b/apps/web/public/solana-logo.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/apps/web/src/components/TxResult.tsx b/apps/web/src/components/TxResult.tsx index 0ce0284..f32eee1 100644 --- a/apps/web/src/components/TxResult.tsx +++ b/apps/web/src/components/TxResult.tsx @@ -2,22 +2,16 @@ import { Badge, Button } from '@solana/design-system'; import { useClusterConfig } from '@/hooks/use-cluster-config'; import { getClusterFromClusterId, getSolanaExplorerUrl } from '@/lib/explorer'; +import { formatTransactionErrorWithLogs } from '@/lib/transactionErrors'; interface TxResultProps { error: unknown; signature: string | null | undefined; } -function getErrorMessage(error: unknown): string | null { - if (!error) return null; - if (error instanceof Error) return error.message; - if (typeof error === 'string') return error; - return 'Transaction failed'; -} - export function TxResult({ signature, error }: TxResultProps) { const { id } = useClusterConfig(); - const errorMessage = getErrorMessage(error); + const errorMessage = error ? formatTransactionErrorWithLogs(error) : null; if (!signature && !errorMessage) return null; @@ -27,7 +21,7 @@ export function TxResult({ signature, error }: TxResultProps) { return (
Failed - {errorMessage} + {errorMessage}
); } diff --git a/apps/web/src/components/instructions/AllowMint.tsx b/apps/web/src/components/instructions/AllowMint.tsx index b048012..a164fe7 100644 --- a/apps/web/src/components/instructions/AllowMint.tsx +++ b/apps/web/src/components/instructions/AllowMint.tsx @@ -3,8 +3,9 @@ import { useState } from 'react'; import { useSavedValues } from '@/contexts/SavedValuesContext'; import { useEscrowMutations } from '@/hooks/use-escrow-mutations'; +import { useTokenFormDefaults } from '@/hooks/use-token-form-defaults'; import { TxResult } from '@/components/TxResult'; -import { firstValidationError, validateAddress } from '@/lib/validation'; +import { firstValidationError, validateAddress, validateOptionalAddress } from '@/lib/validation'; import { FormField, SendButton } from './shared'; interface AllowMintProps { @@ -25,7 +26,7 @@ export function AllowMint({ const { allowMint } = useEscrowMutations(); const { defaultEscrow, defaultMint, rememberEscrow, rememberMint } = useSavedValues(); const [escrow, setEscrow] = useState(initialEscrow); - const [mint, setMint] = useState(initialMint); + const { clusterMint, mint, setMint, setTokenProgram, tokenProgram } = useTokenFormDefaults(initialMint); const [formError, setFormError] = useState(null); const handleSubmit = async (e: React.FormEvent) => { @@ -36,13 +37,14 @@ export function AllowMint({ const validationError = firstValidationError( validateAddress(escrow, 'Escrow address'), validateAddress(mint, 'Mint address'), + validateOptionalAddress(tokenProgram, 'Token program'), ); if (validationError) { setFormError(validationError); return; } - const result = await allowMint.mutateAsync({ escrow, mint }).catch(() => null); + const result = await allowMint.mutateAsync({ escrow, mint, tokenProgram }).catch(() => null); if (!result) return; rememberEscrow(escrow); @@ -72,13 +74,20 @@ export function AllowMint({ label="Mint Address" value={mint} onChange={setMint} - autoFillValue={defaultMint} + autoFillValue={defaultMint || clusterMint} onAutoFill={setMint} placeholder="SPL token mint to allow" required /> )} + diff --git a/apps/web/src/components/instructions/BlockMint.tsx b/apps/web/src/components/instructions/BlockMint.tsx index ccf87b8..249ce4e 100644 --- a/apps/web/src/components/instructions/BlockMint.tsx +++ b/apps/web/src/components/instructions/BlockMint.tsx @@ -4,6 +4,7 @@ import { useState } from 'react'; import { useSavedValues } from '@/contexts/SavedValuesContext'; import { useWallet } from '@/contexts/WalletContext'; import { useEscrowMutations } from '@/hooks/use-escrow-mutations'; +import { useTokenFormDefaults } from '@/hooks/use-token-form-defaults'; import { TxResult } from '@/components/TxResult'; import { firstValidationError, validateAddress, validateOptionalAddress } from '@/lib/validation'; import { FormField, SendButton } from './shared'; @@ -29,7 +30,7 @@ export function BlockMint({ const { blockMint } = useEscrowMutations(); const { defaultEscrow, defaultMint, rememberEscrow, rememberMint } = useSavedValues(); const [escrow, setEscrow] = useState(initialEscrow); - const [mint, setMint] = useState(initialMint); + const { clusterMint, mint, setMint, setTokenProgram, tokenProgram } = useTokenFormDefaults(initialMint); const [rentRecipient, setRentRecipient] = useState(initialRentRecipient); const [formError, setFormError] = useState(null); @@ -42,13 +43,14 @@ export function BlockMint({ validateAddress(escrow, 'Escrow address'), validateAddress(mint, 'Mint address'), validateOptionalAddress(rentRecipient, 'Rent recipient'), + validateOptionalAddress(tokenProgram, 'Token program'), ); if (validationError) { setFormError(validationError); return; } - const result = await blockMint.mutateAsync({ escrow, mint, rentRecipient }).catch(() => null); + const result = await blockMint.mutateAsync({ escrow, mint, rentRecipient, tokenProgram }).catch(() => null); if (!result) return; rememberEscrow(escrow); @@ -78,7 +80,7 @@ export function BlockMint({ label="Mint Address" value={mint} onChange={setMint} - autoFillValue={defaultMint} + autoFillValue={defaultMint || clusterMint} onAutoFill={setMint} placeholder="SPL token mint to block" required @@ -92,6 +94,13 @@ export function BlockMint({ placeholder={account?.address ?? 'Defaults to connected wallet'} hint="Address that receives rent from the closed allowed-mint account" /> + diff --git a/apps/web/src/components/instructions/Deposit.tsx b/apps/web/src/components/instructions/Deposit.tsx index b93cd95..257df8e 100644 --- a/apps/web/src/components/instructions/Deposit.tsx +++ b/apps/web/src/components/instructions/Deposit.tsx @@ -3,8 +3,14 @@ import { useState } from 'react'; import { useSavedValues } from '@/contexts/SavedValuesContext'; import { useEscrowMutations } from '@/hooks/use-escrow-mutations'; +import { useTokenFormDefaults } from '@/hooks/use-token-form-defaults'; import { TxResult } from '@/components/TxResult'; -import { firstValidationError, validateAddress, validatePositiveInteger } from '@/lib/validation'; +import { + firstValidationError, + validateAddress, + validateOptionalAddress, + validatePositiveInteger, +} from '@/lib/validation'; import { FormField, SendButton } from './shared'; interface DepositProps { @@ -27,7 +33,7 @@ export function Deposit({ const { deposit } = useEscrowMutations(); const { defaultEscrow, defaultMint, rememberEscrow, rememberMint, rememberReceipt } = useSavedValues(); const [escrow, setEscrow] = useState(initialEscrow); - const [mint, setMint] = useState(initialMint); + const { clusterMint, mint, setMint, setTokenProgram, tokenProgram } = useTokenFormDefaults(initialMint); const [amount, setAmount] = useState(initialAmount); const [generatedSeed, setGeneratedSeed] = useState(''); const [generatedReceipt, setGeneratedReceipt] = useState(''); @@ -42,6 +48,7 @@ export function Deposit({ validateAddress(escrow, 'Escrow address'), validateAddress(mint, 'Mint address'), validatePositiveInteger(amount, 'Amount'), + validateOptionalAddress(tokenProgram, 'Token program'), ); if (validationError) { setFormError(validationError); @@ -53,6 +60,7 @@ export function Deposit({ amount: BigInt(amount), escrow, mint, + tokenProgram, }) .catch(() => null); if (!result) return; @@ -87,7 +95,7 @@ export function Deposit({ label="Mint Address" value={mint} onChange={setMint} - autoFillValue={defaultMint} + autoFillValue={defaultMint || clusterMint} onAutoFill={setMint} placeholder="SPL token mint address" required @@ -103,6 +111,13 @@ export function Deposit({ hint="Amount in smallest token units (no decimals)" required /> + {generatedSeed && ( (null); @@ -47,6 +48,7 @@ export function Withdraw({ validateAddress(mint, 'Mint address'), validateAddress(receipt, 'Receipt address'), validateOptionalAddress(rentRecipient, 'Rent recipient'), + validateOptionalAddress(tokenProgram, 'Token program'), ); if (validationError) { setFormError(validationError); @@ -59,6 +61,7 @@ export function Withdraw({ mint, receipt, rentRecipient, + tokenProgram, }) .catch(() => null); if (!result) return; @@ -91,7 +94,7 @@ export function Withdraw({ label="Mint Address" value={mint} onChange={setMint} - autoFillValue={defaultMint} + autoFillValue={defaultMint || clusterMint} onAutoFill={setMint} placeholder="SPL token mint address" required @@ -115,6 +118,13 @@ export function Withdraw({ placeholder={account?.address ?? 'Defaults to connected wallet'} hint="Address that receives rent from the closed receipt account" /> + diff --git a/apps/web/src/components/solana/use-wallet-transaction-sign-and-send.ts b/apps/web/src/components/solana/use-wallet-transaction-sign-and-send.ts index ed5fe32..359e97b 100644 --- a/apps/web/src/components/solana/use-wallet-transaction-sign-and-send.ts +++ b/apps/web/src/components/solana/use-wallet-transaction-sign-and-send.ts @@ -37,7 +37,6 @@ export function useWalletTransactionSignAndSend() { await sendAndConfirm(signedTx, { commitment: 'confirmed', - skipPreflight: true, }); return signature; diff --git a/apps/web/src/components/use-transaction-toast.tsx b/apps/web/src/components/use-transaction-toast.tsx index d4a43fe..69954cc 100644 --- a/apps/web/src/components/use-transaction-toast.tsx +++ b/apps/web/src/components/use-transaction-toast.tsx @@ -5,7 +5,7 @@ import { getClusterFromClusterId, getSolanaExplorerUrl } from '@/lib/explorer'; import { formatTransactionError } from '@/lib/transactionErrors'; function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : formatTransactionError(error); + return formatTransactionError(error); } export function useTransactionToast() { diff --git a/apps/web/src/contexts/RecentTransactionsContext.tsx b/apps/web/src/contexts/RecentTransactionsContext.tsx index cb717a6..9f21df9 100644 --- a/apps/web/src/contexts/RecentTransactionsContext.tsx +++ b/apps/web/src/contexts/RecentTransactionsContext.tsx @@ -15,6 +15,7 @@ export interface RecentTransactionValues { lockDuration?: string; hookProgram?: string; rentRecipient?: string; + tokenProgram?: string; } export interface RecentTransaction { diff --git a/apps/web/src/hooks/use-escrow-mutations.ts b/apps/web/src/hooks/use-escrow-mutations.ts index 9b623d3..2075876 100644 --- a/apps/web/src/hooks/use-escrow-mutations.ts +++ b/apps/web/src/hooks/use-escrow-mutations.ts @@ -35,6 +35,7 @@ import { useProgramContext } from '@/contexts/ProgramContext'; import type { RecentTransactionValues } from '@/contexts/RecentTransactionsContext'; import { useRecentTransactions } from '@/contexts/RecentTransactionsContext'; import { useWallet } from '@/contexts/WalletContext'; +import { normalizeTokenProgram } from '@/lib/token'; import { formatTransactionError } from '@/lib/transactionErrors'; import { invalidateWithDelay } from '@/lib/utils'; @@ -66,6 +67,7 @@ export interface DepositInput { readonly amount: bigint; readonly escrow: string; readonly mint: string; + readonly tokenProgram: string; } export interface WithdrawInput { @@ -73,11 +75,13 @@ export interface WithdrawInput { readonly mint: string; readonly receipt: string; readonly rentRecipient?: string; + readonly tokenProgram: string; } export interface EscrowMintInput { readonly escrow: string; readonly mint: string; + readonly tokenProgram: string; } export interface BlockMintInput extends EscrowMintInput { @@ -234,6 +238,7 @@ export function useEscrowMutations() { const programAddress = getProgramAddress(programId); const escrow = input.escrow.trim(); const mint = input.mint.trim(); + const tokenProgram = normalizeTokenProgram(input.tokenProgram); const receiptSeed = await generateKeyPairSigner(); const [receipt] = await findReceiptPda( { @@ -253,6 +258,7 @@ export function useEscrowMutations() { mint: asAddress(mint), payer: txSigner, receiptSeed, + tokenProgram, }, { programAddress }, ); @@ -262,6 +268,7 @@ export function useEscrowMutations() { escrow, mint, receipt, + tokenProgram: tokenProgram.toString(), }); return { amount: input.amount, escrow, mint, receipt, receiptSeed: receiptSeed.address, signature }; }, @@ -277,6 +284,7 @@ export function useEscrowMutations() { const mint = input.mint.trim(); const receipt = input.receipt.trim(); const rentRecipient = input.rentRecipient?.trim() || txSigner.address; + const tokenProgram = normalizeTokenProgram(input.tokenProgram); const [extensionsPda] = await findExtensionsPda({ escrow: asAddress(escrow) }, { programAddress }); const extensionsAccount = await fetchEncodedAccount(rpc, extensionsPda); const remainingAccounts: (AccountMeta | AccountSignerMeta)[] = []; @@ -299,6 +307,7 @@ export function useEscrowMutations() { mint: asAddress(mint), receipt: asAddress(receipt), rentRecipient: asAddress(rentRecipient), + tokenProgram, withdrawer: txSigner, }, { programAddress }, @@ -313,6 +322,7 @@ export function useEscrowMutations() { mint, receipt, rentRecipient, + tokenProgram: tokenProgram.toString(), }); return { signature }; }, @@ -326,16 +336,22 @@ export function useEscrowMutations() { const programAddress = getProgramAddress(programId); const escrow = input.escrow.trim(); const mint = input.mint.trim(); + const tokenProgram = normalizeTokenProgram(input.tokenProgram); const instruction = await getAllowMintInstructionAsync( { admin: txSigner, escrow: asAddress(escrow), mint: asAddress(mint), payer: txSigner, + tokenProgram, }, { programAddress }, ); - const signature = await sendEscrowTransaction([instruction], txSigner, 'Allow Mint', { escrow, mint }); + const signature = await sendEscrowTransaction([instruction], txSigner, 'Allow Mint', { + escrow, + mint, + tokenProgram: tokenProgram.toString(), + }); return { signature }; }, onError, @@ -349,12 +365,14 @@ export function useEscrowMutations() { const escrow = input.escrow.trim(); const mint = input.mint.trim(); const rentRecipient = input.rentRecipient?.trim() || txSigner.address; + const tokenProgram = normalizeTokenProgram(input.tokenProgram); const instruction = await getBlockMintInstructionAsync( { admin: txSigner, escrow: asAddress(escrow), mint: asAddress(mint), rentRecipient: asAddress(rentRecipient), + tokenProgram, }, { programAddress }, ); @@ -362,6 +380,7 @@ export function useEscrowMutations() { escrow, mint, rentRecipient, + tokenProgram: tokenProgram.toString(), }); return { signature }; }, diff --git a/apps/web/src/hooks/use-token-form-defaults.ts b/apps/web/src/hooks/use-token-form-defaults.ts new file mode 100644 index 0000000..4fd4763 --- /dev/null +++ b/apps/web/src/hooks/use-token-form-defaults.ts @@ -0,0 +1,34 @@ +import { useEffect, useRef, useState } from 'react'; + +import { getClusterUsdcMint, TOKEN_PROGRAM_ID } from '@/lib/token'; + +import { useClusterConfig } from './use-cluster-config'; + +export function useTokenFormDefaults(initialMint = '') { + const { id } = useClusterConfig(); + const clusterMint = getClusterUsdcMint(id); + const previousClusterMintRef = useRef(clusterMint); + const [mint, setMint] = useState(initialMint || clusterMint); + const [tokenProgram, setTokenProgram] = useState(TOKEN_PROGRAM_ID); + + useEffect(() => { + const previousClusterMint = previousClusterMintRef.current; + previousClusterMintRef.current = clusterMint; + + if (initialMint) return; + + setMint(current => { + if (!current) return clusterMint; + if (current === previousClusterMint) return clusterMint; + return current; + }); + }, [clusterMint, initialMint]); + + return { + clusterMint, + mint, + setMint, + setTokenProgram, + tokenProgram, + }; +} diff --git a/apps/web/src/hooks/useSendTx.ts b/apps/web/src/hooks/useSendTx.ts index 04dd1c0..eb1cf7d 100644 --- a/apps/web/src/hooks/useSendTx.ts +++ b/apps/web/src/hooks/useSendTx.ts @@ -73,7 +73,6 @@ export function useSendTx() { await sendAndConfirm(signedTx, { commitment: 'confirmed', - skipPreflight: true, }); addRecentTransaction({ action: options?.action ?? 'Transaction', diff --git a/apps/web/src/lib/token.ts b/apps/web/src/lib/token.ts new file mode 100644 index 0000000..0f26ea1 --- /dev/null +++ b/apps/web/src/lib/token.ts @@ -0,0 +1,15 @@ +import { address, type Address } from '@solana/kit'; + +export const DEVNET_USDC_MINT = '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU'; +export const MAINNET_USDC_MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; +export const TOKEN_PROGRAM_ID = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'; + +export function getClusterUsdcMint(clusterId: string) { + if (clusterId === 'solana:devnet') return DEVNET_USDC_MINT; + if (clusterId === 'solana:mainnet') return MAINNET_USDC_MINT; + return ''; +} + +export function normalizeTokenProgram(value: string): Address { + return address(value.trim() || TOKEN_PROGRAM_ID); +} diff --git a/apps/web/src/lib/transactionErrors.ts b/apps/web/src/lib/transactionErrors.ts index 7d0bb6c..5728595 100644 --- a/apps/web/src/lib/transactionErrors.ts +++ b/apps/web/src/lib/transactionErrors.ts @@ -19,6 +19,12 @@ import { ESCROW_PROGRAM_ERROR__TOKEN_EXTENSION_NOT_BLOCKED, ESCROW_PROGRAM_ERROR__ZERO_DEPOSIT_AMOUNT, } from '@solana/escrow'; +import { + isSolanaError, + SOLANA_ERROR__INSTRUCTION_ERROR__CUSTOM, + SOLANA_ERROR__JSON_RPC__SERVER_ERROR_SEND_TRANSACTION_PREFLIGHT_FAILURE, + unwrapSimulationError, +} from '@solana/kit'; const ESCROW_PROGRAM_ERROR_MESSAGES: Record = { [ESCROW_PROGRAM_ERROR__INVALID_ESCROW_ID]: 'Escrow ID invalid or does not respect rules', @@ -41,13 +47,30 @@ const ESCROW_PROGRAM_ERROR_MESSAGES: Record = { }; const FALLBACK_TX_FAILED_MESSAGE = 'Transaction failed'; +const MAX_LOG_LINES = 12; + +export interface TransactionErrorDetails { + readonly logs: readonly string[]; + readonly message: string; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} function getErrorMessage(error: unknown): string { - if (error instanceof Error) return error.message; if (typeof error === 'string') return error; + if (error instanceof Error) return error.message; + if (isRecord(error) && typeof error.message === 'string') return error.message; return ''; } +function getErrorCause(error: unknown): unknown { + if (error instanceof Error) return error.cause; + if (isRecord(error)) return error.cause; + return undefined; +} + function tryDecodePayload(payload: string): string | null { if (typeof globalThis.atob !== 'function') { return null; @@ -93,17 +116,56 @@ function parseCustomProgramCodeFromString(message: string): number | null { return null; } -function parseCustomProgramCode(error: unknown): number | null { - if (error && typeof error === 'object') { - const withContext = error as { context?: { code?: unknown } }; - if (typeof withContext.context?.code === 'number') { - return withContext.context.code; +function parseInstructionErrorCode(value: unknown): number | null { + if (!Array.isArray(value) || value.length < 2) return null; + + const instructionError = value[1]; + if (isRecord(instructionError) && typeof instructionError.Custom === 'number') return instructionError.Custom; + return null; +} + +function parseCustomProgramCode(error: unknown, visited = new Set()): number | null { + if (isRecord(error)) { + if (visited.has(error)) return null; + visited.add(error); + } + + const simulationCause = unwrapSimulationError(error); + if (simulationCause !== error) { + const simulationCode = parseCustomProgramCode(simulationCause, visited); + if (simulationCode !== null) return simulationCode; + } + + if (isSolanaError(error, SOLANA_ERROR__INSTRUCTION_ERROR__CUSTOM)) { + return error.context.code; + } + + if (isRecord(error)) { + const context = error.context; + if (isRecord(context) && typeof context.code === 'number') return context.code; + + const directInstructionErrorCode = parseInstructionErrorCode(error.InstructionError); + if (directInstructionErrorCode !== null) return directInstructionErrorCode; + + const err = error.err; + if (isRecord(err)) { + const errInstructionErrorCode = parseInstructionErrorCode(err.InstructionError); + if (errInstructionErrorCode !== null) return errInstructionErrorCode; + } + + const data = error.data; + if (isRecord(data)) { + const dataInstructionErrorCode = parseCustomProgramCode(data, visited); + if (dataInstructionErrorCode !== null) return dataInstructionErrorCode; } } const message = getErrorMessage(error); - if (!message) return null; - return parseCustomProgramCodeFromString(message); + const parsedMessageCode = message ? parseCustomProgramCodeFromString(message) : null; + if (parsedMessageCode !== null) return parsedMessageCode; + + const cause = getErrorCause(error); + return cause === undefined ? null : parseCustomProgramCode(cause, visited); } function getEscrowProgramErrorMessage(code: number | null): string | null { @@ -111,29 +173,114 @@ function getEscrowProgramErrorMessage(code: number | null): string | null { return ESCROW_PROGRAM_ERROR_MESSAGES[code] ?? null; } -export function formatTransactionError(error: unknown): string { - const message = getErrorMessage(error); +function normalizeLogs(value: unknown): readonly string[] { + if (!Array.isArray(value)) return []; + return value.filter((line): line is string => typeof line === 'string' && line.trim().length > 0); +} - if ( - message === FALLBACK_TX_FAILED_MESSAGE || - message.startsWith(`${FALLBACK_TX_FAILED_MESSAGE}:`) || - message === 'Transaction was rejected in wallet' - ) { - return message; +function collectLogs(error: unknown, visited = new Set()): readonly string[] { + const logs: string[] = []; + + if (isRecord(error)) { + if (visited.has(error)) return []; + visited.add(error); + + if (isSolanaError(error, SOLANA_ERROR__JSON_RPC__SERVER_ERROR_SEND_TRANSACTION_PREFLIGHT_FAILURE)) { + logs.push(...normalizeLogs(error.context.logs)); + } + + logs.push(...normalizeLogs(error.logs)); + + const context = error.context; + if (isRecord(context)) logs.push(...normalizeLogs(context.logs)); + + const data = error.data; + if (isRecord(data)) logs.push(...normalizeLogs(data.logs)); } + const simulationCause = unwrapSimulationError(error); + if (simulationCause !== error) logs.push(...collectLogs(simulationCause, visited)); + + const cause = getErrorCause(error); + if (cause !== undefined) logs.push(...collectLogs(cause, visited)); + + return [...new Set(logs)]; +} + +function isPreflightFailure(error: unknown, visited = new Set()): boolean { + if (isRecord(error)) { + if (visited.has(error)) return false; + visited.add(error); + } + + if (isSolanaError(error, SOLANA_ERROR__JSON_RPC__SERVER_ERROR_SEND_TRANSACTION_PREFLIGHT_FAILURE)) { + return true; + } + + const cause = getErrorCause(error); + return cause === undefined ? false : isPreflightFailure(cause, visited); +} + +export function getTransactionErrorDetails(error: unknown): TransactionErrorDetails { + const message = getErrorMessage(error).trim(); const escrowMessage = getEscrowProgramErrorMessage(parseCustomProgramCode(error)); + if (escrowMessage) { - return `${FALLBACK_TX_FAILED_MESSAGE}: ${escrowMessage}`; + return { + logs: collectLogs(error), + message: `${FALLBACK_TX_FAILED_MESSAGE}: ${escrowMessage}`, + }; } - if (message.includes('-32002')) { - return `${FALLBACK_TX_FAILED_MESSAGE}: request is already pending in your wallet`; + if (/user rejected|rejected the request|declined|cancelled/i.test(message)) { + return { + logs: [], + message: 'Transaction was rejected in wallet', + }; } - if (/user rejected|rejected the request|declined|cancelled/i.test(message)) { - return 'Transaction was rejected in wallet'; + if (/request.*pending|already pending/i.test(message)) { + return { + logs: [], + message: `${FALLBACK_TX_FAILED_MESSAGE}: request is already pending in your wallet`, + }; } - return FALLBACK_TX_FAILED_MESSAGE; + if (isPreflightFailure(error)) { + return { + logs: collectLogs(error), + message: `${FALLBACK_TX_FAILED_MESSAGE}: transaction simulation failed`, + }; + } + + if ( + message === FALLBACK_TX_FAILED_MESSAGE || + message.startsWith(`${FALLBACK_TX_FAILED_MESSAGE}:`) || + message === 'Transaction was rejected in wallet' + ) { + return { + logs: collectLogs(error), + message, + }; + } + + return { + logs: collectLogs(error), + message: message ? `${FALLBACK_TX_FAILED_MESSAGE}: ${message}` : FALLBACK_TX_FAILED_MESSAGE, + }; +} + +export function formatTransactionError(error: unknown): string { + return getTransactionErrorDetails(error).message; +} + +export function formatTransactionErrorWithLogs(error: unknown): string { + const { logs, message } = getTransactionErrorDetails(error); + if (logs.length === 0) return message; + + const visibleLogs = logs.slice(-MAX_LOG_LINES); + const omittedCount = logs.length - visibleLogs.length; + const omittedLine = omittedCount > 0 ? [`... ${omittedCount} earlier log lines omitted`] : []; + + return [message, '', 'Logs:', ...omittedLine, ...visibleLogs].join('\n'); } diff --git a/apps/web/vercel.json b/apps/web/vercel.json index 38bd9ab..24e3972 100644 --- a/apps/web/vercel.json +++ b/apps/web/vercel.json @@ -1,4 +1,6 @@ { + "$schema": "https://openapi.vercel.sh/vercel.json", "framework": "vite", - "installCommand": "pnpm install --frozen-lockfile" + "installCommand": "pnpm install --frozen-lockfile", + "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] }