autrace
Version:
Account Update analyser for MINA
1,129 lines (1,123 loc) • 49.2 kB
JavaScript
import { promises as fs } from 'fs';
import child_process from 'child_process';
import util from 'util';
import * as d3 from 'd3';
import { JSDOM } from 'jsdom';
import sharp from 'sharp';
const exec = util.promisify(child_process.exec);
export class AUVisualizer {
constructor(stateHistory) {
this.getUniqueNodeId = (id, stateIndex) => {
return `N${id.replace(/[^0-9]/g, '')}${stateIndex}`;
};
/*private getUniqueNodeId(id: string): string {
return `N${id.replace(/[^0-9]/g, '')}`;
}*/
this.formatLabel = (relationship) => {
return relationship?.label?.replace(/[()]/g, '') || 'Unknown';
};
this.extractOperationDetails = (operation) => {
try {
const parts = operation.split(',').map(p => p.trim());
let sequence = '', type = '';
parts.forEach(part => {
if (part.toLowerCase().includes('sequence')) {
sequence = part.split(':')[1]?.trim() || '';
}
if (part.toLowerCase().includes('type')) {
type = part.split(':')[1]?.trim() || '';
}
});
return { sequence, type };
}
catch (error) {
return { sequence: '', type: operation };
}
};
this.hasConnections = (nodeId, state) => {
// Check if node is source or target of any edge
return state.edges?.some((edge) => edge.fromNode === nodeId || edge.toNode === nodeId) || false;
};
this.generateStateSubgraph = (state, stateIndex, isFirstState) => {
let subgraph = ` subgraph State${stateIndex}\n`;
const nodeIds = new Map();
// Add nodes that have connections or are in first state
state.nodes.forEach((node, id) => {
if (isFirstState || this.hasConnections(id, state)) {
const nodeId = this.getUniqueNodeId(id, stateIndex);
nodeIds.set(id, nodeId);
const relationship = state.relationships.get(id);
const label = this.formatLabel(relationship);
const color = (node.type === 'account') ? '#lightblue' : '#purple';
subgraph += ` ${nodeId}["${label}"]\n`;
subgraph += ` style ${nodeId} fill:${color},width:300px,height:50px\n`;
}
});
// Add edges
if (Array.isArray(state.edges)) {
state.edges.forEach((edge) => {
if (edge.fromNode && edge.toNode) {
const fromId = nodeIds.get(edge.fromNode);
const toId = nodeIds.get(edge.toNode);
if (fromId && toId) {
const { sequence, type } = this.extractOperationDetails(edge.operation);
const label = sequence && type ? `${sequence}] ${type}` : '';
subgraph += ` ${fromId} -->|"${label}"| ${toId}\n`;
}
}
});
}
subgraph += ' end\n\n';
return subgraph;
};
this.generateMermaidCode = () => {
let mermaidCode = `%%{init: {
'theme': 'base',
'themeVariables': {
'fontSize': '15px',
'fontFamily': '"Helvetica Neue", Arial, sans-serif',
'nodeSpacing': 200,
'rankSpacing': 150,
'labelBackground': '#ffffff',
'fontWeight': 600,
'wrap': true,
'useMaxWidth': false
},
'securityLevel': 'loose',
'flowchart': {
'htmlLabels': true,
'curve': 'basis',
'padding': 30,
'useMaxWidth': false,
'diagramPadding': 50
}
}}%%\n`;
mermaidCode += 'flowchart TB\n';
mermaidCode += ' %% Global styles\n';
mermaidCode += ' classDef accountNode fill:#lightblue,stroke:#333,stroke-width:2px\n';
mermaidCode += ' classDef contractNode fill:#purple,stroke:#333,stroke-width:2px\n\n';
// Generate state subgraphs
this.stateHistory.forEach((state, index) => {
if (state.nodes && state.nodes.size > 0) {
mermaidCode += this.generateStateSubgraph(state, index, index === 0);
}
});
return mermaidCode;
};
this.generateBlockchainSection = (txState) => {
if (!txState.blockchainData) {
return '';
}
const data = txState.blockchainData;
let md = '## Blockchain Transaction Details\n\n';
md += `- **Transaction Hash**: \`${data.txHash || 'Unknown'}\`\n`;
md += `- **Block Height**: ${data.blockHeight || 'Unknown'}\n`;
md += `- **Status**: ${data.status || 'Unknown'}\n`;
if (data.timestamp) {
const date = new Date(data.timestamp);
md += `- **Timestamp**: ${date.toISOString()}\n`;
}
if (data.memo) {
md += `- **Memo**: ${data.memo}\n`;
}
// Add failure information if applicable
if (data.status === 'failed' && data.failures && data.failures.length > 0) {
md += '\n### Failures\n\n';
data.failures.forEach(failure => {
md += `- **Account Update ${failure.index}**: ${failure.failureReason}\n`;
});
}
return md;
};
this.convertSvgToPngWithSharp = async (svgPath, pngPath) => {
try {
const svgBuffer = await fs.readFile(svgPath);
await sharp(svgBuffer)
.png()
.flatten({ background: { r: 255, g: 255, b: 255 } }) // Add white background
.toFile(pngPath);
console.log(`Successfully converted SVG to PNG at: ${pngPath}`);
return pngPath;
}
catch (error) {
console.error('Error converting SVG to PNG:', error);
throw error;
}
};
this.generateBlockchainFlowWithPng = async (txState, svgPath = 'blockchain_flow.svg', pngPath = 'blockchain_flow.png') => {
try {
await this.generateBlockchainFlowSVG(txState, svgPath);
await this.convertSvgToPngWithSharp(svgPath, pngPath);
return {
svgPath,
pngPath
};
}
catch (error) {
console.error('Error generating blockchain flow PNG:', error);
throw error;
}
};
this.generateTransactionVisualization = async (txState, outputFormat = 'png', outputPath) => {
const defaultPaths = {
'svg': 'transaction_visualization.svg',
'png': 'transaction_visualization.png'
};
const finalOutputPath = outputPath || defaultPaths[outputFormat];
// Detect if this is a blockchain transaction
const isBlockchainTx = !!txState.blockchainData;
if (isBlockchainTx) {
this.generateBlockchainVisualization(txState, outputFormat, finalOutputPath);
//this.generateBlockchainFlowSVG(txState, outputPath);
}
else {
// Use standard visualization
if (outputFormat === 'svg') {
return this.generateSVG(finalOutputPath);
}
else {
const tempSvgPath = 'temp_transaction_visualization.svg';
await this.generateSVG(tempSvgPath);
try {
await this.convertSvgToPngWithSharp(tempSvgPath, finalOutputPath);
await fs.unlink(tempSvgPath);
console.log(`Successfully generated transaction PNG at: ${finalOutputPath}`);
}
catch (error) {
console.error('Error converting SVG to PNG:', error);
throw error;
}
}
}
};
this.stateHistory = stateHistory;
this.entities = new Map();
this.processEntities();
}
getNodeStyle(node) {
switch (node.type) {
case 'account':
return 'fill:#B3E0FF,stroke:#333,stroke-width:2px';
case 'contract':
return 'fill:#DDA0DD,stroke:#333,stroke-width:2px';
default:
return 'fill:#90EE90,stroke:#333,stroke-width:2px';
}
}
formatEdgeLabel(operation) {
try {
const parts = operation.split(',').map(p => p.trim());
let sequence = '', type = '', amount = '', fee = '';
parts.forEach(part => {
if (part.toLowerCase().includes('sequence')) {
sequence = part.split(':')[1]?.trim() || '';
}
if (part.toLowerCase().includes('type')) {
type = part.split(':')[1]?.trim() || '';
}
if (part.toLowerCase().includes('amount')) {
amount = part.split(':')[1]?.trim() || '';
}
if (part.toLowerCase().includes('fee')) {
fee = part.split(':')[1]?.trim() || '';
}
});
let label = `${sequence})${type}`;
if (amount)
label += ` ${amount}`;
if (fee)
label += ` fee: ${fee}`;
return label;
}
catch (error) {
return operation;
}
}
processEntities() {
this.stateHistory.forEach(state => {
if (state.nodes) {
state.nodes.forEach((node) => {
if (!this.entities.has(node.publicKey)) {
this.entities.set(node.publicKey, {
id: node.id,
type: node.type,
name: this.extractEntityName(node.label),
operations: new Set(),
publicKey: node.publicKey,
contractType: node.contractType,
labels: new Set([node.label])
});
}
const entity = this.entities.get(node.publicKey);
entity.operations.add(this.extractOperation(node.label));
entity.labels.add(node.label);
});
}
});
}
extractEntityName(label) {
return label.split('.')[0];
}
extractOperation(label) {
return label.split('.')[1]?.replace(/[()]/g, '') || 'unknown';
}
generateEntityRegistry() {
let md = '## Entity Registry\n\n';
const groupedEntities = new Map();
this.entities.forEach(entity => {
if (!groupedEntities.has(entity.type)) {
groupedEntities.set(entity.type, []);
}
groupedEntities.get(entity.type).push(entity);
});
groupedEntities.forEach((entities, type) => {
md += `### ${type.charAt(0).toUpperCase() + type.slice(1)}s\n`;
entities.forEach(entity => {
md += `- **${entity.name}**\n`;
md += ` - Public Key: \`${entity.publicKey}\`\n`;
if (entity.contractType) {
md += ` - Contract Type: ${entity.contractType}\n`;
}
if (entity.operations.size > 0) {
md += ` - Operations: ${Array.from(entity.operations).join(', ')}\n`;
}
md += '\n';
});
});
return md;
}
processStateOperations(state) {
const operations = [];
if (state.edges) {
state.edges.forEach((edge) => {
const fromNode = state.nodes.get(edge.fromNode);
const toNode = state.nodes.get(edge.toNode);
if (fromNode && toNode) {
const operation = this.extractOperationDetails(edge.operation);
operations.push({
from: this.extractEntityName(fromNode.label),
to: this.extractEntityName(toNode.label),
action: operation.type,
fee: state.metadata?.totalFees,
status: edge.operation.includes('REJECTED') ? 'REJECTED' : 'success'
});
}
});
}
return operations;
}
generateASCIIFlow(operations) {
let flow = '';
operations.forEach((op, index) => {
// Determine the arrow type based on status
const arrow = op.status === 'REJECTED' ? '╳' : '→';
flow += `${op.from} ${arrow} ${op.to}\n`;
if (op.action) {
flow += `│ └─ Action: ${op.action}\n`;
}
if (op.fee && op.fee !== '0') {
flow += `│ └─ Fee: ${op.fee}\n`;
}
if (op.status === 'REJECTED') {
flow += `│ └─ Status: ${op.status}\n`;
}
if (op.parameters) {
flow += `│ └─ Parameters:\n`;
Object.entries(op.parameters).forEach(([key, value]) => {
flow += `│ - ${key}: ${value}\n`;
});
}
// Add separator between operations
if (index < operations.length - 1) {
flow += '│\n';
}
});
return flow;
}
generateTransactionFlow() {
let md = '## Transaction Flow\n\n';
this.stateHistory.forEach((state, index) => {
const operations = this.processStateOperations(state);
if (operations.length > 0) {
md += `### State ${index}\n`;
md += this.generateASCIIFlow(operations);
md += '\n\n';
}
});
return md;
}
generateMetadata() {
let md = '## Transaction Metadata\n\n';
this.stateHistory.forEach((state, index) => {
md += `### State ${index}\n`;
if (state.metadata) {
Object.entries(state.metadata).forEach(([key, value]) => {
md += `- ${key}: ${value}\n`;
});
}
md += '\n';
});
return md;
}
/*public generateMermaidCode(): string {
let mermaidCode = `%%{init: {
'theme': 'base',
'themeVariables': {
'fontSize': '14px',
'fontFamily': 'arial',
'nodeSpacing': 150,
'rankSpacing': 100
}
}}%%\n`;
mermaidCode += 'flowchart LR\n';
const processedNodes = new Set();
const edges: string[] = [];
// Process all states
this.stateHistory.forEach(state => {
// Add nodes
state.nodes.forEach((node: any, id: string) => {
if (!processedNodes.has(id)) {
const nodeId = this.getUniqueNodeId(id);
const relationship = state.relationships.get(id);
const label = this.formatLabel(relationship);
const style = this.getNodeStyle(node);
mermaidCode += ` ${nodeId}["${label}\\n${node.publicKey?.substring(0, 10)}..."]\n`;
mermaidCode += ` style ${nodeId} ${style}\n`;
processedNodes.add(id);
}
});
// Collect edges
if (Array.isArray(state.edges)) {
state.edges.forEach((edge: any) => {
const fromId = this.getUniqueNodeId(edge.fromNode);
const toId = this.getUniqueNodeId(edge.toNode);
const label = this.formatEdgeLabel(edge.operation);
edges.push(` ${fromId} -->|"${label}"| ${toId}\n`);
});
}
});
// Add all edges after nodes
edges.forEach(edge => {
mermaidCode += edge;
});
return mermaidCode;
}*/
generateMarkdown() {
let markdown = '# Account Update State History\n\n';
markdown += this.generateEntityRegistry();
markdown += this.generateTransactionFlow();
markdown += this.generateMetadata();
return markdown;
}
async openInBrowser(filePath) {
// Convert to absolute path for browser
const absolutePath = require('path').resolve(filePath);
const fileUrl = `file://${absolutePath}`;
try {
// Different commands for different operating systems
let command;
switch (process.platform) {
case 'darwin': // macOS
command = `open -a "Google Chrome" "${fileUrl}"`;
break;
case 'win32': // Windows
command = `start chrome "${fileUrl}"`;
break;
default: // Linux and others
command = `google-chrome "${fileUrl}"`;
break;
}
await exec(command);
}
catch (firstError) {
try {
// Fallback to default browser if Chrome isn't available
let fallbackCommand;
switch (process.platform) {
case 'darwin': // macOS
fallbackCommand = `open "${fileUrl}"`;
break;
case 'win32': // Windows
fallbackCommand = `start "" "${fileUrl}"`;
break;
default: // Linux and others
fallbackCommand = `xdg-open "${fileUrl}"`;
break;
}
await exec(fallbackCommand);
}
catch (error) {
console.error('Error opening SVG in browser:', error);
throw error;
}
}
}
async generateSVG(outputPath = 'transaction_flow.svg') {
try {
const mermaidCode = this.generateMermaidCode();
const tempFile = 'temp_diagram.mmd';
await fs.writeFile(tempFile, mermaidCode);
const config = {
width: 3840,
height: 2160,
backgroundColor: '#ffffff',
scale: 1.0, // Scale can be 1.0 for SVG since it's vector-based
puppeteerConfig: {
deviceScaleFactor: 1.0
}
};
// Generate SVG
const command = `mmdc -i ${tempFile} -o ${outputPath} ` +
`-w ${config.width} ` +
`-H ${config.height} ` +
`-b ${config.backgroundColor}`;
await exec(command);
// Clean up temporary file
await fs.unlink(tempFile);
console.log(`Successfully generated SVG at: ${outputPath}`);
//await this.openInBrowser(outputPath);
}
catch (error) {
console.error('Error generating SVG:', error);
throw error;
}
}
async generatePNG(outputPath = 'transaction_flow.png') {
try {
const mermaidCode = this.generateMermaidCode();
const tempFile = 'temp_diagram.mmd';
await fs.writeFile(tempFile, mermaidCode);
const config = {
width: 3840, // 4K width
height: 2160, // 4K height
backgroundColor: '#ffffff',
scale: 8.0, // Increased scale for better text quality
puppeteerConfig: {
deviceScaleFactor: 4.0,
defaultViewport: {
width: 3840,
height: 2160,
deviceScaleFactor: 4.0
}
}
};
const command = `mmdc -i ${tempFile} -o ${outputPath} ` +
`-w ${config.width} ` +
`-H ${config.height} ` +
`-b ${config.backgroundColor} ` +
`-s ${config.scale} ` +
`--puppeteerConfig '{"deviceScaleFactor": ${config.puppeteerConfig.deviceScaleFactor}, ` +
`"defaultViewport": {"width": ${config.width}, "height": ${config.height}, ` +
`"deviceScaleFactor": ${config.puppeteerConfig.deviceScaleFactor}}}'`;
await exec(command);
await fs.unlink(tempFile);
console.log(`Successfully generated PNG at: ${outputPath}`);
}
catch (error) {
console.error('Error generating PNG:', error);
throw error;
}
}
async generateMarkdownFile(outputPath = 'state_history.md') {
try {
const markdown = this.generateMarkdown();
await fs.writeFile(outputPath, markdown);
console.log(`Successfully generated Markdown at: ${outputPath}`);
}
catch (error) {
console.error('Error generating Markdown:', error);
throw error;
}
}
generateBlockchainMarkdown(txState) {
let markdown = '# Blockchain Transaction Analysis\n\n';
// Add blockchain-specific section
markdown += this.generateBlockchainSection(txState);
// Add standard sections
markdown += this.generateEntityRegistry();
markdown += this.generateTransactionFlow();
markdown += this.generateMetadata();
return markdown;
}
buildEdgesIfMissing(txState) {
// If edges already exist, don't do anything
if (txState.edges && txState.edges.length > 0) {
console.log(`Edges already exist (${txState.edges.length}), skipping edge generation`);
return txState;
}
const edges = [];
const nodeIds = Array.from(txState.nodes.keys());
console.log(`Found ${nodeIds.length} nodes for edge generation`);
// Fee payer relationship - connect fee payer to other accounts
const feePayer = this.findFeePayer(txState);
console.log(`Fee payer detection result: ${feePayer || 'None found'}`);
if (feePayer) {
console.log(`Connecting fee payer ${feePayer} to all other nodes`);
// Connect fee payer to all other nodes (except itself)
nodeIds.forEach(nodeId => {
if (nodeId !== feePayer) {
edges.push({
fromNode: feePayer,
toNode: nodeId,
operation: 'Fee Payment/Initiation',
failed: txState.blockchainData?.status === 'failed'
});
}
});
}
// Sequential relationships - create a chain of operations in sequence
console.log(`Creating sequential edges for ${nodeIds.length} nodes`);
for (let i = 0; i < nodeIds.length - 1; i++) {
// Skip if this would create a duplicate edge
if (!edges.some(e => e.fromNode === nodeIds[i] && e.toNode === nodeIds[i + 1])) {
edges.push({
fromNode: nodeIds[i],
toNode: nodeIds[i + 1],
operation: 'Sequence',
failed: txState.blockchainData?.status === 'failed'
});
}
}
// Token relationships - connect operations on the same token
const tokenGroups = this.groupNodesByToken(txState);
console.log(`Found ${tokenGroups.size} token groups`);
tokenGroups.forEach((nodeIds, tokenId) => {
if (nodeIds.length > 1 && tokenId !== 'wSHV2S4qX9jFsLjQo8r1BsMLH2ZRKsZx6EJd1sbozGPieEC4Jf') {
console.log(`Creating edges for token ${tokenId.substring(0, 10)}...`);
for (let i = 0; i < nodeIds.length - 1; i++) {
edges.push({
fromNode: nodeIds[i],
toNode: nodeIds[i + 1],
operation: 'Token Operation',
failed: txState.blockchainData?.status === 'failed'
});
}
}
});
console.log(`Generated ${edges.length} edges total`);
// Return updated state with edges
return {
...txState,
edges: edges
};
}
findFeePayer(txState) {
// Try to find fee payer from blockchain data
if (txState.blockchainData?.feePayerAddress) {
console.log(`Found feePayerAddress in blockchain data: ${txState.blockchainData.feePayerAddress.substring(0, 10)}...`);
// Look for node with matching address
for (const [id, node] of txState.nodes.entries()) {
if (node.publicKey === txState.blockchainData.feePayerAddress) {
console.log(`Found fee payer node by address match: ${id}`);
return id;
}
}
console.log("No node with matching feePayerAddress found");
}
// Alternatively, look for node with negative balance change
for (const [id, node] of txState.nodes.entries()) {
console.log(`Node ${id} balance change: ${node.balanceChange || 'undefined'}`);
if (node.balanceChange && node.balanceChange < 0) {
console.log(`Found fee payer node by negative balance: ${id}`);
return id;
}
}
console.log("No fee payer found");
return undefined;
}
groupNodesByToken(txState) {
const tokenGroups = new Map();
txState.nodes.forEach((node, id) => {
const tokenId = node.tokenId || 'default';
if (!tokenGroups.has(tokenId)) {
tokenGroups.set(tokenId, []);
}
tokenGroups.get(tokenId).push(id);
});
return tokenGroups;
}
async generateBlockchainFlowSVG(txState, outputPath = 'blockchain_flow.svg') {
try {
// Build edges if missing
txState = this.buildEdgesIfMissing(txState);
// Create a virtual DOM for server-side rendering
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
const document = dom.window.document;
global.document = document;
// Define a modern color palette
const colors = {
background: '#f8fafc',
nodeStroke: '#475569',
account: '#bfdbfe', // Light blue
contract: '#ddd6fe', // Light purple
token: '#bbf7d0', // Light green
failed: '#fecaca', // Light red
text: '#1e293b',
failText: '#dc2626',
link: '#94a3b8',
linkFailed: '#ef4444'
};
// Create SVG container with improved dimensions
const width = 1200;
const height = 800;
const svg = d3.select(document.body)
.append('svg')
.attr('xmlns', 'http://www.w3.org/2000/svg')
.attr('width', width)
.attr('height', height)
.attr('viewBox', `0 0 ${width} ${height}`)
.style('background-color', colors.background);
// Add a subtle grid pattern for visual guidance
const defs = svg.append('defs');
defs.append('pattern')
.attr('id', 'grid')
.attr('width', 20)
.attr('height', 20)
.attr('patternUnits', 'userSpaceOnUse')
.append('path')
.attr('d', 'M 20 0 L 0 0 0 20')
.attr('fill', 'none')
.attr('stroke', '#e2e8f0')
.attr('stroke-width', 0.5);
svg.append('rect')
.attr('width', width)
.attr('height', height)
.attr('fill', 'url(#grid)');
// Add title with improved styling
svg.append('text')
.attr('x', width / 2)
.attr('y', 40)
.attr('text-anchor', 'middle')
.attr('font-family', 'Arial, sans-serif')
.attr('font-size', '22px')
.attr('font-weight', 'bold')
.attr('fill', colors.text)
.text(`Transaction Flow: ${txState.blockchainData?.txHash?.substring(0, 16) || 'Unknown'}...`);
// Add transaction status with badge-like styling
const statusGroup = svg.append('g')
.attr('transform', `translate(${width / 2}, 70)`);
const status = txState.blockchainData?.status || 'Unknown';
const statusColor = status === 'Applied' ? '#22c55e' :
status === 'Pending' ? '#f59e0b' :
status === 'Failed' ? '#ef4444' : '#94a3b8';
statusGroup.append('rect')
.attr('x', -60)
.attr('y', -18)
.attr('width', 120)
.attr('height', 26)
.attr('rx', 13)
.attr('ry', 13)
.attr('fill', statusColor);
statusGroup.append('text')
.attr('text-anchor', 'middle')
.attr('font-family', 'Arial, sans-serif')
.attr('font-size', '14px')
.attr('font-weight', 'bold')
.attr('fill', 'white')
.attr('dy', 5)
.text(`Status: ${status.toLowerCase()}`);
// Create arrow markers for different relationship types
const markers = [
{ id: 'arrow-standard', color: colors.link },
{ id: 'arrow-failed', color: colors.linkFailed }
];
markers.forEach(marker => {
defs.append('marker')
.attr('id', marker.id)
.attr('viewBox', '0 0 10 10')
.attr('refX', 27)
.attr('refY', 5)
.attr('markerWidth', 8)
.attr('markerHeight', 8)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M 0 0 L 10 5 L 0 10 z')
.attr('fill', marker.color);
});
// Extract and convert nodes from txState
const nodesArray = [];
const nodeMap = new Map();
// Process nodes
txState.nodes.forEach((node, id) => {
const publicKey = node.publicKey || '';
// Format address as xxxx....xxxxx (first 4 and last 4 characters)
const shortAddress = publicKey.length > 8
? `${publicKey.substring(0, 6)}....${publicKey.substring(publicKey.length - 6)}`
: publicKey;
const nodeData = {
id,
label: node.label || 'Unknown',
publicKey: node.publicKey,
shortAddress: shortAddress,
type: node.type,
failed: !!node.failed,
failureReason: node.failureReason,
tokenId: node.tokenId,
// For hierarchical layout
children: [],
level: 0,
column: 0,
parents: []
};
nodesArray.push(nodeData);
nodeMap.set(id, nodeData);
});
// Prepare edges and build parent-child relationships
const linksArray = [];
txState.edges.forEach((edge, i) => {
if (nodeMap.has(edge.fromNode) && nodeMap.has(edge.toNode)) {
const source = nodeMap.get(edge.fromNode);
const target = nodeMap.get(edge.toNode);
// Add child to parent
source.children.push(target);
// Add parent to child for bidirectional navigation
target.parents.push(source);
// Create a formatted operation string
let formattedOp = '';
if (edge.operation) {
const opStr = typeof edge.operation === 'string' ? edge.operation : JSON.stringify(edge.operation);
// Extract useful information from operation string
const matchType = opStr.match(/Type:\s*([\w]+)/);
if (matchType) {
formattedOp = matchType[1];
}
else if (opStr.length > 20) {
formattedOp = opStr.substring(0, 17) + '...';
}
else {
formattedOp = opStr;
}
}
linksArray.push({
id: `edge${i}`,
source: source,
target: target,
operation: edge.operation,
formattedOperation: formattedOp,
failed: !!edge.failed
});
}
});
// Find root nodes (nodes with no parents)
const rootNodes = nodesArray.filter(node => node.parents.length === 0);
// If no root nodes found, use the first node as root
if (rootNodes.length === 0 && nodesArray.length > 0) {
rootNodes.push(nodesArray[0]);
}
// Assign levels to nodes through breadth-first traversal
const assignLevels = () => {
const visited = new Set();
const queue = [...rootNodes];
// Set all root nodes to level 0
rootNodes.forEach(node => {
node.level = 0;
visited.add(node.id);
});
while (queue.length > 0) {
const current = queue.shift();
// Process all children
current.children.forEach((child) => {
// Set child's level to parent's level + 1
child.level = Math.max(child.level, current.level + 1);
// Add to queue if not visited
if (!visited.has(child.id)) {
visited.add(child.id);
queue.push(child);
}
});
}
};
// Assign columns to nodes to minimize overlaps
const assignColumns = () => {
// Group nodes by level
const nodesByLevel = {};
nodesArray.forEach(node => {
if (!nodesByLevel[node.level]) {
nodesByLevel[node.level] = [];
}
nodesByLevel[node.level].push(node);
});
// Assign columns for each level
Object.keys(nodesByLevel).forEach(level => {
const nodesAtLevel = nodesByLevel[level];
nodesAtLevel.forEach((node, i) => {
node.column = i;
});
});
};
// Apply hierarchical layout algorithm
assignLevels();
assignColumns();
// Calculate node positions based on their level and column
const nodeRadius = 25;
const horizontalSpacing = 180; // Space between levels
const verticalSpacing = 100; // Space between nodes at the same level
const topMargin = 150; // Space from top for title and status
// Group nodes by level
const levelGroups = {};
nodesArray.forEach(node => {
if (!levelGroups[node.level]) {
levelGroups[node.level] = [];
}
levelGroups[node.level].push(node);
});
// Find the maximum level and count of nodes in each level
const maxLevel = Math.max(...nodesArray.map(node => node.level));
const maxNodesInLevel = Math.max(...Object.values(levelGroups).map((group) => group.length));
// Calculate horizontal start position to center the diagram
const diagramWidth = maxLevel * horizontalSpacing;
const horizontalStart = (width - diagramWidth) / 2;
// Calculate vertical center positions for each level
const levelHeights = {};
Object.keys(levelGroups).forEach(level => {
const nodesCount = levelGroups[level].length;
levelHeights[level] = (height - topMargin) / 2 - ((nodesCount - 1) * verticalSpacing) / 2;
});
// Assign coordinates to nodes
nodesArray.forEach(node => {
const levelHeight = levelHeights[node.level];
const nodesAtLevel = levelGroups[node.level];
const indexAtLevel = nodesAtLevel.indexOf(node);
// Position with horizontal centering
node.x = horizontalStart + node.level * horizontalSpacing;
node.y = levelHeight + indexAtLevel * verticalSpacing;
});
// Create links (edges) between nodes
const link = svg.append('g')
.selectAll('path')
.data(linksArray)
.enter().append('path')
.attr('d', d => {
// Use straight lines with slight curve for hierarchy
return `M${d.source.x + nodeRadius},${d.source.y}
C${d.source.x + horizontalSpacing / 2},${d.source.y}
${d.target.x - horizontalSpacing / 2},${d.target.y}
${d.target.x - nodeRadius},${d.target.y}`;
})
.attr('fill', 'none')
.attr('stroke', d => d.failed ? colors.linkFailed : colors.link)
.attr('stroke-width', 2)
.attr('stroke-dasharray', d => d.failed ? '5,5' : null)
.attr('marker-end', d => `url(#arrow-${d.failed ? 'failed' : 'standard'})`)
.attr('opacity', 0.7);
// Add link labels
const linkText = svg.append('g')
.selectAll('text')
.data(linksArray)
.enter().append('text')
.attr('text-anchor', 'middle')
.attr('font-family', 'Arial, sans-serif')
.attr('font-size', '11px')
.attr('font-weight', 'normal')
.attr('fill', d => d.failed ? colors.failText : colors.text)
.attr('pointer-events', 'none')
.text(d => d.formattedOperation);
// Position link labels along the path
linkText.each(function (d) {
const textElement = d3.select(this);
// Position halfway between nodes, slightly above the path
const x = (d.source.x + d.target.x) / 2;
const y = (d.source.y + d.target.y) / 2 - 15;
textElement.attr('x', x);
textElement.attr('y', y);
// Add background for better readability
const textNode = textElement.node();
if (textNode) {
const textWidth = d.formattedOperation.length * 6; // Estimate width
const textHeight = 15; // Estimate height
svg.insert('rect', 'text')
.attr('x', x - textWidth / 2 - 4)
.attr('y', y - textHeight / 2 - 2)
.attr('width', textWidth + 8)
.attr('height', textHeight + 4)
.attr('rx', 3)
.attr('ry', 3)
.attr('fill', 'white')
.attr('fill-opacity', 0.9);
}
});
// Create node group
const node = svg.append('g')
.selectAll('g')
.data(nodesArray)
.enter().append('g')
.attr('transform', d => `translate(${d.x},${d.y})`);
// Create node circles
node.append('circle')
.attr('r', nodeRadius)
.attr('fill', d => {
if (d.failed)
return colors.failed;
if (d.type === 'contract')
return colors.contract;
if (d.tokenId && d.tokenId !== 'wSHV2S4qX9jFsLjQo8r1BsMLH2ZRKsZx6EJd1sbozGPieEC4Jf')
return colors.token;
return colors.account;
})
.attr('stroke', d => d.failed ? colors.failText : colors.nodeStroke)
.attr('stroke-width', 1.5);
// Add node icons
node.each(function (d) {
const nodeGroup = d3.select(this);
// Add icon based on node type
if (d.type === 'contract') {
nodeGroup.append('text')
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('font-family', 'Arial, sans-serif')
.attr('font-size', '16px')
.attr('fill', colors.text)
.text('⚙️');
}
else if (d.tokenId && d.tokenId !== 'wSHV2S4qX9jFsLjQo8r1BsMLH2ZRKsZx6EJd1sbozGPieEC4Jf') {
nodeGroup.append('text')
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('font-family', 'Arial, sans-serif')
.attr('font-size', '16px')
.attr('fill', colors.text)
.text('🪙');
}
else {
nodeGroup.append('text')
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('font-family', 'Arial, sans-serif')
.attr('font-size', '16px')
.attr('fill', colors.text)
.text('👤');
}
// Add error icon for failed nodes
if (d.failed) {
nodeGroup.append('text')
.attr('x', 15)
.attr('y', -15)
.attr('text-anchor', 'middle')
.attr('font-family', 'Arial, sans-serif')
.attr('font-size', '14px')
.attr('fill', colors.failText)
.text('❌');
}
});
// Add address labels
node.append('text')
.attr('dy', 40)
.attr('text-anchor', 'middle')
.attr('font-family', 'Arial, sans-serif')
.attr('font-size', '11px')
.attr('font-weight', 'bold')
.attr('fill', colors.text)
.text(d => d.shortAddress);
// Add failure reason if node failed
node.filter(d => d.failed && d.failureReason)
.append('text')
.attr('dy', 55)
.attr('text-anchor', 'middle')
.attr('font-family', 'Arial, sans-serif')
.attr('font-size', '10px')
.attr('fill', colors.failText)
.text(d => d.failureReason.length > 20 ? d.failureReason.substring(0, 17) + '...' : d.failureReason);
// Create a legend
const legendGroup = svg.append('g')
.attr('transform', `translate(40, 120)`);
legendGroup.append('rect')
.attr('width', 150)
.attr('height', 130)
.attr('fill', 'white')
.attr('fill-opacity', 0.8)
.attr('rx', 8)
.attr('ry', 8)
.attr('stroke', '#e2e8f0')
.attr('stroke-width', 1);
legendGroup.append('text')
.attr('x', 10)
.attr('y', 20)
.attr('font-family', 'Arial, sans-serif')
.attr('font-size', '14px')
.attr('font-weight', 'bold')
.attr('fill', colors.text)
.text('Legend');
const legendItems = [
{ color: colors.account, icon: '👤', label: 'Account' },
{ color: colors.contract, icon: '⚙️', label: 'Contract' },
{ color: colors.token, icon: '🪙', label: 'Token' },
{ color: colors.failed, icon: '❌', label: 'Failed' }
];
legendItems.forEach((item, i) => {
const g = legendGroup.append('g')
.attr('transform', `translate(15, ${i * 25 + 35})`);
// Colored circle
g.append('circle')
.attr('r', 8)
.attr('fill', item.color)
.attr('stroke', colors.nodeStroke)
.attr('stroke-width', 1);
// Icon
g.append('text')
.attr('x', 0)
.attr('y', 0)
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('font-family', 'Arial, sans-serif')
.attr('font-size', '10px')
.text(item.icon);
// Label
g.append('text')
.attr('x', 20)
.attr('y', 4)
.attr('font-family', 'Arial, sans-serif')
.attr('font-size', '12px')
.attr('fill', colors.text)
.text(item.label);
});
// Add timestamp or metadata
svg.append('text')
.attr('x', width - 40)
.attr('y', height - 20)
.attr('text-anchor', 'end')
.attr('font-family', 'Arial, sans-serif')
.attr('font-size', '11px')
.attr('fill', '#94a3b8')
.text(`Generated: ${new Date().toLocaleString()}`);
// Save SVG to file
const svgString = document.body.innerHTML;
await fs.writeFile(outputPath, svgString);
console.log(`Successfully generated blockchain flow SVG at: ${outputPath}`);
return outputPath;
}
catch (error) {
console.error('Error generating blockchain flow SVG:', error);
throw error;
}
}
async generateBlockchainVisualization(txState, outputFormat = 'png', outputPath) {
// Default output paths based on format
const defaultPaths = {
'svg': 'blockchain_flow.svg',
'png': 'blockchain_flow.png',
'md': 'blockchain_analysis.md'
};
const finalOutputPath = outputPath || defaultPaths[outputFormat];
switch (outputFormat) {
case 'svg':
return this.generateBlockchainFlowSVG(txState, finalOutputPath);
case 'png':
const tempSvgPath = 'temp_blockchain_flow.svg';
try {
const result = await this.generateBlockchainFlowWithPng(txState, tempSvgPath, finalOutputPath);
await fs.unlink(tempSvgPath);
return finalOutputPath;
}
catch (error) {
console.error('Error generating blockchain flow PNG:', error);
throw error;
}
case 'md':
const markdown = this.generateBlockchainMarkdown(txState);
await fs.writeFile(finalOutputPath, markdown);
console.log(`Successfully generated blockchain analysis markdown at: ${finalOutputPath}`);
return finalOutputPath;
}
}
}
//# sourceMappingURL=Visualiser.js.map