Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

UI for smart contract deployment #44

Merged
merged 20 commits into from
Jan 16, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,12 @@
"category": "Neo Express",
"when": "view == neo-visual-devtracker.rpcServerExplorer && viewItem == startable"
},
{
"command": "neo-visual-devtracker.deployContract",
"title": "Deploy contract",
"category": "Neo Express",
"when": "view == neo-visual-devtracker.rpcServerExplorer && viewItem != editable"
},
{
"command": "neo-visual-devtracker.createInstance",
"title": "Create new Neo Express instance",
Expand Down Expand Up @@ -212,6 +218,10 @@
"command": "neo-visual-devtracker.claim",
"when": "view == neo-visual-devtracker.rpcServerExplorer && viewItem != editable"
},
{
"command": "neo-visual-devtracker.deployContract",
"when": "view == neo-visual-devtracker.rpcServerExplorer && viewItem != editable"
},
{
"command": "neo-visual-devtracker.invokeContract",
"when": "view == neo-visual-devtracker.rpcServerExplorer && viewItem == startable"
Expand Down
4 changes: 4 additions & 0 deletions sample-workspace/neo-servers.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,9 @@
{
"name": "Neo Test Net",
"url": "https://test3.cityofzion.io"
},
{
"name": "Neo Test Net - getunspents supported",
"url": "http://seed5.ngd.network:20332"
}
]
55 changes: 55 additions & 0 deletions src/contractDetector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import * as crypto from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import * as vscode from 'vscode';

class Contract {
public readonly hash?: string;
public readonly name?: string;
public readonly path: string;
public readonly avmHex?: string;
constructor(fullpath: string) {
this.path = fullpath;
try {
this.name = path.basename(fullpath).replace(/\.avm$/, '');
const avmContents = fs.readFileSync(fullpath);
this.avmHex = avmContents.toString('hex');
const sha256Bytes = crypto.createHash('sha256').update(avmContents).digest();
const ripemd160Bytes = crypto.createHash('ripemd160').update(sha256Bytes).digest();
this.hash = '0x' + Buffer.from(ripemd160Bytes.reverse()).toString('hex');
} catch (e) {
console.error('Error parsing', path, e);
}
}
}

export class ContractDetector {

private readonly fileSystemWatcher: vscode.FileSystemWatcher;
private readonly searchPattern: vscode.GlobPattern = '**/*.avm';

public contracts: Contract[];

constructor() {
this.contracts = [];
this.refresh();
this.fileSystemWatcher = vscode.workspace.createFileSystemWatcher(this.searchPattern);
this.fileSystemWatcher.onDidChange(this.refresh, this);
this.fileSystemWatcher.onDidCreate(this.refresh, this);
this.fileSystemWatcher.onDidDelete(this.refresh, this);
}

public dispose() {
if (this.fileSystemWatcher) {
this.fileSystemWatcher.dispose();
}
}

public async refresh() {
const files = await vscode.workspace.findFiles(this.searchPattern);
this.contracts = files
.map(uri => new Contract(uri.fsPath))
.filter(contract => !!contract.hash);
}

}
206 changes: 206 additions & 0 deletions src/deployPanel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import * as fs from 'fs';
import * as neon from '@cityofzion/neon-js';
import * as path from 'path';
import * as vscode from 'vscode';

import { deployEvents } from './panels/deployEvents';

import { ContractDetector } from './contractDetector';
import { NeoExpressConfig } from './neoExpressConfig';
import { WalletExplorer } from './walletExplorer';

const JavascriptHrefPlaceholder : string = '[JAVASCRIPT_HREF]';
const CssHrefPlaceholder : string = '[CSS_HREF]';

class ViewState {
contracts: any[] = [];
contractPath?: string = undefined;
contractName?: string = undefined;
contractHash?: string = undefined;
contractAvmHex?: string = undefined;
isValid: boolean = false;
result: string = '';
showError: boolean = false;
showSuccess: boolean = false;
walletAddress?: string = undefined;
wallets: any[] = [];
}

