diff --git a/es/channel/handlers.js b/es/channel/handlers.js index 1bbac17b74..264a928fed 100644 --- a/es/channel/handlers.js +++ b/es/channel/handlers.js @@ -15,7 +15,7 @@ * PERFORMANCE OF THIS SOFTWARE. */ -import { generateKeyPair } from '../utils/crypto' +import { generateKeyPair, encodeContractAddress } from '../utils/crypto' import { options, changeStatus, @@ -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') { @@ -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 } } diff --git a/es/channel/index.js b/es/channel/index.js index ea2c31cd22..200870a494 100644 --- a/es/channel/index.js +++ b/es/channel/index.js @@ -23,6 +23,7 @@ */ import AsyncInit from '../utils/async-init' +import { snakeToPascal } from '../utils/string' import * as handlers from './handlers' import { eventEmitters, @@ -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 { @@ -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} + * @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} + * @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} + * @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 * @@ -382,7 +513,10 @@ const Channel = AsyncInit.compose({ shutdown, sendMessage, withdraw, - deposit + deposit, + createContract, + callContract, + getContractCall } }) diff --git a/es/utils/crypto.js b/es/utils/crypto.js index b678521048..e4951e357d 100644 --- a/es/utils/crypto.js +++ b/es/utils/crypto.js @@ -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 /** diff --git a/test/integration/channel.js b/test/integration/channel.js index 181340723d..a386ce073f 100644 --- a/test/integration/channel.js +++ b/test/integration/channel.js @@ -44,7 +44,7 @@ function waitForChannel (channel) { ) } -describe.only('Channel', function () { +describe('Channel', function () { configure(this) let initiator @@ -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 () => {