diff --git a/README.md b/README.md index a96f069..5baa224 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Loreum Chamber -Loreum is a secure shared vault for communities. NFT holders elect a board of leaders who work together to manage funds and approve transactions through a unique delegation mechanism. +Loreum is enterprise treasury infrastructure for organizations. Chambers function as corporate entities with an elected board of directors who oversee fiduciary operations and approve transactions through multi-signature governance. ## Overview diff --git a/app/src/components/DelegationManager.tsx b/app/src/components/DelegationManager.tsx index 6cdff95..449fe1b 100644 --- a/app/src/components/DelegationManager.tsx +++ b/app/src/components/DelegationManager.tsx @@ -1,5 +1,6 @@ import { useState, useMemo } from 'react' import { motion } from 'framer-motion' +import { useAccount } from 'wagmi' import { formatUnits, parseUnits } from 'viem' import { FiSend, @@ -17,6 +18,7 @@ import { useChamberEventRefresh, useSimulateDelegate, useSimulateUndelegate, + useUserNFTs, } from '@/hooks' import type { BoardMember } from '@/types' @@ -26,6 +28,7 @@ interface DelegationManagerProps { delegations: { tokenId: bigint; amount: bigint }[] members: BoardMember[] vaultSymbol?: string + nftToken?: `0x${string}` } export default function DelegationManager({ @@ -34,7 +37,10 @@ export default function DelegationManager({ delegations, members, vaultSymbol, + nftToken, }: DelegationManagerProps) { + const { address: userAddress } = useAccount() + const { tokenIds: userNFTTokenIds } = useUserNFTs(nftToken, userAddress) const [delegateTokenId, setDelegateTokenId] = useState('') const [delegateAmount, setDelegateAmount] = useState('') const [undelegateTokenId, setUndelegateTokenId] = useState('') @@ -229,14 +235,34 @@ export default function DelegationManager({ - setDelegateTokenId(e.target.value)} - min="1" - /> + {userNFTTokenIds.length > 0 ? ( + + ) : ( + setDelegateTokenId(e.target.value)} + min="1" + /> + )} + {userNFTTokenIds.length > 0 && ( +

+ Select an NFT you own to delegate voting power to it +

