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 <noreply@anthropic.com>
This commit is contained in:
parent
43007c07f8
commit
01fb250d29
|
|
@ -1,6 +1,23 @@
|
||||||
import { TLRecord, RecordId, TLStore, IndexKey } from "@tldraw/tldraw"
|
import { TLRecord, RecordId, TLStore, IndexKey } from "@tldraw/tldraw"
|
||||||
import * as Automerge from "@automerge/automerge"
|
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<typeof setTimeout> | 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
|
// 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
|
// 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.
|
// 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
|
// Skip records with missing required fields
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
console.error("Failed to sanitize record:", error, record)
|
|
||||||
failedRecords.push(record)
|
failedRecords.push(record)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -443,7 +459,7 @@ export function applyAutomergePatchesToTLStore(
|
||||||
// Log patch application for debugging
|
// Log patch application for debugging
|
||||||
|
|
||||||
if (failedRecords.length > 0) {
|
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
|
// 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)
|
// DO NOT overwrite valid coordinates (including 0, which is a valid position)
|
||||||
// Only set to 0 if the value is undefined, null, or NaN
|
// 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))) {
|
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
|
sanitized.x = 0
|
||||||
}
|
}
|
||||||
if (sanitized.y === undefined || sanitized.y === null || (typeof sanitized.y === 'number' && isNaN(sanitized.y))) {
|
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
|
sanitized.y = 0
|
||||||
}
|
}
|
||||||
if (typeof sanitized.rotation !== 'number') sanitized.rotation = 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)
|
// Examples: "a1", "a2", "a10", "a1V", "a24sT", "a1V4rr" (fractional between a1 and a2)
|
||||||
// Invalid: "c1", "b1", "z999" (old format - not valid fractional indices)
|
// Invalid: "c1", "b1", "z999" (old format - not valid fractional indices)
|
||||||
if (!isValidIndexKey(sanitized.index)) {
|
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
|
sanitized.index = 'a1' as IndexKey
|
||||||
}
|
}
|
||||||
if (!sanitized.parentId) sanitized.parentId = 'page:page'
|
if (!sanitized.parentId) sanitized.parentId = 'page:page'
|
||||||
|
|
@ -619,7 +633,7 @@ export function sanitizeRecord(record: any): TLRecord {
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// If JSON serialization fails (e.g., due to functions or circular references),
|
// If JSON serialization fails (e.g., due to functions or circular references),
|
||||||
// create a shallow copy and recursively clean it
|
// 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 = {}
|
const propsCopy: any = {}
|
||||||
for (const key in sanitized.props) {
|
for (const key in sanitized.props) {
|
||||||
try {
|
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
|
// Preserve original text in meta for backward compatibility
|
||||||
if (!sanitized.meta) sanitized.meta = {}
|
if (!sanitized.meta) sanitized.meta = {}
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,6 @@ function minimalSanitizeRecord(record: any): any {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
console.warn(`⚠️ MinimalSanitization: Invalid index format "${sanitized.index}" for shape ${sanitized.id}`)
|
|
||||||
sanitized.index = 'a1'
|
sanitized.index = 'a1'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,8 @@ const CUSTOM_SHAPE_TYPES = [
|
||||||
'PrivateWorkspace', // Private workspace for Google Export
|
'PrivateWorkspace', // Private workspace for Google Export
|
||||||
'GoogleItem', // Individual Google items
|
'GoogleItem', // Individual Google items
|
||||||
'WorkflowBlock', // Workflow builder blocks
|
'WorkflowBlock', // Workflow builder blocks
|
||||||
|
'BlenderGen', // Blender 3D procedural generation
|
||||||
|
'TransactionBuilder', // Safe multisig transaction builder
|
||||||
]
|
]
|
||||||
|
|
||||||
// Combined set of all known shape types for validation
|
// Combined set of all known shape types for validation
|
||||||
|
|
@ -182,6 +184,8 @@ import { HolonBrowserShape } from "@/shapes/HolonBrowserShapeUtil"
|
||||||
import { PrivateWorkspaceShape } from "@/shapes/PrivateWorkspaceShapeUtil"
|
import { PrivateWorkspaceShape } from "@/shapes/PrivateWorkspaceShapeUtil"
|
||||||
import { GoogleItemShape } from "@/shapes/GoogleItemShapeUtil"
|
import { GoogleItemShape } from "@/shapes/GoogleItemShapeUtil"
|
||||||
import { WorkflowBlockShape } from "@/shapes/WorkflowBlockShapeUtil"
|
import { WorkflowBlockShape } from "@/shapes/WorkflowBlockShapeUtil"
|
||||||
|
import { BlenderGenShape } from "@/shapes/BlenderGenShapeUtil"
|
||||||
|
import { TransactionBuilderShape } from "@/shapes/TransactionBuilderShapeUtil"
|
||||||
|
|
||||||
export function useAutomergeStoreV2({
|
export function useAutomergeStoreV2({
|
||||||
handle,
|
handle,
|
||||||
|
|
@ -226,6 +230,8 @@ export function useAutomergeStoreV2({
|
||||||
PrivateWorkspaceShape, // Private workspace for Google Export
|
PrivateWorkspaceShape, // Private workspace for Google Export
|
||||||
GoogleItemShape, // Individual Google items
|
GoogleItemShape, // Individual Google items
|
||||||
WorkflowBlockShape, // Workflow builder blocks
|
WorkflowBlockShape, // Workflow builder blocks
|
||||||
|
BlenderGenShape, // Blender 3D procedural generation
|
||||||
|
TransactionBuilderShape, // Safe multisig transaction builder
|
||||||
]
|
]
|
||||||
|
|
||||||
// Use the module-level CUSTOM_SHAPE_TYPES constant
|
// Use the module-level CUSTOM_SHAPE_TYPES constant
|
||||||
|
|
@ -358,64 +364,31 @@ export function useAutomergeStoreV2({
|
||||||
const automergeDoc = handle.doc()
|
const automergeDoc = handle.doc()
|
||||||
applyAutomergePatchesToTLStore(payload.patches, store, automergeDoc)
|
applyAutomergePatchesToTLStore(payload.patches, store, automergeDoc)
|
||||||
} catch (patchError) {
|
} catch (patchError) {
|
||||||
console.error("Error applying patches batch, attempting individual patch application:", patchError)
|
// Batch application failed - try individual patches silently, only log summary
|
||||||
// Try applying patches one by one to identify problematic ones
|
|
||||||
// This is a fallback - ideally we should fix the data at the source
|
|
||||||
let successCount = 0
|
let successCount = 0
|
||||||
let failedPatches: any[] = []
|
let failCount = 0
|
||||||
// CRITICAL: Pass Automerge document to patch handler so it can read full records
|
const errorTypes: Record<string, number> = {}
|
||||||
const automergeDoc = handle.doc()
|
const automergeDoc = handle.doc()
|
||||||
for (const patch of payload.patches) {
|
for (const patch of payload.patches) {
|
||||||
try {
|
try {
|
||||||
applyAutomergePatchesToTLStore([patch], store, automergeDoc)
|
applyAutomergePatchesToTLStore([patch], store, automergeDoc)
|
||||||
successCount++
|
successCount++
|
||||||
} catch (individualPatchError) {
|
} catch (individualPatchError) {
|
||||||
failedPatches.push({ patch, error: individualPatchError })
|
failCount++
|
||||||
console.error(`Failed to apply individual patch:`, individualPatchError)
|
// Categorize errors for summary
|
||||||
|
const msg = individualPatchError instanceof Error ? individualPatchError.message : String(individualPatchError)
|
||||||
// Log the problematic patch for debugging
|
const category = msg.includes('props.geo') ? 'missing props.geo' :
|
||||||
const recordId = patch.path[1] as string
|
msg.includes('index') ? 'invalid index' :
|
||||||
console.error("Problematic patch details:", {
|
msg.includes('typeName') ? 'missing typeName' :
|
||||||
action: patch.action,
|
'other'
|
||||||
path: patch.path,
|
errorTypes[category] = (errorTypes[category] || 0) + 1
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log summary
|
// Log a single summary line instead of per-patch errors
|
||||||
if (failedPatches.length > 0) {
|
if (failCount > 0) {
|
||||||
console.error(`❌ Failed to apply ${failedPatches.length} out of ${payload.patches.length} patches`)
|
const errorSummary = Object.entries(errorTypes).map(([k, v]) => `${k}: ${v}`).join(', ')
|
||||||
// Most common issue: geo shapes missing props.geo - this should be fixed in sanitization
|
console.warn(`Patch sync: ${successCount}/${payload.patches.length} applied, ${failCount} failed (${errorSummary})`)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -492,11 +465,8 @@ export function useAutomergeStoreV2({
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
// Log errors for any unknown shape types that were filtered out
|
|
||||||
if (unknownShapeTypes.length > 0) {
|
if (unknownShapeTypes.length > 0) {
|
||||||
console.error(`❌ Unknown shape types filtered out (shapes not loaded):`, unknownShapeTypes)
|
console.warn(`Unknown shape types filtered out: ${unknownShapeTypes.join(', ')}`)
|
||||||
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`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filteredRecords.length > 0) {
|
if (filteredRecords.length > 0) {
|
||||||
|
|
@ -1242,9 +1212,7 @@ export function useAutomergeStoreV2({
|
||||||
} else {
|
} else {
|
||||||
// Patches didn't come through - this should be rare if handler is set up before data load
|
// 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
|
// Log a warning but don't show disruptive confirmation dialog
|
||||||
console.warn(`⚠️ No patches received after ${maxAttempts} attempts for room initialization.`)
|
console.warn(`No patches received after ${maxAttempts} attempts - store may be empty`)
|
||||||
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.`)
|
|
||||||
|
|
||||||
// Simplified fallback: Just log and continue with empty store
|
// Simplified fallback: Just log and continue with empty store
|
||||||
// Patches should handle data loading, so if they don't come through,
|
// Patches should handle data loading, so if they don't come through,
|
||||||
|
|
|
||||||
|
|
@ -152,7 +152,7 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
||||||
const applyJsonSyncData = useCallback((data: TLStoreSnapshot & { deleted?: string[] }) => {
|
const applyJsonSyncData = useCallback((data: TLStoreSnapshot & { deleted?: string[] }) => {
|
||||||
const currentHandle = handleRef.current
|
const currentHandle = handleRef.current
|
||||||
if (!currentHandle || (!data?.store && !data?.deleted)) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: "1px solid #1e293b",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 10,
|
||||||
|
background: "#1e293b",
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header Row */}
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 600 }}>
|
||||||
|
<span style={{ color: "#e2e8f0" }}>#{tx.nonce}</span>
|
||||||
|
<span style={{ color: "#64748b", marginLeft: 8 }}>→ {formatAddress(tx.to)}</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 700,
|
||||||
|
padding: "2px 6px",
|
||||||
|
borderRadius: 4,
|
||||||
|
background: thresholdMet ? "#064e3b" : "#1e1b4b",
|
||||||
|
color: thresholdMet ? "#34d399" : "#818cf8",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sigProgress}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Value */}
|
||||||
|
<div style={{ fontSize: 11, color: "#94a3b8", marginTop: 4 }}>{valueEth}</div>
|
||||||
|
|
||||||
|
{/* Signers */}
|
||||||
|
<div style={{ marginTop: 6, display: "flex", gap: 4, flexWrap: "wrap" }}>
|
||||||
|
{tx.confirmations.map((c) => (
|
||||||
|
<span
|
||||||
|
key={c.owner}
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
padding: "1px 6px",
|
||||||
|
borderRadius: 3,
|
||||||
|
background: "#064e3b",
|
||||||
|
color: "#34d399",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatAddress(c.owner, 3)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions Toggle */}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowActions(!showActions)}
|
||||||
|
style={{
|
||||||
|
marginTop: 6,
|
||||||
|
border: "none",
|
||||||
|
background: "transparent",
|
||||||
|
color: "#818cf8",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 11,
|
||||||
|
padding: 0,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showActions ? "Hide" : thresholdMet ? "Execute" : "Confirm"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Action Panel */}
|
||||||
|
{showActions && (
|
||||||
|
<div style={{ marginTop: 8, display: "flex", flexDirection: "column", gap: 6 }}>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Signer private key (0x...)"
|
||||||
|
value={signerKey}
|
||||||
|
onChange={(e) => setSignerKey(e.target.value)}
|
||||||
|
style={{
|
||||||
|
padding: "6px 8px",
|
||||||
|
border: "1px solid #334155",
|
||||||
|
borderRadius: 4,
|
||||||
|
background: "#0f172a",
|
||||||
|
color: "#e2e8f0",
|
||||||
|
fontSize: 11,
|
||||||
|
fontFamily: "monospace",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ display: "flex", gap: 6 }}>
|
||||||
|
{!thresholdMet && (
|
||||||
|
<button
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={confirming || !signerKey}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: "6px 12px",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 4,
|
||||||
|
background: confirming ? "#334155" : "#818cf8",
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: confirming ? "not-allowed" : "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{confirming ? "..." : "Confirm"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{thresholdMet && (
|
||||||
|
<button
|
||||||
|
onClick={handleExecute}
|
||||||
|
disabled={executing || !signerKey}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: "6px 12px",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 4,
|
||||||
|
background: executing ? "#334155" : "#12ff80",
|
||||||
|
color: "#0f172a",
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: executing ? "not-allowed" : "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{executing ? "..." : "Execute On-Chain"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PendingTransactions() {
|
||||||
|
const { data: txs, isLoading, error, refetch } = usePendingTransactions()
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div style={{ color: "#64748b", fontSize: 12, textAlign: "center" }}>Loading...</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <div style={{ color: "#fca5a5", fontSize: 12 }}>Error: {error}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (txs.length === 0) {
|
||||||
|
return (
|
||||||
|
<div style={{ color: "#64748b", fontSize: 12, textAlign: "center", padding: 20 }}>
|
||||||
|
No pending transactions
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 11, color: "#64748b", marginBottom: 8 }}>
|
||||||
|
{txs.length} pending transaction{txs.length !== 1 ? "s" : ""}
|
||||||
|
</div>
|
||||||
|
{txs.map((tx) => (
|
||||||
|
<TxCard key={tx.safeTxHash} tx={tx} onRefresh={refetch} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<div style={{ padding: 12, textAlign: "center", color: "#64748b", fontSize: 12 }}>
|
||||||
|
Loading Safe info...
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!info) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 12, textAlign: "center", color: "#f87171", fontSize: 12 }}>
|
||||||
|
Treasury not configured
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sum native balance from balances data
|
||||||
|
const nativeBalance = balances?.balances?.find((b) => !b.tokenAddress)
|
||||||
|
const nativeFormatted = nativeBalance
|
||||||
|
? (Number(nativeBalance.balance) / 1e18).toFixed(4)
|
||||||
|
: "..."
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "10px 12px",
|
||||||
|
borderBottom: "1px solid #1e293b",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Address + Chain */}
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 700,
|
||||||
|
fontFamily: "monospace",
|
||||||
|
color: "#12ff80",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatAddress(info.address, 6)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => navigator.clipboard.writeText(info.address)}
|
||||||
|
style={{
|
||||||
|
border: "none",
|
||||||
|
background: "transparent",
|
||||||
|
color: "#64748b",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 11,
|
||||||
|
padding: "2px 4px",
|
||||||
|
}}
|
||||||
|
title="Copy address"
|
||||||
|
>
|
||||||
|
copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 600,
|
||||||
|
padding: "2px 8px",
|
||||||
|
borderRadius: 4,
|
||||||
|
background: "#1e293b",
|
||||||
|
color: "#94a3b8",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Chain {info.chainId}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Owners + Threshold + Balance */}
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 11, color: "#94a3b8" }}>
|
||||||
|
<span>
|
||||||
|
{info.threshold}/{info.owners.length} owners
|
||||||
|
</span>
|
||||||
|
<span>v{info.version}</span>
|
||||||
|
<span>{nativeFormatted} ETH</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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<string | null>(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 (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||||
|
{/* Title */}
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Description (optional)</label>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="Payment for..."
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recipient */}
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Recipient Address *</label>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="0x..."
|
||||||
|
value={recipient}
|
||||||
|
onChange={(e) => setRecipient(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Token (optional) */}
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Token Address (empty = native ETH)</label>
|
||||||
|
<select
|
||||||
|
style={{ ...inputStyle, fontFamily: "Inter, system-ui, sans-serif" }}
|
||||||
|
value={tokenAddress}
|
||||||
|
onChange={(e) => setTokenAddress(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">ETH (native)</option>
|
||||||
|
{balances?.balances
|
||||||
|
?.filter((b) => b.tokenAddress && b.token)
|
||||||
|
.map((b) => (
|
||||||
|
<option key={b.tokenAddress} value={b.tokenAddress!}>
|
||||||
|
{b.token!.symbol} — {(Number(b.balance) / 10 ** b.token!.decimals).toFixed(4)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Amount */}
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Amount * {tokenAddress ? "(token units)" : "(wei)"}</label>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder={tokenAddress ? "100" : "1000000000000000"}
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Signer Key */}
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Signer Private Key *</label>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
type="password"
|
||||||
|
placeholder="0x..."
|
||||||
|
value={signerKey}
|
||||||
|
onChange={(e) => setSignerKey(e.target.value)}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: 10, color: "#64748b", marginTop: 2, display: "block" }}>
|
||||||
|
Must be a Safe owner. Key is sent to treasury-service for signing.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isLoading || !recipient || !amount || !signerKey}
|
||||||
|
style={{
|
||||||
|
padding: "10px 16px",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 6,
|
||||||
|
background: isLoading ? "#334155" : "#12ff80",
|
||||||
|
color: isLoading ? "#94a3b8" : "#0f172a",
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: 13,
|
||||||
|
cursor: isLoading ? "not-allowed" : "pointer",
|
||||||
|
transition: "all 0.15s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLoading ? "Proposing..." : "Propose Transaction"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Result / Error */}
|
||||||
|
{result && (
|
||||||
|
<div style={{ padding: 8, borderRadius: 6, background: "#064e3b", color: "#34d399", fontSize: 11 }}>
|
||||||
|
{result}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div style={{ padding: 8, borderRadius: 6, background: "#450a0a", color: "#fca5a5", fontSize: 11 }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 <div style={{ color: "#64748b", fontSize: 12, textAlign: "center" }}>Loading...</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <div style={{ color: "#fca5a5", fontSize: 12 }}>Error: {error}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (txs.length === 0) {
|
||||||
|
return (
|
||||||
|
<div style={{ color: "#64748b", fontSize: 12, textAlign: "center", padding: 20 }}>
|
||||||
|
No executed transactions
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 11, color: "#64748b", marginBottom: 8 }}>
|
||||||
|
{total} total transaction{total !== 1 ? "s" : ""}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{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 (
|
||||||
|
<div
|
||||||
|
key={tx.safeTxHash}
|
||||||
|
style={{
|
||||||
|
border: "1px solid #1e293b",
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: 8,
|
||||||
|
background: "#1e293b",
|
||||||
|
marginBottom: 6,
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 600 }}>
|
||||||
|
<span style={{ color: "#e2e8f0" }}>#{tx.nonce}</span>
|
||||||
|
<span style={{ color: "#64748b", marginLeft: 8 }}>→ {formatAddress(tx.to)}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: "#94a3b8", marginTop: 2 }}>{valueEth}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: "right" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: tx.isSuccessful ? "#34d399" : "#f87171",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tx.isSuccessful ? "Success" : "Failed"}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 10, color: "#64748b", marginTop: 2 }}>{date}</div>
|
||||||
|
{tx.transactionHash && (
|
||||||
|
<a
|
||||||
|
href={`https://basescan.org/tx/${tx.transactionHash}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{ fontSize: 10, color: "#818cf8", textDecoration: "none" }}
|
||||||
|
>
|
||||||
|
{formatAddress(tx.transactionHash, 4)}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{total > 10 && (
|
||||||
|
<div style={{ display: "flex", justifyContent: "center", gap: 8, marginTop: 8 }}>
|
||||||
|
<button
|
||||||
|
disabled={page === 1}
|
||||||
|
onClick={() => setPage((p) => p - 1)}
|
||||||
|
style={{
|
||||||
|
padding: "4px 12px",
|
||||||
|
border: "1px solid #334155",
|
||||||
|
borderRadius: 4,
|
||||||
|
background: "transparent",
|
||||||
|
color: page === 1 ? "#334155" : "#94a3b8",
|
||||||
|
cursor: page === 1 ? "not-allowed" : "pointer",
|
||||||
|
fontSize: 11,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Prev
|
||||||
|
</button>
|
||||||
|
<span style={{ fontSize: 11, color: "#64748b", lineHeight: "28px" }}>
|
||||||
|
{page}/{Math.ceil(total / 10)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
disabled={page * 10 >= total}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
style={{
|
||||||
|
padding: "4px 12px",
|
||||||
|
border: "1px solid #334155",
|
||||||
|
borderRadius: 4,
|
||||||
|
background: "transparent",
|
||||||
|
color: page * 10 >= total ? "#334155" : "#94a3b8",
|
||||||
|
cursor: page * 10 >= total ? "not-allowed" : "pointer",
|
||||||
|
fontSize: 11,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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<T>(url: string, options?: RequestInit): Promise<T> {
|
||||||
|
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<string, string>).error || `HTTP ${res.status}`)
|
||||||
|
}
|
||||||
|
return res.json() as Promise<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// useSafeInfo
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function useSafeInfo() {
|
||||||
|
const [data, setData] = useState<SafeInfo | null>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const fetch_ = useCallback(async () => {
|
||||||
|
setIsLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const info = await fetchJson<SafeInfo>(`${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<string | null>(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<SafeTransaction[]>([])
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(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<SafeTransaction[]>([])
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(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<string | null>(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<string | null>(null)
|
||||||
|
|
||||||
|
const confirm = useCallback(async (safeTxHash: string, signerPrivateKey: string) => {
|
||||||
|
setIsLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const result = await fetchJson<SafeTransaction>(
|
||||||
|
`${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<string | null>(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 }
|
||||||
|
}
|
||||||
|
|
@ -144,6 +144,7 @@ import { logActivity } from "../lib/activityLogger"
|
||||||
import { ActivityPanel } from "../components/ActivityPanel"
|
import { ActivityPanel } from "../components/ActivityPanel"
|
||||||
|
|
||||||
import { WORKER_URL } from "../constants/workerUrl"
|
import { WORKER_URL } from "../constants/workerUrl"
|
||||||
|
import { TransactionBuilderShape } from "@/shapes/TransactionBuilderShapeUtil"
|
||||||
|
|
||||||
const customShapeUtils = [
|
const customShapeUtils = [
|
||||||
ChatBoxShape,
|
ChatBoxShape,
|
||||||
|
|
@ -170,6 +171,7 @@ const customShapeUtils = [
|
||||||
PrivateWorkspaceShape, // Private zone for Google Export data sovereignty
|
PrivateWorkspaceShape, // Private zone for Google Export data sovereignty
|
||||||
GoogleItemShape, // Individual items from Google Export with privacy badges
|
GoogleItemShape, // Individual items from Google Export with privacy badges
|
||||||
MapShape, // Open Mapping - OSM map shape
|
MapShape, // Open Mapping - OSM map shape
|
||||||
|
TransactionBuilderShape, // Safe multisig transaction builder
|
||||||
// Conditionally included based on feature flags:
|
// Conditionally included based on feature flags:
|
||||||
...(ENABLE_WORKFLOW ? [WorkflowBlockShape] : []), // Workflow Builder - dev only
|
...(ENABLE_WORKFLOW ? [WorkflowBlockShape] : []), // Workflow Builder - dev only
|
||||||
...(ENABLE_CALENDAR ? [CalendarShape, CalendarEventShape] : []), // Calendar - dev only
|
...(ENABLE_CALENDAR ? [CalendarShape, CalendarEventShape] : []), // Calendar - dev only
|
||||||
|
|
|
||||||
|
|
@ -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<ITransactionBuilderShape> {
|
||||||
|
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 <rect x={0} y={0} width={shape.props.w} height={shape.props.h} />
|
||||||
|
}
|
||||||
|
|
||||||
|
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<ITransactionBuilderShape>({
|
||||||
|
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 (
|
||||||
|
<HTMLContainer style={{ width: shape.props.w, height: shape.props.h }}>
|
||||||
|
<StandardizedToolWrapper
|
||||||
|
title="Safe Treasury"
|
||||||
|
primaryColor={TransactionBuilderShape.PRIMARY_COLOR}
|
||||||
|
isSelected={isSelected}
|
||||||
|
width={shape.props.w}
|
||||||
|
height={shape.props.h}
|
||||||
|
onClose={handleClose}
|
||||||
|
onMinimize={handleMinimize}
|
||||||
|
isMinimized={isMinimized}
|
||||||
|
onMaximize={toggleMaximize}
|
||||||
|
isMaximized={isMaximized}
|
||||||
|
isPinnedToView={shape.props.pinnedToView}
|
||||||
|
onPinToggle={handlePinToggle}
|
||||||
|
editor={this.editor}
|
||||||
|
shapeId={shape.id}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
height: "100%",
|
||||||
|
fontFamily: "Inter, system-ui, -apple-system, sans-serif",
|
||||||
|
fontSize: 13,
|
||||||
|
color: "#e2e8f0",
|
||||||
|
background: "#0f172a",
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Safe Info Header */}
|
||||||
|
<SafeHeader />
|
||||||
|
|
||||||
|
{/* Tab Navigation */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
borderBottom: "1px solid #1e293b",
|
||||||
|
padding: "0 8px",
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
onClick={() => setActiveTab(tab.key)}
|
||||||
|
style={{
|
||||||
|
padding: "8px 16px",
|
||||||
|
border: "none",
|
||||||
|
background: activeTab === tab.key ? "#1e293b" : "transparent",
|
||||||
|
color: activeTab === tab.key ? "#12ff80" : "#94a3b8",
|
||||||
|
borderBottom: activeTab === tab.key ? "2px solid #12ff80" : "2px solid transparent",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
borderRadius: "4px 4px 0 0",
|
||||||
|
transition: "all 0.15s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Content */}
|
||||||
|
<div style={{ flex: 1, overflow: "auto", padding: 12 }}>
|
||||||
|
{activeTab === "compose" && <TransactionComposer />}
|
||||||
|
{activeTab === "pending" && <PendingTransactions />}
|
||||||
|
{activeTab === "history" && <TransactionHistory />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</StandardizedToolWrapper>
|
||||||
|
</HTMLContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue