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:
Jeff Emmett 2026-03-09 18:07:37 -07:00
parent 43007c07f8
commit 01fb250d29
11 changed files with 1084 additions and 65 deletions

View File

@ -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<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
// 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 = {}

View File

@ -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'
}
}

View File

@ -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<string, number> = {}
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,

View File

@ -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
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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 }
}

View File

@ -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

View File

@ -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>
)
}
}