Skip to content

Commit

Permalink
Refactor token bridge server to be deployable to Azure App Service
Browse files Browse the repository at this point in the history
  • Loading branch information
NeoXtreem committed Apr 28, 2022
1 parent 9ff4453 commit 94f883d
Show file tree
Hide file tree
Showing 15 changed files with 4,909 additions and 2,190 deletions.
4 changes: 4 additions & 0 deletions .github/workflows/schnoodlev9_schnoodleserver(test).yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ on:

jobs:
build:
defaults:
run:
working-directory: SchnoodleDApp/Server

runs-on: ubuntu-latest

steps:
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,18 @@ The following shared services should be set up **only once** per blockchain (exc
- Integrate the DAO module instance into Snapshot by adding the SafeSnap plugin to [Snapshot space settings](https://snapshot.org/#/schnoodle.eth/settings) with config `{ "address": "<DAO module address>" }`.

# Setup
1. Populate the [secrets.json] file (see [Secrets](#secrets) below).
1. Execute `npm i`.
1. In Visual Studio Code, open each Solidity file in the [contracts](contracts) folder corresponding to the ABI files listed in [Nethereum.Generator.json](SchnoodleDApp/Nethereum.Generator.json), and press F5 ('Solidity: Compile Contract' command).
1. If target network is local, execute `truffle develop` in a separate terminal.
1. Execute `.\Migrate.ps1 <network> $true $true` where `<network>` is the target network per the `networks` property in [truffle-config.js](truffle-config.js).

## Secrets
1. For `mnemonic`, use any account that has some native test tokens.
1. For `infuraProjectId`, register an [Infura](https://infura.io/register) account, then create a project and get the project ID.
1. For `etherscanApiKey`, register an [Etherscan](https://etherscan.io/register) account, then create an API key.
1. For `bscscanApiKey`, register a [BscScan](https://bscscan.com/register) account, then create an API key.

# Blockchain Launch
1. Note the 'To' contract address of the `create_0_1` internal transaction of the `SchnoodleTimelockFactory` Contract Creation transaction. Verify the `SchnoodleTimelock` contract using this address.
1. Add liquidity to Uniswap V2, and note the liquidity token address (UNI-V2 token) from the corresponding transaction.
Expand Down
15 changes: 9 additions & 6 deletions SchnoodleDApp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,18 @@ The following shared services should be set up **only once** per environment:
1. Populate the [secrets.json](secrets.json) file (see [Secrets](#secrets) below), then execute `type .\secrets.json | dotnet user-secrets set`.

## Secrets
1. For `bridgePrivateKey`, use any account that has some native test tokens. Ensure this account has the `BRIDGE` role (see [Roles](#roles) below).
1. For `password`, enter any suitable unique password.
1. For `Pinata:Jwt`, open a [Pinata](https://app.pinata.cloud) account then create an API key.
1. For `Pinata:Jwt`, register a [Pinata](https://app.pinata.cloud) account, then create an API key.
1. For `Blockchain:PrivateKey`, use any account that has some native test tokens. Ensure this account has the `MINTER_ROLE` role (see [Roles](#roles) below).
1. For `Data:Key`, obtain the read-write primary key for the test Azure Cosmos DB account from the administrator of that account.
1. For `Files:Key`, obtain one of the access keys for the test Azure storage account from the administrator of that account.

### Roles
To check that an this account has a role on the smart contract, use the `hasRole` function. To grant the role, the contract owner must grant it using `grantRole`.

# Server
1. Create `.env*` files with the application settings of the test App Service from the administrator of that account. Make the following amendments:
- `URL=localhost`
- `BRIDGE_PRIVATE_KEY=`
- Assign the private key of any account that has some native test tokens. Ensure this account has the `BRIDGE` role (see [Roles](#roles) below).
- `BRIDGE_PASSWORD=`
- Assign any suitable unique password.
1. Execute `.\Server.ps1`.

# Client
Expand All @@ -41,3 +42,5 @@ To check that an this account has a role on the smart contract, use the `hasRole
1. Connect MetaMask to target network (e.g., Rinkeby or http://localhost:8545). You will need to reset MetaMask (in advanced settings) if the network is local and was restarted since the previous run.
1. Add the first account private key in Truffle Develop to MetaMask, and select this account.

# Roles
To check if an account has a role on the smart contract, use the `hasRole` function. To grant the role, the contract owner must grant it using `grantRole`.
5 changes: 2 additions & 3 deletions SchnoodleDApp/Server.ps1
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
Push-Location $PSScriptRoot\Server
$env:DOTENV_CONFIG_PATH=".env.development"
$Process = Start-Process node ("-r", "dotenv/config", "server")
$Process = Start-Process npm "run start:dev"
Start-Sleep -s 5
if ($Process.ExitCode -ne 0) { Start-Process node encrypt }
if ($Process.ExitCode -ne 0) { Start-Process node "-r dotenv/config encrypt dotenv_config_path=./.env.development" }
Pop-Location
1 change: 1 addition & 0 deletions SchnoodleDApp/Server/.gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
.env*
encrypted.json
239 changes: 113 additions & 126 deletions SchnoodleDApp/Server/app.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
'use strict'

const fastify = require('fastify');
const Web3 = require('web3');
const fs = require('fs');
const { writeFile } = require('fs/promises');
const CryptoJS = require('crypto-js');
const BigNumber = require('bignumber.js');
const { password } = require('./secrets.json');
const { initializeApp } = require('firebase/app');
const { getDatabase, ref, get, set, child, onValue } = require('firebase/database');

const SchnoodleV1 = require('../ClientApp/src/contracts/SchnoodleV1.json');
const Schnoodle = require('../ClientApp/src/contracts/SchnoodleV9.json');
const { SecretClient } = require("@azure/keyvault-secrets");
const { DefaultAzureCredential } = require("@azure/identity");

const SchnoodleV1 = require('./contracts/SchnoodleV1.json');
const Schnoodle = require('./contracts/SchnoodleV9.json');

const web3Eth = new Web3(process.env.ETH_CHAIN);
const web3Bsc = new Web3(new Web3.providers.HttpProvider(process.env.BSC_CHAIN));
Expand Down Expand Up @@ -43,154 +47,137 @@ get(dbRef).then(snapshot => {
});
});

let app = fastify();
app.listen(process.env.PORT, process.env.URL, (err, address) => listen(err, address));
const client = new SecretClient(new URL(`https://${process.env.KEY_VAULT_NAME}.vault.azure.net`).href, new DefaultAzureCredential());

function listen(err, address) {
if (err) {
console.log(err);
} else {
console.log('Server runs on', address);
}
}
function build(opts = {}) {
const app = fastify(opts);

app.get('/Alive', async (request, reply) => sendReply(reply, 'ok'));
app.get('/Alive', async (request, reply) => sendReply(reply, 'ok'));

app.post('/WriteSecretMessage', async (request, reply) => {
try {
await writeFile('encrypted.json', JSON.stringify({ "message": request.body.message }), { flag: 'wx' });
sendReply(reply, 'ok');
} catch (err) {
sendReply(reply, 'error', { err });
}
});

app.post('/WriteSecretMessage', async (request, reply) => {
const encrypted = `encrypted.json`;
app.post('/GetFee', async (request, reply) => {
var data = JSON.parse(request.body);

fs.readFile(encrypted, 'utf-8', async (err, dataFile) => {
if (err) {
try {
sendReply(reply, 'ok', { fee: fees[data.network] });
} catch (err) {
console.log(err);
sendReply(reply, 'error', { err });
}
});

const message = request.body.message;
app.post('/GetTokensPending', async (request, reply) => {
var data = JSON.parse(request.body);

if (message === '') {
sendReply(reply, 'error', { message: 'Message is empty' });
} else if (dataFile != null) {
sendReply(reply, 'error', { message: 'Message file already exists' });
try {
sendReply(reply, 'ok', { tokensPending: await getTokensPending(data) });
} catch (err) {
console.log(err);
sendReply(reply, 'error', { err });
}
else {
fs.writeFile(encrypted, JSON.stringify({ "message": message }), (err) => {
if (err) {
sendReply(reply, 'error', { err });
}
});

app.post('/ReceiveTokens', async (request, reply) => {
var data = JSON.parse(request.body);
console.log('-'.repeat(60));
console.log('Timestamp:', new Date().toISOString());
console.log('Data:', data);

let message;

try {
const web3 = getWeb3(data.targetNetwork);
const privateKey = (await decryptMessage()).toString(CryptoJS.enc.Utf8);
const schnoodleReceiver = getContract(data.targetNetwork);
const tokensPending = await getTokensPending(data, true);

// Build transaction to call receiveTokens
const txSend = {
from: web3.eth.accounts.privateKeyToAccount(privateKey).address,
to: schnoodleReceiver.options.address,
gasPrice: (new BigNumber(await web3.eth.getGasPrice())).times(1.2).toFixed(0),
data: schnoodleReceiver.methods.receiveTokens(data.address, await getNetworkId(data.sourceNetwork), tokensPending, fees[data.targetNetwork]).encodeABI()
};

txSend.gasLimit = await web3.eth.estimateGas(txSend) * 2;
const receipt = await web3.eth.sendSignedTransaction((await web3.eth.accounts.signTransaction(txSend, privateKey)).rawTransaction);

if (receipt.status) {
set(child(dbRef, data.targetNetwork), receipt.gasUsed * txSend.gasPrice);
sendReply(reply, 'ok');
});
}
} catch (err) {
console.log(err);
message = err.message;
}

sendReply(reply, 'error', { message });
});
});

app.post('/GetFee', async (request, reply) => {
var data = JSON.parse(request.body);
return app;

try {
sendReply(reply, 'ok', { fee: fees[data.network] });
} catch (err) {
console.log(err);
sendReply(reply, 'error', { err });
}
});
async function getTokensPending(data, log) {
const tokensSent = new BigNumber(await getContract(data.sourceNetwork).methods.tokensSent(data.address, await getNetworkId(data.targetNetwork)).call());
const tokensReceived = new BigNumber(await getContract(data.targetNetwork).methods.tokensReceived(data.address, await getNetworkId(data.sourceNetwork)).call());
const tokensPending = tokensSent.minus(tokensReceived);

app.post('/GetTokensPending', async (request, reply) => {
var data = JSON.parse(request.body);
if (tokensPending > 0 && log) {
console.log(`${tokensPending} tokens pending to bridge from ${data.sourceNetwork} to ${data.targetNetwork} (${tokensSent} sent less ${tokensReceived} received).`);
}

try {
sendReply(reply, 'ok', { tokensPending: await getTokensPending(data) });
} catch (err) {
console.log(err);
sendReply(reply, 'error', { err });
return tokensPending;
}
});

app.post('/ReceiveTokens', async (request, reply) => {
var data = JSON.parse(request.body);
console.log('-'.repeat(60));
console.log('Timestamp:', new Date().toISOString());
console.log('Data:', data);

let message;

try {
const web3 = getWeb3(data.targetNetwork);
const privateKey = decryptMessage().toString(CryptoJS.enc.Utf8);
const schnoodleReceiver = getContract(data.targetNetwork);
const tokensPending = await getTokensPending(data, true);

// Build transaction to call receiveTokens
const txSend = {
from: web3.eth.accounts.privateKeyToAccount(privateKey).address,
to: schnoodleReceiver.options.address,
gasPrice: (new BigNumber(await web3.eth.getGasPrice())).times(1.2).toFixed(0),
data: schnoodleReceiver.methods.receiveTokens(data.address, await getNetworkId(data.sourceNetwork), tokensPending, fees[data.targetNetwork]).encodeABI()
};

txSend.gasLimit = await web3.eth.estimateGas(txSend) * 2;
const receipt = await web3.eth.sendSignedTransaction((await web3.eth.accounts.signTransaction(txSend, privateKey)).rawTransaction);

if (receipt.status) {
set(child(dbRef, data.targetNetwork), receipt.gasUsed * txSend.gasPrice);
sendReply(reply, 'ok');
function getWeb3(network) {
switch (network) {
case 'ethereum':
return web3Eth;
case 'bsc':
return web3Bsc;
default:
throw `Network not supported: ${network}`;
}
} catch (err) {
console.log(err);
message = err.message;
}

sendReply(reply, 'error', { message });
});

async function getTokensPending(data, log) {
const tokensSent = new BigNumber(await getContract(data.sourceNetwork).methods.tokensSent(data.address, await getNetworkId(data.targetNetwork)).call());
const tokensReceived = new BigNumber(await getContract(data.targetNetwork).methods.tokensReceived(data.address, await getNetworkId(data.sourceNetwork)).call());
const tokensPending = tokensSent.minus(tokensReceived);

if (tokensPending > 0 && log) {
console.log(`${tokensPending} tokens pending to bridge from ${data.sourceNetwork} to ${data.targetNetwork} (${tokensSent} sent less ${tokensReceived} received).`);
async function getNetworkId(network) {
return await getWeb3(network).eth.net.getId();
}

return tokensPending;
}

function getWeb3(network) {
switch (network) {
case 'ethereum':
return web3Eth;
case 'bsc':
return web3Bsc;
default:
throw `Network not supported: ${network}`;
function getContract(network) {
switch (network) {
case 'ethereum':
return schnoodleEth;
case 'bsc':
return schnoodleBsc;
default:
throw `Network not supported: ${network}`;
}
}
}

async function getNetworkId(network) {
return await getWeb3(network).eth.net.getId();
}

function getContract(network) {
switch (network) {
case 'ethereum':
return schnoodleEth;
case 'bsc':
return schnoodleBsc;
default:
throw `Network not supported: ${network}`;
async function decryptMessage() {
const { message } = require(`./encrypted.json`);
const keySize = 256;
const iterations = 100;
const password = process.env.BRIDGE_PASSWORD || await client.getSecret('bridgePassword');
const salt = CryptoJS.enc.Hex.parse(message.substring(0, 32));
const iv = CryptoJS.enc.Hex.parse(message.substring(32, 64));
const key = CryptoJS.PBKDF2(password, salt, { keySize: keySize / 32, iterations });
return CryptoJS.AES.decrypt(message.substring(64), key, { iv });
}
}

function decryptMessage() {
const { message } = require(`./encrypted.json`);
const keySize = 256;
const iterations = 100;
const salt = CryptoJS.enc.Hex.parse(message.substring(0, 32));
const iv = CryptoJS.enc.Hex.parse(message.substring(32, 64));
const key = CryptoJS.PBKDF2(password, salt, { keySize: keySize / 32, iterations });
return CryptoJS.AES.decrypt(message.substring(64), key, { iv });
function sendReply(reply, status, body) {
reply
.header('Access-Control-Allow-Origin', '*')
.send({ status, body });
}
}

function sendReply(reply, status, body) {
reply
.header('Access-Control-Allow-Origin', '*')
.send({ status, body });
}
module.exports = build
14 changes: 14 additions & 0 deletions SchnoodleDApp/Server/app.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
'use strict'

const { test } = require('tap')
const build = require('./app')

test('requests the "/" route', async t => {
const app = build();

const response = await app.inject({
method: 'GET',
url: '/Alive'
})
t.equal(response.statusCode, 200, 'returns a status code of 200')
})
13 changes: 6 additions & 7 deletions SchnoodleDApp/Server/encrypt.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,18 @@
require('dotenv').config();
var CryptoJS = require("crypto-js");
var request = require('request'); // TODO: Replace with Node fetch when available: https://github.com/nodejs/node/pull/41749
const { bridgePrivateKey, password } = require('./secrets.json');
const CryptoJS = require("crypto-js");
const request = require('request'); // TODO: Replace with Node fetch when available: https://github.com/nodejs/node/pull/41749

const keySize = 256;
const iterations = 100;
const salt = CryptoJS.lib.WordArray.random(128/8);
const key = CryptoJS.PBKDF2(password, salt, { keySize: keySize / 32, iterations });
var iv = CryptoJS.lib.WordArray.random(128/8);
const key = CryptoJS.PBKDF2(process.env.BRIDGE_PASSWORD, salt, { keySize: keySize / 32, iterations });
const iv = CryptoJS.lib.WordArray.random(128/8);

const encrypted = CryptoJS.AES.encrypt(bridgePrivateKey.toString(CryptoJS.format.Base64), key, { iv });
const encrypted = CryptoJS.AES.encrypt(process.env.BRIDGE_PRIVATE_KEY.toString(CryptoJS.format.Base64), key, { iv });

request.post({
headers: {'content-type' : 'application/json'},
url: `http://localhost:${process.env.PORT}/WriteSecretMessage`,
url: `http://${process.env.URL}:${process.env.PORT}/WriteSecretMessage`,
body: JSON.stringify({ message: `${salt.toString()}${encrypted.iv.toString()}${encrypted}` })
}, function(error, response, body) {
console.log(body);
Expand Down

0 comments on commit 94f883d

Please sign in to comment.