+ )}
diff --git a/app/src/components/Layout.tsx b/app/src/components/Layout.tsx index f69680c..24920e7 100644 --- a/app/src/components/Layout.tsx +++ b/app/src/components/Layout.tsx @@ -1,10 +1,11 @@ import { Outlet, Link, useLocation } from 'react-router-dom' import { ConnectButton } from '@rainbow-me/rainbowkit' import { motion } from 'framer-motion' -import { FiHome, FiGithub, FiBook, FiCpu } from 'react-icons/fi' +import { FiHome, FiGithub, FiBook, FiCpu, FiPlus } from 'react-icons/fi' const navItems = [ { path: '/', label: 'Chambers', icon: FiHome }, + { path: '/deploy', label: 'Deploy Chamber', icon: FiPlus }, { path: '/deploy-agent', label: 'Deploy Agent', icon: FiCpu }, { path: '/docs', label: 'Docs', icon: FiBook }, ] diff --git a/app/src/contracts/abis.ts b/app/src/contracts/abis.ts index 8c61466..5d13741 100644 --- a/app/src/contracts/abis.ts +++ b/app/src/contracts/abis.ts @@ -281,6 +281,16 @@ export const chamberAbi = [ outputs: [], stateMutability: 'nonpayable', }, + { + type: 'function', + name: 'cancelTransaction', + inputs: [ + { name: 'tokenId', type: 'uint256' }, + { name: 'transactionId', type: 'uint256' }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, { type: 'function', name: 'submitBatchTransactions', @@ -343,6 +353,30 @@ export const chamberAbi = [ outputs: [{ name: '', type: 'bool' }], stateMutability: 'view', }, + { + type: 'function', + name: 'getCancelled', + inputs: [{ name: 'nonce', type: 'uint256' }], + outputs: [{ name: '', type: 'bool' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'getCancelConfirmation', + inputs: [ + { name: 'tokenId', type: 'uint256' }, + { name: 'nonce', type: 'uint256' }, + ], + outputs: [{ name: '', type: 'bool' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'getCancelConfirmations', + inputs: [{ name: 'nonce', type: 'uint256' }], + outputs: [{ name: '', type: 'uint8' }], + stateMutability: 'view', + }, { type: 'function', name: 'getNextTransactionId', @@ -385,6 +419,19 @@ export const chamberAbi = [ { name: 'executor', type: 'address', indexed: true }, ], }, + { + type: 'event', + name: 'CancelTransaction', + inputs: [ + { name: 'tokenId', type: 'uint256', indexed: true }, + { name: 'nonce', type: 'uint256', indexed: true }, + ], + }, + { + type: 'event', + name: 'TransactionCancelled', + inputs: [{ name: 'nonce', type: 'uint256', indexed: true }], + }, { type: 'event', name: 'Received', diff --git a/app/src/contracts/deployments.json b/app/src/contracts/deployments.json index ffed290..039f76a 100644 --- a/app/src/contracts/deployments.json +++ b/app/src/contracts/deployments.json @@ -1,9 +1,9 @@ { - "agentImplementation": "0x4A679253410272dd5232B3Ff7cF5dbB88f295319", + "agentImplementation": "0x4826533B4897376654Bb4d4AD88B7faFD0C98528", "chainId": 31337, - "chamberImplementation": "0xa85233C63b9Ee964Add6F2cffe00Fd84eb32338f", - "mockERC20": "0x67d269191c92Caf3cD7723F116c85e6E9bf55933", - "mockERC721": "0xE6E340D132b5f46d1e472DebcD681B2aBc16e57E", - "registry": "0xc5a5C42992dECbae36851359345FE25997F5C42d", - "timestamp": 1772158875 + "chamberImplementation": "0x70e0bA845a1A0F2DA3359C97E0285013525FFC49", + "mockERC20": "0x9d4454B023096f34B160D6B654540c56A1F81688", + "mockERC721": "0x5eb3Bc0a489C5A8288765d2336659EbCA68FCd00", + "registry": "0x8f86403A4DE0BB5791fa46B8e795C547942fE4Cf", + "timestamp": 1773521038 } \ No newline at end of file diff --git a/app/src/contracts/generated-abis.ts b/app/src/contracts/generated-abis.ts index 348adff..c236866 100644 --- a/app/src/contracts/generated-abis.ts +++ b/app/src/contracts/generated-abis.ts @@ -673,6 +673,10 @@ export const chamberAbi = [ "inputs": [], "stateMutability": "nonpayable" }, + { + "type": "fallback", + "stateMutability": "payable" + }, { "type": "receive", "stateMutability": "payable" @@ -764,6 +768,24 @@ export const chamberAbi = [ ], "stateMutability": "view" }, + { + "type": "function", + "name": "cancelTransaction", + "inputs": [ + { + "name": "tokenId", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "transactionId", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, { "type": "function", "name": "confirmBatchTransactions", @@ -966,6 +988,68 @@ export const chamberAbi = [ ], "stateMutability": "view" }, + { + "type": "function", + "name": "getCancelConfirmation", + "inputs": [ + { + "name": "tokenId", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "nonce", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getCancelConfirmations", + "inputs": [ + { + "name": "nonce", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "uint8", + "internalType": "uint8" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getCancelled", + "inputs": [ + { + "name": "nonce", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, { "type": "function", "name": "getConfirmation", @@ -1421,6 +1505,40 @@ export const chamberAbi = [ ], "stateMutability": "view" }, + { + "type": "function", + "name": "onERC721Received", + "inputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + }, + { + "name": "from", + "type": "address", + "internalType": "address" + }, + { + "name": "tokenId", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes4", + "internalType": "bytes4" + } + ], + "stateMutability": "nonpayable" + }, { "type": "function", "name": "previewDeposit", @@ -1813,6 +1931,25 @@ export const chamberAbi = [ ], "anonymous": false }, + { + "type": "event", + "name": "CancelTransaction", + "inputs": [ + { + "name": "tokenId", + "type": "uint256", + "indexed": true, + "internalType": "uint256" + }, + { + "name": "nonce", + "type": "uint256", + "indexed": true, + "internalType": "uint256" + } + ], + "anonymous": false + }, { "type": "event", "name": "ConfirmTransaction", @@ -2027,6 +2164,31 @@ export const chamberAbi = [ ], "anonymous": false }, + { + "type": "event", + "name": "ReceivedERC721", + "inputs": [ + { + "name": "token", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "from", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "tokenId", + "type": "uint256", + "indexed": true, + "internalType": "uint256" + } + ], + "anonymous": false + }, { "type": "event", "name": "RevokeConfirmation", @@ -2115,6 +2277,38 @@ export const chamberAbi = [ ], "anonymous": false }, + { + "type": "event", + "name": "TransactionCancelVoted", + "inputs": [ + { + "name": "transactionId", + "type": "uint256", + "indexed": true, + "internalType": "uint256" + }, + { + "name": "voter", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "TransactionCancelled", + "inputs": [ + { + "name": "nonce", + "type": "uint256", + "indexed": true, + "internalType": "uint256" + } + ], + "anonymous": false + }, { "type": "event", "name": "TransactionConfirmed", @@ -2613,6 +2807,11 @@ export const chamberAbi = [ "name": "TooManySeats", "inputs": [] }, + { + "type": "error", + "name": "TransactionAlreadyCancelled", + "inputs": [] + }, { "type": "error", "name": "TransactionAlreadyConfirmed", @@ -2623,6 +2822,11 @@ export const chamberAbi = [ "name": "TransactionAlreadyExecuted", "inputs": [] }, + { + "type": "error", + "name": "TransactionCancelAlreadyConfirmed", + "inputs": [] + }, { "type": "error", "name": "TransactionDoesNotExist", @@ -3080,6 +3284,19 @@ export const mockERC721Abi = [ ], "stateMutability": "view" }, + { + "type": "function", + "name": "burn", + "inputs": [ + { + "name": "tokenId", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, { "type": "function", "name": "getApproved", diff --git a/app/src/hooks/useChamber.ts b/app/src/hooks/useChamber.ts index c5edfe8..74c3e2c 100644 --- a/app/src/hooks/useChamber.ts +++ b/app/src/hooks/useChamber.ts @@ -1,5 +1,5 @@ -import { useReadContract, useWriteContract, useWaitForTransactionReceipt, useSimulateContract, useAccount } from 'wagmi' -import { chamberAbi, erc20Abi } from '@/contracts/abis' +import { useReadContract, useReadContracts, useWriteContract, useWaitForTransactionReceipt, useSimulateContract, useAccount } from 'wagmi' +import { chamberAbi, erc20Abi, erc721Abi } from '@/contracts/abis' import type { Transaction, BoardMember, SeatUpdate } from '@/types' // ERC20 Allowance hook @@ -278,6 +278,46 @@ export function useSeatUpdate(chamberAddress: `0x${string}` | undefined) { return { seatUpdate, refetch } } +/** + * Fetches token IDs of NFTs owned by a user from an ERC721 contract. + * Uses tokenOfOwnerByIndex (ERC721Enumerable). Falls back to empty array if not supported. + */ +export function useUserNFTs(nftAddress: `0x${string}` | undefined, ownerAddress: `0x${string}` | undefined) { + const { data: balance } = useReadContract({ + address: nftAddress, + abi: erc721Abi, + functionName: 'balanceOf', + args: ownerAddress ? [ownerAddress] : undefined, + query: { enabled: !!nftAddress && !!ownerAddress }, + }) + + const balanceNum = balance ? Number(balance) : 0 + const indices = Array.from({ length: Math.min(balanceNum, 50) }, (_, i) => i) + + const { data: tokenIdsResults } = useReadContracts({ + contracts: indices.map((i) => ({ + address: nftAddress!, + abi: erc721Abi, + functionName: 'tokenOfOwnerByIndex', + args: [ownerAddress!, BigInt(i)], + })), + query: { + enabled: !!nftAddress && !!ownerAddress && balanceNum > 0 && balanceNum <= 50, + }, + }) + + const tokenIds: bigint[] = [] + if (tokenIdsResults) { + for (const r of tokenIdsResults) { + if (r.status === 'success' && r.result !== undefined) { + tokenIds.push(BigInt(r.result as string | number | bigint)) + } + } + } + + return { tokenIds, balance: balance ?? 0n, isLoading: balance === undefined } +} + export function useDelegations(chamberAddress: `0x${string}` | undefined, account: `0x${string}` | undefined) { const { data, refetch } = useReadContract({ address: chamberAddress, @@ -353,6 +393,76 @@ export function useExecuteTransaction(chamberAddress: `0x${string}` | undefined) return { execute, isPending, isConfirming, isSuccess, error, hash } } +export function useRevokeConfirmation(chamberAddress: `0x${string}` | undefined) { + const { writeContractAsync, data: hash, isPending, error } = useWriteContract() + const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({ hash }) + + const revoke = async (tokenId: bigint, transactionId: bigint) => { + if (!chamberAddress) return + return writeContractAsync({ + address: chamberAddress, + abi: chamberAbi, + functionName: 'revokeConfirmation', + args: [tokenId, transactionId], + }) + } + + return { revoke, isPending, isConfirming, isSuccess, error, hash } +} + +export function useCancelTransaction(chamberAddress: `0x${string}` | undefined) { + const { writeContract, data: hash, isPending, error } = useWriteContract() + const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({ hash }) + + const cancel = async (tokenId: bigint, transactionId: bigint) => { + if (!chamberAddress) return + writeContract({ + address: chamberAddress, + abi: chamberAbi, + functionName: 'cancelTransaction', + args: [tokenId, transactionId], + }) + } + + return { cancel, isPending, isConfirming, isSuccess, error, hash } +} + +export function useTransactionConfirmation( + chamberAddress: `0x${string}` | undefined, + tokenId: bigint | undefined, + transactionId: number | undefined +) { + const { data: isConfirmed } = useReadContract({ + address: chamberAddress, + abi: chamberAbi, + functionName: 'getConfirmation', + args: tokenId !== undefined && transactionId !== undefined ? [tokenId, BigInt(transactionId)] : undefined, + query: { + enabled: !!chamberAddress && tokenId !== undefined && transactionId !== undefined, + }, + }) + + return { isConfirmed: isConfirmed as boolean | undefined } +} + +export function useTransactionCancelConfirmation( + chamberAddress: `0x${string}` | undefined, + tokenId: bigint | undefined, + transactionId: number | undefined +) { + const { data: hasVotedToCancel } = useReadContract({ + address: chamberAddress, + abi: chamberAbi, + functionName: 'getCancelConfirmation', + args: tokenId !== undefined && transactionId !== undefined ? [tokenId, BigInt(transactionId)] : undefined, + query: { + enabled: !!chamberAddress && tokenId !== undefined && transactionId !== undefined, + }, + }) + + return { hasVotedToCancel: hasVotedToCancel as boolean | undefined } +} + /** * Hook for delegating shares to an NFT token ID. * Uses simulation to validate the transaction before sending. diff --git a/app/src/hooks/useChamberEvents.ts b/app/src/hooks/useChamberEvents.ts index b0482e0..333d918 100644 --- a/app/src/hooks/useChamberEvents.ts +++ b/app/src/hooks/useChamberEvents.ts @@ -149,6 +149,31 @@ export function useChamberEvents( enabled: watchEnabled, }) + // Watch for CancelTransaction and TransactionCancelled events + useWatchContractEvent({ + address: isValidAddress ? chamberAddress : undefined, + abi: chamberAbi, + eventName: 'CancelTransaction', + onLogs: (logs) => { + console.log('Chamber CancelTransaction event:', logs) + invalidateChamberQueries() + onTransactionEvent?.() + }, + enabled: watchEnabled, + }) + + useWatchContractEvent({ + address: isValidAddress ? chamberAddress : undefined, + abi: chamberAbi, + eventName: 'TransactionCancelled', + onLogs: (logs) => { + console.log('Chamber TransactionCancelled event:', logs) + invalidateChamberQueries() + onTransactionEvent?.() + }, + enabled: watchEnabled, + }) + // Watch for SetSeats events (board size changes) useWatchContractEvent({ address: isValidAddress ? chamberAddress : undefined, diff --git a/app/src/hooks/useRegistry.ts b/app/src/hooks/useRegistry.ts index f396768..1273299 100644 --- a/app/src/hooks/useRegistry.ts +++ b/app/src/hooks/useRegistry.ts @@ -1,5 +1,5 @@ -import { useReadContract, useWriteContract, useWaitForTransactionReceipt, useChainId } from 'wagmi' -import { chamberRegistryAbi } from '@/contracts/abis' +import { useReadContract, useReadContracts, useWriteContract, useWaitForTransactionReceipt, useChainId } from 'wagmi' +import { chamberRegistryAbi, chamberAbi } from '@/contracts/abis' import { getContractAddresses, hasValidAddresses } from '@/lib/wagmi' export function useRegistryAddress() { @@ -146,6 +146,56 @@ export function useAssets() { } } +/** + * Groups chambers by their membership NFT (ERC721). + * Organizations are defined by shared membership token. + */ +export function useOrganizationsByNFT() { + const { chambers, isLoading: chambersLoading } = useAllChambers() + + const validChambers = (chambers ?? []).filter( + (addr): addr is `0x${string}` => + !!addr && + addr !== '0x0000000000000000000000000000000000000000' && + addr.startsWith('0x') && + addr.length === 42 + ) + + const { data: nftResults, isLoading: nftsLoading } = useReadContracts({ + contracts: validChambers.map((addr) => ({ + address: addr, + abi: chamberAbi, + functionName: 'nft', + })) as readonly { address: `0x${string}`; abi: typeof chamberAbi; functionName: 'nft' }[], + query: { + enabled: validChambers.length > 0, + }, + }) + + const organizations = (() => { + if (!nftResults || nftResults.length !== validChambers.length) return [] + const byNft = new Map() + for (let i = 0; i < validChambers.length; i++) { + const r = nftResults[i] + const chamber = validChambers[i] + if (r?.status === 'success' && r.result && chamber) { + const nft = (r.result as string).toLowerCase() as `0x${string}` + if (!byNft.has(nft)) byNft.set(nft, []) + byNft.get(nft)!.push(chamber) + } + } + return Array.from(byNft.entries()).map(([nft, chams]) => ({ + nft: nft as `0x${string}`, + chambers: chams, + })) + })() + + return { + organizations, + isLoading: chambersLoading || nftsLoading, + } +} + export function useParentChamber(chamber: `0x${string}` | undefined) { const registryAddress = useRegistryAddress() diff --git a/app/src/lib/proposalMetadata.ts b/app/src/lib/proposalMetadata.ts new file mode 100644 index 0000000..e8aafff --- /dev/null +++ b/app/src/lib/proposalMetadata.ts @@ -0,0 +1,44 @@ +/** + * Proposal metadata storage (title, description) for Chamber transactions. + * Stored in localStorage since the contract does not support on-chain metadata. + * Key: chamber-proposal-{chamberAddress}-{txId} + */ + +const STORAGE_PREFIX = 'chamber-proposal' + +export interface ProposalMetadata { + title: string + description?: string + templateId?: string + createdAt: number +} + +function storageKey(chamberAddress: string, txId: number): string { + return `${STORAGE_PREFIX}-${chamberAddress.toLowerCase()}-${txId}` +} + +export function getProposalMetadata( + chamberAddress: string, + txId: number +): ProposalMetadata | null { + try { + const raw = localStorage.getItem(storageKey(chamberAddress, txId)) + if (!raw) return null + return JSON.parse(raw) as ProposalMetadata + } catch { + return null + } +} + +export function setProposalMetadata( + chamberAddress: string, + txId: number, + meta: Omit +): void { + const key = storageKey(chamberAddress, txId) + const full: ProposalMetadata = { + ...meta, + createdAt: Date.now(), + } + localStorage.setItem(key, JSON.stringify(full)) +} diff --git a/app/src/lib/utils.ts b/app/src/lib/utils.ts index c460ce9..e080322 100644 --- a/app/src/lib/utils.ts +++ b/app/src/lib/utils.ts @@ -2,6 +2,29 @@ export function cn(...inputs: (string | undefined | null | false)[]): string { return inputs.filter(Boolean).join(' ') } +/** Chain ID to block explorer base URL */ +const BLOCK_EXPLORERS: Record = { + 1: 'https://etherscan.io', + 11155111: 'https://sepolia.etherscan.io', + 31337: 'http://localhost:8545', // Anvil - no block explorer by default +} + +export function getBlockExplorerUrl(chainId: number): string { + return BLOCK_EXPLORERS[chainId] ?? 'https://etherscan.io' +} + +export function getBlockExplorerAddressUrl(address: string, chainId: number): string { + const base = getBlockExplorerUrl(chainId) + if (chainId === 31337) return base + return `${base}/address/${address}` +} + +export function getBlockExplorerTxUrl(txHash: string, chainId: number): string { + const base = getBlockExplorerUrl(chainId) + if (chainId === 31337) return base + return `${base}/tx/${txHash}` +} + export function shortenAddress(address: string, chars = 4): string { if (!address) return '' return `${address.slice(0, chars + 2)}...${address.slice(-chars)}` @@ -55,7 +78,7 @@ export function parseDataField(data: string): { method?: string; decoded?: strin return { method: 'Native Transfer' } } - // Common method signatures + // Common method signatures (4-byte selectors) const methodSignatures: Record = { '0xa9059cbb': 'transfer(address,uint256)', '0x23b872dd': 'transferFrom(address,address,uint256)', @@ -67,6 +90,14 @@ export function parseDataField(data: string): { method?: string; decoded?: strin '0xd0e30db0': 'deposit()', '0x70a08231': 'balanceOf(address)', '0x18160ddd': 'totalSupply()', + '0x38ed1739': 'swapExactTokensForTokens(uint256,uint256,address[],address,uint256)', + '0x7ff36ab5': 'swapExactETHForTokens(uint256,address[],address,uint256)', + '0x18cbafe5': 'swapExactTokensForETH(uint256,uint256,address[],address,uint256)', + '0x5c11d795': 'swapExactTokensForTokensSupportingFeeOnTransferTokens(...)', + '0x4e71d92d': 'claim()', + '0x2e17de78': 'claim(uint256)', + '0x3d18b912': 'withdraw(uint256,uint256)', + '0xe2bbb158': 'deposit(uint256,uint256)', } const methodId = data.slice(0, 10).toLowerCase() diff --git a/app/src/pages/ChamberDetail.tsx b/app/src/pages/ChamberDetail.tsx index 53713b0..a7b7e89 100644 --- a/app/src/pages/ChamberDetail.tsx +++ b/app/src/pages/ChamberDetail.tsx @@ -1,6 +1,6 @@ import { useParams, Link, useNavigate } from 'react-router-dom' import { motion } from 'framer-motion' -import { useAccount, useBalance, useReadContract } from 'wagmi' +import { useAccount, useBalance, useReadContract, useChainId } from 'wagmi' import { formatUnits } from 'viem' import { erc20Abi } from '@/contracts' import { @@ -29,6 +29,7 @@ import { import BoardVisualization from '@/components/BoardVisualization' import TreasuryOverview from '@/components/TreasuryOverview' import DelegationManager from '@/components/DelegationManager' +import { getBlockExplorerAddressUrl } from '@/lib/utils' type Tab = 'overview' | 'board' | 'treasury' | 'delegation' @@ -39,6 +40,7 @@ export default function ChamberDetail() { const navigate = useNavigate() const chamberAddress = address as `0x${string}` const { address: userAddress } = useAccount() + const chainId = useChainId() // Derive active tab from URL, default to 'overview' const activeTab: Tab = tab && validTabs.includes(tab as Tab) ? (tab as Tab) : 'overview' @@ -125,7 +127,7 @@ export default function ChamberDetail() { )} @@ -291,7 +294,7 @@ interface OverviewTabProps { } function OverviewTab({ chamberAddress, chamberInfo, members, totalDelegated, userBalance, setActiveTab }: OverviewTabProps) { - const { chambers: relatedChambers, isLoading: isLoadingRelated } = useChambersByAsset(chamberInfo.assetToken as `0x${string}`) + const { chambers: relatedChambers } = useChambersByAsset(chamberInfo.assetToken as `0x${string}`) const { parentChamber, isLoading: isLoadingParent } = useParentChamber(chamberAddress) const { childChambers, isLoading: isLoadingChildren } = useChildChambers(chamberAddress) @@ -400,11 +403,22 @@ function OverviewTab({ chamberAddress, chamberInfo, members, totalDelegated, use {/* Organization / Hierarchy */}
-
-

Organization (Sub Chambers)

-

- Hierarchy and related chambers in this organization -

+
+
+

Organization (Sub Chambers)

+

+ Hierarchy and related chambers in this organization +

+
+ {chamberInfo.nftToken && ( + + + Sub Chamber + + )}
{/* Parent Chamber */} diff --git a/app/src/pages/Dashboard.tsx b/app/src/pages/Dashboard.tsx index fffaf0e..bd5b4b2 100644 --- a/app/src/pages/Dashboard.tsx +++ b/app/src/pages/Dashboard.tsx @@ -2,9 +2,9 @@ import { useEffect, useState } from 'react' import { Link, useLocation } from 'react-router-dom' import { motion, AnimatePresence } from 'framer-motion' import { useAccount, useReadContract } from 'wagmi' -import { FiArrowRight, FiUsers, FiLayers, FiShield, FiPlus, FiAlertTriangle, FiGrid, FiBriefcase } from 'react-icons/fi' -import { useAllChambers, useChamberCount, useHasValidConfig, useAssets, useChambersByAsset } from '@/hooks' -import { erc20Abi } from '@/contracts' +import { FiArrowRight, FiLayers, FiPlus, FiAlertTriangle, FiGrid, FiBriefcase } from 'react-icons/fi' +import { useAllChambers, useChamberCount, useHasValidConfig, useOrganizationsByNFT } from '@/hooks' +import { erc721Abi } from '@/contracts' import ChamberCard from '@/components/ChamberCard' export default function Dashboard() { @@ -14,7 +14,7 @@ export default function Dashboard() { const { chambers, isLoading, refetch: refetchChambers, error: chambersError } = useAllChambers() const { count: chamberCount, refetch: refetchCount, isLoading: countLoading, error: countError, registryAddress } = useChamberCount() - const { assets, isLoading: assetsLoading } = useAssets() + const { organizations, isLoading: orgsLoading } = useOrganizationsByNFT() const { isValid, chainId } = useHasValidConfig() // Debug logging @@ -81,8 +81,8 @@ export default function Dashboard() { )} - {/* Debug Info - Remove in production */} - {(countError || chambersError || !isValid) && ( + {/* Debug Info - dev only */} + {import.meta.env.DEV && (countError || chambersError || !isValid) && (
-
-
- Chamber Logo -
-

Treasury Governance

-

- Chamber Protocol -

+
- {/* Stats */} -
-
-
- {countLoading ? '...' : chamberCount} -
-
Active Chambers
- {countError && ( -
- Error: {countError.message} + {/* Stats - right side column */} +
+
+
+ {countLoading ? '...' : chamberCount}
- )} - {!isValid && ( -
- Registry not configured +
Active Chambers
+ {countError && ( +
+ Error: {countError.message} +
+ )} + {!isValid && ( +
+ Registry not configured +
+ )} +
+
+
+
+ + {isConnected ? 'Connected' : 'Not Connected'} +
- )} -
-
-
-
- - {isConnected ? 'Connected' : 'Not Connected'} - +
Wallet Status
+
+
+
v1.1.3
+
Protocol Version
-
Wallet Status
-
-
-
v1.1.3
-
Protocol Version
- {/* Feature Cards */} -
- -
- -
-

ERC4626 Vault

-

- Standard tokenized vault for managing treasury assets with share-based accounting. -

-
- - -
- -
-

Board Governance

-

- Delegate voting power to NFT holders who compete for board seats based on stake. -

-
- - -
- -
-

Multi-Sig Wallet

-

- Execute transactions with quorum-based approval from board directors. -

-
-
- {/* Chambers List */}
-

Chambers

+

Chamber Registry

Active treasury governance instances

@@ -310,7 +263,7 @@ export default function Dashboard() { transition={{ duration: 0.2 }} className="space-y-8" > - {assetsLoading ? ( + {orgsLoading ? (
{[1, 2].map(i => (
@@ -323,9 +276,9 @@ export default function Dashboard() {
))}
- ) : assets && assets.length > 0 ? ( - assets.map((asset, index) => ( - + ) : organizations && organizations.length > 0 ? ( + organizations.map((org, index) => ( + )) ) : ( @@ -338,20 +291,19 @@ export default function Dashboard() { ) } -function OrganizationGroup({ asset, index }: { asset: `0x${string}`, index: number }) { - const { chambers, isLoading } = useChambersByAsset(asset) +function OrganizationGroup({ nftToken, chambers, index }: { nftToken: `0x${string}`; chambers: `0x${string}`[]; index: number }) { const { data: symbol } = useReadContract({ - address: asset, - abi: erc20Abi, + address: nftToken, + abi: erc721Abi, functionName: 'symbol', }) const { data: name } = useReadContract({ - address: asset, - abi: erc20Abi, + address: nftToken, + abi: erc721Abi, functionName: 'name', }) - if (!isLoading && (!chambers || chambers.length === 0)) return null + if (!chambers || chambers.length === 0) return null return ( ({symbol as string || '...'}) -

{asset}

+

Membership NFT: {nftToken}

- {isLoading ? ( - [1, 2].map(i =>
) - ) : chambers?.map((address) => ( - + {chambers.map((address) => ( + ))}
diff --git a/app/src/pages/DeployAgent.tsx b/app/src/pages/DeployAgent.tsx index fe7b7a8..9234ee8 100644 --- a/app/src/pages/DeployAgent.tsx +++ b/app/src/pages/DeployAgent.tsx @@ -1,12 +1,15 @@ import { useState } from 'react' -import { useNavigate } from 'react-router-dom' +import { useNavigate, Link } from 'react-router-dom' import { motion } from 'framer-motion' import { useAccount } from 'wagmi' import { useQueryClient } from '@tanstack/react-query' +import { decodeEventLog } from 'viem' import { FiAlertCircle, FiCheck, FiLoader, FiCpu } from 'react-icons/fi' +import toast from 'react-hot-toast' import { useCreateAgentWithStatus } from '@/hooks' import { useRegistryAddress } from '@/hooks/useRegistry' import { ConnectButton } from '@rainbow-me/rainbowkit' +import { chamberRegistryAbi } from '@/contracts/abis' export default function DeployAgent() { const navigate = useNavigate() @@ -23,18 +26,38 @@ export default function DeployAgent() { hash, reset, } = useCreateAgentWithStatus(registryAddress, { - onSuccess: () => { + onSuccess: (receipt) => { // Invalidate queries if needed - if (registryAddress) { + if (registryAddress) { queryClient.invalidateQueries({ - predicate: (_query) => { - // Should match agent related queries - return true - }, + predicate: () => true, }) } - // Navigate after successful deployment - setTimeout(() => navigate('/'), 2000) + // Decode AgentCreated event to get agent address + let agentAddress: string | null = null + if (receipt?.logs) { + for (const log of receipt.logs) { + try { + const decoded = decodeEventLog({ + abi: chamberRegistryAbi, + data: log.data, + topics: log.topics, + }) + if (decoded.eventName === 'AgentCreated') { + agentAddress = (decoded.args as { agent: `0x${string}` }).agent + break + } + } catch { + // Skip logs that don't match + } + } + } + if (agentAddress) { + setDeployedAgentAddress(agentAddress) + setTimeout(() => navigate(`/agent/${agentAddress}`), 1500) + } else { + setTimeout(() => navigate('/'), 2000) + } }, onError: (err) => { console.error('Agent deployment failed:', err) @@ -45,6 +68,7 @@ export default function DeployAgent() { showNotifications: true, }) + const [deployedAgentAddress, setDeployedAgentAddress] = useState(null) const [formData, setFormData] = useState({ name: '', description: '', @@ -224,6 +248,26 @@ export default function DeployAgent() {

Your agent has been successfully registered on the blockchain.

+ {deployedAgentAddress && ( +
+ + View Agent → + + +
+ )} {hash && (

Tx: {hash.slice(0, 10)}...{hash.slice(-8)} diff --git a/app/src/pages/DeployChamber.tsx b/app/src/pages/DeployChamber.tsx index 6af67f4..1ff2fc0 100644 --- a/app/src/pages/DeployChamber.tsx +++ b/app/src/pages/DeployChamber.tsx @@ -1,5 +1,5 @@ -import { useState } from 'react' -import { useNavigate } from 'react-router-dom' +import { useState, useEffect } from 'react' +import { useNavigate, useSearchParams } from 'react-router-dom' import { motion } from 'framer-motion' import { useAccount } from 'wagmi' import { useQueryClient } from '@tanstack/react-query' @@ -76,6 +76,7 @@ export default function DeployChamber() { autoReset: false, // Don't auto-reset so we can show success state }) + const [searchParams] = useSearchParams() const [formData, setFormData] = useState({ erc20Token: '', erc721Token: '', @@ -84,6 +85,19 @@ export default function DeployChamber() { symbol: '', }) + // Pre-populate from URL when creating a sub chamber (e.g. from + Sub Chamber button) + useEffect(() => { + const erc20 = searchParams.get('erc20') + const erc721 = searchParams.get('erc721') + if (erc20 && erc721 && /^0x[a-fA-F0-9]{40}$/.test(erc20) && /^0x[a-fA-F0-9]{40}$/.test(erc721)) { + setFormData((prev) => ({ + ...prev, + erc20Token: erc20, + erc721Token: erc721, + })) + } + }, [searchParams]) + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() diff --git a/app/src/pages/TransactionQueue.tsx b/app/src/pages/TransactionQueue.tsx index 56ba875..63cc678 100644 --- a/app/src/pages/TransactionQueue.tsx +++ b/app/src/pages/TransactionQueue.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useMemo } from 'react' import { useParams, Link } from 'react-router-dom' import { motion, AnimatePresence } from 'framer-motion' -import { useAccount, useReadContracts } from 'wagmi' +import { useAccount, useReadContracts, useChainId } from 'wagmi' import { formatEther, isAddress, encodeFunctionData, parseAbi } from 'viem' import { FiArrowLeft, @@ -17,6 +17,7 @@ import { FiCode, FiDollarSign, FiHash, + FiX, } from 'react-icons/fi' import toast from 'react-hot-toast' import { @@ -25,9 +26,15 @@ import { useSubmitTransaction, useConfirmTransaction, useExecuteTransaction, + useRevokeConfirmation, + useCancelTransaction, + useTransactionConfirmation, + useTransactionCancelConfirmation, useChamberEvents, } from '@/hooks' import { chamberAbi, erc20Abi } from '@/contracts/abis' +import { getBlockExplorerAddressUrl, parseDataField } from '@/lib/utils' +import { getProposalMetadata, setProposalMetadata } from '@/lib/proposalMetadata' import type { Transaction } from '@/types' type TabType = 'queue' | 'history' | 'new' @@ -36,9 +43,10 @@ export default function TransactionQueue() { const { address } = useParams<{ address: string }>() const chamberAddress = address as `0x${string}` const { address: userAddress } = useAccount() + const chainId = useChainId() const [activeTab, setActiveTab] = useState('queue') - const [transactions, setTransactions] = useState<(Transaction & { status: string })[]>([]) + const [transactions, setTransactions] = useState<(Transaction & { status: string; cancelled?: boolean; cancelConfirmations?: number })[]>([]) const chamberInfo = useChamberInfo(chamberAddress) const { members } = useBoardMembers(chamberAddress, chamberInfo.seats || 5) @@ -48,12 +56,39 @@ export default function TransactionQueue() { const transactionIds = Array.from({ length: transactionCount }, (_, i) => i) const { data: transactionsData, refetch: refetchTransactions } = useReadContracts({ - contracts: transactionIds.map(id => ({ + contracts: transactionIds.map((id) => ({ address: chamberAddress, abi: chamberAbi, functionName: 'getTransaction', args: [BigInt(id)], - })), + })) as readonly { address: `0x${string}`; abi: typeof chamberAbi; functionName: 'getTransaction'; args: [bigint] }[], + query: { + enabled: transactionCount > 0, + }, + }) + + const { data: cancelledData } = useReadContracts({ + contracts: transactionIds.map((id) => ({ + address: chamberAddress, + abi: chamberAbi, + functionName: 'getCancelled', + args: [BigInt(id)], + })) as readonly { address: `0x${string}`; abi: typeof chamberAbi; functionName: 'getCancelled'; args: [bigint] }[], + query: { + enabled: transactionCount > 0, + }, + }) + + const { data: cancelConfirmationsData } = useReadContracts({ + contracts: transactionIds.map((id) => ({ + address: chamberAddress, + abi: chamberAbi, + functionName: 'getCancelConfirmations', + args: [BigInt(id)], + })) as readonly { address: `0x${string}`; abi: typeof chamberAbi; functionName: 'getCancelConfirmations'; args: [bigint] }[], + query: { + enabled: transactionCount > 0, + }, }) // Watch for transaction events and auto-refresh when transactions are mined @@ -66,11 +101,22 @@ export default function TransactionQueue() { useEffect(() => { if (transactionsData) { - const txs: (Transaction & { status: string })[] = [] + const cancelledList: boolean[] = [] + cancelledData?.forEach((r: { status: string; result?: unknown }, i: number) => { + cancelledList[i] = r.status === 'success' && r.result === true + }) + + const cancelConfirmationsList: number[] = [] + cancelConfirmationsData?.forEach((r: { status: string; result?: unknown }, i: number) => { + cancelConfirmationsList[i] = r.status === 'success' && typeof r.result === 'bigint' ? Number(r.result) : 0 + }) + + const txs: (Transaction & { status: string; cancelled?: boolean; cancelConfirmations?: number })[] = [] transactionsData.forEach((result, index) => { if (result.status === 'success' && result.result) { const [executed, confirmations, target, value, data] = result.result as [boolean, number, `0x${string}`, bigint, `0x${string}`] - const status = executed ? 'executed' : confirmations >= (chamberInfo.quorum || 1) ? 'ready' : 'pending' + const cancelled = cancelledList[index] ?? false + const status = cancelled ? 'cancelled' : executed ? 'executed' : confirmations >= (chamberInfo.quorum || 1) ? 'ready' : 'pending' txs.push({ id: index, executed, @@ -79,15 +125,18 @@ export default function TransactionQueue() { value, data, status, + cancelled, + cancelConfirmations: cancelConfirmationsList[index] ?? 0, }) } }) setTransactions(txs) } - }, [transactionsData, chamberInfo.quorum]) + }, [transactionsData, cancelledData, cancelConfirmationsData, chamberInfo.quorum]) - const pendingTransactions = transactions.filter(tx => !tx.executed && tx.status !== 'ready') - const readyTransactions = transactions.filter(tx => !tx.executed && tx.status === 'ready') + const pendingTransactions = transactions.filter(tx => !tx.executed && tx.status !== 'ready' && !tx.cancelled) + const readyTransactions = transactions.filter(tx => !tx.executed && tx.status === 'ready' && !tx.cancelled) + const cancelledTransactions = transactions.filter(tx => tx.cancelled) const executedTransactions = transactions.filter(tx => tx.executed) // Check if user is a director and get their token ID @@ -126,10 +175,10 @@ export default function TransactionQueue() {

- Transaction Queue + Proposals & Queue

- {chamberInfo.name} • {chamberInfo.quorum} of {chamberInfo.seats} required + {chamberInfo.name} • {chamberInfo.quorum} of {chamberInfo.seats} confirmations required

@@ -139,12 +188,12 @@ export default function TransactionQueue() { className="btn btn-primary" > - New Transaction + New Proposal
{/* Stats */} -
+
{pendingTransactions.length} @@ -157,6 +206,12 @@ export default function TransactionQueue() {
Ready
+
+
+ {cancelledTransactions.length} +
+
Cancelled
+
{executedTransactions.length} @@ -177,7 +232,7 @@ export default function TransactionQueue() { {[ { id: 'queue', label: 'Queue', count: pendingTransactions.length + readyTransactions.length }, { id: 'history', label: 'History', count: executedTransactions.length }, - { id: 'new', label: 'New Transaction', count: 0 }, + { id: 'new', label: 'New Proposal', count: 0 }, ].map((tab) => (
@@ -250,29 +306,65 @@ export default function TransactionQueue() { chamberAddress={chamberAddress} quorum={chamberInfo.quorum || 1} userTokenId={userTokenId} + chainId={chainId} /> ))}
)} - {pendingTransactions.length === 0 && readyTransactions.length === 0 && ( + {/* Cancelled */} + {cancelledTransactions.length > 0 && ( +
+

+ + Cancelled +

+ {cancelledTransactions.map((tx) => ( + + ))} +
+ )} + + {pendingTransactions.length === 0 && readyTransactions.length === 0 && cancelledTransactions.length === 0 && (

- No Pending Transactions + No Pending Proposals

-

- Create a new transaction to manage treasury assets +

+ Create a proposal with a title and description. Directors can confirm and execute once quorum is reached.

)} + + {/* Non-director banner when there are pending txs */} + {userTokenId === undefined && (pendingTransactions.length > 0 || readyTransactions.length > 0 || cancelledTransactions.length > 0) && ( +
+

+ You're not a director. Delegate shares to an NFT to participate in governance. +

+ + Go to Delegation → + +
+ )} )} @@ -292,6 +384,7 @@ export default function TransactionQueue() { chamberAddress={chamberAddress} quorum={chamberInfo.quorum || 1} userTokenId={userTokenId} + chainId={chainId} /> )) ) : ( @@ -318,6 +411,7 @@ export default function TransactionQueue() { setActiveTab('queue')} /> @@ -329,15 +423,28 @@ export default function TransactionQueue() { // Transaction Card Component interface TransactionCardProps { - transaction: Transaction & { status: string } + transaction: Transaction & { status: string; cancelled?: boolean; cancelConfirmations?: number } chamberAddress: `0x${string}` quorum: number userTokenId?: bigint + chainId: number } -function TransactionCard({ transaction, chamberAddress, quorum, userTokenId }: TransactionCardProps) { +function TransactionCard({ transaction, chamberAddress, quorum, userTokenId, chainId }: TransactionCardProps) { const { confirm, isPending: isConfirming } = useConfirmTransaction(chamberAddress) const { execute, isPending: isExecuting } = useExecuteTransaction(chamberAddress) + const { revoke, isPending: isRevoking } = useRevokeConfirmation(chamberAddress) + const { cancel, isPending: isCancelling } = useCancelTransaction(chamberAddress) + const { isConfirmed: userHasConfirmed } = useTransactionConfirmation( + chamberAddress, + userTokenId, + transaction.id + ) + const { hasVotedToCancel } = useTransactionCancelConfirmation( + chamberAddress, + userTokenId, + transaction.id + ) const handleConfirm = async () => { if (!userTokenId) { @@ -367,15 +474,50 @@ function TransactionCard({ transaction, chamberAddress, quorum, userTokenId }: T } } + const handleRevoke = async () => { + if (!userTokenId) { + toast.error('You must be a director to revoke') + return + } + try { + await revoke(userTokenId, BigInt(transaction.id)) + toast.success('Confirmation revoked!') + } catch (err) { + console.error(err) + toast.error('Revoke failed') + } + } + + const handleCancel = async () => { + if (!userTokenId) { + toast.error('You must be a director to vote to cancel') + return + } + try { + await cancel(userTokenId, BigInt(transaction.id)) + toast.success('Cancel vote submitted!') + } catch (err) { + console.error(err) + toast.error('Cancel vote failed') + } + } + const shortTarget = `${transaction.target.slice(0, 10)}...${transaction.target.slice(-8)}` const hasData = transaction.data !== '0x' + const decodedData = hasData ? parseDataField(transaction.data) : null + const proposalMeta = getProposalMetadata(chamberAddress, transaction.id) + const displayTitle = proposalMeta?.title || (decodedData?.method || 'Transaction') + + const isCancelled = transaction.cancelled === true return ( {/* Transaction ID */}
-
- {shortTarget} + + {proposalMeta?.description && ( +

{proposalMeta.description}

+ )}
{formatEther(transaction.value)} ETH - {hasData && ( - + {hasData && decodedData && ( + - Contract Call + {decodedData.method} )} {transaction.confirmations} / {quorum} + {!isCancelled && !transaction.executed && (transaction.cancelConfirmations ?? 0) > 0 && ( + + + {transaction.cancelConfirmations} / {quorum} cancel votes + + )}
{/* Progress bar */} - {!transaction.executed && ( + {!transaction.executed && !isCancelled && (
{/* Actions */} - {!transaction.executed && userTokenId !== undefined && ( + {!transaction.executed && !isCancelled && userTokenId !== undefined && (
+ + {userHasConfirmed && ( + + )} {transaction.status !== 'ready' && (
{/* Data Preview */} - {hasData && ( + {hasData && decodedData && (
Transaction Data
- - {transaction.data.slice(0, 66)}... +
{decodedData.method}
+ + {transaction.data}
)} @@ -504,10 +699,19 @@ function TransactionCard({ transaction, chamberAddress, quorum, userTokenId }: T ) } +// Proposal templates for quick creation +const PROPOSAL_TEMPLATES = [ + { id: 'grant', label: 'Grant', title: 'Grant', description: '', txType: 'eth' as const }, + { id: 'token-payment', label: 'Token Payment', title: 'Token Payment', description: '', txType: 'token' as const }, + { id: 'treasury-transfer', label: 'Treasury Transfer', title: 'Treasury Transfer', description: '', txType: 'eth' as const }, + { id: 'custom', label: 'Custom Call', title: '', description: '', txType: 'custom' as const }, +] + // New Transaction Form interface NewTransactionFormProps { chamberAddress: `0x${string}` userTokenId?: bigint + nextTransactionId: number onSuccess: () => void } @@ -584,9 +788,11 @@ function parseParamValue(value: string, type: string): unknown { return value } -function NewTransactionForm({ chamberAddress, userTokenId, onSuccess }: NewTransactionFormProps) { +function NewTransactionForm({ chamberAddress, userTokenId, nextTransactionId, onSuccess }: NewTransactionFormProps) { const { address: userAddress } = useAccount() const [txType, setTxType] = useState<'eth' | 'token' | 'custom'>('eth') + const [title, setTitle] = useState('') + const [description, setDescription] = useState('') const [target, setTarget] = useState('') const [value, setValue] = useState('') const [data, setData] = useState('0x') @@ -690,6 +896,11 @@ function NewTransactionForm({ chamberAddress, userTokenId, onSuccess }: NewTrans }) // Target becomes token address await submit(userTokenId, tokenAddress as `0x${string}`, 0n, txData) + const metaTitle = title.trim() || `Send ${tokenAmount} tokens` + setProposalMetadata(chamberAddress, nextTransactionId, { + title: metaTitle, + description: description.trim() || undefined, + }) toast.success('Transaction submitted!') onSuccess() return @@ -699,6 +910,12 @@ function NewTransactionForm({ chamberAddress, userTokenId, onSuccess }: NewTrans } await submit(userTokenId, target as `0x${string}`, txValue, txData) + // Store proposal metadata (title/description) for display + const metaTitle = title.trim() || (txType === 'eth' ? `Send ${value} ETH` : parsedFunction?.name || 'Custom Transaction') + setProposalMetadata(chamberAddress, nextTransactionId, { + title: metaTitle, + description: description.trim() || undefined, + }) toast.success('Transaction submitted!') onSuccess() } catch (err) { @@ -735,8 +952,59 @@ function NewTransactionForm({ chamberAddress, userTokenId, onSuccess }: NewTrans
-

New Transaction

-

Create a new multisig transaction

+

New Proposal

+

Create a proposal for board approval

+
+
+ + {/* Proposal Templates */} +
+ +
+ {PROPOSAL_TEMPLATES.map((tpl) => ( + + ))} +
+
+ + {/* Title & Description */} +
+
+ + setTitle(e.target.value)} + /> +
+
+ +