├── .gitignore ├── README.md ├── app ├── index.html ├── javascripts │ ├── app.js │ └── mustache.min.js └── stylesheets │ ├── app.css │ └── pure-min.css ├── contracts ├── Migrations.sol └── TimeClock.sol ├── images └── TimeClockScreenshot.png ├── licence.txt ├── migrations ├── 1_initial_migration.js └── 2_TruffleTestMigration.js ├── test └── timeclock.js └── truffle.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | .DS_Store 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | TimeClock - Ethereum based service delivery contract with escrow 2 | ================================================================ 3 | 4 | Overview 5 | -------- 6 | TimeClock is a prototype/proof of concept smart contract and javascript/html interface that enables one to setup a Contractor and Contractee relationship for the delivery of services. 7 | 8 | TimeClock is written in Solidity and makes heavy use of Truffle. It can be deployed on any Ethereum block chain. 9 | 10 | Status 11 | ---------------------- 12 | TimeClock is currently only in the idea / proof of concept phase. It hasn't had any reviews our audits done against it. TimeClock should not be used for any serious contract work until a time that it is fully proven. TimeClock is licenced under the MIT licence (see licence.txt). 13 | 14 | How it works 15 | ------------ 16 | TimeClock uses escrow and time based intervals to manage the payments to limit the risk to all parties when entering into a services contract. One payment is kept in escrow and will be paid to the Contractor at the completion of the current time interval. The Contractee can withdraw all their funds (that aren't in escrow at any time). 17 | 18 | Example 19 | ------- 20 | Bob (the contractee) wants to hire Mary (the contractor) to build a website for him. Mary wants to ensure she is paid for her work, and Bob wants to ensure Mary does the work to his satisfaction before parting with the money. Mary and Bob agree that the website will be built over 10 weeks with payments of 100 ether per week. 21 | 22 | Mary sets up a TimeClock contract with the following properties: 23 | 24 | 1. Contract Details = Bob's Gardening Website build 25 | 2. Start Interval Seconds = 86400 - The contract will start in 24 hours (entered in seconds) 26 | 3. Payment Interval Seconds = 604800 - The Payment Interval is 1 week (entered in seconds) 27 | 4. Payments Count = 10 28 | 5. Minimum Payment = 1000000000000000000 (This is 1 ether in wei. Note: this doesn't stop Bob paying more, it just stops someone spamming small payments to the contract) 29 | 30 | Bob deposits the full payment of 1000 ether into the contract (10 payments of 100 ether). When Bob deposits the ether, 100 ether immediately goes into escrow. The other 900 ether stays in Bob's balance in the contract. At anytime before the next payment, Bob can withdraw his remaining 900 ether and thus terminate the contract. 31 | 32 | In a weeks time - Mary will trigger the 'Update' function of the contract. This will do 2 things: 33 | 34 | 1. Transfer any funds in escrow to her balance in the contract. This would be the 100 ether originally put in escrow by Bob's deposit. 35 | 2. Assuming Bob is happy with the progress and hasn't withdrawn his funds, another 100 ether will be moved from Bob's balance into escrow. 36 | 37 | Mary can withdraw any funds from her balance at any time. Bob can also withdraw his remaining funds and terminate the contract at anytime. 38 | 39 | This continues for the remaining 9 intervals until all the money has been transferred from Bob to Mary and the website is complete. 40 | 41 | Example Contract 42 | ---------------------- 43 | There is a Test Contract deployed at 0x566d8A075D55122CC19Ad09006114B7B656E6596 on the main ethernet chain. This is a payment to itself that pays out every 7 days for a total of 10 payments. 44 | 45 | Future Ideas 46 | ------------ 47 | 48 | - Sub TimeClock contracts. Enable one larger contract to pay into a smaller contract so as to enable large work to be split up and sub contractors employed. 49 | - Multiple Contractees (already allowed up to 100) 50 | - Use for crowd funding / foundations. E.g. a foundation is formed that sets up a TimeClock contract with 100 payments at a weekly interval. Donees can donate into the contract. As long as the donee is happy with the foundation, the donation will slowly move over to the foundations control every week. If at anytime the foundation breaks the trust of the donees, the donees can withdraw their remaining balance. 51 | 52 | 53 | How to install and use (using geth and Ethereum Wallet) 54 | ---------------------- 55 | 1. Clone the repository from Github 56 | 1. Deploy the contract to an ethereum blockchain. This can be done using Ethereum Mist Wallet by going to Contracts -> Deploy New Contract and then pasting the contents of contracts/TimeClock.sol into the sources folder. 57 | 1. Select "Time Clock" in the "Select Contract to Deploy" selection 58 | 1. Enter values for the Constructor Parameters. Note "Minimum Payment" amount is in wei. I use http://ether.fund/tool/converter to get the wei amount to enter. 59 | 1. Deploy the contract and make note of the contract address 60 | 1. Go into the root of where you downloaded the Time Clock repo. 61 | 1. Make sure truffle is installed (npm install -g truffle) 62 | 1. Run "truffle build" 63 | 1. Run "truffle server -p 8082" 64 | 1. In your browser navigate to "http://localhost:8082" 65 | 1. Enter the contract address in the "Timeclock contract" text field and click "Display Contract" 66 | 1. Note: To trigger any actions on the contract you will need to unlock the account in your geth console 67 | 68 | Building 69 | ---------------------- 70 | see Truffle for detailed instructions: https://github.com/ConsenSys/truffle 71 | 72 | Notes 73 | ---------------------- 74 | 75 | 1. Anybody can trigger the 'Update' and 'Contractor Withdraw' functions. This is by design to potentially support sub contracts in the future. If only the Contractor could do these, they could prevent funds from flowing onto any subcontracts. 76 | 2. On completion of the contract (all payment intervals have been processed), an update will call selfdestruct and any remaining funds in the contract will flow to the Contractor. 77 | 3. A Contractee (new or existing) can pay ether into the contract at anytime. Ether will be transferred into escrow based on the number of intervals remaining. E.g. If there are 5 intervals remaining and Bob transfers another 5 ether into the contract, 1 ether will go into escrow and 4 ether will go into Bob's balance. 78 | 4. Currently there is no sanity check on the time that is returned from block.timestamp. If this is a security gap, a check on the block number will be needed. 79 | 5. There is a limit of 100 Contractees allowed for a contract and 1 Contractor. The 100 limit is to prevent the loop in the update function taking too long. I'm not sure if this is necessary or not? 80 | 81 | UI Screenshot 82 | ------- 83 | ![enter image description here](https://raw.githubusercontent.com/dmozzy/TimeClock/master/images/TimeClockScreenshot.png) 84 | 85 | Licence 86 | ------- 87 | MIT License 88 | 89 | > Written with [StackEdit](https://stackedit.io/). 90 | -------------------------------------------------------------------------------- /app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | TimeClock - Ethereum based service delivery contract with escrow 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |

