diff --git a/contracts/Migrations.sol b/contracts/Migrations.sol index 37bdecd..c4efb65 100644 --- a/contracts/Migrations.sol +++ b/contracts/Migrations.sol @@ -1,25 +1,23 @@ -pragma solidity ^0.4.21; - +pragma solidity ^0.4.23; contract Migrations { - address public owner; - uint public last_completed_migration; + address public owner; + uint public last_completed_migration; - modifier restricted() { - if (msg.sender == owner) - _; - } + constructor() public { + owner = msg.sender; + } - function Migrations() public { - owner = msg.sender; - } + modifier restricted() { + if (msg.sender == owner) _; + } - function setCompleted(uint completed) restricted public { - last_completed_migration = completed; - } + function setCompleted(uint completed) public restricted { + last_completed_migration = completed; + } - function upgrade(address newAddress) restricted public { - Migrations upgraded = Migrations(newAddress); - upgraded.setCompleted(last_completed_migration); - } + function upgrade(address new_address) public restricted { + Migrations upgraded = Migrations(new_address); + upgraded.setCompleted(last_completed_migration); + } } diff --git a/contracts/MultiPartyEscrow.sol b/contracts/MultiPartyEscrow.sol new file mode 100644 index 0000000..f0c705a --- /dev/null +++ b/contracts/MultiPartyEscrow.sol @@ -0,0 +1,221 @@ +pragma solidity ^0.4.24; + +import "openzeppelin-solidity/contracts/token/ERC20/ERC20.sol"; + +contract MultiPartyEscrow { + + //it seems we don't need SafeMath + //using SafeMath for uint256; + + + //TODO: we could use uint64 for replicaId and nonce (it could be cheaper to store but more expensive to operate with) + + //the full ID of "atomic" payment channel = "[this, channelId, nonce]" + struct PaymentChannel { + address sender; // The account sending payments. + address recipient; // The account receiving the payments. + uint256 replicaId; // id of particular service replica + uint256 value; // Total amount of tokens deposited to the channel. + uint256 nonce; // "nonce" of the channel (by changing nonce we effectivly close the old channel ([this, channelId, oldNonce]) + // and open the new channel [this, channelId, newNonce]) + //!!! nonce also prevents race conditon between channelClaim and channelExtendAndAddFunds + uint256 expiration; // Timeout in case the recipient never closes. + } + + + mapping (uint256 => PaymentChannel) public channels; + mapping (address => uint256) public balances; //tokens which have been deposit but haven't been escrowed in the channels + + uint256 public nextChannelId; //id of the next channel (and size of channels) + + ERC20 public token; // Address of token contract + + //TODO: optimize events. Do we need more (or less) events? + event EventChannelOpen (uint256 channelId, address indexed sender, address indexed recipient, uint256 indexed replicaId); + //event EventChannelReopen (uint256 channelId, address indexed sender, address indexed recipient, uint256 indexed replicaId, uint256 nonce); + //event EventChannelTorecipient(uint256 indexed channelId, address indexed sender, address indexed recipient, uint256 amount); + //event EventChannelTosender (uint256 indexed channelId, address indexed sender, address indexed recipient, uint256 amount); + + constructor (address _token) + public + { + token = ERC20(_token); + } + + function deposit(uint256 value) + public + returns(bool) + { + require(token.transferFrom(msg.sender, this, value), "Unable to transfer token to the contract"); + balances[msg.sender] += value; + return true; + } + + function withdraw(uint256 value) + public + returns(bool) + { + require(balances[msg.sender] >= value); + require(token.transfer(msg.sender, value)); + balances[msg.sender] -= value; + return true; + } + + //open a channel, token should be already being deposit + //openChannel should be run only once for given sender, recipient, replicaId + //channel can be reused even after channelClaim(..., isSendback=true) + function openChannel(address recipient, uint256 value, uint256 expiration, uint256 replicaId) + public + returns(bool) + { + require(balances[msg.sender] >= value); + channels[nextChannelId] = PaymentChannel({ + sender : msg.sender, + recipient : recipient, + value : value, + replicaId : replicaId, + nonce : 0, + expiration : expiration + }); + balances[msg.sender] -= value; + emit EventChannelOpen(nextChannelId, msg.sender, recipient, replicaId); + nextChannelId += 1; + return true; + } + + + + function depositAndOpenChannel(address recipient, uint256 value, uint256 expiration, uint256 replicaId) + public + returns(bool) + { + require(deposit(value)); + require(openChannel(recipient, value, expiration, replicaId)); + return true; + } + + + function _channelSendbackAndReopenSuspended(uint256 channelId) + private + { + PaymentChannel storage channel = channels[channelId]; + balances[channel.sender] += channel.value; + channel.value = 0; + channel.nonce += 1; + channel.expiration = 0; + } + + // the recipient can close the channel at any time by presenting a + // signed amount from the sender. The recipient will be sent that amount. The recipient can choose: + // send the remainder to the sender (isSendback == true), or put that amount into the new channel. + function channelClaim(uint256 channelId, uint256 amount, bytes memory signature, bool isSendback) + public + { + PaymentChannel storage channel = channels[channelId]; + require(amount <= channel.value); + require(msg.sender == channel.recipient); + + //compose the message which was signed + bytes32 message = prefixed(keccak256(abi.encodePacked(this, channelId, channel.nonce, amount))); + // check that the signature is from the channel.sender + require(recoverSigner(message, signature) == channel.sender); + + balances[msg.sender] += amount; + channels[channelId].value -= amount; + + if (isSendback) + { + _channelSendbackAndReopenSuspended(channelId); + } + else + { + //reopen new "channel", without sending back funds to "sender" + channels[channelId].nonce += 1; + } + } + + + /// the sender can extend the expiration at any time + function channelExtend(uint256 channelId, uint256 newExpiration) + public + returns(bool) + { + PaymentChannel storage channel = channels[channelId]; + + require(msg.sender == channel.sender); + require(newExpiration > channel.expiration); + + channels[channelId].expiration = newExpiration; + return true; + } + + /// the sender could add funds to the channel at any time + function channelAddFunds(uint256 channelId, uint256 amount) + public + returns(bool) + { + require(balances[msg.sender] >= amount); + + PaymentChannel storage channel = channels[channelId]; + + //TODO: we could remove this require and allow everybody to funds it + require(msg.sender == channel.sender); + + channels[channelId].value += amount; + balances[msg.sender] -= amount; + return true; + } + + function channelExtendAndAddFunds(uint256 channelId, uint256 newExpiration, uint256 amount) + public + { + require(channelExtend(channelId, newExpiration)); + require(channelAddFunds(channelId, amount)); + } + + // sender can claim refund if the timeout is reached + function channelClaimTimeout(uint256 channelId) + public + { + require(msg.sender == channels[channelId].sender); + require(now >= channels[channelId].expiration); + _channelSendbackAndReopenSuspended(channelId); + } + + function splitSignature(bytes memory sig) + internal + pure + returns (uint8 v, bytes32 r, bytes32 s) + { + require(sig.length == 65); + + assembly { + // first 32 bytes, after the length prefix + r := mload(add(sig, 32)) + // second 32 bytes + s := mload(add(sig, 64)) + // final byte + v := and(mload(add(sig, 65)), 255) + } + + if (v < 27) v += 27; + + return (v, r, s); + } + + function recoverSigner(bytes32 message, bytes memory sig) + internal + pure + returns (address) + { + (uint8 v, bytes32 r, bytes32 s) = splitSignature(sig); + + return ecrecover(message, v, r, s); + } + + /// builds a prefixed hash to mimic the behavior of ethSign. + function prefixed(bytes32 hash) internal pure returns (bytes32) + { + return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", hash)); + } +} diff --git a/migrations/3_MultiPartyEscrow.js b/migrations/3_MultiPartyEscrow.js new file mode 100644 index 0000000..03255af --- /dev/null +++ b/migrations/3_MultiPartyEscrow.js @@ -0,0 +1,12 @@ +let MultiPartyEscrow = artifacts.require("./MultiPartyEscrow.sol"); +let Contract = require("truffle-contract"); +let TokenAbi = require("singularitynet-token-contracts/abi/SingularityNetToken.json"); +let TokenNetworks = require("singularitynet-token-contracts/networks/SingularityNetToken.json"); +let TokenBytecode = require("singularitynet-token-contracts/bytecode/SingularityNetToken.json"); +let Token = Contract({contractName: "SingularityNetToken", abi: TokenAbi, networks: TokenNetworks, bytecode: TokenBytecode}); + +module.exports = function(deployer, network, accounts) { + Token.setProvider(web3.currentProvider) + Token.defaults({from: accounts[0], gas: 4000000}); + deployer.deploy(Token, {overwrite: false}).then((TokenInstance) => deployer.deploy(MultiPartyEscrow, TokenInstance.address)); +}; diff --git a/package.json b/package.json index 51ebe5f..ed088ce 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,8 @@ "eth-gas-reporter": "^0.1.9", "fs-extra": "^5.0.0", "moment": "^2.22.2", - "webpack-cli": "^3.0.8" + "webpack-cli": "^3.0.8", + "ethereumjs-abi": "^0.6.5", + "ethereumjs-util":"^5.2.0" } } diff --git a/test/MultiPartyEscrow_test1.js b/test/MultiPartyEscrow_test1.js new file mode 100644 index 0000000..694ee86 --- /dev/null +++ b/test/MultiPartyEscrow_test1.js @@ -0,0 +1,174 @@ +"use strict"; +var MultiPartyEscrow = artifacts.require("./MultiPartyEscrow.sol"); + +let Contract = require("truffle-contract"); +let TokenAbi = require("singularitynet-token-contracts/abi/SingularityNetToken.json"); +let TokenNetworks = require("singularitynet-token-contracts/networks/SingularityNetToken.json"); +let TokenBytecode = require("singularitynet-token-contracts/bytecode/SingularityNetToken.json"); +let Token = Contract({contractName: "SingularityNetToken", abi: TokenAbi, networks: TokenNetworks, bytecode: TokenBytecode}); +Token.setProvider(web3.currentProvider); + +var ethereumjsabi = require('ethereumjs-abi'); +var ethereumjsutil = require('ethereumjs-util'); +let signFuns = require('./sign_mpe_funs'); + + + +async function testErrorRevert(prom) +{ + let rezE = -1 + try { await prom } + catch(e) { + rezE = e.message.indexOf('revert') + } + assert(rezE >= 0, "Must generate error and error message must contain revert"); +} + +contract('MultiPartyEscrow', function(accounts) { + + var escrow; + var tokenAddress; + var token; + let N1 = 42000 + let N2 = 420000 + let N3 = 42 + + + before(async () => + { + escrow = await MultiPartyEscrow.deployed(); + tokenAddress = await escrow.token.call(); + token = Token.at(tokenAddress); + }); + + + it ("Test Simple wallet 1", async function() + { + //Deposit 42000 from accounts[0] + await token.approve(escrow.address,N1, {from:accounts[0]}); + await escrow.deposit(N1, {from:accounts[0]}); + assert.equal((await escrow.balances.call(accounts[0])).toNumber(), N1) + + //Deposit 420000 from accounts[4] (frist we need transfert from a[0] to a[4]) + await token.transfer(accounts[4], N2, {from:accounts[0]}); + await token.approve(escrow.address,N2, {from:accounts[4]}); + await escrow.deposit(N2, {from:accounts[4]}); + + assert.equal((await escrow.balances.call(accounts[4])).toNumber(), N2) + + assert.equal((await token.balanceOf(escrow.address)).toNumber(), N1 + N2) + + //try to withdraw more than we have + await testErrorRevert(escrow.withdraw(N2 + 1, {from:accounts[4]})) + + escrow.withdraw(N3, {from:accounts[4]}) + assert.equal((await escrow.balances.call(accounts[4])).toNumber(), N2 - N3) + assert.equal((await token.balanceOf(escrow.address)).toNumber(), N1 + N2 - N3) + assert.equal((await token.balanceOf(accounts[4])).toNumber(), N3) + }); + + it ("Initial openning (first and second channel)", async function() + { + //first channel + testErrorRevert( escrow.openChannel(accounts[5], N1 + 1, web3.eth.getBlock(web3.eth.blockNumber).timestamp + 10000000, 0, {from:accounts[0]})) + await escrow.openChannel(accounts[5], N1, web3.eth.getBlock(web3.eth.blockNumber).timestamp + 10000000, 0, {from:accounts[0]}) + assert.equal((await escrow.nextChannelId.call()).toNumber(), 1) + + //full balance doesn't change + assert.equal((await token.balanceOf(escrow.address)).toNumber(), N1 + N2 - N3) + assert.equal((await escrow.balances.call(accounts[0])).toNumber(), 0) + //second channel + await escrow.openChannel(accounts[6], N1 * 2, web3.eth.getBlock(web3.eth.blockNumber).timestamp + 10000000, 27, {from:accounts[4]}) + assert.equal((await escrow.nextChannelId.call()).toNumber(), 2) + + assert.equal((await escrow.balances.call(accounts[4])).toNumber(), N2 - N3 - N1 * 2) + + + }); + + it("Fail to Claim timeout ", async function() + { + await testErrorRevert(escrow.channelClaimTimeout(0, {from:accounts[0]})) + await testErrorRevert(escrow.channelClaimTimeout(1, {from:accounts[4]})) + }); + + + it ("closing transaction (first channel)", async function() + { + //sign message by the privet key of accounts[0] + let sgn = await signFuns.waitSignedClaimMessage(accounts[0], escrow.address, 0, 0, N1 - 1000); + await escrow.channelClaim(0, N1 - 1000, sgn.toString("hex"), true, {from:accounts[5]}); + assert.equal((await escrow.balances.call(accounts[5])).toNumber(), N1 - 1000) + assert.equal((await escrow.balances.call(accounts[0])).toNumber(), 1000) + // let balance4 = await token.balanceOf.call(accounts[4]); + // assert.equal(balance4, 41000, "After closure balance of accounts[4] should be 41000"); + }); + it ("closing transaction (second channel), with partly closure", async function() + { + //first we claim, and put remaing funds in the new channel (with nonce 1) + let sgn = await signFuns.waitSignedClaimMessage(accounts[4], escrow.address, 1, 0, N1); + await escrow.channelClaim(1, N1, sgn.toString("hex"), false, {from:accounts[6]}); + assert.equal((await escrow.balances.call(accounts[6])).toNumber(), N1) + assert.equal((await escrow.balances.call(accounts[4])).toNumber(), N2 - N3 - N1*2) + + //claim all funds and close channel + //try to use old signutature (should fail) + testErrorRevert( escrow.channelClaim(1, N1, sgn.toString("hex"), false, {from:accounts[6]})) + + //make new signature with nonce 1 + let sgn2 = await signFuns.waitSignedClaimMessage(accounts[4], escrow.address, 1, 1, N1 - 1000); + await escrow.channelClaim(1, N1 - 1000, sgn2.toString("hex"), true, {from:accounts[6]}); + assert.equal((await escrow.balances.call(accounts[6])).toNumber(), N1 * 2 - 1000) + assert.equal((await escrow.balances.call(accounts[4])).toNumber(), N2 - N3 - N1*2 + 1000) + + }); + + it ("Open the third channel", async function() + { + let expiration = web3.eth.getBlock(web3.eth.blockNumber).timestamp + 10000000 + let value = 1000 + let replicaId = 44 + let messageNonce = 666 + await escrow.openChannel(accounts[7], value, expiration, replicaId, {from:accounts[4]}) + assert.equal((await escrow.nextChannelId.call()).toNumber(), 3) + assert.equal((await escrow.balances.call(accounts[4])).toNumber(), N2 - N3 - N1*2) + }); + + it ("Extend and add funds to the third channel", async function() + { + let expiration = web3.eth.getBlock(web3.eth.blockNumber).timestamp + 10000000 + 1; + let addValue = N1; + + //try extend from the wrong account + testErrorRevert( escrow.channelExtendAndAddFunds(2, expiration, 1, {from:accounts[0]}) ) + await escrow.channelExtendAndAddFunds(2, expiration, addValue, {from:accounts[4]}) + + assert.equal((await escrow.balances.call(accounts[4])).toNumber(), N2 - N3 - N1*3) + + }); + it ("Close the third channel", async function() + { + //sign message by the privet key of accounts[0] + let sgn = await signFuns.waitSignedClaimMessage(accounts[4], escrow.address, 2, 0, 1000 - 10); + await escrow.channelClaim(2, 1000 - 10, sgn.toString("hex"), true, {from:accounts[7]}); + assert.equal((await escrow.balances.call(accounts[7])).toNumber(), 1000 - 10) + assert.equal((await escrow.balances.call(accounts[4])).toNumber(), N2 - N3 - N1*2 + 10) + // let balance4 = await token.balanceOf.call(accounts[4]); + // assert.equal(balance4, 41000, "After closure balance of accounts[4] should be 41000"); + }); + + it ("Check validity of the signatures with js-server part (claim)", async function() + { + //claim message + let sgn = await signFuns.waitSignedClaimMessage(accounts[2], escrow.address, 1789, 1917, 31415); + assert.equal(signFuns.isValidSignatureClaim(escrow.address, 1789, 1917, 31415, sgn, accounts[2]), true, "signature should be ok") + assert.equal(signFuns.isValidSignatureClaim(escrow.address, 1789, 1917, 31415, sgn, accounts[3]), false, "signature should be false") + assert.equal(signFuns.isValidSignatureClaim(escrow.address, 1789, 1917, 27182, sgn, accounts[2]), false, "signature should be false") + assert.equal(signFuns.isValidSignatureClaim(escrow.address, 1789, 1918, 31415, sgn, accounts[2]), false, "signature should be false") + assert.equal(signFuns.isValidSignatureClaim(escrow.address, 1941, 1917, 31415, sgn, accounts[2]), false, "signature should be false") + assert.equal(signFuns.isValidSignatureClaim(accounts[2], 1789, 1917, 31415, sgn, accounts[2]), false, "signature should be false") + + }); + +}); + diff --git a/test/sign_mpe_funs.js b/test/sign_mpe_funs.js new file mode 100644 index 0000000..fdaf9a8 --- /dev/null +++ b/test/sign_mpe_funs.js @@ -0,0 +1,75 @@ +var ethereumjsabi = require('ethereumjs-abi'); +var ethereumjsutil = require('ethereumjs-util'); + + +function sleep(ms) +{ + return new Promise(resolve => setTimeout(resolve, ms)); +} + + +function signMessage(fromAccount, message, callback) +{ + web3.eth.sign(fromAccount, "0x" + message.toString("hex"), callback) +} + + +function composeClaimMessage(contractAddress, channelId, nonce, amount) +{ + return ethereumjsabi.soliditySHA3( + ["address", "uint256", "uint256", "uint256"], + [contractAddress, channelId, nonce, amount]); +} + + +function signClaimMessage(fromAccount, contractAddress, channelId, nonce, amount, callback) +{ + var message = composeClaimMessage(contractAddress, channelId, nonce, amount); + signMessage(fromAccount, message, callback); +} + + +// this mimics the prefixing behavior of the ethSign JSON-RPC method. +function prefixed(hash) { + return ethereumjsabi.soliditySHA3( + ["string", "bytes32"], + ["\x19Ethereum Signed Message:\n32", hash] + ); +} + +function recoverSigner(message, signature) { + var split = ethereumjsutil.fromRpcSig(signature); + var publicKey = ethereumjsutil.ecrecover(message, split.v, split.r, split.s); + + var signer = ethereumjsutil.pubToAddress(publicKey).toString("hex"); + return signer; +} + +function isValidSignatureClaim(contractAddress, channelId, nonce, amount, signature, expectedSigner) { + var message = prefixed(composeClaimMessage(contractAddress, channelId, nonce, amount)); + var signer = recoverSigner(message, signature); + return signer.toLowerCase() == + ethereumjsutil.stripHexPrefix(expectedSigner).toLowerCase(); +} + + +async function waitSignedClaimMessage(fromAccount, contractAddress, channelId, nonce, amount) +{ + let detWait = true; + let rezSign; + signClaimMessage(fromAccount, contractAddress, channelId, nonce, amount, function(err,sgn) + { + detWait = false; + rezSign = sgn + }); + while(detWait) + { + await sleep(1) + } + return rezSign; +} + + +module.exports.waitSignedClaimMessage = waitSignedClaimMessage; +module.exports.isValidSignatureClaim = isValidSignatureClaim; +