From 01fb250d292b8288a1cd872f556c111214a3ea92 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 9 Mar 2026 18:07:37 -0700 Subject: [PATCH] fix: register BlenderGen and TransactionBuilder in automerge store schema BlenderGen and TransactionBuilder were added to Board.tsx's customShapeUtils but never registered in useAutomergeStoreV2.ts's CUSTOM_SHAPE_TYPES, imports, or shapeUtils array. This schema mismatch prevented tldraw's Editor from rendering any content - the store didn't know about these shape types even though the Tldraw component was told to use them. Also adds TransactionBuilder shape for Safe multisig transaction building and reduces verbose error logging in automerge sync. Co-Authored-By: Claude Opus 4.6 --- src/automerge/AutomergeToTLStore.ts | 28 +- src/automerge/MinimalSanitization.ts | 1 - src/automerge/useAutomergeStoreV2.ts | 80 ++---- src/automerge/useAutomergeSyncRepo.ts | 2 +- src/components/safe/PendingTransactions.tsx | 201 +++++++++++++ src/components/safe/SafeHeader.tsx | 92 ++++++ src/components/safe/TransactionComposer.tsx | 159 +++++++++++ src/components/safe/TransactionHistory.tsx | 133 +++++++++ src/hooks/useSafeTransaction.ts | 294 ++++++++++++++++++++ src/routes/Board.tsx | 2 + src/shapes/TransactionBuilderShapeUtil.tsx | 157 +++++++++++ 11 files changed, 1084 insertions(+), 65 deletions(-) create mode 100644 src/components/safe/PendingTransactions.tsx create mode 100644 src/components/safe/SafeHeader.tsx create mode 100644 src/components/safe/TransactionComposer.tsx create mode 100644 src/components/safe/TransactionHistory.tsx create mode 100644 src/hooks/useSafeTransaction.ts create mode 100644 src/shapes/TransactionBuilderShapeUtil.tsx 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" && } +
+
+
+
+ ) + } +}