Timeclock - Ethereum based service delivery contract with escrow

14 |
15 |
16 | TimeClock contract 17 | 18 | 19 |
20 |
21 | 22 |
23 | 276 | 277 | 278 | 279 | -------------------------------------------------------------------------------- /app/javascripts/app.js: -------------------------------------------------------------------------------- 1 | function getIntervalString(value) { 2 | var seconds = value % 60; 3 | value = (value - seconds) / 60; 4 | var minutes = value % 60; 5 | value = (value - minutes) / 60; 6 | var hours = value % 24; 7 | value = (value - hours) / 24; 8 | var days = value 9 | return days + " days, " + hours + " hours, " + minutes + " minutes, " + seconds + " seconds"; 10 | } 11 | 12 | function hasAccessToAddress(addressIn) { 13 | return window.accounts.filter(function(userAccount) { 14 | return userAccount == addressIn 15 | }).length > 0 16 | } 17 | 18 | function displayContract() { 19 | var contractAddress = document.forms['contractSelection'].timeClockContract.value; 20 | window.contract = TimeClock.at(contractAddress); 21 | window.pendingTransactions = []; 22 | refreshDisplay(); 23 | } 24 | 25 | function setStatus(message) { 26 | var status = document.getElementById("status"); 27 | status.innerHTML = message; 28 | }; 29 | 30 | function rerenderPage(data) { 31 | if(typeof data.contractorAddress == 'undefined' || data.contractorAddress == '0x') { 32 | document.getElementById('templateInsert').innerHTML = "Contract not found"; 33 | } else { 34 | data.allowUpdate = data.nextPaymentTime < new Date(); 35 | 36 | var output = Mustache.render(window.template, data); 37 | document.getElementById('templateInsert').innerHTML = output; 38 | } 39 | } 40 | 41 | function getField(field, fieldName, data, displayFunction) { 42 | if (typeof displayFunction == 'undefined') { 43 | displayFunction = function(value) { 44 | return value; 45 | }; 46 | } 47 | field.call({ 48 | from: window.account 49 | }).then(function(value) { 50 | data[fieldName] = displayFunction(value); 51 | rerenderPage(data); 52 | }).catch(function(e) { 53 | alert("An error has occured, see log"); 54 | console.log(e); 55 | setStatus("Error getting " + fieldName + "; see log."); 56 | }); 57 | } 58 | 59 | 60 | function refreshDisplay() { 61 | if (typeof window.contract == 'undefined') { 62 | document.getElementById('templateInsert').innerHTML = ""; 63 | return; 64 | } 65 | 66 | var templateData = { 67 | contractees: [], 68 | accounts: window.accounts, 69 | pendingTransactions: window.pendingTransactions, 70 | hasPendingTransactions: window.pendingTransactions.length > 0, 71 | allowUpdate : true 72 | }; 73 | 74 | var timeClock = window.contract; 75 | getField(timeClock.contractDetails, 'contractDetails', templateData); 76 | 77 | getField(timeClock.startTime, 'startTime', templateData, function(value) { 78 | return new Date(new Number(value.toString()) * 1000); 79 | }); 80 | getField(timeClock.getNextPaymentDate, 'nextPaymentTime', templateData, function(value) { 81 | return new Date(new Number(value.toString()) * 1000); 82 | }); 83 | getField(timeClock.paymentInterval, 'paymentInterval', templateData, getIntervalString); 84 | getField(timeClock.paymentsCount, 'paymentsCount', templateData); 85 | getField(timeClock.minimumPayment, 'minimumPayment', templateData, function(value) { 86 | return web3.fromWei(value, 'ether'); 87 | }); 88 | 89 | 90 | getField(timeClock.currentPaymentsCount, 'currentPaymentsCount', templateData); 91 | getField(timeClock.contractorAddress, 'contractorAddress', templateData); 92 | templateData.isContractor = hasAccessToAddress(templateData.contractorAddress); 93 | 94 | getField(timeClock.contracteesSize, 'contracteesSize', templateData); 95 | getField(timeClock.amountInEscrow, 'amountInEscrow', templateData, function(value) { 96 | return web3.fromWei(value, 'ether'); 97 | }); 98 | getField(timeClock.contractorBalance, 'contractorBalance', templateData, function(value) { 99 | return web3.fromWei(value, 'ether'); 100 | }); 101 | 102 | timeClock.contracteesSize.call({ 103 | from: window.account 104 | }).then(function(size) { 105 | console.log("size = " + size); 106 | for (var i = 0; i < size; i++) { 107 | (function(index) { 108 | timeClock.contractees.call(index, { 109 | from: window.account 110 | }).then(function(returned) { 111 | if (returned[1] > 0) { 112 | templateData.contractees.push({ 113 | index: index, 114 | address: returned[0], 115 | balance: web3.fromWei(returned[1], 'ether'), 116 | description: returned[2], 117 | isContractee: hasAccessToAddress(returned[0]) 118 | }); 119 | } 120 | 121 | rerenderPage(templateData); 122 | }).catch(function(e) { 123 | console.log(e); 124 | setStatus("Error getting balance; see log."); 125 | });; 126 | })(i); 127 | } 128 | }).catch(function(e) { 129 | alert("An error has occured, see log"); 130 | 131 | console.log(e); 132 | setStatus("Error getting balance; see log."); 133 | }); 134 | 135 | }; 136 | 137 | function pay() { 138 | var timeClock = window.contract; 139 | var accountToPayFrom = document.forms['timeClockForm'].accountToPayFrom.value; 140 | var paymentAmountFromForm = document.forms['timeClockForm'].paymentAmount.value; 141 | var paymentAmount = web3.toWei(paymentAmountFromForm, 'ether'); 142 | var paymentDescription = document.forms['timeClockForm'].paymentDescription.value; 143 | var gasAmount = document.forms['timeClockForm'].gasAmount.value; 144 | 145 | if (confirm("Are you sure you want to send " + paymentAmountFromForm + " ether to this TimeClock contract?\n\n" + 146 | "(Note: some funds will be immediately transferred into escrow and will not be withdrawable by you)")) { 147 | 148 | var pendingTransaction = { 149 | type: "Payment", 150 | amount: paymentAmountFromForm + " ether", 151 | description: paymentDescription, 152 | status: "Pending", 153 | class: "transactionPending" 154 | }; 155 | var purchaseTransaction = timeClock.purchase(paymentDescription, { 156 | from: accountToPayFrom, 157 | value: paymentAmount, 158 | gas: gasAmount 159 | }).then(function(txId) { 160 | getTransactionStatus(txId, pendingTransaction); 161 | refreshDisplay(); 162 | }).catch(function(e) { 163 | pendingTransaction.status = "Failed:" + e.message; 164 | pendingTransaction.class = "transactionFailed"; 165 | alert("An error has occured: " + e.message); 166 | console.log(e); 167 | d 168 | refreshDisplay(); 169 | }); 170 | 171 | window.pendingTransactions.push(pendingTransaction); 172 | refreshDisplay(); 173 | 174 | console.log("Purchase Transaction = " + purchaseTransaction); 175 | } 176 | } 177 | 178 | function getTransactionStatus(txId, pendingTransaction) { 179 | var transactionData = web3.eth.getTransaction(txId); 180 | var transactionReceipt = web3.eth.getTransactionReceipt(txId); 181 | console.log("txid:" + txId + ", gas used:" + transactionReceipt.gasUsed); 182 | if (transactionData.gas == transactionReceipt.gasUsed) { 183 | pendingTransaction.status = "Transaction failed"; 184 | pendingTransaction.class = "transactionFailed"; 185 | } else { 186 | pendingTransaction.status = "Processed"; 187 | pendingTransaction.class = "transactionProcessed"; 188 | } 189 | 190 | } 191 | 192 | function contractorWithdraw() { 193 | if (confirm("This will transfer all withdrawable funds to the contractor and will cost you gas. \n\nDo you wish to proceed?")) { 194 | 195 | var timeClock = window.contract; 196 | var accountToPayFrom = document.forms['contractorWithdrawForm'].accountToPayFrom.value; 197 | var gasAmount = document.forms['contractorWithdrawForm'].gasAmount.value; 198 | 199 | var pendingTransaction = { 200 | type: "Contractor Withdraw", 201 | amount: "", 202 | description: "Withdraw to contractor address", 203 | status: "Pending", 204 | class: "transactionPending" 205 | }; 206 | 207 | timeClock.contractorWithdraw({ 208 | from: accountToPayFrom, 209 | gas: gasAmount 210 | }).then(function(txId) { 211 | getTransactionStatus(txId, pendingTransaction); 212 | refreshDisplay(); 213 | }).catch(function(e) { 214 | pendingTransaction.status = "Failed:" + e.message; 215 | pendingTransaction.class = "transactionFailed"; 216 | alert("An error has occured: " + e.message); 217 | console.log(e); 218 | refreshDisplay(); 219 | }); 220 | window.pendingTransactions.push(pendingTransaction); 221 | refreshDisplay(); 222 | } 223 | } 224 | 225 | function contracteeWithdraw() { 226 | if (confirm("Are you sure you want to withdraw your remaining payments from this TimeClock contract?\n\n(Note: this will withdraw all payments made from " + withdrawAddress + ")")) { 227 | 228 | var timeClock = window.contract; 229 | var withdrawAddress = document.forms['contracteeWithdrawForm'].withdrawAddress.value; 230 | var gasAmount = document.forms['contracteeWithdrawForm'].gasAmount.value; 231 | var withdrawIndex = document.forms['contracteeWithdrawForm'].withdrawIndex.value; 232 | 233 | var pendingTransaction = { 234 | type: "Contractee Withdraw", 235 | amount: "", 236 | description: "Withdraw all Contractee balance from " + withdrawAddress, 237 | status: "Pending", 238 | class: "transactionPending" 239 | }; 240 | 241 | timeClock.contracteeWithdraw(withdrawIndex, { 242 | from: withdrawAddress, 243 | gas: gasAmount 244 | }).then(function(txId) { 245 | getTransactionStatus(txId, pendingTransaction); 246 | refreshDisplay(); 247 | }).catch(function(e) { 248 | pendingTransaction.status = "Failed:" + e.message; 249 | pendingTransaction.class = "transactionFailed"; 250 | alert("An error has occured: " + e.message); 251 | console.log(e); 252 | refreshDisplay(); 253 | }); 254 | } 255 | window.pendingTransactions.push(pendingTransaction); 256 | refreshDisplay(); 257 | 258 | } 259 | 260 | function updateContract() { 261 | if (confirm("This will attempt to move the contract to the next payment interval. This will update the 'Current payment #' and change balances.\n\n" + 262 | "This can only be done after the 'Next update time' has passed. This operation will cost gas.\n\n" + 263 | "Do you wish to proceed?" 264 | )) { 265 | var timeClock = window.contract; 266 | var updateAddress = document.forms['updateContractForm'].accountToPayFrom.value; 267 | var gasAmount = document.forms['updateContractForm'].gasAmount.value; 268 | 269 | var pendingTransaction = { 270 | type: "Update", 271 | amount: "", 272 | description: "Update the contract state.", 273 | status: "Pending", 274 | class: "transactionPending" 275 | }; 276 | 277 | timeClock.update({ 278 | from: updateAddress, 279 | gas: gasAmount 280 | }).then(function(txId) { 281 | getTransactionStatus(txId, pendingTransaction); 282 | refreshDisplay(); 283 | }).catch(function(e) { 284 | pendingTransaction.status = "Failed:" + e.message; 285 | pendingTransaction.class = "transactionFailed"; 286 | alert("The contract could not be updated. This is normally caused by trying to update the contract before the 'Next update time'. Error was " + e.message); 287 | refreshDisplay(); 288 | console.log(e); 289 | }); 290 | } 291 | window.pendingTransactions.push(pendingTransaction); 292 | refreshDisplay(); 293 | } 294 | 295 | function overlay(dialogName) { 296 | el = document.getElementById(dialogName); 297 | el.style.visibility = (el.style.visibility == "visible") ? "hidden" : "visible"; 298 | } 299 | 300 | window.onload = function() { 301 | web3.eth.getAccounts(function(err, accs) { 302 | if (err != null) { 303 | alert("There was an error fetching your accounts."); 304 | return; 305 | } 306 | 307 | if (accs.length == 0) { 308 | alert("Couldn't get any accounts! Make sure your Ethereum client is configured correctly."); 309 | return; 310 | } 311 | 312 | window.accounts = accs; 313 | window.account = accounts[0]; 314 | window.pendingTransactions = []; 315 | window.template = document.getElementById('template').innerHTML; 316 | var urlParams = new URLSearchParams(window.location.search); 317 | if (urlParams.get('contract')) { 318 | document.forms['contractSelection'].timeClockContract.value = urlParams.get('contract'); 319 | } else if (typeof TimeClock.deployed() != 'undefined') { 320 | document.forms['contractSelection'].timeClockContract.value = TimeClock.deployed().address; 321 | } 322 | refreshDisplay(); 323 | }); 324 | } 325 | -------------------------------------------------------------------------------- /app/javascripts/mustache.min.js: -------------------------------------------------------------------------------- 1 | (function defineMustache(global,factory){if(typeof exports==="object"&&exports&&typeof exports.nodeName!=="string"){factory(exports)}else if(typeof define==="function"&&define.amd){define(["exports"],factory)}else{global.Mustache={};factory(global.Mustache)}})(this,function mustacheFactory(mustache){var objectToString=Object.prototype.toString;var isArray=Array.isArray||function isArrayPolyfill(object){return objectToString.call(object)==="[object Array]"};function isFunction(object){return typeof object==="function"}function typeStr(obj){return isArray(obj)?"array":typeof obj}function escapeRegExp(string){return string.replace(/[\-\[\]{}()*+?.,\\\^$|#\s]/g,"\\$&")}function hasProperty(obj,propName){return obj!=null&&typeof obj==="object"&&propName in obj}var regExpTest=RegExp.prototype.test;function testRegExp(re,string){return regExpTest.call(re,string)}var nonSpaceRe=/\S/;function isWhitespace(string){return!testRegExp(nonSpaceRe,string)}var entityMap={"&":"&","<":"<",">":">",'"':""","'":"'","/":"/","`":"`","=":"="};function escapeHtml(string){return String(string).replace(/[&<>"'`=\/]/g,function fromEntityMap(s){return entityMap[s]})}var whiteRe=/\s*/;var spaceRe=/\s+/;var equalsRe=/\s*=/;var curlyRe=/\s*\}/;var tagRe=/#|\^|\/|>|\{|&|=|!/;function parseTemplate(template,tags){if(!template)return[];var sections=[];var tokens=[];var spaces=[];var hasTag=false;var nonSpace=false;function stripSpace(){if(hasTag&&!nonSpace){while(spaces.length)delete tokens[spaces.pop()]}else{spaces=[]}hasTag=false;nonSpace=false}var openingTagRe,closingTagRe,closingCurlyRe;function compileTags(tagsToCompile){if(typeof tagsToCompile==="string")tagsToCompile=tagsToCompile.split(spaceRe,2);if(!isArray(tagsToCompile)||tagsToCompile.length!==2)throw new Error("Invalid tags: "+tagsToCompile);openingTagRe=new RegExp(escapeRegExp(tagsToCompile[0])+"\\s*");closingTagRe=new RegExp("\\s*"+escapeRegExp(tagsToCompile[1]));closingCurlyRe=new RegExp("\\s*"+escapeRegExp("}"+tagsToCompile[1]))}compileTags(tags||mustache.tags);var scanner=new Scanner(template);var start,type,value,chr,token,openSection;while(!scanner.eos()){start=scanner.pos;value=scanner.scanUntil(openingTagRe);if(value){for(var i=0,valueLength=value.length;i0?sections[sections.length-1][4]:nestedTokens;break;default:collector.push(token)}}return nestedTokens}function Scanner(string){this.string=string;this.tail=string;this.pos=0}Scanner.prototype.eos=function eos(){return this.tail===""};Scanner.prototype.scan=function scan(re){var match=this.tail.match(re);if(!match||match.index!==0)return"";var string=match[0];this.tail=this.tail.substring(string.length);this.pos+=string.length;return string};Scanner.prototype.scanUntil=function scanUntil(re){var index=this.tail.search(re),match;switch(index){case-1:match=this.tail;this.tail="";break;case 0:match="";break;default:match=this.tail.substring(0,index);this.tail=this.tail.substring(index)}this.pos+=match.length;return match};function Context(view,parentContext){this.view=view;this.cache={".":this.view};this.parent=parentContext}Context.prototype.push=function push(view){return new Context(view,this)};Context.prototype.lookup=function lookup(name){var cache=this.cache;var value;if(cache.hasOwnProperty(name)){value=cache[name]}else{var context=this,names,index,lookupHit=false;while(context){if(name.indexOf(".")>0){value=context.view;names=name.split(".");index=0;while(value!=null&&index")value=this.renderPartial(token,context,partials,originalTemplate);else if(symbol==="&")value=this.unescapedValue(token,context);else if(symbol==="name")value=this.escapedValue(token,context);else if(symbol==="text")value=this.rawValue(token);if(value!==undefined)buffer+=value}return buffer};Writer.prototype.renderSection=function renderSection(token,context,partials,originalTemplate){var self=this;var buffer="";var value=context.lookup(token[1]);function subRender(template){return self.render(template,context,partials)}if(!value)return;if(isArray(value)){for(var j=0,valueLength=value.length;j.pure-menu-children,.pure-menu-active>.pure-menu-children{display:block;position:absolute}.pure-menu-has-children>.pure-menu-link:after{padding-left:.5em;content:"\25B8";font-size:small}.pure-menu-horizontal .pure-menu-has-children>.pure-menu-link:after{content:"\25BE"}.pure-menu-scrollable{overflow-y:scroll;overflow-x:hidden}.pure-menu-scrollable .pure-menu-list{display:block}.pure-menu-horizontal.pure-menu-scrollable .pure-menu-list{display:inline-block}.pure-menu-horizontal.pure-menu-scrollable{white-space:nowrap;overflow-y:hidden;overflow-x:auto;-ms-overflow-style:none;-webkit-overflow-scrolling:touch;padding:.5em 0}.pure-menu-horizontal.pure-menu-scrollable::-webkit-scrollbar{display:none}.pure-menu-separator{background-color:#ccc;height:1px;margin:.3em 0}.pure-menu-horizontal .pure-menu-separator{width:1px;height:1.3em;margin:0 .3em}.pure-menu-heading{text-transform:uppercase;color:#565d64}.pure-menu-link{color:#777}.pure-menu-children{background-color:#fff}.pure-menu-link,.pure-menu-disabled,.pure-menu-heading{padding:.5em 1em}.pure-menu-disabled{opacity:.5}.pure-menu-disabled .pure-menu-link:hover{background-color:transparent}.pure-menu-active>.pure-menu-link,.pure-menu-link:hover,.pure-menu-link:focus{background-color:#eee}.pure-menu-selected .pure-menu-link,.pure-menu-selected .pure-menu-link:visited{color:#000}.pure-table{border-collapse:collapse;border-spacing:0;empty-cells:show;border:1px solid #cbcbcb}.pure-table caption{color:#000;font:italic 85%/1 arial,sans-serif;padding:1em 0;text-align:center}.pure-table td,.pure-table th{border-left:1px solid #cbcbcb;border-width:0 0 0 1px;font-size:inherit;margin:0;overflow:visible;padding:.5em 1em}.pure-table td:first-child,.pure-table th:first-child{border-left-width:0}.pure-table thead{background-color:#e0e0e0;color:#000;text-align:left;vertical-align:bottom}.pure-table td{background-color:transparent}.pure-table-odd td{background-color:#f2f2f2}.pure-table-striped tr:nth-child(2n-1) td{background-color:#f2f2f2}.pure-table-bordered td{border-bottom:1px solid #cbcbcb}.pure-table-bordered tbody>tr:last-child>td{border-bottom-width:0}.pure-table-horizontal td,.pure-table-horizontal th{border-width:0 0 1px;border-bottom:1px solid #cbcbcb}.pure-table-horizontal tbody>tr:last-child>td{border-bottom-width:0} -------------------------------------------------------------------------------- /contracts/Migrations.sol: -------------------------------------------------------------------------------- 1 | pragma solidity ^0.4.2; 2 | 3 | contract Migrations { 4 | address public owner; 5 | uint public last_completed_migration; 6 | 7 | modifier restricted() { 8 | if (msg.sender == owner) _; 9 | } 10 | 11 | function Migrations() { 12 | owner = msg.sender; 13 | } 14 | 15 | function setCompleted(uint completed) restricted { 16 | last_completed_migration = completed; 17 | } 18 | 19 | function upgrade(address new_address) restricted { 20 | Migrations upgraded = Migrations(new_address); 21 | upgraded.setCompleted(last_completed_migration); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /contracts/TimeClock.sol: -------------------------------------------------------------------------------- 1 | 2 | /* 3 | MIT License 4 | 5 | Copyright (c) 2016 Daniel Moscufo 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. 24 | */ 25 | 26 | pragma solidity ^0.4.2; 27 | 28 | contract TimeClock { 29 | 30 | /***************************************************************************** 31 | * Structs 32 | *****************************************************************************/ 33 | //This struct is used to hold the details of a Contractee 34 | struct Contractee { 35 | //Contractee address 36 | address addr; 37 | //Contractee Balance 38 | uint balance; 39 | //Contractee self provided description 40 | string description; 41 | } 42 | 43 | //Reentrant mutex check state variable 44 | bool mutex; 45 | 46 | /***************************************************************************** 47 | * Public Variables 48 | *****************************************************************************/ 49 | //Contract start time 50 | uint public startTime; 51 | 52 | //Total number of payments 53 | uint public paymentsCount; 54 | 55 | //Current payment interval, initially 0. 56 | uint public currentPaymentsCount; 57 | 58 | //How far apart each payment is, e.g. 1 week 59 | uint public paymentInterval; 60 | 61 | //Amount in the escrow balance that will move to the contractorBalance on next update 62 | uint public amountInEscrow; 63 | 64 | //The amount in the contractor balance 65 | uint public contractorBalance; 66 | 67 | //A string description of the contract 68 | string public contractDetails; 69 | 70 | //The minimum payment, less than this will trigger an error 71 | uint public minimumPayment; 72 | 73 | //The contractors ethereum address 74 | address public contractorAddress; 75 | 76 | //The list of all contractees. 77 | //There is a maximum of 100 contractees for now. This is to avoid 78 | //making the cost of looping in the update not too expensive 79 | Contractee[100] public contractees; 80 | 81 | /***************************************************************************** 82 | * Events 83 | *****************************************************************************/ 84 | event Purchase(address from, uint value, string description); 85 | event ContracteeWithdraw(address contractor, uint value, string description); 86 | event ContractorWithdraw(address contractee, uint value); 87 | event UpdateTriggered(address updator, uint escrowValue, uint paymentsCount); 88 | 89 | /***************************************************************************** 90 | * Constructor 91 | * contractDetailsText - The human readable description text 92 | * startDelayInSeconds - Time the contract will start in 93 | * paymentIntervalInSeconds - The timeframe in between each update/payment 94 | * numberOfPayments - Total number of payments/updates/intervals 95 | * minimumPaymentAmount - spam prevention mechanism to stop small payments with messages to the contract 96 | *****************************************************************************/ 97 | function TimeClock (string contractDetailsText, uint startDelayInSeconds, uint paymentIntervalInSeconds, uint numberOfPayments, uint minimumPaymentAmount) { 98 | contractDetails = contractDetailsText; 99 | startTime = block.timestamp + startDelayInSeconds; 100 | paymentInterval = paymentIntervalInSeconds; 101 | paymentsCount = numberOfPayments; 102 | contractorAddress = msg.sender; 103 | currentPaymentsCount = 0; 104 | amountInEscrow = 0; 105 | contractorBalance = 0; 106 | minimumPayment = minimumPaymentAmount; 107 | } 108 | 109 | /***************************************************************************** 110 | * Modifiers see https://forum.ethereum.org/discussion/10889/my-first-contract-timeclock-service-delivery-labor-hire-contract#latest 111 | *****************************************************************************/ 112 | modifier protected() { 113 | if (mutex) throw; // Checks if contract has already been entered. 114 | mutex = true; 115 | _; 116 | delete mutex; // sets to false and refunds some gas 117 | return; 118 | } 119 | 120 | /***************************************************************************** 121 | * Public Read Functions 122 | *****************************************************************************/ 123 | //Returns the next date at which update can be called. This may be in the past 124 | //meaning that update can be called now. 125 | function getNextPaymentDate() returns (uint){ 126 | return startTime + ((currentPaymentsCount +1) * paymentInterval); 127 | } 128 | 129 | //Returns the highest populated number in the contractees Array 130 | //Note: This can still have some values in it that are empty and should be filtered 131 | //on the ui. 132 | function contracteesSize() constant returns (uint contracteesLocation) { 133 | uint maxContracteesCount = 0; 134 | 135 | for (uint i = 0; i < contractees.length; i++) { 136 | if(contractees[i].addr != address(0) ) { 137 | maxContracteesCount = i+1; 138 | } 139 | } 140 | 141 | return maxContracteesCount; 142 | } 143 | 144 | /***************************************************************************** 145 | * Public Transactional Functions 146 | *****************************************************************************/ 147 | 148 | //Default function just calls purchase with No Description provided. 149 | //Note: please do not use this, its just here to stop people from incorrectly 150 | //sending ether and then losing the ability to withdraw them 151 | function() { 152 | purchase("No Description provided"); 153 | } 154 | 155 | //Adds a Contractee to the contract. Important values are: 156 | //description - The Contractees description (e.g. From Bob). Limited to 64 characters 157 | //msg.value - The amount paid into the contract 158 | //msg.address - The address of the Contractee and where any withdrawn funds will be deposited 159 | function purchase(string description) protected payable { 160 | if(bytes(description).length>128) { 161 | throw; 162 | } 163 | 164 | if(msg.value < minimumPayment) { 165 | throw; 166 | } 167 | 168 | if(currentPaymentsCount >= paymentsCount){ 169 | throw; 170 | } 171 | 172 | bool notFound = true; 173 | uint insertPosition = 0; 174 | //loop through the contractees to see if there is one we can take 175 | for (uint i = 0; i < contractees.length && notFound; i++) { 176 | Contractee thisContractee = contractees[i]; 177 | if(thisContractee.balance == 0) { 178 | insertPosition = i; 179 | notFound = false; 180 | } 181 | } 182 | 183 | if(notFound) { 184 | throw; 185 | } 186 | 187 | uint paymentsRemaining = paymentsCount - currentPaymentsCount; 188 | 189 | if (paymentsRemaining<=0) { 190 | throw; 191 | } 192 | 193 | uint toEscrow = msg.value / paymentsRemaining; 194 | amountInEscrow += toEscrow; 195 | 196 | contractees[insertPosition] = Contractee(msg.sender, msg.value - toEscrow, description); 197 | Purchase(msg.sender, msg.value, description); 198 | } 199 | 200 | //This triggers the update of the contract and will only work if the current block.timestamp 201 | //is less than the next payment date 202 | function update() protected { 203 | uint _currentBlock = calculatedPaymentInterval(); 204 | 205 | //The 15 seconds here are to allow the unit testing of this function without a delay 206 | if(getNextPaymentDate() > (block.timestamp+15)) { 207 | throw; 208 | } 209 | 210 | //Self destruct on completion of the contract 211 | if(currentPaymentsCount >= paymentsCount){ 212 | if(msg.sender == contractorAddress) { 213 | selfdestruct(contractorAddress); 214 | } else { 215 | throw; 216 | } 217 | } 218 | 219 | if(_currentBlock > currentPaymentsCount ) { 220 | currentPaymentsCount ++; 221 | uint paymentsRemaining = paymentsCount - currentPaymentsCount; 222 | contractorBalance += amountInEscrow; 223 | amountInEscrow = 0; 224 | 225 | if(paymentsRemaining > 0) { 226 | for (uint i = 0; i < contractees.length; i++) { 227 | Contractee thisContractee = contractees[i]; 228 | uint currentBalance = thisContractee.balance; 229 | if(currentBalance > 0) { 230 | uint toEscrow = currentBalance / paymentsRemaining; 231 | amountInEscrow += toEscrow; 232 | thisContractee.balance = currentBalance - toEscrow; 233 | } 234 | } 235 | } 236 | } 237 | UpdateTriggered(msg.sender, amountInEscrow, currentPaymentsCount); 238 | } 239 | 240 | //Enables a Contractee to withdraw any and all funds that are in their balance 241 | function contracteeWithdraw(uint index) protected { 242 | Contractee thisContractee = contractees[index]; 243 | if(thisContractee.addr != msg.sender && contractorAddress != msg.sender) { 244 | throw; 245 | } 246 | 247 | if(thisContractee.balance > 0) { 248 | uint balanceToSend = thisContractee.balance; 249 | thisContractee.balance = 0; 250 | if(!thisContractee.addr.send(balanceToSend)) { 251 | throw; 252 | } 253 | ContracteeWithdraw(msg.sender,balanceToSend,thisContractee.description); 254 | } 255 | } 256 | 257 | //Enables a Contractor to withdraw any funds that have been moved over into 258 | //their balance 259 | function contractorWithdraw() protected { 260 | uint amountToWithdraw = contractorBalance; 261 | contractorBalance = 0; 262 | if(!contractorAddress.send(amountToWithdraw)) { 263 | throw; 264 | } 265 | ContractorWithdraw(msg.sender, amountToWithdraw); 266 | } 267 | 268 | /***************************************************************************** 269 | * Private Functions 270 | *****************************************************************************/ 271 | //Returns the calculated payment interval. Uses the start time and the current block.timestamp 272 | function calculatedPaymentInterval() private returns (uint calculatedInterval) { 273 | return (block.timestamp - startTime) / paymentInterval; 274 | } 275 | } 276 | -------------------------------------------------------------------------------- /images/TimeClockScreenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dmozzy/TimeClock/a92ade8be1c62f7a00887d279268893affac82a7/images/TimeClockScreenshot.png -------------------------------------------------------------------------------- /licence.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Daniel Moscufo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /migrations/1_initial_migration.js: -------------------------------------------------------------------------------- 1 | var Migrations = artifacts.require("./Migrations.sol"); 2 | 3 | module.exports = function(deployer) { 4 | deployer.deploy(Migrations); 5 | }; 6 | -------------------------------------------------------------------------------- /migrations/2_TruffleTestMigration.js: -------------------------------------------------------------------------------- 1 | var TimeClock = artifacts.require("./TimeClock.sol"); 2 | 3 | module.exports = function(deployer) { 4 | deployer.deploy(TimeClock, "This is the unit test contract",1, 1, 10, web3.toWei(0.01, 'ether')); 5 | }; 6 | -------------------------------------------------------------------------------- /test/timeclock.js: -------------------------------------------------------------------------------- 1 | contract('TimeClock', function(accounts) { 2 | it("should be 10 paymentCount", function() { 3 | var meta = TimeClock.deployed(); 4 | 5 | return meta.paymentsCount.call().then(function(paymentCount) { 6 | assert.equal(paymentCount, 10, "10 paymentCount check"); 7 | }); 8 | }); 9 | 10 | it("startTime should be set", function() { 11 | var meta = TimeClock.deployed(); 12 | 13 | return meta.startTime.call().then(function(startTime) { 14 | console.log("startTime is:" + startTime); 15 | assert.isAbove(startTime, 0, "Starttime should be set"); 16 | }); 17 | 18 | }); 19 | 20 | it("transferMoney test", function() { 21 | var meta = TimeClock.deployed(); 22 | return meta.purchase("Hello", { 23 | from: accounts[0], 24 | value: web3.toWei(1, 'ether') 25 | }).then(function() { 26 | return meta.amountInEscrow.call().then(function(amountInEscrow) { 27 | console.log("amountInEscrow is " + amountInEscrow); 28 | assert.isAbove(amountInEscrow, 0, "amountInEscrow should be set"); 29 | return meta.getContractee.call(0, { 30 | from: accounts[0] 31 | }).then(function(returned) { 32 | assert.isAbove(returned[1], 0, "Contractee funds should be above zero now"); 33 | }); 34 | }); 35 | }); 36 | }); 37 | 38 | 39 | it("update test", function() { 40 | console.log("Update test"); 41 | var meta = TimeClock.deployed(); 42 | return meta.update({ 43 | from: accounts[0] 44 | }).then(function() { 45 | return meta.contractorBalance.call().then(function(contractorWithdrawable) { 46 | console.log("contractorWithdrawable is " + contractorWithdrawable); 47 | assert.isAbove(contractorWithdrawable, 0, "contractorWithdrawable should be set"); 48 | }); 49 | }); 50 | }); 51 | 52 | it("Withdraw Contractee", function() { 53 | var meta = TimeClock.deployed(); 54 | return meta.contracteeWithdraw(0,{ 55 | from: accounts[0] 56 | }).then(function() { 57 | return meta.getContractee.call(0, { 58 | from: accounts[0] 59 | }).then(function(returned) { 60 | console.log("returned = " + returned); 61 | assert.equal(returned[1], 0, "Contractee funds should be zero now"); 62 | }); 63 | }); 64 | }); 65 | 66 | it("Withdraw Contractor", function() { 67 | var meta = TimeClock.deployed(); 68 | return meta.contractorWithdraw({ 69 | from: accounts[0] 70 | }).then(function() { 71 | return meta.contractorBalance.call().then(function(contractorWithdrawable) { 72 | console.log("contractorWithdrawable is " + contractorWithdrawable); 73 | assert.equal(0, 0, "amountInEscrow should be set"); 74 | }); 75 | }); 76 | }); 77 | 78 | 79 | var exceptionCaught = false; 80 | it("transferMoney test fail to small", function() { 81 | var meta = TimeClock.deployed(); 82 | return meta.purchase("Hello", { 83 | from: accounts[0], 84 | value: web3.toWei(0.001, 'ether') 85 | }).catch(function(){ 86 | console.log("Success") 87 | exceptionCaught = true; 88 | }).then(function(){ 89 | assert.isTrue(exceptionCaught,"an exception should have been thrown and caught as the fee is too low"); 90 | 91 | }); 92 | }); 93 | }) 94 | -------------------------------------------------------------------------------- /truffle.js: -------------------------------------------------------------------------------- 1 | var DefaultBuilder = require("truffle-default-builder"); 2 | 3 | module.exports = { 4 | build: new DefaultBuilder({ 5 | "index.html": "index.html", 6 | "app.js": [ 7 | "javascripts/mustache.min.js", 8 | "javascripts/app.js" 9 | ], 10 | "app.css": [ 11 | "stylesheets/pure-min.css", 12 | "stylesheets/app.css" 13 | ] 14 | }), 15 | rpc: { 16 | host: "localhost", 17 | port: 8545 18 | }, 19 | networks: { 20 | dev: { 21 | host: "localhost", 22 | port: 8545, 23 | network_id: "*" 24 | }, 25 | staging: { 26 | host: "localhost", 27 | port: 8546, 28 | network_id: 1337 29 | }, 30 | ropsten: { 31 | host: "158.253.8.12", 32 | port: 8545, 33 | network_id: 3 34 | } 35 | } 36 | }; 37 | --------------------------------------------------------------------------------