Transaction and Governance Best Practices
This document outlines best practices for handling transactions and governance operations in the aeqi platform. These patterns ensure consistency, security, and excellent user experience across the entire application.
Table of Contents
- Transaction Handling
- Governance Proposals
- Error Handling
- Security Considerations
- UI/UX Guidelines
- Code Examples
Transaction Handling
1. Always Use Transaction Context Wrapper
Never call contract methods directly. Always wrap them with the transaction context for consistent UX.
// ❌ Bad - Direct contract call
await writeContract({
address: contractAddress,
abi: SomeModule__factory.abi,
functionName: 'someFunction',
args: [arg1, arg2],
});
// ✅ Good - Using transaction wrapper
await wrapTransaction(
async () => {
const hash = await writeContractAsync({
address: contractAddress,
abi: SomeModule__factory.abi,
functionName: 'someFunction',
args: [arg1, arg2],
});
return hash;
},
{
pendingTitle: 'Processing Transaction',
pendingDescription: 'Your transaction is being processed...',
successTitle: 'Transaction Successful',
successDescription: 'Your transaction has been completed',
errorTitle: 'Transaction Failed',
onSuccess: () => {
// Handle success (e.g., close modal, refresh data)
},
},
);
2. Transaction Context Options
Always provide meaningful, user-friendly messages:
interface TransactionOptions {
pendingTitle: string; // Short, action-oriented title
pendingDescription: string; // Clear description of what's happening
successTitle: string; // Confirmation title
successDescription: string; // What was accomplished
errorTitle: string; // Error title
metadata?: Record<string, any>; // Additional data for logging
onSuccess?: () => void; // Success callback
onError?: (error: Error) => void; // Error callback
}
3. Hook Pattern for Contract Interactions
Create dedicated hooks for complex contract interactions:
export function useModuleAction() {
const { writeContractAsync } = useWriteContract();
const { wrapTransaction } = useTransaction();
const performAction = useCallback(
async (params: ActionParams) => {
return wrapTransaction(
async () => {
// Build calldata if needed
const calldata = buildCalldata(params);
// Execute transaction
const hash = await writeContractAsync({
address: params.moduleAddress,
abi: Module__factory.abi,
functionName: 'actionFunction',
args: [calldata],
});
return hash;
},
{
pendingTitle: 'Performing Action',
pendingDescription: `Processing ${params.actionName}...`,
successTitle: 'Action Complete',
successDescription: `Successfully completed ${params.actionName}`,
errorTitle: 'Action Failed',
},
);
},
[writeContractAsync, wrapTransaction],
);
return { performAction };
}
Governance Proposals
1. Centralized Proposal Creation
All proposals MUST go through the global proposal modal. Never create proposals directly.
// ❌ Bad - Direct proposal creation
const proposalCid = await uploadToIPFS(proposalData);
await governanceContract.propose(...);
// ✅ Good - Using proposal modal
openProposalModal({
title: 'Update Configuration',
description: 'Updating system configuration...',
executionDetails: {
targets: [targetAddress],
calldatas: [calldata],
values: ['0'],
},
governanceAddress: trustContract.governanceContract?.id,
governanceConfigId: governancePower?.activeGovernanceType,
});
2. Proposal Modal Configuration
The proposal modal handles the complete flow:
- User input (title, description, reasoning, impact)
- IPFS upload
- Governance contract interaction
- Transaction wrapping
interface ProposalModalConfig {
title: string; // Default title
description: string; // Default description
executionDetails: {
targets: string[]; // Contract addresses to call
calldatas: string[]; // Encoded function calls
values: string[]; // ETH values (usually '0')
humanReadable?: string[]; // Human-readable descriptions
};
governanceAddress?: string; // Governance contract address
governanceConfigId?: string; // For role-based governance
onComplete?: () => void; // Success callback
}
3. Calldata Generation
Always generate calldata using contract factories:
// For TRUST configuration updates
const calldata = TRUST__factory.createInterface().encodeFunctionData(
'setBytesConfig',
[
keccak256(toHex('trust')), // key
keccak256(toHex('trust.ipfsCid')), // id
stringToHex(ipfsCid), // value
],
) as `0x${string}`;
// For module-specific actions
const calldata = FundingModule__factory.createInterface().encodeFunctionData(
'advanceFunding',
[],
) as `0x${string}`;
4. Proposal Decoding
Maintain the proposal decoder for human-readable action descriptions:
// In proposal-decoder.ts
export const FUNCTION_SIGNATURES = {
'0x4b4f9a2d': {
name: 'advanceFunding',
icon: DollarSign,
description: 'Advance to next funding round',
abi: 'function advanceFunding()',
},
// Add all governance-executable functions
};
Error Handling
1. User-Friendly Error Messages
Transform technical errors into understandable messages:
try {
await performAction();
} catch (error) {
if (error.message.includes('insufficient funds')) {
toast.error('Insufficient balance to perform this action');
} else if (error.message.includes('user rejected')) {
toast.info('Transaction cancelled');
} else {
toast.error('Transaction failed. Please try again.');
console.error('Transaction error:', error);
}
}
2. Validation Before Submission
Always validate before initiating transactions:
// Check governance power
if (!governancePower?.canPropose) {
toast.error('You do not have permission to create proposals');
return;
}
// Validate addresses
if (!isAddress(delegateAddress)) {
toast.error('Please enter a valid address');
return;
}
// Check balances
if (amount > balance) {
toast.error('Amount exceeds available balance');
return;
}
Security Considerations
1. Input Validation
Always validate user inputs:
// Address validation
const isValidAddress = (address: string): boolean => {
return address.startsWith('0x') && address.length === 42;
};
// Amount validation
const isValidAmount = (amount: string, decimals: number): boolean => {
try {
const value = parseUnits(amount, decimals);
return value > 0n;
} catch {
return false;
}
};
// IPFS CID validation
const isValidIpfsCid = (cid: string): boolean => {
return cid.startsWith('Qm') || cid.startsWith('bafy');
};
2. Access Control
Check permissions before showing UI elements:
// Check if user can create proposals
const canPropose = governancePower?.canPropose || false;
// Check if user can execute
const canExecute =
proposal.state === 'succeeded' && (hasRole('EXECUTOR_ROLE') || isProposer);
// Role-based access
const hasRequiredRole = userRoles.includes(requiredRole);
3. Calldata Verification
Never trust external calldata:
// Decode and verify calldata before display
const decoded = decodeProposalAction(call);
if (!decoded || decoded.name === 'Unknown') {
console.warn('Unable to decode action:', call);
// Show raw data with warning
}
UI/UX Guidelines
1. Loading States
Show appropriate loading indicators:
// Button loading state
<button disabled={isLoading}>
{isLoading ? (
<>
<Spinner className="animate-spin" />
Processing...
</>
) : (
'Submit'
)}
</button>
// Full-screen transaction modal (handled by context)
// Automatically shows animated aeqi logo with status
2. Success Feedback
Provide clear success confirmation:
onSuccess: () => {
// Close modals
setIsModalOpen(false);
// Show success toast (if not handled by transaction context)
toast.success('Operation completed successfully');
// Refresh data if needed
refetch();
};
3. Progressive Disclosure
Don't overwhelm users with technical details:
// Simple view by default
<div>
<h3>Update Configuration</h3>
<p>Change your configuration</p>
</div>;
// Advanced details on expansion
{
showAdvanced && (
<div>
<p>Target: {target}</p>
<p>Function: {functionName}</p>
<p>Calldata: {calldata}</p>
</div>
);
}
Code Examples
Complete Module Action Example
// hooks/modules/useModuleUpdate.ts
export function useModuleUpdate() {
const { writeContractAsync } = useWriteContract();
const { wrapTransaction } = useTransaction();
const { openProposalModal } = useProposalModal();
const updateModule = useCallback(
async ({
trustContract,
moduleId,
newImplementation,
requiresProposal = true,
}: UpdateModuleParams) => {
// Build calldata
const calldata = TRUST__factory.createInterface().encodeFunctionData(
'setModule',
[moduleId, newImplementation, true],
) as `0x${string}`;
if (requiresProposal) {
// Create proposal for module update
openProposalModal({
title: 'Update Module',
description: `Updating configuration`,
executionDetails: {
targets: [trustContract.id],
calldatas: [calldata],
values: ['0'],
},
governanceAddress: trustContract.governanceContract?.id,
governanceConfigId: EMPTY_BYTES,
});
} else {
// Direct execution (for admins)
await wrapTransaction(
async () => {
const hash = await writeContractAsync({
address: trustContract.id as Address,
abi: TRUST__factory.abi,
functionName: 'setModule',
args: [moduleId, newImplementation, true],
});
return hash;
},
{
pendingTitle: 'Updating Configuration',
pendingDescription: `Installing new implementation`,
successTitle: 'Configuration Updated',
successDescription: 'Configuration has been successfully updated',
errorTitle: 'Update Failed',
},
);
}
},
[writeContractAsync, wrapTransaction, openProposalModal],
);
return { updateModule };
}
Complete Delegation Example
// components/governance/delegation.tsx
export function DelegationManager({ trustContract, governancePower }) {
const { writeContractAsync } = useWriteContract();
const { wrapTransaction } = useTransaction();
const [delegateAddress, setDelegateAddress] = useState('');
const [delegationType, setDelegationType] = useState<'token' | 'vesting'>(
'token',
);
const handleDelegate = useCallback(async () => {
// Validation
if (!isAddress(delegateAddress)) {
toast.error('Please enter a valid address');
return;
}
const contractAddress =
delegationType === 'token'
? trustContract.tokenContract?.id
: trustContract.vestingContract?.id;
if (!contractAddress) {
toast.error(`No ${delegationType} contract found`);
return;
}
// Check if user has balance
const balance =
delegationType === 'token'
? governancePower?.tokenVotingPower
: governancePower?.vestingVotingPower;
if (!balance || balance === 0n) {
toast.error(`No ${delegationType} balance to delegate`);
return;
}
// Execute delegation
await wrapTransaction(
async () => {
const hash = await writeContractAsync({
address: contractAddress as Address,
abi: TokenModule__factory.abi,
functionName: 'delegate',
args: [delegateAddress as Address],
});
return hash;
},
{
pendingTitle: 'Delegating Voting Power',
pendingDescription: `Delegating ${delegationType} voting power to ${truncateAddress(
delegateAddress,
)}`,
successTitle: 'Delegation Successful',
successDescription: `Your ${delegationType} voting power has been delegated`,
errorTitle: 'Delegation Failed',
metadata: {
delegationType,
delegateAddress,
balance: balance.toString(),
},
onSuccess: () => {
setDelegateAddress('');
toast.success('Delegation complete!');
},
},
);
}, [
delegateAddress,
delegationType,
trustContract,
governancePower,
writeContractAsync,
wrapTransaction,
]);
return <div>{/* UI components */}</div>;
}
Summary
These best practices ensure:
- Consistency: All transactions follow the same pattern
- Security: Proper validation and error handling
- UX Excellence: Clear feedback and intuitive flows
- Maintainability: Centralized logic and reusable patterns
- Governance Integrity: All proposals go through proper channels
Remember: The goal is to abstract blockchain complexity while maintaining transparency and security. Users should understand what's happening without needing technical knowledge.