diff --git a/src/automerge/AutomergeToTLStore.ts b/src/automerge/AutomergeToTLStore.ts index 25e4baa..49b3307 100644 --- a/src/automerge/AutomergeToTLStore.ts +++ b/src/automerge/AutomergeToTLStore.ts @@ -1,6 +1,23 @@ import { TLRecord, RecordId, TLStore, IndexKey } from "@tldraw/tldraw" import * as Automerge from "@automerge/automerge" +// Track invalid index warnings to avoid console spam - log summary instead of per-shape +let _invalidIndexCount = 0 +let _invalidIndexLogTimer: ReturnType | null = null +function logInvalidIndexThrottled(index: string, shapeId: string) { + _invalidIndexCount++ + // Log a summary every 2 seconds instead of per-shape + if (!_invalidIndexLogTimer) { + _invalidIndexLogTimer = setTimeout(() => { + if (_invalidIndexCount > 0) { + console.warn(`Invalid index reset to 'a1' for ${_invalidIndexCount} shape(s) (e.g. "${index}" on ${shapeId})`) + _invalidIndexCount = 0 + } + _invalidIndexLogTimer = null + }, 2000) + } +} + // Helper function to validate if a string is a valid tldraw IndexKey // tldraw uses fractional indexing based on https://observablehq.com/@dgreensp/implementing-fractional-indexing // The first letter encodes integer part length: a=1 digit, b=2 digits, c=3 digits, etc. @@ -434,7 +451,6 @@ export function applyAutomergePatchesToTLStore( // Skip records with missing required fields return } - console.error("Failed to sanitize record:", error, record) failedRecords.push(record) } }) @@ -443,7 +459,7 @@ export function applyAutomergePatchesToTLStore( // Log patch application for debugging if (failedRecords.length > 0) { - console.error("Failed to sanitize records:", failedRecords) + console.warn(`Failed to sanitize ${failedRecords.length} record(s)`) } // CRITICAL: Final safety check - ensure no geo shapes have w/h/geo at top level @@ -583,11 +599,9 @@ export function sanitizeRecord(record: any): TLRecord { // DO NOT overwrite valid coordinates (including 0, which is a valid position) // Only set to 0 if the value is undefined, null, or NaN if (sanitized.x === undefined || sanitized.x === null || (typeof sanitized.x === 'number' && isNaN(sanitized.x))) { - console.warn(`⚠️ Shape ${sanitized.id} (${sanitized.type}) has invalid x coordinate, defaulting to 0. Original value:`, sanitized.x) sanitized.x = 0 } if (sanitized.y === undefined || sanitized.y === null || (typeof sanitized.y === 'number' && isNaN(sanitized.y))) { - console.warn(`⚠️ Shape ${sanitized.id} (${sanitized.type}) has invalid y coordinate, defaulting to 0. Original value:`, sanitized.y) sanitized.y = 0 } if (typeof sanitized.rotation !== 'number') sanitized.rotation = 0 @@ -605,7 +619,7 @@ export function sanitizeRecord(record: any): TLRecord { // Examples: "a1", "a2", "a10", "a1V", "a24sT", "a1V4rr" (fractional between a1 and a2) // Invalid: "c1", "b1", "z999" (old format - not valid fractional indices) if (!isValidIndexKey(sanitized.index)) { - console.warn(`⚠️ Invalid index "${sanitized.index}" for shape ${sanitized.id}, resetting to 'a1'`) + logInvalidIndexThrottled(sanitized.index, sanitized.id) sanitized.index = 'a1' as IndexKey } if (!sanitized.parentId) sanitized.parentId = 'page:page' @@ -619,7 +633,7 @@ export function sanitizeRecord(record: any): TLRecord { } catch (e) { // If JSON serialization fails (e.g., due to functions or circular references), // create a shallow copy and recursively clean it - console.warn(`⚠️ Could not deep copy props for shape ${sanitized.id}, using shallow copy:`, e) + // Deep copy failed, using shallow copy const propsCopy: any = {} for (const key in sanitized.props) { try { @@ -1183,7 +1197,7 @@ export function sanitizeRecord(record: any): TLRecord { }] }] } - console.log(`🔧 AutomergeToTLStore: Converted props.text to richText for text shape ${sanitized.id}`) + // Converted props.text to richText for text shape } // Preserve original text in meta for backward compatibility if (!sanitized.meta) sanitized.meta = {} diff --git a/src/automerge/MinimalSanitization.ts b/src/automerge/MinimalSanitization.ts index 904ba5d..ee9d6a9 100644 --- a/src/automerge/MinimalSanitization.ts +++ b/src/automerge/MinimalSanitization.ts @@ -48,7 +48,6 @@ function minimalSanitizeRecord(record: any): any { } if (!isValid) { - console.warn(`⚠️ MinimalSanitization: Invalid index format "${sanitized.index}" for shape ${sanitized.id}`) sanitized.index = 'a1' } } diff --git a/src/automerge/useAutomergeStoreV2.ts b/src/automerge/useAutomergeStoreV2.ts index 099ec0e..1477425 100644 --- a/src/automerge/useAutomergeStoreV2.ts +++ b/src/automerge/useAutomergeStoreV2.ts @@ -52,6 +52,8 @@ const CUSTOM_SHAPE_TYPES = [ 'PrivateWorkspace', // Private workspace for Google Export 'GoogleItem', // Individual Google items 'WorkflowBlock', // Workflow builder blocks + 'BlenderGen', // Blender 3D procedural generation + 'TransactionBuilder', // Safe multisig transaction builder ] // Combined set of all known shape types for validation @@ -182,6 +184,8 @@ import { HolonBrowserShape } from "@/shapes/HolonBrowserShapeUtil" import { PrivateWorkspaceShape } from "@/shapes/PrivateWorkspaceShapeUtil" import { GoogleItemShape } from "@/shapes/GoogleItemShapeUtil" import { WorkflowBlockShape } from "@/shapes/WorkflowBlockShapeUtil" +import { BlenderGenShape } from "@/shapes/BlenderGenShapeUtil" +import { TransactionBuilderShape } from "@/shapes/TransactionBuilderShapeUtil" export function useAutomergeStoreV2({ handle, @@ -226,6 +230,8 @@ export function useAutomergeStoreV2({ PrivateWorkspaceShape, // Private workspace for Google Export GoogleItemShape, // Individual Google items WorkflowBlockShape, // Workflow builder blocks + BlenderGenShape, // Blender 3D procedural generation + TransactionBuilderShape, // Safe multisig transaction builder ] // Use the module-level CUSTOM_SHAPE_TYPES constant @@ -358,64 +364,31 @@ export function useAutomergeStoreV2({ const automergeDoc = handle.doc() applyAutomergePatchesToTLStore(payload.patches, store, automergeDoc) } catch (patchError) { - console.error("Error applying patches batch, attempting individual patch application:", patchError) - // Try applying patches one by one to identify problematic ones - // This is a fallback - ideally we should fix the data at the source + // Batch application failed - try individual patches silently, only log summary let successCount = 0 - let failedPatches: any[] = [] - // CRITICAL: Pass Automerge document to patch handler so it can read full records + let failCount = 0 + const errorTypes: Record = {} const automergeDoc = handle.doc() for (const patch of payload.patches) { try { applyAutomergePatchesToTLStore([patch], store, automergeDoc) successCount++ } catch (individualPatchError) { - failedPatches.push({ patch, error: individualPatchError }) - console.error(`Failed to apply individual patch:`, individualPatchError) - - // Log the problematic patch for debugging - const recordId = patch.path[1] as string - console.error("Problematic patch details:", { - action: patch.action, - path: patch.path, - recordId: recordId, - value: 'value' in patch ? patch.value : undefined, - errorMessage: individualPatchError instanceof Error ? individualPatchError.message : String(individualPatchError) - }) - - // Try to get more context about the failing record - try { - const existingRecord = store.get(recordId as any) - console.error("Existing record that failed:", existingRecord) - - // If it's a geo shape missing props.geo, try to fix it - if (existingRecord && (existingRecord as any).typeName === 'shape' && (existingRecord as any).type === 'geo') { - const geoRecord = existingRecord as any - if (!geoRecord.props || !geoRecord.props.geo) { - // This won't help with the current patch, but might help future patches - // The real fix should happen in AutomergeToTLStore sanitization - } - } - } catch (e) { - console.error("Could not retrieve existing record:", e) - } + failCount++ + // Categorize errors for summary + const msg = individualPatchError instanceof Error ? individualPatchError.message : String(individualPatchError) + const category = msg.includes('props.geo') ? 'missing props.geo' : + msg.includes('index') ? 'invalid index' : + msg.includes('typeName') ? 'missing typeName' : + 'other' + errorTypes[category] = (errorTypes[category] || 0) + 1 } } - - // Log summary - if (failedPatches.length > 0) { - console.error(`❌ Failed to apply ${failedPatches.length} out of ${payload.patches.length} patches`) - // Most common issue: geo shapes missing props.geo - this should be fixed in sanitization - const geoShapeErrors = failedPatches.filter(p => - p.error instanceof Error && p.error.message.includes('props.geo') - ) - if (geoShapeErrors.length > 0) { - console.error(`⚠️ ${geoShapeErrors.length} failures due to missing props.geo - this should be fixed in AutomergeToTLStore sanitization`) - } - } - - if (successCount < payload.patches.length || payload.patches.length > 5) { - // Partial patches applied + + // Log a single summary line instead of per-patch errors + if (failCount > 0) { + const errorSummary = Object.entries(errorTypes).map(([k, v]) => `${k}: ${v}`).join(', ') + console.warn(`Patch sync: ${successCount}/${payload.patches.length} applied, ${failCount} failed (${errorSummary})`) } } } @@ -492,11 +465,8 @@ export function useAutomergeStoreV2({ return true }) - // Log errors for any unknown shape types that were filtered out if (unknownShapeTypes.length > 0) { - console.error(`❌ Unknown shape types filtered out (shapes not loaded):`, unknownShapeTypes) - console.error(` These shapes exist in the document but are not registered in KNOWN_SHAPE_TYPES.`) - console.error(` To fix: Add these types to CUSTOM_SHAPE_TYPES in useAutomergeStoreV2.ts`) + console.warn(`Unknown shape types filtered out: ${unknownShapeTypes.join(', ')}`) } if (filteredRecords.length > 0) { @@ -1242,9 +1212,7 @@ export function useAutomergeStoreV2({ } else { // Patches didn't come through - this should be rare if handler is set up before data load // Log a warning but don't show disruptive confirmation dialog - console.warn(`⚠️ No patches received after ${maxAttempts} attempts for room initialization.`) - console.warn(`⚠️ This may happen if Automerge doc was initialized with server data before handler was ready.`) - console.warn(`⚠️ Store will remain empty - patches should handle data loading in normal operation.`) + console.warn(`No patches received after ${maxAttempts} attempts - store may be empty`) // Simplified fallback: Just log and continue with empty store // Patches should handle data loading, so if they don't come through, diff --git a/src/automerge/useAutomergeSyncRepo.ts b/src/automerge/useAutomergeSyncRepo.ts index e18c67a..1dbf9d6 100644 --- a/src/automerge/useAutomergeSyncRepo.ts +++ b/src/automerge/useAutomergeSyncRepo.ts @@ -152,7 +152,7 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus const applyJsonSyncData = useCallback((data: TLStoreSnapshot & { deleted?: string[] }) => { const currentHandle = handleRef.current if (!currentHandle || (!data?.store && !data?.deleted)) { - console.warn('⚠️ Cannot apply JSON sync - no handle or data') + // No handle or data available for JSON sync - expected during initialization return } diff --git a/src/components/safe/PendingTransactions.tsx b/src/components/safe/PendingTransactions.tsx new file mode 100644 index 0000000..fd46623 --- /dev/null +++ b/src/components/safe/PendingTransactions.tsx @@ -0,0 +1,201 @@ +import { useState } from "react" +import { + usePendingTransactions, + useConfirmTransaction, + useExecuteTransaction, + type SafeTransaction, +} from "../../hooks/useSafeTransaction" +import { formatAddress } from "../../hooks/useWallet" + +function TxCard({ tx, onRefresh }: { tx: SafeTransaction; onRefresh: () => void }) { + const { confirm, isLoading: confirming } = useConfirmTransaction() + const { execute, isLoading: executing } = useExecuteTransaction() + const [signerKey, setSignerKey] = useState("") + const [showActions, setShowActions] = useState(false) + + const thresholdMet = tx.confirmations.length >= tx.confirmationsRequired + const sigProgress = `${tx.confirmations.length}/${tx.confirmationsRequired}` + + const valueEth = tx.value !== "0" + ? `${(Number(tx.value) / 1e18).toFixed(6)} ETH` + : "Contract call" + + const handleConfirm = async () => { + if (!signerKey) return + await confirm(tx.safeTxHash, signerKey) + setSignerKey("") + setShowActions(false) + onRefresh() + } + + const handleExecute = async () => { + if (!signerKey) return + await execute(tx.safeTxHash, signerKey) + setSignerKey("") + setShowActions(false) + onRefresh() + } + + return ( +
+ {/* Header Row */} +
+
+ #{tx.nonce} + → {formatAddress(tx.to)} +
+ + {sigProgress} + +
+ + {/* Value */} +
{valueEth}
+ + {/* Signers */} +
+ {tx.confirmations.map((c) => ( + + {formatAddress(c.owner, 3)} + + ))} +
+ + {/* Actions Toggle */} + + + {/* Action Panel */} + {showActions && ( +
+ setSignerKey(e.target.value)} + style={{ + padding: "6px 8px", + border: "1px solid #334155", + borderRadius: 4, + background: "#0f172a", + color: "#e2e8f0", + fontSize: 11, + fontFamily: "monospace", + }} + /> +
+ {!thresholdMet && ( + + )} + {thresholdMet && ( + + )} +
+
+ )} +
+ ) +} + +export function PendingTransactions() { + const { data: txs, isLoading, error, refetch } = usePendingTransactions() + + if (isLoading) { + return
Loading...
+ } + + if (error) { + return
Error: {error}
+ } + + if (txs.length === 0) { + return ( +
+ No pending transactions +
+ ) + } + + return ( +
+
+ {txs.length} pending transaction{txs.length !== 1 ? "s" : ""} +
+ {txs.map((tx) => ( + + ))} +
+ ) +} diff --git a/src/components/safe/SafeHeader.tsx b/src/components/safe/SafeHeader.tsx new file mode 100644 index 0000000..f645b73 --- /dev/null +++ b/src/components/safe/SafeHeader.tsx @@ -0,0 +1,92 @@ +import { useSafeInfo, useSafeBalances } from "../../hooks/useSafeTransaction" +import { formatAddress } from "../../hooks/useWallet" + +export function SafeHeader() { + const { data: info, isLoading: infoLoading } = useSafeInfo() + const { data: balances, isLoading: balLoading } = useSafeBalances() + + if (infoLoading) { + return ( +
+ Loading Safe info... +
+ ) + } + + if (!info) { + return ( +
+ Treasury not configured +
+ ) + } + + // Sum native balance from balances data + const nativeBalance = balances?.balances?.find((b) => !b.tokenAddress) + const nativeFormatted = nativeBalance + ? (Number(nativeBalance.balance) / 1e18).toFixed(4) + : "..." + + return ( +
+ {/* Address + Chain */} +
+
+ + {formatAddress(info.address, 6)} + + +
+ + Chain {info.chainId} + +
+ + {/* Owners + Threshold + Balance */} +
+ + {info.threshold}/{info.owners.length} owners + + v{info.version} + {nativeFormatted} ETH +
+
+ ) +} diff --git a/src/components/safe/TransactionComposer.tsx b/src/components/safe/TransactionComposer.tsx new file mode 100644 index 0000000..ce7848c --- /dev/null +++ b/src/components/safe/TransactionComposer.tsx @@ -0,0 +1,159 @@ +import { useState } from "react" +import { useProposeTransaction, useSafeBalances } from "../../hooks/useSafeTransaction" +import { useWalletConnection } from "../../hooks/useWallet" + +export function TransactionComposer() { + const { address, isConnected } = useWalletConnection() + const { data: balances } = useSafeBalances() + const { propose, isLoading, error } = useProposeTransaction() + + const [recipient, setRecipient] = useState("") + const [amount, setAmount] = useState("") + const [tokenAddress, setTokenAddress] = useState("") + const [title, setTitle] = useState("") + const [signerKey, setSignerKey] = useState("") + const [result, setResult] = useState(null) + + const handleSubmit = async () => { + if (!recipient || !amount || !signerKey) return + + const res = await propose({ + recipientAddress: recipient, + amount, + tokenAddress: tokenAddress || undefined, + title: title || undefined, + signerPrivateKey: signerKey, + }) + + if (res) { + setResult(`Proposed: ${res.safeTxHash.slice(0, 16)}...`) + setRecipient("") + setAmount("") + setTitle("") + } + } + + const inputStyle: React.CSSProperties = { + width: "100%", + padding: "8px 10px", + border: "1px solid #334155", + borderRadius: 6, + background: "#1e293b", + color: "#e2e8f0", + fontSize: 12, + fontFamily: "monospace", + outline: "none", + boxSizing: "border-box", + } + + const labelStyle: React.CSSProperties = { + fontSize: 11, + fontWeight: 600, + color: "#94a3b8", + marginBottom: 4, + display: "block", + } + + return ( +
+ {/* Title */} +
+ + setTitle(e.target.value)} + /> +
+ + {/* Recipient */} +
+ + setRecipient(e.target.value)} + /> +
+ + {/* Token (optional) */} +
+ + +
+ + {/* Amount */} +
+ + setAmount(e.target.value)} + type="text" + /> +
+ + {/* Signer Key */} +
+ + setSignerKey(e.target.value)} + /> + + Must be a Safe owner. Key is sent to treasury-service for signing. + +
+ + {/* Submit */} + + + {/* Result / Error */} + {result && ( +
+ {result} +
+ )} + {error && ( +
+ {error} +
+ )} +
+ ) +} diff --git a/src/components/safe/TransactionHistory.tsx b/src/components/safe/TransactionHistory.tsx new file mode 100644 index 0000000..5547101 --- /dev/null +++ b/src/components/safe/TransactionHistory.tsx @@ -0,0 +1,133 @@ +import { useState } from "react" +import { useTransactionHistory } from "../../hooks/useSafeTransaction" +import { formatAddress } from "../../hooks/useWallet" + +export function TransactionHistory() { + const [page, setPage] = useState(1) + const { data: txs, total, isLoading, error } = useTransactionHistory(page, 10) + + if (isLoading) { + return
Loading...
+ } + + if (error) { + return
Error: {error}
+ } + + if (txs.length === 0) { + return ( +
+ No executed transactions +
+ ) + } + + return ( +
+
+ {total} total transaction{total !== 1 ? "s" : ""} +
+ + {txs.map((tx) => { + const valueEth = + tx.value !== "0" + ? `${(Number(tx.value) / 1e18).toFixed(6)} ETH` + : "Contract call" + + const date = tx.executionDate + ? new Date(tx.executionDate).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }) + : "—" + + return ( +
+
+
+ #{tx.nonce} + → {formatAddress(tx.to)} +
+
{valueEth}
+
+
+
+ {tx.isSuccessful ? "Success" : "Failed"} +
+
{date}
+ {tx.transactionHash && ( + + {formatAddress(tx.transactionHash, 4)} + + )} +
+
+ ) + })} + + {/* Pagination */} + {total > 10 && ( +
+ + + {page}/{Math.ceil(total / 10)} + + +
+ )} +
+ ) +} diff --git a/src/hooks/useSafeTransaction.ts b/src/hooks/useSafeTransaction.ts new file mode 100644 index 0000000..5f74810 --- /dev/null +++ b/src/hooks/useSafeTransaction.ts @@ -0,0 +1,294 @@ +/** + * useSafeTransaction - Hooks for interacting with Safe treasury API + * + * Provides data fetching and mutation hooks for Safe multisig operations. + * Talks to the treasury-service REST API on payment-infra. + */ + +import { useState, useEffect, useCallback } from 'react' + +const TREASURY_API = import.meta.env.VITE_TREASURY_API_URL || 'http://localhost:3006' + +// ============================================================================= +// Types +// ============================================================================= + +export interface SafeInfo { + address: string + chainId: number + threshold: number + owners: string[] + nonce: number + modules: string[] + guard: string + fallbackHandler: string + version: string +} + +export interface SafeBalance { + tokenAddress: string | null + token: { + name: string + symbol: string + decimals: number + logoUri?: string + } | null + balance: string + fiatBalance?: string +} + +export interface SafeConfirmation { + owner: string + signature: string + signatureType: string + submissionDate: string +} + +export interface SafeTransaction { + safeTxHash: string + to: string + value: string + data: string | null + operation: number + nonce: number + confirmations: SafeConfirmation[] + confirmationsRequired: number + isExecuted: boolean + isSuccessful: boolean | null + executionDate: string | null + transactionHash: string | null + submissionDate: string + proposer: string +} + +// ============================================================================= +// Helper +// ============================================================================= + +async function fetchJson(url: string, options?: RequestInit): Promise { + const res = await fetch(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers, + }, + }) + if (!res.ok) { + const body = await res.json().catch(() => ({})) + throw new Error((body as Record).error || `HTTP ${res.status}`) + } + return res.json() as Promise +} + +// ============================================================================= +// useSafeInfo +// ============================================================================= + +export function useSafeInfo() { + const [data, setData] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + + const fetch_ = useCallback(async () => { + setIsLoading(true) + setError(null) + try { + const info = await fetchJson(`${TREASURY_API}/api/treasury/safe`) + setData(info) + } catch (e) { + setError((e as Error).message) + } finally { + setIsLoading(false) + } + }, []) + + useEffect(() => { fetch_() }, [fetch_]) + + return { data, isLoading, error, refetch: fetch_ } +} + +// ============================================================================= +// useSafeBalances +// ============================================================================= + +export function useSafeBalances() { + const [data, setData] = useState<{ balances: SafeBalance[] } | null>(null) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + + const fetch_ = useCallback(async () => { + setIsLoading(true) + setError(null) + try { + const result = await fetchJson<{ balances: SafeBalance[] }>(`${TREASURY_API}/api/treasury/balance`) + setData(result) + } catch (e) { + setError((e as Error).message) + } finally { + setIsLoading(false) + } + }, []) + + useEffect(() => { fetch_() }, [fetch_]) + + return { data, isLoading, error, refetch: fetch_ } +} + +// ============================================================================= +// usePendingTransactions +// ============================================================================= + +export function usePendingTransactions(pollInterval = 15000) { + const [data, setData] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + + const fetch_ = useCallback(async () => { + try { + const result = await fetchJson<{ transactions: SafeTransaction[] }>(`${TREASURY_API}/api/treasury/pending`) + setData(result.transactions) + setError(null) + } catch (e) { + setError((e as Error).message) + } finally { + setIsLoading(false) + } + }, []) + + useEffect(() => { + fetch_() + const interval = setInterval(fetch_, pollInterval) + return () => clearInterval(interval) + }, [fetch_, pollInterval]) + + return { data, isLoading, error, refetch: fetch_ } +} + +// ============================================================================= +// useTransactionHistory +// ============================================================================= + +export function useTransactionHistory(page = 1, limit = 20) { + const [data, setData] = useState([]) + const [total, setTotal] = useState(0) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + + const fetch_ = useCallback(async () => { + setIsLoading(true) + setError(null) + try { + const result = await fetchJson<{ + transactions: SafeTransaction[] + pagination: { total: number } + }>(`${TREASURY_API}/api/treasury/transactions?page=${page}&limit=${limit}`) + setData(result.transactions) + setTotal(result.pagination.total) + } catch (e) { + setError((e as Error).message) + } finally { + setIsLoading(false) + } + }, [page, limit]) + + useEffect(() => { fetch_() }, [fetch_]) + + return { data, total, isLoading, error, refetch: fetch_ } +} + +// ============================================================================= +// useProposeTransaction (mutation) +// ============================================================================= + +export function useProposeTransaction() { + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + + const propose = useCallback(async (params: { + recipientAddress: string + amount: string + tokenAddress?: string + title?: string + description?: string + signerPrivateKey: string + }) => { + setIsLoading(true) + setError(null) + try { + const result = await fetchJson<{ safeTxHash: string }>(`${TREASURY_API}/api/treasury/propose`, { + method: 'POST', + body: JSON.stringify(params), + }) + return result + } catch (e) { + setError((e as Error).message) + return null + } finally { + setIsLoading(false) + } + }, []) + + return { propose, isLoading, error } +} + +// ============================================================================= +// useConfirmTransaction (mutation) +// ============================================================================= + +export function useConfirmTransaction() { + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + + const confirm = useCallback(async (safeTxHash: string, signerPrivateKey: string) => { + setIsLoading(true) + setError(null) + try { + const result = await fetchJson( + `${TREASURY_API}/api/treasury/confirm/${safeTxHash}`, + { + method: 'POST', + body: JSON.stringify({ signerPrivateKey }), + } + ) + return result + } catch (e) { + setError((e as Error).message) + return null + } finally { + setIsLoading(false) + } + }, []) + + return { confirm, isLoading, error } +} + +// ============================================================================= +// useExecuteTransaction (mutation) +// ============================================================================= + +export function useExecuteTransaction() { + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + + const execute = useCallback(async (safeTxHash: string, signerPrivateKey: string) => { + setIsLoading(true) + setError(null) + try { + const result = await fetchJson<{ transactionHash: string }>( + `${TREASURY_API}/api/treasury/execute/${safeTxHash}`, + { + method: 'POST', + body: JSON.stringify({ signerPrivateKey }), + } + ) + return result + } catch (e) { + setError((e as Error).message) + return null + } finally { + setIsLoading(false) + } + }, []) + + return { execute, isLoading, error } +} diff --git a/src/routes/Board.tsx b/src/routes/Board.tsx index 14cf09d..15e1e36 100644 --- a/src/routes/Board.tsx +++ b/src/routes/Board.tsx @@ -144,6 +144,7 @@ import { logActivity } from "../lib/activityLogger" import { ActivityPanel } from "../components/ActivityPanel" import { WORKER_URL } from "../constants/workerUrl" +import { TransactionBuilderShape } from "@/shapes/TransactionBuilderShapeUtil" const customShapeUtils = [ ChatBoxShape, @@ -170,6 +171,7 @@ const customShapeUtils = [ PrivateWorkspaceShape, // Private zone for Google Export data sovereignty GoogleItemShape, // Individual items from Google Export with privacy badges MapShape, // Open Mapping - OSM map shape + TransactionBuilderShape, // Safe multisig transaction builder // Conditionally included based on feature flags: ...(ENABLE_WORKFLOW ? [WorkflowBlockShape] : []), // Workflow Builder - dev only ...(ENABLE_CALENDAR ? [CalendarShape, CalendarEventShape] : []), // Calendar - dev only diff --git a/src/shapes/TransactionBuilderShapeUtil.tsx b/src/shapes/TransactionBuilderShapeUtil.tsx new file mode 100644 index 0000000..a09961c --- /dev/null +++ b/src/shapes/TransactionBuilderShapeUtil.tsx @@ -0,0 +1,157 @@ +import { useState } from "react" +import { BaseBoxShapeUtil, TLBaseShape, HTMLContainer } from "tldraw" +import { StandardizedToolWrapper } from "../components/StandardizedToolWrapper" +import { usePinnedToView } from "../hooks/usePinnedToView" +import { useMaximize } from "../hooks/useMaximize" +import { SafeHeader } from "../components/safe/SafeHeader" +import { TransactionComposer } from "../components/safe/TransactionComposer" +import { PendingTransactions } from "../components/safe/PendingTransactions" +import { TransactionHistory } from "../components/safe/TransactionHistory" + +export type ITransactionBuilderShape = TLBaseShape< + "TransactionBuilder", + { + w: number + h: number + mode: "compose" | "pending" | "history" + pinnedToView: boolean + tags: string[] + } +> + +export class TransactionBuilderShape extends BaseBoxShapeUtil { + static override type = "TransactionBuilder" + + getDefaultProps(): ITransactionBuilderShape["props"] { + return { + w: 480, + h: 620, + mode: "pending", + pinnedToView: false, + tags: ["safe", "treasury"], + } + } + + // Safe green theme + static readonly PRIMARY_COLOR = "#12ff80" + + indicator(shape: ITransactionBuilderShape) { + return + } + + component(shape: ITransactionBuilderShape) { + const [isMinimized, setIsMinimized] = useState(false) + const [activeTab, setActiveTab] = useState<"compose" | "pending" | "history">(shape.props.mode) + const isSelected = this.editor.getSelectedShapeIds().includes(shape.id) + + usePinnedToView(this.editor, shape.id, shape.props.pinnedToView) + + const { isMaximized, toggleMaximize } = useMaximize({ + editor: this.editor, + shapeId: shape.id, + currentW: shape.props.w, + currentH: shape.props.h, + shapeType: "TransactionBuilder", + }) + + const handleClose = () => { + this.editor.deleteShape(shape.id) + } + + const handleMinimize = () => { + setIsMinimized(!isMinimized) + } + + const handlePinToggle = () => { + this.editor.updateShape({ + id: shape.id, + type: shape.type, + props: { + ...shape.props, + pinnedToView: !shape.props.pinnedToView, + }, + }) + } + + const tabs = [ + { key: "compose" as const, label: "Compose" }, + { key: "pending" as const, label: "Pending" }, + { key: "history" as const, label: "History" }, + ] + + return ( + + +
e.stopPropagation()} + > + {/* Safe Info Header */} + + + {/* Tab Navigation */} +
+ {tabs.map((tab) => ( + + ))} +
+ + {/* Tab Content */} +
+ {activeTab === "compose" && } + {activeTab === "pending" && } + {activeTab === "history" && } +
+
+
+
+ ) + } +}