Skip to content

Commit

Permalink
Channel contracts (#279)
Browse files Browse the repository at this point in the history
* Add support for contracts in state channels

* Remove console.log

* Remove console.log

* Improve channel rpc usage (#275)

* Improve channel rpc usage

* Fix lint error

* Remove unreachable code

* Improve channel tests and error handling (#276)

* Make sure that sign function is correctly called

* Improve error handling for update method

* Improve state channel params handling. Fixes #299 (#300)
  • Loading branch information
mpowaga committed Apr 2, 2019
1 parent 8e01600 commit 988f089
Show file tree
Hide file tree
Showing 4 changed files with 301 additions and 4 deletions.
53 changes: 52 additions & 1 deletion es/channel/handlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
* PERFORMANCE OF THIS SOFTWARE.
*/

import { generateKeyPair } from '../utils/crypto'
import { generateKeyPair, encodeContractAddress } from '../utils/crypto'
import {
options,
changeStatus,
Expand All @@ -24,6 +24,7 @@ import {
emit,
channelId
} from './internal'
import { unpackTx } from '../tx/builder'

export function awaitingConnection (channel, message, state) {
if (message.method === 'channels.info') {
Expand Down Expand Up @@ -310,6 +311,56 @@ export function awaitingDepositCompletion (channel, message, state) {
}
}

export async function awaitingNewContractTx (channel, message, state) {
if (message.method === 'channels.sign.update') {
const signedTx = await Promise.resolve(state.sign(message.params.data.tx))
send(channel, { jsonrpc: '2.0', method: 'channels.update', params: { tx: signedTx } })
return { handler: awaitingNewContractCompletion, state }
}
}

export function awaitingNewContractCompletion (channel, message, state) {
if (message.method === 'channels.update') {
const { round } = unpackTx(message.params.data.state).tx.encodedTx.tx
// eslint-disable-next-line standard/computed-property-even-spacing
const owner = options.get(channel)[{
initiator: 'initiatorId',
responder: 'responderId'
}[options.get(channel).role]]
changeState(channel, message.params.data.state)
state.resolve({
accepted: true,
address: encodeContractAddress(owner, round),
state: message.params.data.state
})
return { handler: channelOpen }
}
if (message.method === 'channels.conflict') {
state.resolve({ accepted: false })
return { handler: channelOpen }
}
}

export async function awaitingCallContractUpdateTx (channel, message, state) {
if (message.method === 'channels.sign.update') {
const signedTx = await Promise.resolve(state.sign(message.params.data.tx))
send(channel, { jsonrpc: '2.0', method: 'channels.update', params: { tx: signedTx } })
return { handler: awaitingCallContractCompletion, state }
}
}

export function awaitingCallContractCompletion (channel, message, state) {
if (message.method === 'channels.update') {
changeState(channel, message.params.data.state)
state.resolve({ accepted: true, state: message.params.data.state })
return { handler: channelOpen }
}
if (message.method === 'channels.conflict') {
state.resolve({ accepted: false })
return { handler: channelOpen }
}
}

export function channelClosed (channel, message, state) {
return { handler: channelClosed }
}
138 changes: 136 additions & 2 deletions es/channel/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
*/

import AsyncInit from '../utils/async-init'
import { snakeToPascal } from '../utils/string'
import * as handlers from './handlers'
import {
eventEmitters,
Expand Down Expand Up @@ -192,7 +193,7 @@ function shutdown (sign) {
return new Promise((resolve) => {
enqueueAction(
this,
(channel, state) => true,
(channel, state) => state.handler === handlers.channelOpen,
(channel, state) => {
send(channel, { jsonrpc: '2.0', method: 'channels.shutdown', params: {} })
return {
Expand Down Expand Up @@ -297,6 +298,136 @@ function deposit (amount, sign, { onOnChainTx, onOwnDepositLocked, onDepositLock
})
}

/**
* Create a contract
*
* @param {object} options
* @param {string} [options.code] - Api encoded compiled AEVM byte code
* @param {string} [options.callData] - Api encoded compiled AEVM call data for the code
* @param {number} [options.deposit] - Initial amount the owner of the contract commits to it
* @param {number} [options.vmVersion] - Version of the AEVM
* @param {number} [options.abiVersion] - Version of the ABI
* @param {function} sign - Function which verifies and signs create contract transaction
* @return {Promise<object>}
* @example channel.createContract({
* code: 'cb_HKtpipK4aCgYb17wZ...',
* callData: 'cb_1111111111111111...',
* deposit: 10,
* vmVersion: 3,
* abiVersion: 1
* }).then(({ accepted, state, address }) => {
* if (accepted) {
* console.log('New contract has been created')
* console.log('Contract address:', address)
* } else {
* console.log('New contract has been rejected')
* }
* })
*/
function createContract ({ code, callData, deposit, vmVersion, abiVersion }, sign) {
return new Promise((resolve) => {
enqueueAction(
this,
(channel, state) => state.handler === handlers.channelOpen,
(channel, state) => {
send(channel, {
jsonrpc: '2.0',
method: 'channels.update.new_contract',
params: {
code,
call_data: callData,
deposit,
vm_version: vmVersion,
abi_version: abiVersion
}
})
return {
handler: handlers.awaitingNewContractTx,
state: {
sign,
resolve
}
}
}
)
})
}

/**
* Call a contract
*
* @param {object} options
* @param {string} [options.amount] - Amount the caller of the contract commits to it
* @param {string} [options.callData] - ABI encoded compiled AEVM call data for the code
* @param {number} [options.contract] - Address of the contract to call
* @param {number} [options.abiVersion] - Version of the ABI
* @param {function} sign - Function which verifies and signs contract call transaction
* @return {Promise<object>}
* @example channel.callContract({
* contract: 'ct_9sRA9AVE4BYTAkh5RNfJYmwQe1NZ4MErasQLXZkFWG43TPBqa',
* callData: 'cb_1111111111111111...',
* amount: 0,
* abiVersion: 1
* }).then(({ accepted, state }) => {
* if (accepted) {
* console.log('Contract called succesfully')
* console.log('The new state is:', state)
* } else {
* console.log('Contract call has been rejected')
* }
* })
*/
function callContract ({ amount, callData, contract, abiVersion }, sign) {
return new Promise((resolve) => {
enqueueAction(
this,
(channel, state) => state.handler === handlers.channelOpen,
(channel, state) => {
send(channel, {
jsonrpc: '2.0',
method: 'channels.update.call_contract',
params: {
amount,
call_data: callData,
contract,
abi_version: abiVersion
}
})
return {
handler: handlers.awaitingCallContractUpdateTx,
state: { resolve, sign }
}
}
)
})
}

/**
* Get contract call result
*
* @param {object} options
* @param {string} [options.caller] - Address of contract caller
* @param {string} [options.contract] - Address of the contract
* @param {number} [options.round] - Round when contract was called
* @return {Promise<object>}
* @example channel.getContractCall({
* caller: 'ak_Y1NRjHuoc3CGMYMvCmdHSBpJsMDR6Ra2t5zjhRcbtMeXXLpLH',
* contract: 'ct_9sRA9AVE4BYTAkh5RNfJYmwQe1NZ4MErasQLXZkFWG43TPBqa',
* round: 3
* }).then(({ returnType, returnValue }) => {
* if (returnType === 'ok') console.log(returnValue)
* })
*/
async function getContractCall ({ caller, contract, round }) {
const result = await call(this, 'channels.get.contract_call', { caller, contract, round })
return R.fromPairs(
R.map(
([key, value]) => ([snakeToPascal(key), value]),
R.toPairs(result)
)
)
}

/**
* Send generic message
*
Expand Down Expand Up @@ -382,7 +513,10 @@ const Channel = AsyncInit.compose({
shutdown,
sendMessage,
withdraw,
deposit
deposit,
createContract,
callContract,
getContractCall
}
})

Expand Down
26 changes: 26 additions & 0 deletions es/utils/crypto.js
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,32 @@ export function hexStringToByte (str) {
return new Uint8Array(a)
}

/**
* Converts a positive integer to the smallest possible
* representation in a binary digit representation
* @rtype (value: Number) => Buffer
* @param {Number} value - Value to encode
* @return {Buffer} - Encoded data
*/
export function encodeUnsigned (value) {
const binary = Buffer.allocUnsafe(4)
binary.writeUInt32BE(value)
return binary.slice(binary.findIndex(i => i !== 0))
}

/**
* Compute contract address
* @rtype (owner: String, nonce: Number) => String
* @param {String} owner - Address of contract owner
* @param {Number} nonce - Round when contract was created
* @return {String} - Contract address
*/
export function encodeContractAddress (owner, nonce) {
const publicKey = decodeBase58Check(assertedType(owner, 'ak'))
const binary = Buffer.concat([publicKey, encodeUnsigned(nonce)])
return `ct_${encodeBase58Check(hash(binary))}`
}

// KEY-PAIR HELPERS

/**
Expand Down
88 changes: 87 additions & 1 deletion test/integration/channel.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ function waitForChannel (channel) {
)
}

describe.only('Channel', function () {
describe('Channel', function () {
configure(this)

let initiator
Expand Down Expand Up @@ -403,6 +403,92 @@ describe.only('Channel', function () {
await Promise.all([waitForChannel(initiatorCh), waitForChannel(responderCh)])
sinon.assert.notCalled(initiatorSign)
sinon.assert.notCalled(responderSign)
await initiatorCh.leave()
})

it('can create a contract and accept', async () => {
initiatorCh = await Channel({
...sharedParams,
role: 'initiator',
port: 3003,
sign: initiatorSign
})
responderCh = await Channel({
...sharedParams,
role: 'responder',
port: 3003,
sign: responderSign
})
await Promise.all([waitForChannel(initiatorCh), waitForChannel(responderCh)])
const code = await initiator.compileContractAPI(identityContract)
const callData = await initiator.contractEncodeCallDataAPI(identityContract, 'init', [])
const result = await initiatorCh.createContract({
code,
callData,
deposit: 1000,
vmVersion: 3,
abiVersion: 1
}, async (tx) => await initiator.signTransaction(tx))
result.should.eql({ accepted: true, address: result.address, state: initiatorCh.state() })
contractAddress = result.address
contractEncodeCall = (method, args) => initiator.contractEncodeCallDataAPI(identityContract, method, args)
})

it('can create a contract and reject', async () => {
responderShouldRejectUpdate = true
const code = await initiator.compileContractAPI(identityContract)
const callData = await initiator.contractEncodeCallDataAPI(identityContract, 'init', [])
const result = await initiatorCh.createContract({
code,
callData,
deposit: 1000,
vmVersion: 3,
abiVersion: 1
}, async (tx) => await initiator.signTransaction(tx))
result.should.eql({ accepted: false })
})

it('can call a contract and accept', async () => {
const result = await initiatorCh.callContract({
amount: 0,
callData: await contractEncodeCall('main', ['42']),
contract: contractAddress,
abiVersion: 1
}, async (tx) => await initiator.signTransaction(tx))
result.should.eql({ accepted: true, state: initiatorCh.state() })
callerNonce = Number(unpackTx(initiatorCh.state()).tx.encodedTx.tx.round)
})

it('can call a contract and reject', async () => {
responderShouldRejectUpdate = true
const result = await initiatorCh.callContract({
amount: 0,
callData: await contractEncodeCall('main', ['42']),
contract: contractAddress,
abiVersion: 1
}, async (tx) => await initiator.signTransaction(tx))
result.should.eql({ accepted: false })
})

it('can get contract call', async () => {
const result = await initiatorCh.getContractCall({
caller: await initiator.address(),
contract: contractAddress,
round: callerNonce
})
result.should.eql({
callerId: await initiator.address(),
callerNonce,
contractId: contractAddress,
gasPrice: result.gasPrice,
gasUsed: result.gasUsed,
height: result.height,
log: result.log,
returnType: 'ok',
returnValue: result.returnValue
})
const value = await initiator.contractDecodeDataAPI('int', result.returnValue)
value.should.eql({ type: 'word', value: 42 })
})

it('can solo close a channel', async () => {
Expand Down

0 comments on commit 988f089

Please sign in to comment.