diff --git a/frontend/src/__tests__/contractErrorParser.test.ts b/frontend/src/__tests__/contractErrorParser.test.ts new file mode 100644 index 00000000..79fc37c5 --- /dev/null +++ b/frontend/src/__tests__/contractErrorParser.test.ts @@ -0,0 +1,264 @@ +/** + * Integration tests for contractErrorParser + * + * Covers every mapped error code and verifies the fallback behaviour for + * unknown XDR strings. + * + * Run with: npx vitest run src/__tests__/contractErrorParser.test.ts + * + * Issue: https://github.com/Gildado/PayD/issues/82 + */ + +import { describe, it, expect } from 'vitest'; +import { + parseContractError, + getAllContractErrorCodes, +} from '../services/contractErrorParser'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Wraps a numeric contract error code into the Soroban host error format. */ +function sorobanHostErr(n: number): string { + return `HostError: Value(ContractError(${n}))`; +} + +// --------------------------------------------------------------------------- +// Known error code mapping +// --------------------------------------------------------------------------- + +describe('parseContractError — known error codes', () => { + it('recognises invoke_host_function_trapped via direct key', () => { + const result = parseContractError('invoke_host_function_trapped'); + expect(result.known).toBe(true); + expect(result.error.code).toBe('invoke_host_function_trapped'); + expect(result.error.description).toBeTruthy(); + expect(result.error.suggestedAction).toBeTruthy(); + expect(result.error.rawXdr).toBeUndefined(); + }); + + it('recognises invoke_host_function_malformed via direct key', () => { + const result = parseContractError('invoke_host_function_malformed'); + expect(result.known).toBe(true); + expect(result.error.code).toBe('invoke_host_function_malformed'); + }); + + it('recognises auth_required via direct key', () => { + const result = parseContractError('auth_required'); + expect(result.known).toBe(true); + expect(result.error.code).toBe('auth_required'); + }); + + it('recognises bad_auth via direct key', () => { + const result = parseContractError('bad_auth'); + expect(result.known).toBe(true); + expect(result.error.code).toBe('bad_auth'); + }); + + it('recognises insufficient_refundable_fee', () => { + const result = parseContractError('insufficient_refundable_fee'); + expect(result.known).toBe(true); + expect(result.error.code).toBe('insufficient_refundable_fee'); + }); + + it('recognises resource_limit_exceeded', () => { + const result = parseContractError('resource_limit_exceeded'); + expect(result.known).toBe(true); + expect(result.error.code).toBe('resource_limit_exceeded'); + }); + + it('recognises invalid_ledger_entry_access', () => { + const result = parseContractError('invalid_ledger_entry_access'); + expect(result.known).toBe(true); + expect(result.error.code).toBe('invalid_ledger_entry_access'); + }); + + it('recognises payroll_duplicate_period', () => { + const result = parseContractError('payroll_duplicate_period'); + expect(result.known).toBe(true); + expect(result.error.code).toBe('payroll_duplicate_period'); + }); + + it('recognises payroll_employee_not_found', () => { + const result = parseContractError('payroll_employee_not_found'); + expect(result.known).toBe(true); + expect(result.error.code).toBe('payroll_employee_not_found'); + }); + + it('recognises swap_slippage_exceeded', () => { + const result = parseContractError('swap_slippage_exceeded'); + expect(result.known).toBe(true); + expect(result.error.code).toBe('swap_slippage_exceeded'); + }); + + it('recognises no_liquidity', () => { + const result = parseContractError('no_liquidity'); + expect(result.known).toBe(true); + expect(result.error.code).toBe('no_liquidity'); + }); +}); + +// --------------------------------------------------------------------------- +// Numeric ContractError(N) extraction +// --------------------------------------------------------------------------- + +describe('parseContractError — numeric ContractError(N) codes', () => { + it.each([0, 1, 2, 3, 4, 5])( + 'maps ContractError(%i) to contract_error_%i', + (n) => { + const result = parseContractError(sorobanHostErr(n)); + expect(result.known).toBe(true); + expect(result.error.code).toBe(`contract_error_${n}`); + expect(result.error.description).toBeTruthy(); + expect(result.error.suggestedAction).toBeTruthy(); + expect(result.error.rawXdr).toBeUndefined(); + } + ); + + it('returns rawXdr for an unmapped ContractError(99)', () => { + const input = sorobanHostErr(99); + const result = parseContractError(input); + expect(result.known).toBe(false); + expect(result.error.rawXdr).toBe(input); + }); +}); + +// --------------------------------------------------------------------------- +// Token-based heuristic matching +// --------------------------------------------------------------------------- + +describe('parseContractError — heuristic token matching', () => { + it('detects "trapped" in a verbose error string', () => { + const result = parseContractError( + 'Error executing contract: host function trapped during execution' + ); + expect(result.known).toBe(true); + expect(result.error.code).toBe('invoke_host_function_trapped'); + }); + + it('detects "malformed" in a verbose error string', () => { + const result = parseContractError('Invocation malformed: argument count mismatch'); + expect(result.known).toBe(true); + expect(result.error.code).toBe('invoke_host_function_malformed'); + }); + + it('detects "authrequired" (camelCase variant)', () => { + const result = parseContractError('ContractAuthRequired: missing signer'); + expect(result.known).toBe(true); + expect(result.error.code).toBe('auth_required'); + }); + + it('detects "refundable_fee" token', () => { + const result = parseContractError('InsufficientRefundableFee: fee too low'); + expect(result.known).toBe(true); + expect(result.error.code).toBe('insufficient_refundable_fee'); + }); + + it('detects "resource_limit" token', () => { + const result = parseContractError('resource_limit exceeded during execution'); + expect(result.known).toBe(true); + expect(result.error.code).toBe('resource_limit_exceeded'); + }); + + it('detects "ledger_entry_access" token', () => { + const result = parseContractError('InvalidLedgerEntryAccess: footprint mismatch'); + expect(result.known).toBe(true); + expect(result.error.code).toBe('invalid_ledger_entry_access'); + }); + + it('detects "badauth" (camelCase variant)', () => { + const result = parseContractError('BadAuth: signature verification failed'); + expect(result.known).toBe(true); + expect(result.error.code).toBe('bad_auth'); + }); +}); + +// --------------------------------------------------------------------------- +// Unknown / fallback behaviour +// --------------------------------------------------------------------------- + +describe('parseContractError — unknown error fallback', () => { + it('falls back for an opaque base64 XDR blob', () => { + const rawXdr = 'AAAABAAAAAgAAAAA3kP6AAAAAQAAAAEAAAAAAAAAB9AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAlEXgQAAAAAAAAAAA=='; + const result = parseContractError(rawXdr); + expect(result.known).toBe(false); + expect(result.error.code).toBe('unknown_contract_error'); + expect(result.error.rawXdr).toBe(rawXdr); + expect(result.resultXdr).toBe(rawXdr); + expect(result.error.description).toBeTruthy(); + expect(result.error.suggestedAction).toMatch(/copy/i); + }); + + it('falls back for a completely arbitrary string', () => { + const result = parseContractError('some random unrecognised error text'); + expect(result.known).toBe(false); + expect(result.error.rawXdr).toBe('some random unrecognised error text'); + }); + + it('handles an empty string gracefully', () => { + const result = parseContractError(''); + expect(result.known).toBe(true); // treated as unknown_contract_error (mapped) + expect(result.error.code).toBe('unknown_contract_error'); + expect(result.error.rawXdr).toBeUndefined(); + }); + + it('handles a whitespace-only string gracefully', () => { + const result = parseContractError(' '); + expect(result.known).toBe(true); + expect(result.error.code).toBe('unknown_contract_error'); + }); +}); + +// --------------------------------------------------------------------------- +// resultXdr is always preserved +// --------------------------------------------------------------------------- + +describe('parseContractError — resultXdr preservation', () => { + it('always returns the original resultXdr for known errors', () => { + const input = 'bad_auth'; + const result = parseContractError(input); + expect(result.resultXdr).toBe(input); + }); + + it('always returns the original resultXdr for unknown errors', () => { + const input = 'AAAAABCXYZ123opaque'; + const result = parseContractError(input); + expect(result.resultXdr).toBe(input); + }); +}); + +// --------------------------------------------------------------------------- +// getAllContractErrorCodes +// --------------------------------------------------------------------------- + +describe('getAllContractErrorCodes', () => { + it('returns an array of all mapped codes with description and suggestedAction', () => { + const codes = getAllContractErrorCodes(); + expect(codes.length).toBeGreaterThan(0); + for (const entry of codes) { + expect(entry.code).toBeTruthy(); + expect(entry.description).toBeTruthy(); + expect(entry.suggestedAction).toBeTruthy(); + } + }); + + it('includes all contract_error_N codes (0–5)', () => { + const codes = getAllContractErrorCodes().map((c) => c.code); + for (let n = 0; n <= 5; n++) { + expect(codes).toContain(`contract_error_${n}`); + } + }); + + it('includes payroll-specific codes', () => { + const codes = getAllContractErrorCodes().map((c) => c.code); + expect(codes).toContain('payroll_duplicate_period'); + expect(codes).toContain('payroll_employee_not_found'); + }); + + it('includes cross-asset payment codes', () => { + const codes = getAllContractErrorCodes().map((c) => c.code); + expect(codes).toContain('swap_slippage_exceeded'); + expect(codes).toContain('no_liquidity'); + }); +}); diff --git a/frontend/src/components/ContractErrorPanel.module.css b/frontend/src/components/ContractErrorPanel.module.css new file mode 100644 index 00000000..c3c0376a --- /dev/null +++ b/frontend/src/components/ContractErrorPanel.module.css @@ -0,0 +1,203 @@ +/* ----------------------------------------------------------------------- + ContractErrorPanel — CSS Module + Issue: https://github.com/Gildado/PayD/issues/82 + ----------------------------------------------------------------------- */ + +.container { + border-radius: 1rem; + border: 1px solid rgba(239, 68, 68, 0.25); + background: rgba(239, 68, 68, 0.04); + overflow: hidden; +} + +/* ---- Collapsible header ---- */ +.header { + display: flex; + align-items: center; + gap: 0.625rem; + padding: 0.875rem 1rem; + cursor: pointer; + user-select: none; + transition: background 0.15s; +} + +.header:hover { + background: rgba(239, 68, 68, 0.06); +} + +.headerIcon { + flex-shrink: 0; + color: #ef4444; + display: flex; + align-items: center; +} + +.headerTitle { + flex: 1; + font-size: 0.8125rem; + font-weight: 800; + color: #ef4444; + letter-spacing: 0.02em; +} + +.chevron { + color: #ef4444; + opacity: 0.6; + transition: transform 0.2s; +} + +.chevronOpen { + transform: rotate(180deg); +} + +/* ---- Collapsible body ---- */ +.body { + padding: 0 1rem 1rem; + border-top: 1px solid rgba(239, 68, 68, 0.15); +} + +/* ---- Error card ---- */ +.errorCard { + margin-top: 1rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.codeRow { + display: flex; + align-items: center; + gap: 0.625rem; +} + +.codeLabel { + font-size: 9px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--muted, #6b7280); +} + +.codeBadge { + font-family: var(--font-mono, monospace); + font-size: 10px; + font-weight: 700; + padding: 0.1875rem 0.5rem; + background: rgba(239, 68, 68, 0.15); + border: 1px solid rgba(239, 68, 68, 0.3); + border-radius: 4px; + color: #ef4444; +} + +.description { + font-size: 0.8125rem; + color: var(--text, #e5e7eb); + line-height: 1.55; +} + +/* ---- Suggested action ---- */ +.actionBox { + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding: 0.625rem 0.75rem; + background: rgba(245, 158, 11, 0.06); + border: 1px solid rgba(245, 158, 11, 0.2); + border-radius: 0.5rem; +} + +.actionIcon { + flex-shrink: 0; + color: #f59e0b; + margin-top: 0.125rem; +} + +.actionText { + font-size: 0.8125rem; + color: #f59e0b; + line-height: 1.5; +} + +/* ---- Raw XDR fallback block ---- */ +.rawXdrSection { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.rawXdrLabel { + font-size: 9px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--muted, #6b7280); +} + +.rawXdrBox { + position: relative; + background: rgba(0, 0, 0, 0.3); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 0.5rem; + padding: 0.625rem; +} + +.rawXdrText { + font-family: var(--font-mono, monospace); + font-size: 10px; + color: var(--muted, #6b7280); + word-break: break-all; + line-height: 1.6; + white-space: pre-wrap; + max-height: 120px; + overflow-y: auto; +} + +.copyBtn { + margin-top: 0.375rem; + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.25rem 0.625rem; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 0.375rem; + font-size: 10px; + font-weight: 700; + color: var(--muted, #6b7280); + text-transform: uppercase; + letter-spacing: 0.05em; + cursor: pointer; + transition: all 0.15s; +} + +.copyBtn:hover { + background: rgba(255, 255, 255, 0.09); + color: var(--text, #e5e7eb); +} + +.copyBtnCopied { + color: #10b981; + border-color: rgba(16, 185, 129, 0.3); +} + +/* ---- Dismiss button ---- */ +.dismissBtn { + margin-top: 1rem; + width: 100%; + padding: 0.5rem; + background: transparent; + border: 1px solid rgba(239, 68, 68, 0.2); + border-radius: 0.5rem; + font-size: 11px; + font-weight: 700; + color: rgba(239, 68, 68, 0.6); + text-transform: uppercase; + letter-spacing: 0.05em; + cursor: pointer; + transition: all 0.15s; +} + +.dismissBtn:hover { + background: rgba(239, 68, 68, 0.07); + color: #ef4444; +} diff --git a/frontend/src/components/ContractErrorPanel.tsx b/frontend/src/components/ContractErrorPanel.tsx new file mode 100644 index 00000000..17485374 --- /dev/null +++ b/frontend/src/components/ContractErrorPanel.tsx @@ -0,0 +1,222 @@ +/** + * ContractErrorPanel + * + * Collapsible error panel that decodes and displays Soroban contract + * invocation failures. Uses `parseContractError` to map known XDR result + * codes to human-readable descriptions and suggested remediation steps. + * Falls back to a raw XDR display with a copy button for unknown codes. + * + * Issue: https://github.com/Gildado/PayD/issues/82 + */ + +import React, { useState } from 'react'; +import { parseContractError } from '../services/contractErrorParser'; +import type { ContractErrorResult } from '../services/contractErrorParser'; +import styles from './ContractErrorPanel.module.css'; + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- + +interface Props { + /** + * The result XDR or Soroban error string from a failed contract invocation. + * When null/undefined the panel renders nothing. + */ + resultXdr: string | null | undefined; + /** Optional callback fired when the user dismisses the panel */ + onDismiss?: () => void; +} + +// --------------------------------------------------------------------------- +// Icons (inline SVG — no extra deps) +// --------------------------------------------------------------------------- + +const ErrorCircleIcon = () => ( + + + + + +); + +const LightbulbIcon = () => ( + + + + + +); + +const CopyIcon = () => ( + + + + +); + +const CheckIcon = () => ( + + + +); + +const ChevronDownIcon = () => ( + + + +); + +// --------------------------------------------------------------------------- +// Sub-component: CopyButton +// --------------------------------------------------------------------------- + +interface CopyButtonProps { + text: string; +} + +const CopyButton: React.FC = ({ text }) => { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // Clipboard API unavailable — silently skip + } + }; + + return ( + + ); +}; + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- + +export const ContractErrorPanel: React.FC = ({ resultXdr, onDismiss }) => { + const [isOpen, setIsOpen] = useState(true); + + if (!resultXdr) return null; + + const parsed: ContractErrorResult = parseContractError(resultXdr); + const { error } = parsed; + + return ( +
+ {/* Collapsible header */} +
setIsOpen((prev) => !prev)} + aria-expanded={isOpen} + > + + + + Contract Invocation Failed + + + +
+ + {/* Collapsible body */} + {isOpen && ( +
+
+ {/* Error code badge */} +
+ Error Code + {error.code} +
+ + {/* Human-readable description */} +

{error.description}

+ + {/* Suggested action */} +
+ + + + {error.suggestedAction} +
+ + {/* Raw XDR fallback for unknown errors */} + {!parsed.known && error.rawXdr && ( +
+ Raw XDR +
+
{error.rawXdr}
+
+ +
+ )} +
+ + {onDismiss && ( + + )} +
+ )} +
+ ); +}; + +export default ContractErrorPanel; diff --git a/frontend/src/pages/CrossAssetPayment.tsx b/frontend/src/pages/CrossAssetPayment.tsx index 71343860..f04da1b1 100644 --- a/frontend/src/pages/CrossAssetPayment.tsx +++ b/frontend/src/pages/CrossAssetPayment.tsx @@ -26,6 +26,7 @@ export default function CrossAssetPayment() { const [submissionTxHash, setSubmissionTxHash] = useState(null); const [liveStatusMessage, setLiveStatusMessage] = useState('Waiting for submission...'); const [status, setStatus] = useState('idle'); + const [contractErrorXdr, setContractErrorXdr] = useState(null); const selectedPath = useMemo( () => paths.find((path) => path.id === selectedPathId) || null, @@ -142,10 +143,9 @@ export default function CrossAssetPayment() { notifySuccess('Payment submitted', `On-chain transaction hash: ${result.txHash}`); } catch (error) { setStatus('error'); - notifyError( - 'Payment failed', - error instanceof Error ? error.message : 'An unexpected error occurred.' - ); + const xdr = error instanceof Error ? error.message : String(error); + setContractErrorXdr(xdr); + notifyError('Payment failed', 'Contract returned an error. See details below.'); } }; diff --git a/frontend/src/pages/PayrollScheduler.tsx b/frontend/src/pages/PayrollScheduler.tsx index 802f3c9c..64ca1200 100644 --- a/frontend/src/pages/PayrollScheduler.tsx +++ b/frontend/src/pages/PayrollScheduler.tsx @@ -3,6 +3,7 @@ import { AutosaveIndicator } from '../components/AutosaveIndicator'; import { useAutosave } from '../hooks/useAutosave'; import { useTransactionSimulation } from '../hooks/useTransactionSimulation'; import { TransactionSimulationPanel } from '../components/TransactionSimulationPanel'; +import { ContractErrorPanel } from '../components/ContractErrorPanel'; import { useNotification } from '../hooks/useNotification'; import { useSocket } from '../hooks/useSocket'; import { createClaimableBalanceTransaction, generateWallet } from '../services/stellar'; @@ -57,6 +58,7 @@ export default function PayrollScheduler() { const { socket, subscribeToTransaction, unsubscribeFromTransaction } = useSocket(); const [formData, setFormData] = useState(initialFormState); const [isBroadcasting, setIsBroadcasting] = useState(false); + const [contractErrorXdr, setContractErrorXdr] = useState(null); const [isWizardOpen, setIsWizardOpen] = useState(false); const [activeSchedule, setActiveSchedule] = useState<{ frequency: string; @@ -120,6 +122,7 @@ export default function PayrollScheduler() { const { name, value } = e.target; setFormData((prev) => ({ ...prev, [name]: value })); if (simulationResult) resetSimulation(); + if (contractErrorXdr) setContractErrorXdr(null); }; useEffect(() => { @@ -223,7 +226,9 @@ export default function PayrollScheduler() { setFormData(initialFormState); } catch (err) { console.error(err); - notifyError('Broadcast failed', 'Please check your network connection and try again.'); + const xdr = err instanceof Error ? err.message : String(err); + setContractErrorXdr(xdr); + notifyError('Broadcast failed', 'Contract returned an error. See details below.'); } finally { setIsBroadcasting(false); } @@ -411,6 +416,11 @@ export default function PayrollScheduler() { onReset={resetSimulation} /> + setContractErrorXdr(null)} + /> +
{ + await new Promise((resolve) => setTimeout(resolve, 900)); + if (!beneficiary.startsWith('G') || beneficiary.length < 10) { + throw new Error('HostError: Value(ContractError(4))'); + } + return { + totalAmount: '100,000', + claimedAmount: '25,000', + claimableAmount: '12,500', + startDate: '2024-01-01', + endDate: '2025-12-31', + cliffDate: '2024-04-01', + }; +} + +/** Simulates calling the claim function on the Soroban contract. */ +async function mockClaimTokens(beneficiary: string): Promise { + await new Promise((resolve) => setTimeout(resolve, 1100)); + if (!beneficiary.startsWith('G')) { + throw new Error('HostError: Value(ContractError(3))'); + } +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export default function VestingEscrow() { + const { notifySuccess, notifyError } = useNotification(); + const [beneficiary, setBeneficiary] = useState(''); + const [schedule, setSchedule] = useState(null); + const [isFetching, setIsFetching] = useState(false); + const [isClaiming, setIsClaiming] = useState(false); + const [contractErrorXdr, setContractErrorXdr] = useState(null); + + const handleLookup = async () => { + if (!beneficiary.trim()) { + notifyError('Missing input', 'Please enter a beneficiary address.'); + return; + } + setIsFetching(true); + setSchedule(null); + setContractErrorXdr(null); + try { + const result = await mockFetchSchedule(beneficiary); + setSchedule(result); + } catch (err) { + const xdr = err instanceof Error ? err.message : String(err); + setContractErrorXdr(xdr); + notifyError('Contract error', 'Could not fetch vesting schedule.'); + } finally { + setIsFetching(false); + } + }; + + const handleClaim = async () => { + setIsClaiming(true); + setContractErrorXdr(null); + try { + await mockClaimTokens(beneficiary); + notifySuccess('Tokens claimed!', `${schedule?.claimableAmount ?? ''} USDC sent to your wallet.`); + setSchedule((prev) => + prev + ? { + ...prev, + claimedAmount: String( + Number(prev.claimedAmount.replace(/,/g, '')) + + Number(prev.claimableAmount.replace(/,/g, '')) + ), + claimableAmount: '0', + } + : null + ); + } catch (err) { + const xdr = err instanceof Error ? err.message : String(err); + setContractErrorXdr(xdr); + notifyError('Claim failed', 'Contract returned an error. See details below.'); + } finally { + setIsClaiming(false); + } + }; + + const progress = schedule + ? Math.round( + (Number(schedule.claimedAmount.replace(/,/g, '')) / + Number(schedule.totalAmount.replace(/,/g, ''))) * + 100 + ) + : 0; + + return ( +
+ {/* Page header */} +
+ + Vesting{' '} + Escrow + + + Token vesting schedule & claim interface + +
+ +
+ {/* Left: lookup form */} +
+
+ + Beneficiary Lookup + + +
+ { + setBeneficiary(e.target.value); + setContractErrorXdr(null); + }} + /> + +
+
+ + {/* Contract error panel */} + {contractErrorXdr && ( + setContractErrorXdr(null)} + /> + )} + + {/* Schedule card */} + {schedule && ( +
+ + Vesting Schedule + + + {/* Progress bar */} +
+
+ Claimed + {progress}% +
+
+
+
+
+ +
+ {[ + { label: 'Total', value: `${schedule.totalAmount} USDC` }, + { label: 'Claimed', value: `${schedule.claimedAmount} USDC` }, + { label: 'Available', value: `${schedule.claimableAmount} USDC` }, + { label: 'Cliff Date', value: schedule.cliffDate }, + { label: 'Start', value: schedule.startDate }, + { label: 'End', value: schedule.endDate }, + ].map(({ label, value }) => ( +
+ + {label} + + {value} +
+ ))} +
+ + +
+ )} +
+ + {/* Right: info panel */} +
+
+ + How Vesting Works + + + Token vesting ensures long-term alignment by releasing funds over a defined schedule. + +
    +
  • Tokens are locked in a Soroban escrow contract
  • +
  • A cliff period must pass before any tokens are claimable
  • +
  • Tokens unlock linearly after the cliff date
  • +
  • Claims execute directly on-chain via contract invocation
  • +
+
+
+
+
+ ); +} diff --git a/frontend/src/services/contractErrorParser.ts b/frontend/src/services/contractErrorParser.ts new file mode 100644 index 00000000..f2c3465c --- /dev/null +++ b/frontend/src/services/contractErrorParser.ts @@ -0,0 +1,265 @@ +/** + * Contract Error Parser + * + * Decodes Soroban invocation failures from XDR result codes into structured, + * human-readable error objects. Mirrors the pattern established in + * transactionSimulation.ts for Horizon errors, but targets Soroban contract + * invocation failures specifically. + * + * Issue: https://github.com/Gildado/PayD/issues/82 + */ + +// --------------------------------------------------------------------------- +// Known contract error code mappings +// --------------------------------------------------------------------------- + +/** + * Soroban host-level error codes returned inside the InvokeHostFunctionResult XDR. + * Keys are the canonical string codes surfaced by the Soroban RPC error field. + */ +const CONTRACT_ERROR_MESSAGES: Record< + string, + { description: string; suggestedAction: string } +> = { + // ── Invocation failures ────────────────────────────────────────────── + invoke_host_function_trapped: { + description: 'The contract function panicked during execution.', + suggestedAction: 'Check the contract arguments for out-of-range values or invalid state.', + }, + invoke_host_function_malformed: { + description: 'The invocation arguments are malformed or do not match the contract ABI.', + suggestedAction: 'Verify the argument types and ordering expected by the contract function.', + }, + + // ── Authorization failures ─────────────────────────────────────────── + auth_required: { + description: 'The caller is not authorized to invoke this contract function.', + suggestedAction: 'Ensure the signing account has the required role or approval.', + }, + bad_auth: { + description: 'Contract authorization check failed — signature invalid or missing.', + suggestedAction: 'Re-sign the transaction with the correct account keypair.', + }, + + // ── Footprint / resource failures ─────────────────────────────────── + insufficient_refundable_fee: { + description: 'The fee is too low to cover Soroban resource usage for this invocation.', + suggestedAction: 'Increase the base fee or resource fee to meet the contract execution cost.', + }, + resource_limit_exceeded: { + description: 'Contract execution exceeded CPU, memory, or ledger entry limits.', + suggestedAction: + 'Simplify the invocation or batch fewer items per call to reduce resource usage.', + }, + invalid_ledger_entry_access: { + description: 'The contract attempted to access a ledger entry not declared in the footprint.', + suggestedAction: + 'Re-simulate the transaction to refresh the footprint before submitting.', + }, + + // ── Contract-defined error codes (numeric, surfaced as strings) ────── + contract_error_0: { + description: 'Contract error code 0 — operation not permitted in the current contract state.', + suggestedAction: 'Check that the contract is in the expected state before invoking.', + }, + contract_error_1: { + description: 'Contract error code 1 — arithmetic overflow or underflow detected.', + suggestedAction: 'Verify that amount values are within the accepted range for this contract.', + }, + contract_error_2: { + description: 'Contract error code 2 — vesting schedule has not started yet.', + suggestedAction: 'Wait until the vesting start date before claiming tokens.', + }, + contract_error_3: { + description: 'Contract error code 3 — no vested tokens are available to claim at this time.', + suggestedAction: 'Check the vesting schedule to confirm the claimable amount.', + }, + contract_error_4: { + description: 'Contract error code 4 — recipient address is not registered in this contract.', + suggestedAction: 'Confirm the recipient was added during contract initialization.', + }, + contract_error_5: { + description: 'Contract error code 5 — insufficient token balance in the contract escrow.', + suggestedAction: 'Ensure the contract was funded before attempting a withdrawal.', + }, + + // ── Payroll-specific codes ─────────────────────────────────────────── + payroll_duplicate_period: { + description: 'A payroll disbursement for this period has already been processed.', + suggestedAction: 'Advance to the next pay period before submitting.', + }, + payroll_employee_not_found: { + description: 'The specified employee ID does not exist in the payroll contract.', + suggestedAction: 'Register the employee before scheduling a payment.', + }, + + // ── Cross-asset payment codes ──────────────────────────────────────── + swap_slippage_exceeded: { + description: 'The swap could not be completed — price moved beyond the slippage tolerance.', + suggestedAction: 'Increase slippage tolerance or retry when market conditions stabilize.', + }, + no_liquidity: { + description: 'Insufficient liquidity in the pool for this asset pair.', + suggestedAction: 'Try a different asset pair or reduce the payment amount.', + }, + + // ── Generic fallback ───────────────────────────────────────────────── + unknown_contract_error: { + description: 'An unrecognised contract error occurred.', + suggestedAction: 'Copy the raw XDR and open a support ticket for further investigation.', + }, +}; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** A structured, parsed representation of a single contract error */ +export interface ContractError { + /** The canonical error code string */ + code: string; + /** Human-readable description of what the error means */ + description: string; + /** Suggested action for the user to resolve the issue */ + suggestedAction: string; + /** + * The raw XDR result string, included when the error code was not + * recognised so the user can inspect or copy it directly. + */ + rawXdr?: string; +} + +/** The full result of calling `parseContractError` */ +export interface ContractErrorResult { + /** Whether a known error mapping was found */ + known: boolean; + /** The primary parsed error */ + error: ContractError; + /** + * The original result XDR passed in — always preserved so the UI can + * render a copy button regardless of whether the error was recognised. + */ + resultXdr: string; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Attempts to extract a canonical error code string from a Soroban result XDR + * or error message string. + * + * The Soroban RPC `simulateTransaction` response surfaces errors as plain + * strings (e.g. "HostError: Value(ContractError(3))") or as result_xdr + * blobs. This function normalises both forms into the lookup key used by + * `CONTRACT_ERROR_MESSAGES`. + */ +function extractErrorCode(resultXdr: string): string | null { + const lower = resultXdr.toLowerCase(); + + // Direct key match — highest priority (e.g. from structured RPC error fields) + const directMatch = Object.keys(CONTRACT_ERROR_MESSAGES).find((key) => + lower.includes(key.toLowerCase()) + ); + if (directMatch) return directMatch; + + // Soroban host error pattern: "ContractError(N)" → contract_error_N + const contractErrMatch = /contracterror\((\d+)\)/i.exec(resultXdr); + if (contractErrMatch) { + const numericKey = `contract_error_${contractErrMatch[1]}`; + if (numericKey in CONTRACT_ERROR_MESSAGES) return numericKey; + } + + // Soroban invoke_host_function result codes surfaced in result XDR strings + if (lower.includes('trapped')) return 'invoke_host_function_trapped'; + if (lower.includes('malformed')) return 'invoke_host_function_malformed'; + if (lower.includes('resourcelimitexceeded') || lower.includes('resource_limit')) + return 'resource_limit_exceeded'; + if (lower.includes('insufficientrefundablefee') || lower.includes('refundable_fee')) + return 'insufficient_refundable_fee'; + if (lower.includes('invalidledgerentryaccess') || lower.includes('ledger_entry_access')) + return 'invalid_ledger_entry_access'; + if (lower.includes('authrequired') || lower.includes('auth_required')) return 'auth_required'; + if (lower.includes('badauth') || lower.includes('bad_auth')) return 'bad_auth'; + + return null; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Decodes a Soroban invocation failure from an XDR result code string into a + * structured `ContractErrorResult`. + * + * For known error codes the result includes a human-readable description and a + * suggested action. For unknown codes the raw XDR is surfaced so the user can + * inspect or copy it. + * + * @param resultXdr - The result XDR or error string from a failed invocation. + * This can be a base64-encoded XDR blob, a Soroban RPC error message string, + * or any representation that contains recognisable error tokens. + * + * @example + * // Known error + * const result = parseContractError('HostError: Value(ContractError(2))'); + * // result.known === true + * // result.error.code === 'contract_error_2' + * // result.error.description === 'Vesting schedule has not started yet.' + * + * @example + * // Unknown error — falls back to raw XDR display + * const result = parseContractError('AAAABAAAAAgAAAAA...'); + * // result.known === false + * // result.error.rawXdr is set to the original string + */ +export function parseContractError(resultXdr: string): ContractErrorResult { + if (!resultXdr || resultXdr.trim() === '') { + return { + known: true, + error: { + code: 'unknown_contract_error', + ...CONTRACT_ERROR_MESSAGES.unknown_contract_error, + }, + resultXdr, + }; + } + + const code = extractErrorCode(resultXdr); + + if (code && code in CONTRACT_ERROR_MESSAGES) { + return { + known: true, + error: { + code, + ...CONTRACT_ERROR_MESSAGES[code], + }, + resultXdr, + }; + } + + // Unknown error — fall back to raw XDR display + return { + known: false, + error: { + code: 'unknown_contract_error', + description: 'An unrecognised contract error occurred.', + suggestedAction: + 'Copy the raw XDR below and open a support ticket for further investigation.', + rawXdr: resultXdr, + }, + resultXdr, + }; +} + +/** + * Returns all currently mapped error codes and their metadata. + * Useful for generating documentation or test fixtures. + */ +export function getAllContractErrorCodes(): Array< + { code: string } & { description: string; suggestedAction: string } +> { + return Object.entries(CONTRACT_ERROR_MESSAGES).map(([code, meta]) => ({ code, ...meta })); +}