From ddf80106e99411b9a1ca32df86f06ed93b4cea35 Mon Sep 17 00:00:00 2001 From: David Nicholson Date: Wed, 4 Mar 2020 17:24:01 +0000 Subject: [PATCH] storage explorer --- package.json | 13 +++ src/cachedRpcClient.ts | 7 ++ src/extension.ts | 23 ++++++ src/invocationPanel.ts | 30 +------ src/neoRpcConnection.ts | 18 ++++- src/panels/storage.html | 63 +++++++++++++++ src/panels/storage.main.ts | 95 ++++++++++++++++++++++ src/panels/storage.scss | 65 +++++++++++++++ src/panels/storageEvents.ts | 9 +++ src/panels/storageSelectors.ts | 13 +++ src/resultValue.ts | 30 +++++++ src/storagePanel.ts | 140 +++++++++++++++++++++++++++++++++ 12 files changed, 476 insertions(+), 30 deletions(-) create mode 100644 src/panels/storage.html create mode 100644 src/panels/storage.main.ts create mode 100644 src/panels/storage.scss create mode 100644 src/panels/storageEvents.ts create mode 100644 src/panels/storageSelectors.ts create mode 100644 src/resultValue.ts create mode 100644 src/storagePanel.ts diff --git a/package.json b/package.json index 006fc86..b79c7a8 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,11 @@ "dark": "resources/dark/open.svg" } }, + { + "command": "neo-visual-devtracker.storageExplorer", + "title": "Open Storage Explorer", + "category": "Neo Visual DevTracker" + }, { "command": "neo-visual-devtracker.refreshObjectExplorerNode", "title": "Refresh Neo RPC Server list", @@ -275,6 +280,14 @@ "command": "neo-visual-devtracker.openTracker", "when": "view == neo-visual-devtracker.rpcServerExplorer && viewItem == url" }, + { + "command": "neo-visual-devtracker.storageExplorer", + "when": "view == neo-visual-devtracker.rpcServerExplorer && viewItem == expressNode" + }, + { + "command": "neo-visual-devtracker.storageExplorer", + "when": "view == neo-visual-devtracker.rpcServerExplorer && viewItem == expressNodeMulti" + }, { "command": "neo-visual-devtracker.transferAssets", "when": "view == neo-visual-devtracker.rpcServerExplorer && viewItem == expressNode" diff --git a/src/cachedRpcClient.ts b/src/cachedRpcClient.ts index c6e2273..b463200 100644 --- a/src/cachedRpcClient.ts +++ b/src/cachedRpcClient.ts @@ -140,6 +140,13 @@ export class CachedRpcClient { return this.rpcClient.query({ method: 'express-get-populated-blocks' }); } + public getContractStorage(contractHash: string): Promise { + // Note that this method is not cached. Contract storage could potentially change at any + // new block. + + return this.rpcClient.query({ method: 'express-get-contract-storage', params: [ contractHash ] }); + } + public getUnclaimed(address: string): Promise { // Note that this method is not cached. The state of an address can change in-between calls. return this.rpcClient.getUnclaimed(address); diff --git a/src/extension.ts b/src/extension.ts index 35b3f67..9a98d7e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -14,6 +14,7 @@ import { NeoTrackerPanel } from './neoTrackerPanel'; import { RpcServerExplorer, RpcServerTreeItemIdentifier } from './rpcServerExplorer'; import { RpcConnectionPool } from './rpcConnectionPool'; import { StartPanel } from './startPanel'; +import { StoragePanel } from './storagePanel'; import { TransferPanel } from './transferPanel'; import { WalletExplorer } from './walletExplorer'; @@ -188,6 +189,27 @@ export function activate(context: vscode.ExtensionContext) { } }); + const openStorageExplorerCommand = vscode.commands.registerCommand('neo-visual-devtracker.storageExplorer', async (server) => { + await requireNeoExpress(async () => { + server = server || await selectBlockchain('Explore Storage', context.globalState, [ 'expressNode', 'expressNodeMulti' ]); + if (!server) { + return; + } + const rpcUri = await selectUri('Explore Storage', server, context.globalState); + try { + const panel = new StoragePanel( + context.extensionPath, + rpcUri, + rpcConnectionPool.getConnection(rpcUri), + contractDetector, + context.subscriptions, + server.jsonFile ? new NeoExpressConfig(server.jsonFile) : undefined); + } catch (e) { + console.error('Error opening new storage explorer panel ', e); + } + }); + }); + const refreshServersCommand = vscode.commands.registerCommand('neo-visual-devtracker.refreshObjectExplorerNode', () => { rpcServerExplorer.refresh(); walletExplorer.refresh(); @@ -495,6 +517,7 @@ export function activate(context: vscode.ExtensionContext) { const waletExplorerProvider = vscode.languages.registerCodeLensProvider({ language: 'json' }, walletExplorer); context.subscriptions.push(openTrackerCommand); + context.subscriptions.push(openStorageExplorerCommand); context.subscriptions.push(refreshServersCommand); context.subscriptions.push(startServerCommand); context.subscriptions.push(startServerAdvancedCommand); diff --git a/src/invocationPanel.ts b/src/invocationPanel.ts index 121673f..49c1feb 100644 --- a/src/invocationPanel.ts +++ b/src/invocationPanel.ts @@ -9,6 +9,7 @@ import { INeoRpcConnection } from './neoRpcConnection'; import { invokeEvents } from './panels/invokeEvents'; import { NeoExpressConfig } from './neoExpressConfig'; import { NeoTrackerPanel } from './neoTrackerPanel'; +import { ResultValue } from './resultValue'; import { WalletExplorer } from './walletExplorer'; import { api } from '@cityofzion/neon-js'; @@ -20,35 +21,6 @@ const bs58check = require('bs58check'); const JavascriptHrefPlaceholder : string = '[JAVASCRIPT_HREF]'; const CssHrefPlaceholder : string = '[CSS_HREF]'; -class ResultValue { - public readonly asInteger: string; - public readonly asByteArray: string; - public readonly asString: string; - public readonly asAddress: string; - constructor(result: any) { - let value = result.value; - if ((value !== null) && (value !== undefined)) { - if (value.length === 0) { - value = '00'; - } - this.asByteArray = '0x' + value; - const buffer = Buffer.from(value, 'hex'); - this.asString = buffer.toString(); - this.asAddress = ''; - if (value.length === 42) { - this.asAddress = bs58check.encode(buffer); - } - buffer.reverse(); - this.asInteger = BigInt('0x' + buffer.toString('hex')).toString(); - } else { - this.asInteger = '0'; - this.asByteArray = '(empty)'; - this.asString = '(empty)'; - this.asAddress = '(empty)'; - } - } -} - class ViewState { rpcUrl: string = ''; rpcDescription: string = ''; diff --git a/src/neoRpcConnection.ts b/src/neoRpcConnection.ts index d5bdaaf..40357b6 100644 --- a/src/neoRpcConnection.ts +++ b/src/neoRpcConnection.ts @@ -40,6 +40,7 @@ export interface INeoRpcConnection { getBlock(index: string | number, statusReceiver?: INeoStatusReceiver): Promise; getBlocks(index: number | undefined, hideEmptyBlocks: boolean, forwards: boolean, statusReceiver: INeoStatusReceiver): Promise; getClaimable(address: string, statusReceiver: INeoStatusReceiver): Promise; + getContractStorage(contractHash: string, statusReceiver: INeoStatusReceiver): Promise; getTransaction(txid: string, statusReceiver: INeoStatusReceiver): Promise; getUnspents(address: string, statusReceiver: INeoStatusReceiver): Promise; getUnclaimed(address: string, statusReceiver: INeoStatusReceiver): Promise; @@ -220,6 +221,21 @@ export class NeoRpcConnection implements INeoRpcConnection { } } + public async getContractStorage(contractHash: string, statusReceiver: INeoStatusReceiver) { + try { + statusReceiver.updateStatus('Getting storage for contract ' + contractHash); + return (await this.rpcClient.getContractStorage(contractHash)).result; + } catch(e) { + if (e.message.toLowerCase().indexOf('method not found') !== -1) { + console.warn('NeoRpcConnection: get-contract-storage unsupported by ' + this.rpcUrl + ' (contractHash=' + contractHash + '): ' + e); + return undefined; + } else { + console.error('NeoRpcConnection could not retrieve contract storage (contractHash=' + contractHash + '): ' + e); + return undefined; + } + } + } + public async getTransaction(txid: string, statusReceiver: INeoStatusReceiver) { try { statusReceiver.updateStatus('Retrieving transaction ' + txid); @@ -362,7 +378,7 @@ export class NeoRpcConnection implements INeoRpcConnection { try { await this.subscriptions[i].onNewBlock(blockchainInfo); } catch (e) { - console.error('Error within INeoSubscription #' + i + ' (' + this.subscriptions[i] + ')'); + console.error('Error within INeoSubscription #' + i, e, this.subscriptions[i]); } } } diff --git a/src/panels/storage.html b/src/panels/storage.html new file mode 100644 index 0000000..53af0e2 --- /dev/null +++ b/src/panels/storage.html @@ -0,0 +1,63 @@ + + + + + + + + + + + + +
+
+ Contract: +
+
+ +
+
+   +
+
+ +
+ Could not retrieve storage for the selected contract. + Ensure that Neo Express is running. +
+ + + + + + + + + + + + + + + + + + + +
KeyValueConstant
(as string)(as bytes)(as bytes)(as string)(as number)(as address)
+ +
+ (No stored data found for this contract) +
+ +
+ + +
+ + + + + + \ No newline at end of file diff --git a/src/panels/storage.main.ts b/src/panels/storage.main.ts new file mode 100644 index 0000000..bc10f4d --- /dev/null +++ b/src/panels/storage.main.ts @@ -0,0 +1,95 @@ +import { htmlHelpers } from "./htmlHelpers"; +import { storageEvents } from "./storageEvents"; +import { storageSelectors } from "./storageSelectors"; + +/* + * This code runs in the context of the WebView panel. It exchanges JSON messages with the main + * extension code. The browser instance running this code is disposed whenever the panel loses + * focus, and reloaded when it later gains focus (so navigation state should not be stored here). + */ + +declare var acquireVsCodeApi: any; + +let viewState: any = {}; + +let vsCodePostMessage: Function; + +const contractDropdown = document.querySelector(storageSelectors.ContractDropdown) as HTMLSelectElement; +const storageTableBody = document.querySelector(storageSelectors.StorageTableBody) as HTMLElement; + +function hexToAscii(hex?: string) { + var result = ''; + if (hex) { + for (var i = 0; i < hex.length; i += 2) { + result += String.fromCharCode(parseInt(hex.substr(i, 2), 16)); + } + } + return result; +} + +function storageRow(key?: string, value?: string, parsed?: any, constant?: boolean) { + const keyAsString = hexToAscii(key); + const keyAsBytes = key || ' '; + const valueAsBytes = value || ' '; + const valueAsString = hexToAscii(value); + const valueAsNumber = parsed?.asInteger || ' '; + const valueAsAddress = parsed?.asAddress || ' '; + return htmlHelpers.newTableRow( + htmlHelpers.text(keyAsString), + htmlHelpers.text(keyAsBytes), + htmlHelpers.text(valueAsBytes), + htmlHelpers.text(valueAsString), + htmlHelpers.text(valueAsNumber), + htmlHelpers.text(valueAsAddress), + htmlHelpers.text(constant === undefined ? '' : (constant ? 'true' : 'false'))); +} + +function render() { + htmlHelpers.setPlaceholder(storageSelectors.RpcDescription, htmlHelpers.text(viewState.rpcDescription)); + htmlHelpers.setPlaceholder(storageSelectors.RpcUrl, htmlHelpers.text(viewState.rpcUrl)); + htmlHelpers.showHide(storageSelectors.Error, viewState.selectedContractHash && !viewState.selectedContractStorage); + htmlHelpers.showHide(storageSelectors.NoStorage, viewState.selectedContractHash && viewState.selectedContractStorage && !viewState.selectedContractStorage.length); + htmlHelpers.showHide(storageSelectors.StorageTable, viewState.selectedContractStorage, 'table'); + htmlHelpers.clearChildren(contractDropdown); + for (let i = 0; i < viewState.contracts.length; i++) { + const contract = viewState.contracts[i]; + const contractHash = contract.hash; + const contractName = contract.name; + const option = document.createElement('option') as HTMLOptionElement; + option.value = contractHash; + option.appendChild(htmlHelpers.text(contractName + ' - ' + contractHash)); + contractDropdown.appendChild(option); + if (viewState.selectedContractHash === contractHash) { + option.selected = true; + } + } + htmlHelpers.clearChildren(storageTableBody); + if (viewState.selectedContractStorage) { + for (let i = 0; i < viewState.selectedContractStorage.length; i++) { + const row = viewState.selectedContractStorage[i]; + storageTableBody.appendChild(storageRow(row.key, row.value, row.parsed, row.constant)); + } + } + contractDropdown.focus(); +} + +function handleMessage(message: any) { + if (message.viewState) { + viewState = message.viewState; + console.log('<-', viewState); + render(); + } +} + +function initializePanel() { + const vscode = acquireVsCodeApi(); + vsCodePostMessage = vscode.postMessage; + contractDropdown.addEventListener('change', _ => { + viewState.selectedContractHash = contractDropdown.options[contractDropdown.selectedIndex].value; + vsCodePostMessage({ e: storageEvents.Update, c: viewState }); + }); + window.addEventListener('message', msg => handleMessage(msg.data)); + vscode.postMessage({ e: storageEvents.Init }); +} + +window.onload = initializePanel; \ No newline at end of file diff --git a/src/panels/storage.scss b/src/panels/storage.scss new file mode 100644 index 0000000..07ef364 --- /dev/null +++ b/src/panels/storage.scss @@ -0,0 +1,65 @@ +@import 'common'; + +body { + padding: 0; + background-color: var(--vscode-editor-background); +} + +.header { + padding: 10px 0px 10px 0px; + margin: 0px; + height: 45px; + line-height: 45px; + background-color: var(--vscode-sideBarSectionHeader-background); + color: var(--vscode-sideBarSectionHeader-foreground); + border-bottom: 1px solid var(--vscode-sideBarSectionHeader-border); + .col { + float: left; + padding: 0px 10px 0px 10px; + box-sizing: border-box; + font-size: 1.0em; + font-weight: bold; + &.col-1 { + width: 15%; + text-align: right; + } + &.col-2 { + width: 80%; + select { + width: 100%; + background-color: var(--vscode-input-background); + color: var(--vscode-input-foreground); + } + } + &.col-3 { + width: 5%; + } + } +} + +.error { + padding: 10px; +} + +.no-storage { + padding: 10px; + text-align: center; + font-style: italic; +} + +#storageTable { + width: 100%; + border-spacing: 0px; + th, td { + padding: 10px; + border: 1px solid var(--vscode-sideBarSectionHeader-background); + } + th { + white-space: nowrap; + background-color: var(--vscode-sideBar-background); + } + td { + word-wrap: break-word; + word-break: break-all; + } +} \ No newline at end of file diff --git a/src/panels/storageEvents.ts b/src/panels/storageEvents.ts new file mode 100644 index 0000000..aa191f3 --- /dev/null +++ b/src/panels/storageEvents.ts @@ -0,0 +1,9 @@ +// Names of events expected by the code running in storage.main.ts: + +const storageEvents = { + Init: 'init', + Refresh: 'refresh', + Update: 'update', +}; + +export { storageEvents }; \ No newline at end of file diff --git a/src/panels/storageSelectors.ts b/src/panels/storageSelectors.ts new file mode 100644 index 0000000..e4534c7 --- /dev/null +++ b/src/panels/storageSelectors.ts @@ -0,0 +1,13 @@ +// DOM query selectors for various elements in the storage.html template: + +const storageSelectors = { + ContractDropdown: '#contractDropdown', + Error: '.error', + NoStorage: '.no-storage', + RpcDescription: '#rpcDescription', + RpcUrl: '#rpcUrl', + StorageTable: '#storageTable', + StorageTableBody: '#storageTable tbody', +}; + +export { storageSelectors }; \ No newline at end of file diff --git a/src/resultValue.ts b/src/resultValue.ts new file mode 100644 index 0000000..09583ec --- /dev/null +++ b/src/resultValue.ts @@ -0,0 +1,30 @@ +const bs58check = require('bs58check'); + +export class ResultValue { + public readonly asInteger: string; + public readonly asByteArray: string; + public readonly asString: string; + public readonly asAddress: string; + constructor(result: any) { + let value = result.value; + if ((value !== null) && (value !== undefined)) { + if (value.length === 0) { + value = '00'; + } + this.asByteArray = '0x' + value; + const buffer = Buffer.from(value, 'hex'); + this.asString = buffer.toString(); + this.asAddress = ''; + if (value.length === 42) { + this.asAddress = bs58check.encode(buffer); + } + buffer.reverse(); + this.asInteger = BigInt('0x' + buffer.toString('hex')).toString(); + } else { + this.asInteger = '0'; + this.asByteArray = '(empty)'; + this.asString = '(empty)'; + this.asAddress = '(empty)'; + } + } +} \ No newline at end of file diff --git a/src/storagePanel.ts b/src/storagePanel.ts new file mode 100644 index 0000000..5934399 --- /dev/null +++ b/src/storagePanel.ts @@ -0,0 +1,140 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as vscode from 'vscode'; + +import { ContractDetector } from './contractDetector'; +import { BlockchainInfo, INeoRpcConnection, INeoSubscription, INeoStatusReceiver } from './neoRpcConnection'; +import { NeoExpressConfig } from './neoExpressConfig'; +import { ResultValue } from './resultValue'; +import { storageEvents } from './panels/storageEvents'; + +const JavascriptHrefPlaceholder : string = '[JAVASCRIPT_HREF]'; +const CssHrefPlaceholder : string = '[CSS_HREF]'; + +class ViewState { + contracts: any[] = []; + rpcUrl: string = ''; + rpcDescription: string = ''; + selectedContractHash: string = ''; + selectedContractStorage: any = []; +} + +export class StoragePanel implements INeoSubscription, INeoStatusReceiver { + + private readonly neoExpressConfig?: NeoExpressConfig; + private readonly panel: vscode.WebviewPanel; + private readonly rpcUri: string; + private readonly rpcConnection: INeoRpcConnection; + private readonly contractDetector: ContractDetector; + + private viewState: ViewState; + + constructor( + extensionPath: string, + rpcUri: string, + rpcConnection: INeoRpcConnection, + contractDetector: ContractDetector, + disposables: vscode.Disposable[], + neoExpressConfig?: NeoExpressConfig) { + + this.rpcUri = rpcUri; + this.contractDetector = contractDetector; + this.neoExpressConfig = neoExpressConfig; + this.viewState = new ViewState(); + this.viewState.rpcDescription = neoExpressConfig ? neoExpressConfig.neoExpressJsonFullPath : ''; + this.viewState.rpcUrl = rpcUri; + + this.rpcConnection = rpcConnection; + this.rpcConnection.subscribe(this); + + this.panel = vscode.window.createWebviewPanel( + 'storagePanel', + (this.neoExpressConfig ? this.neoExpressConfig.basename : this.rpcUri) + ' - Storage Explorer', + vscode.ViewColumn.Active, + { enableScripts: true }); + this.panel.iconPath = vscode.Uri.file(path.join(extensionPath, 'resources', 'neo.svg')); + this.panel.onDidDispose(this.onClose, this, disposables); + this.panel.webview.onDidReceiveMessage(this.onMessage, this, disposables); + + const htmlFileContents = fs.readFileSync( + path.join(extensionPath, 'src', 'panels', 'storage.html'), { encoding: 'utf8' }); + const javascriptHref : string = this.panel.webview.asWebviewUri( + vscode.Uri.file(path.join(extensionPath, 'out', 'panels', 'bundles', 'storage.main.js'))) + ''; + const cssHref : string = this.panel.webview.asWebviewUri( + vscode.Uri.file(path.join(extensionPath, 'out', 'panels', 'storage.css'))) + ''; + this.panel.webview.html = htmlFileContents + .replace(JavascriptHrefPlaceholder, javascriptHref) + .replace(CssHrefPlaceholder, cssHref); + } + + public dispose() { + this.panel.dispose(); + } + + public updateStatus(status: string) { + console.info('StoragePanel status:', status); + } + + public async onNewBlock(blockchainInfo: BlockchainInfo) { + if (this.viewState.selectedContractHash) { + await this.refresh(); + await this.panel.webview.postMessage({ viewState: this.viewState }); + } + } + + private onClose() { + this.dispose(); + } + + private async onMessage(message: any) { + if (message.e === storageEvents.Init) { + await this.refresh(); + await this.panel.webview.postMessage({ viewState: this.viewState }); + } else if (message.e === storageEvents.Refresh) { + await this.refresh(); + await this.panel.webview.postMessage({ viewState: this.viewState }); + } else if (message.e === storageEvents.Update) { + this.viewState = message.c; + await this.refresh(); + await this.panel.webview.postMessage({ viewState: this.viewState }); + } + } + + private async refresh() { + this.viewState.contracts = []; + this.viewState.selectedContractStorage = {}; + + if (this.neoExpressConfig) { + this.neoExpressConfig.refresh(); + this.viewState.contracts = this.neoExpressConfig.contracts.slice(); + } + + // TODO: Don't assume all contracts found in the workspace have been deployed. + const workspaceContracts = this.contractDetector.contracts; + for (let i = 0; i < workspaceContracts.length; i++) { + if (this.viewState.contracts.filter(c => c.hash.toLowerCase() === workspaceContracts[i].hash.toLowerCase()).length === 0) { + this.viewState.contracts.push(workspaceContracts[i].abi); + } + } + + if (!this.viewState.contracts.find(c => c.hash === this.viewState.selectedContractHash)) { + this.viewState.selectedContractHash = ''; + } + + if (this.viewState.contracts.length) { + this.viewState.selectedContractHash = this.viewState.selectedContractHash || this.viewState.contracts[0].hash; + } + + if (this.viewState.selectedContractHash) { + this.viewState.selectedContractStorage = + await this.rpcConnection.getContractStorage(this.viewState.selectedContractHash, this); + if (this.viewState.selectedContractStorage) { + for (let i = 0; i < this.viewState.selectedContractStorage.length; i++) { + this.viewState.selectedContractStorage[i].parsed = + new ResultValue(this.viewState.selectedContractStorage[i]); + } + } + } + } + +} \ No newline at end of file