export class DeployPanel {

private readonly contractDetector: ContractDetector;
private readonly neoExpressConfig?: NeoExpressConfig;
private readonly panel: vscode.WebviewPanel;
private readonly rpcUri: string;
private readonly walletExplorer: WalletExplorer;

private viewState: ViewState;
private initialized: boolean = false;

constructor(
extensionPath: string,
rpcUri: string,
walletExplorer: WalletExplorer,
contractDetector: ContractDetector,
disposables: vscode.Disposable[],
neoExpressConfig?: NeoExpressConfig) {

this.contractDetector = contractDetector;
this.rpcUri = rpcUri;
this.walletExplorer = walletExplorer;
this.neoExpressConfig = neoExpressConfig;
this.viewState = new ViewState();

this.panel = vscode.window.createWebviewPanel(
'deployPanel',
(this.neoExpressConfig ? this.neoExpressConfig.basename : this.rpcUri) + ' - Deploy contract',
vscode.ViewColumn.Active,
{ enableScripts: true });
this.panel.iconPath = vscode.Uri.file(path.join(extensionPath, 'resources', 'neo.svg'));
this.panel.webview.onDidReceiveMessage(this.onMessage, this, disposables);

const htmlFileContents = fs.readFileSync(
path.join(extensionPath, 'src', 'panels', 'deploy.html'), { encoding: 'utf8' });
const javascriptHref : string = this.panel.webview.asWebviewUri(
vscode.Uri.file(path.join(extensionPath, 'out', 'panels', 'bundles', 'deploy.main.js'))) + '';
const cssHref : string = this.panel.webview.asWebviewUri(
vscode.Uri.file(path.join(extensionPath, 'out', 'panels', 'deploy.css'))) + '';
this.panel.webview.html = htmlFileContents
.replace(JavascriptHrefPlaceholder, javascriptHref)
.replace(CssHrefPlaceholder, cssHref);
}

public dispose() {
this.panel.dispose();
}

private async doDeploy() {
try {
//
// Construct a script that calls Neo.Contract.Create
// -- see: https://docs.neo.org/docs/en-us/reference/scapi/fw/dotnet/neo/Contract/Create.html
//
// TODO: Find the contracts .abi.json and correctly determine parameters and return types. For now
// we assume that all contracts take two parameters (a string and an array) and return a byte
// array.
// TODO: Allow storage usage to be specified in the UI. For now we assume storage is used.
// TODO: Allow specification of description, email, etc. in the UI. For now we use blank values.
//
const sb = neon.default.create.scriptBuilder();
const script = sb
.emitPush(neon.default.u.str2hexstring('')) // description
Copy link
Contributor

@devhawk devhawk Jan 15, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Contract metadata should be in the abi.json file as of NEON v2.6. If the metadata property is missing, using empty strings is a fine default, but these should be using abi.json if possible

.emitPush(neon.default.u.str2hexstring('')) // email
.emitPush(neon.default.u.str2hexstring('')) // author
.emitPush(neon.default.u.str2hexstring('')) // code_version
.emitPush(neon.default.u.str2hexstring('')) // name
.emitPush(0x01) // storage: {none: 0x00, storage: 0x01, dynamic: 0x02, storage+dynamic:0x03}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

storage and dynamic is part of the aformentioned contract metadata. If the metadata property is missing, prompt the user instead of assuming contracts use storage but arent dynamic

.emitPush('05') // return type - see https://docs.neo.org/docs/en-us/sc/deploy/Parameter.html
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This information is in the abi.json file. If the abi.json file is missing, fail deployment rather than assuming return/parameter types

.emitPush('0710') // parameter list - see https://docs.neo.org/docs/en-us/sc/deploy/Parameter.html
.emitPush(this.viewState.contractAvmHex)
.emitSysCall('Neo.Contract.Create')
.str;

// Determine required GAS:
const rpcClient = new neon.rpc.RPCClient(this.rpcUri);
const invokeResult = await rpcClient.invokeScript(script);
const gas = parseFloat(invokeResult.gas_consumed);

const walletConfig = this.viewState.wallets.filter(_ => _.address === this.viewState.walletAddress)[0];
if (await walletConfig.unlock()) {
const api = new neon.api.neoCli.instance(this.rpcUri);
const config = {
api: api,
script: script,
account: walletConfig.account,
signingFunction: walletConfig.signingFunction,
gas: gas,
};
const result = await neon.default.doInvoke(config);
if (result.response && result.response.txid) {
this.viewState.result = result.response.txid;
this.viewState.showError = false;
this.viewState.showSuccess = true;
} else {
this.viewState.result = 'No response from RPC server; contract may not have deployed';
this.viewState.showError = true;
this.viewState.showSuccess = false;
}
}
} catch (e) {
this.viewState.result = 'Error deploying contract: ' + e;
this.viewState.showError = true;
this.viewState.showSuccess = false;
}
}

private async onMessage(message: any) {
if (message.e === deployEvents.Init) {
await this.refresh(false);
await this.panel.webview.postMessage({ viewState: this.viewState });
} else if (message.e === deployEvents.Refresh) {
await this.refresh(true);
await this.panel.webview.postMessage({ viewState: this.viewState });
} else if (message.e === deployEvents.Update) {
this.viewState = message.c;
await this.refresh(true);
await this.panel.webview.postMessage({ viewState: this.viewState });
} else if (message.e === deployEvents.Deploy) {
await this.refresh(true);
if (this.viewState.isValid) {
await this.doDeploy();
}
await this.panel.webview.postMessage({ viewState: this.viewState });
} else if (message.e === deployEvents.Close) {
this.dispose();
} else if (message.e === deployEvents.NewWallet) {
this.initialized = false; // cause wallet list to be refreshed when this panel is next initialized
vscode.commands.executeCommand('neo-visual-devtracker.createWalletFile');
}
}

private async refresh(force: boolean) {
if (!force && this.initialized) {
return;
}

this.viewState.showError = false;
this.viewState.showSuccess = false;

this.viewState.wallets = [];
if (this.neoExpressConfig) {
this.neoExpressConfig.refresh();
this.viewState.wallets = this.neoExpressConfig.wallets.slice();
}

for (let i = 0; i < this.walletExplorer.allAccounts.length; i++) {
this.viewState.wallets.push(this.walletExplorer.allAccounts[i]);
}

const walletConfig = this.viewState.wallets.filter(_ => _.address === this.viewState.walletAddress)[0];
if (!walletConfig) {
this.viewState.walletAddress = undefined;
}

await this.contractDetector.refresh();
this.viewState.contracts = this.contractDetector.contracts;

const contractConfig = this.viewState.contracts.filter(_ => _.path === this.viewState.contractPath)[0];
if (!contractConfig) {
this.viewState.contractPath = undefined;
this.viewState.contractName = undefined;
this.viewState.contractHash = undefined;
this.viewState.contractAvmHex = undefined;
} else {
this.viewState.contractName = contractConfig.name;
this.viewState.contractHash = contractConfig.hash;
this.viewState.contractAvmHex = contractConfig.avmHex;
}

this.viewState.isValid =
!!this.viewState.walletAddress &&
!!this.viewState.contractPath;

this.initialized = true;
}

}
Loading