diff --git a/karma.conf.js b/karma.conf.js index a79d58ffe..1a156109f 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -74,6 +74,9 @@ module.exports = function (config) { 'tests/account_info_spec.js', 'tests/metadata_spec.js', 'tests/exchange_delegate_spec.js', + 'tests/contact_spec.js', + 'tests/facilitatedTx_spec.js', + 'tests/contacts_spec.js', 'tests/labels_spec.js', 'tests/address_hd_spec.js' ] diff --git a/package.json b/package.json index 44b4313b5..2971fcb39 100644 --- a/package.json +++ b/package.json @@ -48,12 +48,14 @@ "bs58": "2.0.*", "core-js": "^2.4.1", "es6-promise": "^3.0.2", + "jwt-decode": "^2.1.0", "isomorphic-fetch": "^2.2.1", "pbkdf2": "^3.0.12", "ramda": "^0.22.1", "randombytes": "^2.0.1", "unorm": "^1.4.1", - "ws": "2.0.*" + "ws": "2.0.*", + "uuid": "^3.0.1" }, "devDependencies": { "babel-cli": "~6.24.0", diff --git a/src/blockchain-wallet.js b/src/blockchain-wallet.js index d4c1ed9c4..74883f811 100644 --- a/src/blockchain-wallet.js +++ b/src/blockchain-wallet.js @@ -21,6 +21,8 @@ var AccountInfo = require('./account-info'); var Metadata = require('./metadata'); var constants = require('./constants'); var Payment = require('./payment'); +var SharedMetadata = require('./sharedMetadata'); +var Contacts = require('./contacts'); var Labels = require('./labels'); var Bitcoin = require('bitcoinjs-lib'); @@ -89,6 +91,7 @@ function Wallet (object) { this._latestBlock = null; this._accountInfo = null; this._external = null; + this._contacts = null; } Object.defineProperties(Wallet.prototype, { @@ -239,6 +242,10 @@ Object.defineProperties(Wallet.prototype, { configurable: false, get: function () { return this._external; } }, + 'contacts': { + configurable: false, + get: function () { return this._contacts; } + }, 'isEncryptionConsistent': { configurable: false, get: function () { @@ -828,6 +835,18 @@ Wallet.prototype.fetchAccountInfo = function () { }); }; +Wallet.prototype.loadContacts = function () { + if (this.isDoubleEncrypted === true || !this.isUpgradedToHD) { + return Promise.resolve(); + } else { + var masterhdnode = this.hdwallet.getMasterHDNode(); + this._contacts = new Contacts(masterhdnode); + const signature = this._contacts._sharedMetadata.signWithMDID(this._guid); + this.MDIDregistration('register-mdid', signature.toString('base64')); + return this._contacts.fetch(); + } +}; + Wallet.prototype.metadata = function (typeId) { var masterhdnode = this.hdwallet.getMasterHDNode(); return Metadata.fromMasterHDNode(masterhdnode, typeId); @@ -909,6 +928,18 @@ Wallet.prototype.createPayment = function (initialState) { return new Payment(this, initialState); }; +Wallet.prototype.MDIDregistration = function (method, signedMDID) { + // method: register-mdid / unregister-mdid + var data = { + guid: this._guid, + sharedKey: this._sharedKey, + method: method, + payload: signedMDID, + length: signedMDID.length + }; + return API.request('POST', 'wallet', data); +}; + Wallet.prototype.cacheMetadataKey = function (secondPassword) { if (!secondPassword) { return Promise.reject('second password needed'); } if (!this.validateSecondPassword(secondPassword)) { return Promise.reject('wrong second password'); } diff --git a/src/contact.js b/src/contact.js new file mode 100644 index 000000000..b112e8f0d --- /dev/null +++ b/src/contact.js @@ -0,0 +1,90 @@ +'use strict'; + +const Bitcoin = require('bitcoinjs-lib'); +const Metadata = require('./metadata'); +// individual imports to reduce bundle size +const assoc = require('ramda/src/assoc'); +const prop = require('ramda/src/prop'); +const map = require('ramda/src/map'); +const uuid = require('uuid'); +const FacilitatedTx = require('./facilitatedTx'); + +class Contact { + constructor (o) { + this.id = o.id; + this.mdid = o.mdid; + this.name = o.name; + this.xpub = o.xpub; + this.trusted = o.trusted; + this.invitationSent = o.invitationSent; // I invited somebody + this.invitationReceived = o.invitationReceived; // Somebody invited me + this.facilitatedTxList = o.facilitatedTxList ? map(FacilitatedTx.factory, o.facilitatedTxList) : {}; + } + get pubKey () { + return this.xpub ? Bitcoin.HDNode.fromBase58(this.xpub).keyPair : null; + } +} + +Contact.factory = function (o) { + return new Contact(o); +}; + +Contact.new = function (o) { + const id = uuid(); + const namedContact = assoc('id', id, o); + return new Contact(namedContact); +}; + +Contact.prototype.fetchXPUB = function () { + return this.mdid + ? Metadata.read(this.mdid).then((r) => { this.xpub = r.xpub; return r.xpub; }) + : Promise.reject('UNKNOWN_MDID'); +}; + +// create and add a request payment request to that contact facilitated tx list +Contact.prototype.RPR = function (intendedAmount, id, role, note) { + const rpr = FacilitatedTx.RPR(intendedAmount, id, role, note); + this.facilitatedTxList = assoc(id, rpr, this.facilitatedTxList); + return rpr; +}; + +// create and/or add a payment request to that contact facilitated tx list +Contact.prototype.PR = function (intendedAmount, id, role, address, note) { + var existingTx = prop(id, this.facilitatedTxList); + if (existingTx) { + existingTx.address = address; + existingTx.state = FacilitatedTx.WAITING_PAYMENT; + existingTx.last_updated = Date.now(); + return existingTx; + } else { + const pr = FacilitatedTx.PR(intendedAmount, id, role, address, note); + this.facilitatedTxList = assoc(id, pr, this.facilitatedTxList); + return pr; + } +}; + +// modify the state of facilitated tx to broadcasted +Contact.prototype.PRR = function (txHash, id) { + var existingTx = prop(id, this.facilitatedTxList); + existingTx.tx_hash = txHash; + existingTx.state = FacilitatedTx.PAYMENT_BROADCASTED; + existingTx.last_updated = Date.now(); + return existingTx; +}; + +// modify the state of facilitated tx to declined +Contact.prototype.Decline = function (id) { + var existingTx = prop(id, this.facilitatedTxList); + existingTx.state = FacilitatedTx.DECLINED; + existingTx.last_updated = Date.now(); + return existingTx; +}; + +// modify the state of facilitated tx to Cancelled +Contact.prototype.Cancel = function (id) { + var existingTx = prop(id, this.facilitatedTxList); + existingTx.state = FacilitatedTx.CANCELLED; + existingTx.last_updated = Date.now(); + return existingTx; +}; +module.exports = Contact; diff --git a/src/contacts.js b/src/contacts.js new file mode 100644 index 000000000..36f32fefa --- /dev/null +++ b/src/contacts.js @@ -0,0 +1,369 @@ +'use strict'; + +const MyWallet = require('./wallet'); +const Metadata = require('./metadata'); +const R = require('ramda'); +const uuid = require('uuid'); +const SharedMetadata = require('./sharedMetadata'); +const FacilitatedTx = require('./facilitatedTx'); +const Contact = require('./contact'); +const METADATA_TYPE_EXTERNAL = 4; + +// messages types +const REQUEST_PAYMENT_REQUEST_TYPE = 0; +const PAYMENT_REQUEST_TYPE = 1; +const PAYMENT_REQUEST_RESPONSE_TYPE = 2; +const DECLINE_RESPONSE_TYPE = 3; +const CANCEL_RESPONSE_TYPE = 4; + +class Contacts { + constructor (masterhdnode) { + this.list = {}; + this._metadata = Metadata.fromMasterHDNode(masterhdnode, METADATA_TYPE_EXTERNAL); + this._sharedMetadata = SharedMetadata.fromMasterHDNode(masterhdnode); + this._sharedMetadata.publishXPUB(); + } +} + +Contacts.prototype.toJSON = function () { + return this.list; +}; + +Contacts.prototype.fetch = function () { + var Populate = function (o) { + this.list = o ? R.map(Contact.factory, o) : {}; + return this; + }; + var fetchFailed = function (e) { + return Promise.reject(e); + }; + return this._metadata.fetch().then(Populate.bind(this)).catch(fetchFailed.bind(this)); +}; + +Contacts.prototype.save = function () { + return this._metadata.update(this); +}; + +Contacts.prototype.wipe = function () { + this._metadata.update({}).then(this.fetch.bind(this)); + this.list = {}; +}; + +Contacts.prototype.new = function (object) { + const c = Contact.new(object); + this.list = R.assoc(c.id, c, this.list); + return c; +}; + +const fromNull = (str) => str || ''; +const isContact = (uniqueId) => R.where({id: R.equals(uniqueId)}); +const contains = R.curry((substr, key) => R.where(R.objOf(key, R.compose(R.contains(substr), fromNull)))); + +Contacts.prototype.delete = function (id) { + this.list = R.reject(isContact(id), this.list); +}; + +Contacts.prototype.search = function (str) { + const search = contains(str); + const predicate = R.anyPass(R.map(search, ['name', 'surname', 'email', 'company', 'note', 'mdid'])); + return R.filter(predicate, this.list); +}; + +Contacts.prototype.fetchXPUB = function (uuid) { + const c = this.get(uuid); + return c.fetchXPUB(); +}; + +Contacts.prototype.get = function (uuid) { + return R.prop(uuid, this.list); +}; + +// returns a promise with the invitation and updates my contact list +Contacts.prototype.readInvitation = function (invitation) { + // invitation is an object with contact information and mandatory invitationReceived + // {name: "Biel", invitationReceived: "4d7f9088-4a1e-45f0-bd93-1baba7b0ec58"} + return this._sharedMetadata.readInvitation(invitation.invitationReceived) + .then((i) => { + const c = this.new(R.assoc('mdid', i.mdid, invitation)); + // contact.RPR.bind(contact, + // message.payload.intended_amount, + // message.payload.id, + // FacilitatedTx.RPR_RECEIVER, + // message.payload.note) + return c; + }); +}; + +Contacts.prototype.acceptInvitation = function (uuid) { + const c = this.get(uuid); + return this._sharedMetadata.acceptInvitation(c.invitationReceived); +}; + +Contacts.prototype.readInvitationSent = function (uuid) { + const c = this.get(uuid); + return this._sharedMetadata.readInvitation(c.invitationSent) + .then((i) => { + c.mdid = i.contact; + return c; + }); +}; + +Contacts.prototype.addTrusted = function (uuid) { + const c = this.get(uuid); + return this._sharedMetadata.addTrusted(c.mdid) + .then(() => { c.trusted = true; return true; }); +}; + +Contacts.prototype.deleteTrusted = function (uuid) { + const c = this.get(uuid); + return this._sharedMetadata.deleteTrusted(c.mdid) + .then(() => { c.trusted = false; return true; }); +}; + +Contacts.prototype.sendMessage = function (uuid, type, message) { + const c = this.get(uuid); + return this._sharedMetadata.sendMessage(c, type, message); +}; + +Contacts.prototype.getMessages = function (onlyNew) { + return this._sharedMetadata.getMessages(onlyNew); +}; + +Contacts.prototype.readMessage = function (messageId) { + return this._sharedMetadata.getMessage(messageId) + .then(this._sharedMetadata.readMessage.bind(this._sharedMetadata, this)); +}; + +// ////////////////////////////////////////////////////////////////////////////// +// simple interface for making friends +// ////////////////////////////////////////////////////////////////////////////// + +// returns a promise with the invitation and updates my contact list +Contacts.prototype.createInvitation = function (myInfoToShare, contactInfo, message) { + // myInfoToShare could be a contact object that will be encoded on the QR + // contactInfo comes from a form that is filled before pressing invite (I am inviting James bla bla) + return this._sharedMetadata.createInvitation(message) + .then((i) => { + this.new(R.assoc('invitationSent', i.id, contactInfo)); + return R.assoc('invitationReceived', i.id, myInfoToShare); + }); +}; + +Contacts.prototype.acceptRelation = function (invitation) { + return this.readInvitation(invitation) + .then(c => this.acceptInvitation(c.id) + .then(this.addTrusted(c.id)) + .then(this.fetchXPUB(c.id)) + ) + .then(this.save.bind(this)); +}; + +// used by the sender once websocket notification is received that recipient accepted +Contacts.prototype.completeRelation = function (uuid) { + return this.readInvitationSent(uuid) + .then(this.addTrusted.bind(this, uuid)) + .then(this.fetchXPUB.bind(this, uuid)) + .then(this.save.bind(this)); +}; + +// ///////////////////////////////////////////////////////////////////////////// +// Messaging facilities + +// :: returns a message string of a payment request +const paymentRequest = function (id, intendedAmount, address, note) { + const body = { + id: id, + intended_amount: intendedAmount, + address: address + } + if (note) { + body.note = note; + } + return JSON.stringify(body); +}; +Contacts.prototype.paymentRequest = R.compose(JSON.parse, paymentRequest) + +// :: returns a message string of a payment request +const requestPaymentRequest = function (intendedAmount, id, note) { + return JSON.stringify( + { + intended_amount: intendedAmount, + id: id, + note: note + }); +}; + +Contacts.prototype.requestPaymentRequest = R.compose(JSON.parse, requestPaymentRequest) +// :: returns a message string of a payment request +const paymentRequestResponse = function (id, txHash) { + return JSON.stringify( + { + id: id, + tx_hash: txHash + }); +}; +Contacts.prototype.paymentRequestResponse = R.compose(JSON.parse, paymentRequestResponse) + +// :: returns a message string of a decline response +const declineResponse = function (id) { + return JSON.stringify({id: id}); +}; + +// :: returns a message string of a cancel response +const cancelResponse = function (id) { + return JSON.stringify({id: id}); +}; + +// I want you to pay me +Contacts.prototype.sendPR = function (userId, intendedAmount, id, note, initiatorSource) { + // we should reserve the address (check buy-sell) - should probable be an argument + id = id ? id : uuid() + const contact = this.get(userId); + const account = initiatorSource != null ? MyWallet.wallet.hdwallet.accounts[initiatorSource] : MyWallet.wallet.hdwallet.defaultAccount; + const address = account.receiveAddress; + const accountIndex = initiatorSource != null ? initiatorSource : MyWallet.wallet.hdwallet.defaultAccountIndex; + const reserveAddress = () => { + const label = 'payment request to ' + contact.name; + return MyWallet.wallet.labels.setLabel(accountIndex, account.receiveIndex, label); + }; + const message = paymentRequest(id, intendedAmount, address, note); + return reserveAddress() + .then(this.sendMessage.bind(this, userId, PAYMENT_REQUEST_TYPE, message)) + .then(contact.PR.bind(contact, intendedAmount, id, FacilitatedTx.PR_INITIATOR, address, note)) + .then(this.save.bind(this)); +}; + +// request payment request (step-1) +Contacts.prototype.sendRPR = function (userId, intendedAmount, id, note) { + id = id ? id : uuid() + const message = requestPaymentRequest(intendedAmount, id, note); + const contact = this.get(userId); + return this.sendMessage(userId, REQUEST_PAYMENT_REQUEST_TYPE, message) + .then(contact.RPR.bind(contact, intendedAmount, id, FacilitatedTx.RPR_INITIATOR, note)) + .then(this.save.bind(this)); +}; + +// payment request response +Contacts.prototype.sendPRR = function (userId, txHash, id) { + id = id ? id : uuid() + const message = paymentRequestResponse(id, txHash); + const contact = this.get(userId); + return this.sendMessage(userId, PAYMENT_REQUEST_RESPONSE_TYPE, message) + .then(contact.PRR.bind(contact, txHash, id)) + .then(this.save.bind(this)); +}; + +// decline response +Contacts.prototype.sendDeclination = function (userId, id) { + const message = declineResponse(id); + const contact = this.get(userId); + return this.sendMessage(userId, DECLINE_RESPONSE_TYPE, message) + .then(contact.Decline.bind(contact, id)) + .then(this.save.bind(this)); +}; +// cancel response +Contacts.prototype.sendCancellation = function (userId, id) { + const message = cancelResponse(id); + const contact = this.get(userId); + return this.sendMessage(userId, CANCEL_RESPONSE_TYPE, message) + .then(contact.Cancel.bind(contact, id)) + .then(this.save.bind(this)); +}; +// ///////////////////////////////////////////////////////////////////////////// +// digestion logic +Contacts.prototype.digestRPR = function (message) { + // console.log('digesting RPR') + // console.log(message) + const result = this.search(message.sender); + const contact = result[Object.keys(result)[0]]; + return this._sharedMetadata.processMessage(message.id) + .then(contact.RPR.bind(contact, + message.payload.intended_amount, + message.payload.id, + FacilitatedTx.RPR_RECEIVER, + message.payload.note)) + .then(this.save.bind(this)) + .then(() => message); +}; + +Contacts.prototype.digestDecline = function (message) { + // console.log('digesting Cancellation') + // console.log(message) + const result = this.search(message.sender); + const contact = result[Object.keys(result)[0]]; + return this._sharedMetadata.processMessage(message.id) + .then(contact.Decline.bind(contact, message.payload.id)) + .then(this.save.bind(this)) + .then(() => message); +}; + +Contacts.prototype.digestCancel = function (message) { + // console.log('digesting Declination') + // console.log(message) + const result = this.search(message.sender); + const contact = result[Object.keys(result)[0]]; + return this._sharedMetadata.processMessage(message.id) + .then(contact.Cancel.bind(contact, message.payload.id)) + .then(this.save.bind(this)) + .then(() => message); +}; + +Contacts.prototype.digestPR = function (message) { + // console.log('digesting PR') + // console.log(message) + const result = this.search(message.sender); + const contact = result[Object.keys(result)[0]]; + return this._sharedMetadata.processMessage(message.id) + .then(contact.PR.bind(contact, + message.payload.intended_amount, + message.payload.id, + contact.facilitatedTxList[message.payload.id] ? message.payload.role : FacilitatedTx.PR_RECEIVER, + message.payload.address, + message.payload.note)) + .then(this.save.bind(this)) + .then(() => message); +}; + +Contacts.prototype.digestPRR = function (message) { + // console.log('digesting PRR') + // console.log(message) + const result = this.search(message.sender); + const contact = result[Object.keys(result)[0]]; + // todo :: validate txhash on network and amount + return this._sharedMetadata.processMessage(message.id) + .then(contact.PRR.bind(contact, message.payload.tx_hash, message.payload.id)) + .then(this.save.bind(this)) + .then(() => message); +}; + +Contacts.prototype.digestMessage = function (message) { + switch (message.type) { + case REQUEST_PAYMENT_REQUEST_TYPE: + return this.digestRPR(message); + case PAYMENT_REQUEST_TYPE: + return this.digestPR(message); + case PAYMENT_REQUEST_RESPONSE_TYPE: + return this.digestPRR(message); + case DECLINE_RESPONSE_TYPE: + return this.digestDecline(message); + case CANCEL_RESPONSE_TYPE: + return this.digestCancel(message); + default: + return message; + } +}; + +Contacts.prototype.digestNewMessages = function () { + return this.getMessages(true) + .then( + msgs => { + const messages = R.map(this._sharedMetadata.decryptMessage.bind(this._sharedMetadata, this), msgs); + return Promise.all(messages); + }) + .then(msgs => { + return Promise.all(R.map(this.digestMessage.bind(this), msgs)); + } + ); +}; + +module.exports = Contacts; diff --git a/src/facilitatedTx.js b/src/facilitatedTx.js new file mode 100644 index 000000000..25d7c34d7 --- /dev/null +++ b/src/facilitatedTx.js @@ -0,0 +1,62 @@ +'use strict'; + +class FacilitatedTx { + constructor (o) { + this.id = o.id; + this.state = o.state; + this.intended_amount = o.intended_amount; + this.address = o.address; + this.tx_hash = o.tx_hash; + this.role = o.role; + this.note = o.note; + this.created = o.created; + this.last_updated = o.last_updated; + } +} + +// create a Request for a Payment Request +FacilitatedTx.RPR = function (intendedAmount, id, role, note) { + return new FacilitatedTx( + { + state: FacilitatedTx.WAITING_ADDRESS, + intended_amount: intendedAmount, + role: role, + id: id, + note: note, + created: Date.now(), + last_updated: Date.now() + }); +}; + +// create a payment request +FacilitatedTx.PR = function (intendedAmount, id, role, address, note) { + return new FacilitatedTx( + { + state: FacilitatedTx.WAITING_PAYMENT, + intended_amount: intendedAmount, + role: role, + id: id, + address: address, + note: note, + created: Date.now(), + last_updated: Date.now() + }); +}; + +FacilitatedTx.factory = function (o) { + return new FacilitatedTx(o); +}; +// ftx roles +FacilitatedTx.RPR_INITIATOR = 'rpr_initiator'; +FacilitatedTx.RPR_RECEIVER = 'rpr_receiver'; +FacilitatedTx.PR_INITIATOR = 'pr_initiator'; +FacilitatedTx.PR_RECEIVER = 'pr_receiver'; + +// ftx states +FacilitatedTx.WAITING_ADDRESS = 'waiting_address'; +FacilitatedTx.WAITING_PAYMENT = 'waiting_payment'; +FacilitatedTx.PAYMENT_BROADCASTED = 'payment_broadcasted'; +FacilitatedTx.DECLINED = 'declined' +FacilitatedTx.CANCELLED = 'cancelled' + +module.exports = FacilitatedTx; diff --git a/src/index.js b/src/index.js index f99f87890..99a160294 100644 --- a/src/index.js +++ b/src/index.js @@ -47,5 +47,9 @@ module.exports = { BigInteger: require('bigi/lib'), BIP39: require('bip39'), Networks: require('bitcoinjs-lib/src/networks'), - ECDSA: require('bitcoinjs-lib/src/ecdsa') + ECDSA: require('bitcoinjs-lib/src/ecdsa'), + SharedMetadata: require('./sharedMetadata'), + Contacts: require('./contacts'), + SharedMetadataAPI: require('./sharedMetadataAPI'), + R: require('ramda') }; diff --git a/src/metadata.js b/src/metadata.js index e78a298f7..0e2e3b83b 100644 --- a/src/metadata.js +++ b/src/metadata.js @@ -85,7 +85,6 @@ Metadata.message = curry( } ); -// Metadata.magic :: Buffer -> Buffer -> Buffer Metadata.magic = curry( function (payload, prevMagic) { const msg = this.message(payload, prevMagic); diff --git a/src/sharedMetadata.js b/src/sharedMetadata.js new file mode 100644 index 000000000..c1a599dd2 --- /dev/null +++ b/src/sharedMetadata.js @@ -0,0 +1,190 @@ +'use strict'; + +const Metadata = require('./metadata'); +const WalletCrypto = require('./wallet-crypto'); +const Bitcoin = require('bitcoinjs-lib'); +const jwtDecode = require('jwt-decode'); +const API = require('./sharedMetadataAPI'); +const R = require('ramda'); + +class SharedMetadata { + constructor (mdidHDNode) { + this._node = mdidHDNode; + this._xpub = mdidHDNode.neutered().toBase58(); + this._priv = mdidHDNode.toBase58(); + this._mdid = mdidHDNode.getAddress(); + this._keyPair = mdidHDNode.keyPair; + this._auth_token = null; + this._sequence = Promise.resolve(); + } + get mdid () { return this._mdid; } + get node () { return this._node; } + get token () { return this._auth_token; } +}; + +// should be overwritten by iOS +SharedMetadata.sign = Bitcoin.message.sign; +SharedMetadata.verify = Bitcoin.message.verify; +SharedMetadata.signChallenge = R.curry((key, r) => ( + { + nonce: r.nonce, + signature: SharedMetadata.sign(key, r.nonce).toString('base64'), + mdid: key.getAddress() + } +)); + +SharedMetadata.getAuthToken = (mdidHDNode) => + API.getAuth().then(SharedMetadata.signChallenge(mdidHDNode.keyPair)) + .then(API.postAuth); + +SharedMetadata.isValidToken = (token) => { + try { + const decoded = jwtDecode(token); + var expDate = new Date(decoded.exp * 1000); + var now = new Date(); + return now < expDate; + } catch (e) { + return false; + } +}; + +SharedMetadata.prototype.authorize = function () { + const saveToken = (r) => { this._auth_token = r.token; return r.token; }; + return this.next( + () => SharedMetadata.isValidToken(this.token) + ? Promise.resolve(this.token) + : SharedMetadata.getAuthToken(this.node).then(saveToken) + ); +}; + +SharedMetadata.prototype.sendMessage = function (contact, type, payload) { + const encrypted = this.encryptFor(payload, contact); + const signature = SharedMetadata.sign(this.node.keyPair, encrypted).toString('base64'); + return this.authorize().then((t) => + this.next(API.sendMessage.bind(null, t, contact.mdid, encrypted, signature, type))); +}; + +SharedMetadata.prototype.decryptMessage = function (contacts, msg) { + const sender = R.head(R.values(contacts.search(msg.sender))); + const open = (m) => { + var msg = this.decryptFrom(m.payload, sender); + try { msg = JSON.parse(msg); } catch (e) { console.log(e); } + return R.assoc('payload', msg, m); + }; + return SharedMetadata.verify(msg.sender, msg.signature, msg.payload) + ? Promise.resolve(open(msg)) + : Promise.reject('Wrong Signature'); +}; + +SharedMetadata.prototype.readMessage = function (contacts, msg) { + const sender = R.head(R.values(contacts.search(msg.sender))); + return SharedMetadata.verify(msg.sender, msg.signature, msg.payload) + ? Promise.resolve(this.decryptFrom(msg.payload, sender)) + : Promise.reject('Wrong Signature'); +}; + +SharedMetadata.prototype.encryptFor = function (message, contact) { + var sharedSecret = contact.pubKey.Q.multiply(this._keyPair.d).getEncoded(true); + var sharedKey = WalletCrypto.sha256(sharedSecret); + return WalletCrypto.encryptDataWithKey(message, sharedKey); +}; + +SharedMetadata.prototype.decryptFrom = function (message, contact) { + var sharedSecret = contact.pubKey.Q.multiply(this._keyPair.d).getEncoded(true); + var sharedKey = WalletCrypto.sha256(sharedSecret); + return WalletCrypto.decryptDataWithKey(message, sharedKey); +}; +// +// SharedMetadata.prototype.sendPaymentRequest = function (mdid, amount, note) { +// // type 1 :: paymentRequest +// var paymentRequest = { +// amount: amount, +// note: note +// }; +// return this.sendMessage(mdid, JSON.stringify(paymentRequest), 1); +// }; +// +// SharedMetadata.prototype.sendPaymentRequestResponse = function (requestMessage) { +// // type 2 :: payment request answer +// var msgP = this.readMessage(requestMessage); +// var f = function (msg) { +// var requestResponse = { +// address: MyWallet.wallet.hdwallet.defaultAccount.receiveAddress, +// amount: msg.amount, +// note: msg.note +// }; +// return this.sendMessage(requestMessage.sender, JSON.stringify(requestResponse), 2); +// }; +// return msgP.then(f.bind(this)); +// }; +// +SharedMetadata.prototype.publishXPUB = function () { + return this.next(() => { + var myDirectory = new Metadata(this._keyPair); + myDirectory.fetch(); + return myDirectory.update({xpub: this._xpub}); + }); +}; +// createInvitation :: Promise InvitationID +SharedMetadata.prototype.createInvitation = function (data) { + return this.authorize().then((t) => this.next(API.createInvitation.bind(null, t, data))); +}; +// readInvitation :: String -> Promise RequesterID +SharedMetadata.prototype.readInvitation = function (uuid) { + return this.authorize().then((t) => this.next(API.readInvitation.bind(null, t, uuid))); +}; +// acceptInvitation :: String -> Promise () +SharedMetadata.prototype.acceptInvitation = function (uuid, data) { + return this.authorize().then((t) => this.next(API.acceptInvitation.bind(null, t, uuid, data))); +}; +// deleteInvitation :: String -> Promise () +SharedMetadata.prototype.deleteInvitation = function (uuid) { + return this.authorize().then((t) => this.next(API.deleteInvitation.bind(null, t, uuid))); +}; + +SharedMetadata.prototype.addTrusted = function (mdid) { + return this.authorize().then((t) => this.next(API.addTrusted.bind(null, t, mdid))); +}; + +SharedMetadata.prototype.deleteTrusted = function (mdid) { + return this.authorize().then((t) => this.next(API.deleteTrusted.bind(null, t, mdid))); +}; + +SharedMetadata.prototype.getMessages = function (onlyNew) { + return this.authorize().then((t) => this.next(API.getMessages.bind(null, t, onlyNew))); +}; + +SharedMetadata.prototype.getMessage = function (uuid) { + return this.authorize().then((t) => this.next(API.getMessage.bind(null, t, uuid))); +}; + +SharedMetadata.prototype.processMessage = function (uuid) { + return this.authorize().then((t) => this.next(API.processMessage.bind(null, t, uuid))); +}; + +SharedMetadata.prototype.deleteTrusted = function (mdid) { + return this.authorize().then((t) => this.next(API.deleteTrusted.bind(null, t, mdid))); +}; + +SharedMetadata.prototype.signWithMDID = function (message) { + return SharedMetadata.sign(this._node.keyPair, message); +}; + +SharedMetadata.fromMDIDHDNode = function (mdidHDNode) { + return new SharedMetadata(mdidHDNode); +}; + +SharedMetadata.fromMasterHDNode = function (masterHDNode) { + var hash = WalletCrypto.sha256('info.blockchain.mdid'); + var purpose = hash.slice(0, 4).readUInt32BE(0) & 0x7FFFFFFF; + var mdidHDNode = masterHDNode.deriveHardened(purpose); + return SharedMetadata.fromMDIDHDNode(mdidHDNode); +}; + +SharedMetadata.prototype.next = function (f) { + var nextInSeq = this._sequence.then(f); + this._sequence = nextInSeq.then(x => x, x => x); + return nextInSeq; +}; + +module.exports = SharedMetadata; diff --git a/src/sharedMetadataAPI.js b/src/sharedMetadataAPI.js new file mode 100644 index 000000000..7b6d16265 --- /dev/null +++ b/src/sharedMetadataAPI.js @@ -0,0 +1,67 @@ +'use strict'; +const API = require('./api'); +const S = {}; + +S.request = function (method, endpoint, data, authToken) { + var url = API.API_ROOT_URL + 'iwcs/' + endpoint; + var options = { + headers: { 'Content-Type': 'application/json' }, + credentials: 'omit' + }; + if (authToken) { + options.headers.Authorization = 'Bearer ' + authToken; + } + // encodeFormData :: Object -> url encoded params + var encodeFormData = function (data) { + if (!data) return ''; + var encoded = Object.keys(data).map(function (k) { + return encodeURIComponent(k) + '=' + encodeURIComponent(data[k]); + }).join('&'); + return encoded ? '?' + encoded : encoded; + }; + if (data && data !== {}) { + if (method === 'GET') { + url += encodeFormData(data); + } else { + options.body = JSON.stringify(data); + } + } + options.method = method; + var handleNetworkError = function (e) { + return Promise.reject({ error: 'SHARED_METADATA_CONNECT_ERROR', message: e }); + }; + var checkStatus = function (response) { + if (response.ok) { + return response.json(); + } else { + return response.text().then(Promise.reject.bind(Promise)); + } + }; + return fetch(url, options) + .catch(handleNetworkError) + .then(checkStatus); +}; + +// authentication +S.getAuth = () => S.request('GET', 'auth'); +S.postAuth = (data) => S.request('POST', 'auth', data); + +// messages +S.getMessages = (token, onlyNew) => S.request('GET', 'messages', onlyNew ? {new: true} : {}, token); +S.getMessage = (token, uuid) => S.request('GET', 'message/' + uuid, null, token); +S.sendMessage = (token, recipient, payload, signature, type) => + S.request('POST', 'messages', {type, payload, signature, recipient}, token); +S.processMessage = (token, uuid) => S.request('PUT', 'message/' + uuid + '/processed', {processed: true}, token); + +// trusted contact list +S.addTrusted = (token, mdid) => S.request('PUT', 'trusted/' + mdid, null, token); +S.getTrusted = (token, mdid) => S.request('GET', 'trusted/' + mdid, null, token); +S.deleteTrusted = (token, mdid) => S.request('DELETE', 'trusted/' + mdid, null, mdid); +S.getTrustedList = (token) => S.request('GET', 'trusted', null, token); + +S.createInvitation = (token, data = {}) => S.request('POST', 'share', data, token); +S.readInvitation = (token, uuid) => S.request('GET', 'share/' + uuid, null, token); +S.acceptInvitation = (token, uuid, data = {}) => S.request('POST', 'share/' + uuid, data, token); +S.deleteInvitation = (token, uuid) => S.request('DELETE', 'share/' + uuid, null, token); + +module.exports = S; diff --git a/tests/_prepare_spec.js.coffee b/tests/_prepare_spec.js.coffee new file mode 100644 index 000000000..0772bf947 --- /dev/null +++ b/tests/_prepare_spec.js.coffee @@ -0,0 +1 @@ +JasminePromiseMatchers.install() diff --git a/tests/contact_spec.js b/tests/contact_spec.js new file mode 100644 index 000000000..e31067600 --- /dev/null +++ b/tests/contact_spec.js @@ -0,0 +1,83 @@ +const proxyquire = require('proxyquireify')(require); + +const uuid = () => 'my-uuid'; +const Metadata = { + read: (mdid) => { + return Promise.resolve('xpub'); + } +}; + +const stubs = { + 'uuid': uuid, + './metadata': Metadata +}; + +const Contact = proxyquire('../src/contact', stubs); + +describe('contact', () => { + it('should contruct an object with new', () => { + const o = { + id: 'id', + mdid: 'mdid', + name: 'name', + xpub: 'xpub', + trusted: 'trusted', + invitationSent: 'invitationSent', + invitationReceived: 'invitationReceived', + facilitatedTxList: {} + }; + const c = new Contact(o); + expect(c.id).toEqual(o.id); + expect(c.mdid).toEqual(o.mdid); + expect(c.name).toEqual(o.name); + expect(c.xpub).toEqual(o.xpub); + expect(c.trusted).toEqual(o.trusted); + expect(c.invitationSent).toEqual(o.invitationSent); + expect(c.invitationReceived).toEqual(o.invitationReceived); + }); + + it('should contruct a contact with generated id', () => { + const c = Contact.new({name: 'name'}); + expect(c.id).toEqual('my-uuid'); + expect(c.name).toEqual('name'); + }); + + it('fetchXpub should fetch the xpub', (done) => { + const c = Contact.new({name: 'name', mdid: 'mdid'}); + const promise = c.fetchXPUB(); + expect(promise).toBeResolved(done); + }); + + it('RPR should add an RPR to the list', () => { + const c = Contact.new({name: 'name', mdid: 'mdid', facilitatedTxList: {}}); + c.RPR(1000, 'my-id', 'role', 'note', 0); + const addedTx = c.facilitatedTxList['my-id']; + expect(addedTx.id).toEqual('my-id'); + }); + + it('PR should add a new PR to the list', () => { + const c = Contact.new({name: 'name', mdid: 'mdid', facilitatedTxList: {}}); + c.PR(1000, 'my-id', 'role', 'address', 'note', 0); + const addedTx = c.facilitatedTxList['my-id']; + expect(addedTx.id).toEqual('my-id'); + }); + + it('PR should add address to an exiting RPR and change the state to waiting payment', () => { + const c = Contact.new({name: 'name', mdid: 'mdid', facilitatedTxList: {}}); + c.RPR(1000, 'my-id', 'role', 'note', 0); + c.PR(undefined, 'my-id', undefined, 'address', undefined, 0); + const addedTx = c.facilitatedTxList['my-id']; + expect(addedTx.id).toEqual('my-id'); + expect(addedTx.state).toEqual('waiting_payment'); + }); + + it('PRR should add the txhash and update state', () => { + const c = Contact.new({name: 'name', mdid: 'mdid', facilitatedTxList: {}}); + c.RPR(1000, 'my-id', 'role', 'note', 0); + c.PR(undefined, 'my-id', undefined, 'address', undefined, 0); + c.PRR('txhash', 'my-id'); + const addedTx = c.facilitatedTxList['my-id']; + expect(addedTx.id).toEqual('my-id'); + expect(addedTx.state).toEqual('payment_broadcasted'); + }); +}); diff --git a/tests/contacts_spec.js b/tests/contacts_spec.js new file mode 100644 index 000000000..b16e99e74 --- /dev/null +++ b/tests/contacts_spec.js @@ -0,0 +1,255 @@ +const R = require('ramda'); +const proxyquire = require('proxyquireify')(require); + +const uuid = () => 'my-uuid'; + +let mockPayload = {}; + +let RPRMessage = { + id: 'da8730bd-19ec-4f6f-b115-343008913dd2', + notified: false, + payload: { + id: '3c00935a-04bd-418e-94ba-2d87e98603cb', + intended_amount: 1000 + }, + processed: false, + recipient: '18gZzsF5T92rT7WpvdZDEdo6KEmE8vu5sJ', + sender: '13XvRvToUZxfaTSydniv4roTh3jY5rMcWH', + signature: 'H+BRYJzTDpTX+RqvFSw857CvsgpcchKQOXOvJG/tWJrzM6gUPIm9ulxpMoOF58wGP9ynpvTbx1LGHCmEVJMHeXQ=', + type: 0 +}; + +let PRMessage = { + id: 'f863ac50-b5dc-49bc-9c75-d900dc229120', + notified: false, + payload: { + address: '1PbNwFMdJm1tnvacAA3LQCiC2aojbzzThf', + id: '3c00935a-04bd-418e-94ba-2d87e98603cb', + intended_amount: 1000 + }, + processed: false, + recipient: '13XvRvToUZxfaTSydniv4roTh3jY5rMcWH', + sender: '18gZzsF5T92rT7WpvdZDEdo6KEmE8vu5sJ', + signature: 'INOKgasWmu6H/92IXrC5JFxXOU7AQwDh7rbxqFRuAzcJXXzUTLoaujqUKlhMiNZVPPT49afBt8MhYVwzqk+mCkE=', + type: 1 +}; + +let PRRMessage = { + id: 'e402dde8-3429-447f-9cb3-a20157930b3c', + notified: false, + payload: { + address: '13ZZBJPxYTrSBxGT6hFZMBMm9VUmY1yzam', + id: '79c1b029-cf84-474f-8e79-afadec42fc8e', + tx_hash: 'tx-hash' + }, + processed: false, + recipient: '13XvRvToUZxfaTSydniv4roTh3jY5rMcWH', + sender: '18gZzsF5T92rT7WpvdZDEdo6KEmE8vu5sJ', + signature: 'IOXMhX3ErT74UcRvWcqq6PP+TCeJ+Ynb0THOUe/yGUP3cKBuxStR0z7AI0HFA5Gpa7dn8c0EWWZBOBO62+AYU3c=', + type: 2 +} + +let Metadata = { + read (mdid) { + return Promise.resolve('xpub'); + }, + fromMasterHDNode (n, masterhdnode) { + return { + create () {}, + fetch () { + return Promise.resolve(mockPayload); + }, + update () { + return Promise.resolve(mockPayload); + } + }; + } +}; + +let SharedMetadata = { + fromMasterHDNode (n, masterhdnode) { + return { + publishXPUB () { return; }, + readInvitation (i) { + switch (i) { + case 'link-received': + return Promise.resolve({id: 'invitation-id', mdid: 'biel-mdid', contact: undefined}); + case 'link-sent': + return Promise.resolve({id: 'invitation-id', mdid: 'myself-mdid', contact: 'joan-mdid'}); + default: + return Promise.resolve({id: 'invitation-id', mdid: 'dude1', contact: 'dude2'}); + } + }, + addTrusted (mdid) { + return Promise.resolve(true); + }, + deleteTrusted (mdid) { + return Promise.resolve(true); + }, + createInvitation () { + return Promise.resolve({id: 'shared-link', mdid: 'invitator-mdid', contact: null}); + }, + processMessage (id) { + return Promise.resolve(true); + } + }; + } +}; + +const stubs = { + 'uuid': uuid, + './metadata': Metadata, + './sharedMetadata': SharedMetadata +}; + +const Contacts = proxyquire('../src/contacts', stubs); + +describe('contacts', () => { + it('should contruct an object with an empty contact list', () => { + const cs = new Contacts('fakeMasterHDNode'); + return expect(cs.list).toEqual({}); + }); + + it('Contacts.new should add a new contact to the list', () => { + const cs = new Contacts('fakeMasterHDNode'); + const nc = cs.new({name: 'mr moon'}); + const nameNewAdded = R.prop(nc.id, cs.list).name; + return expect(nameNewAdded).toEqual('mr moon'); + }); + + it('Contacts.delete should remove a contact from the list', () => { + const cs = new Contacts('fakeMasterHDNode'); + const nc = cs.new({name: 'mr moon'}); + cs.delete(nc.id); + const missing = R.prop(nc.id, cs.list); + return expect(missing).toBe(undefined); + }); + + it('Contacts.search should filter the contacts list by text', () => { + const cs = new Contacts('fakeMasterHDNode'); + const nc = cs.new({name: 'mr moon'}); + const result = cs.search(nc.name); + const found = R.prop(nc.id, result); + return expect(found.name).toBe('mr moon'); + }); + + it('Contacts.get should get the contact by id', () => { + const cs = new Contacts('fakeMasterHDNode'); + const nc = cs.new({name: 'mr moon'}); + const result = cs.get(nc.id); + return expect(result.name).toBe('mr moon'); + }); + + it('read Invitation received', (done) => { + const cs = new Contacts('fakeMasterHDNode'); + const i = {name: 'Biel', invitationReceived: 'link-received'}; + const promise = cs.readInvitation(i) + .then(c => { + expect(c.mdid).toBe('biel-mdid'); + expect(c.name).toBe('Biel'); + expect(c.invitationReceived).toBe('link-received'); + return c; + }); + return expect(promise).toBeResolved(done); + }); + + it('read invitation sent', (done) => { + const cs = new Contacts('fakeMasterHDNode'); + const myContact = {name: 'Joan', invitationSent: 'link-sent'}; + cs.new(myContact); + const promise = cs.readInvitationSent('my-uuid') + .then(c => { + expect(c.mdid).toBe('joan-mdid'); + return c; + }); + return expect(promise).toBeResolved(done); + }); + + it('add trusted', (done) => { + const cs = new Contacts('fakeMasterHDNode'); + const myContact = cs.new({name: 'Trusted Contact'}); + const promise = cs.addTrusted('my-uuid') + .then(c => { + expect(myContact.trusted).toBe(true); + return c; + }); + return expect(promise).toBeResolved(done); + }); + + it('delete trusted', (done) => { + const cs = new Contacts('fakeMasterHDNode'); + const myContact = cs.new({name: 'UnTrusted Contact'}); + const promise = cs.deleteTrusted('my-uuid') + .then(c => { + expect(myContact.trusted).toBe(false); + return c; + }); + return expect(promise).toBeResolved(done); + }); + + it('create invitation', (done) => { + const cs = new Contacts('fakeMasterHDNode'); + const promise = cs.createInvitation({name: 'me'}, {name: 'him'}) + .then(inv => { + expect(inv.name).toBe('me'); + expect(inv.invitationReceived).toBe('shared-link'); + return inv; + }); + return expect(promise).toBeResolved(done); + }); + + it('digestion of RPR', (done) => { + spyOn(Contacts.prototype, 'save').and.callFake((something) => Promise.resolve({action: 'saved'})); + const cs = new Contacts('fakeMasterHDNode'); + const contact = cs.new({name: 'Josep', mdid: '13XvRvToUZxfaTSydniv4roTh3jY5rMcWH'}); + const promise = cs.digestRPR(RPRMessage); + promise.then(x => { + const k = Object.keys(contact.facilitatedTxList)[0]; + const ftx = contact.facilitatedTxList[k]; + expect(ftx.state).toBe('waiting_address'); + expect(ftx.intended_amount).toBe(1000); + expect(ftx.role).toBe('rpr_receiver'); + return x; + }); + expect(promise).toBeResolved(done); + }); + + it('digestion of PR', (done) => { + spyOn(Contacts.prototype, 'save').and.callFake((something) => Promise.resolve({action: 'saved'})); + const cs = new Contacts('fakeMasterHDNode'); + const contact = cs.new({name: 'Enric', mdid: '18gZzsF5T92rT7WpvdZDEdo6KEmE8vu5sJ'}); + const promise = cs.digestPR(PRMessage); + promise.then(x => { + const k = Object.keys(contact.facilitatedTxList)[0]; + const ftx = contact.facilitatedTxList[k]; + expect(ftx.state).toBe('waiting_payment'); + expect(ftx.intended_amount).toBe(1000); + expect(ftx.role).toBe('pr_receiver'); + expect(ftx.address).toBe('1PbNwFMdJm1tnvacAA3LQCiC2aojbzzThf'); + return x; + }); + expect(promise).toBeResolved(done); + }); + + it('digestion of PRR', (done) => { + spyOn(Contacts.prototype, 'save').and.callFake((something) => Promise.resolve({action: 'saved'})); + const cs = new Contacts('fakeMasterHDNode'); + const contact = cs.new({name: 'you', mdid: '18gZzsF5T92rT7WpvdZDEdo6KEmE8vu5sJ'}); + const algo = contact.PR(15000, '79c1b029-cf84-474f-8e79-afadec42fc8e', 'pr_initiator', '13ZZBJPxYTrSBxGT6hFZMBMm9VUmY1yzam', 'my-note') + + const promise = cs.digestPRR(PRRMessage); + promise.then(x => { + const k = Object.keys(contact.facilitatedTxList)[0]; + const ftx = contact.facilitatedTxList[k]; + console.log(ftx) + expect(ftx.state).toBe('payment_broadcasted'); + expect(ftx.intended_amount).toBe(15000); + expect(ftx.role).toBe('pr_initiator'); + expect(ftx.note).toBe('my-note'); + expect(ftx.address).toBe('13ZZBJPxYTrSBxGT6hFZMBMm9VUmY1yzam'); + expect(ftx.tx_hash).toBe('tx-hash'); + return x; + }); + expect(promise).toBeResolved(done); + }); +}); diff --git a/tests/facilitatedTx_spec.js b/tests/facilitatedTx_spec.js new file mode 100644 index 000000000..875f335c5 --- /dev/null +++ b/tests/facilitatedTx_spec.js @@ -0,0 +1,70 @@ +let proxyquire = require('proxyquireify')(require); +let FacilitatedTx = proxyquire('../src/facilitatedTx', {}); + +describe('FacilitatedTx', () => { + it('should contruct an object with new', () => { + const o = { + id: 'id', + state: 'state', + intended_amount: 'intended_amount', + address: 'address', + tx_hash: 'tx_hash', + role: 'role', + note: 'note', + last_updated: 'last_updated' + } + const f = new FacilitatedTx(o) + expect(f.id).toEqual(o.id); + expect(f.state).toEqual(o.state); + expect(f.intended_amount).toEqual(o.intended_amount); + expect(f.address).toEqual(o.address); + expect(f.tx_hash).toEqual(o.tx_hash); + expect(f.role).toEqual(o.role); + expect(f.note).toEqual(o.note); + expect(f.last_updated).toEqual(o.last_updated); + }); + + it('should contruct an object with factory', () => { + const o = { + id: 'id', + state: 'state', + intended_amount: 'intended_amount', + address: 'address', + tx_hash: 'tx_hash', + role: 'role', + note: 'note', + last_updated: 'last_updated' + } + const f = FacilitatedTx.factory(o) + expect(f.id).toEqual(o.id); + expect(f.state).toEqual(o.state); + expect(f.intended_amount).toEqual(o.intended_amount); + expect(f.address).toEqual(o.address); + expect(f.tx_hash).toEqual(o.tx_hash); + expect(f.role).toEqual(o.role); + expect(f.note).toEqual(o.note); + expect(f.last_updated).toEqual(o.last_updated); + }); + + it('should contruct a Request for a Payment Request', () => { + const rpr = FacilitatedTx.RPR(1000, 'id', 'role', 'note') + expect(rpr.id).toEqual('id'); + expect(rpr.state).toEqual(FacilitatedTx.WAITING_ADDRESS); + expect(rpr.intended_amount).toEqual(1000); + expect(rpr.address).toEqual(undefined); + expect(rpr.tx_hash).toEqual(undefined); + expect(rpr.role).toEqual('role'); + expect(rpr.note).toEqual('note'); + }); + + it('should contruct a Payment Request', () => { + const rpr = FacilitatedTx.PR(1000, 'id', 'role', 'address', 'note') + expect(rpr.id).toEqual('id'); + expect(rpr.state).toEqual(FacilitatedTx.WAITING_PAYMENT); + expect(rpr.intended_amount).toEqual(1000); + expect(rpr.address).toEqual('address'); + expect(rpr.tx_hash).toEqual(undefined); + expect(rpr.role).toEqual('role'); + expect(rpr.note).toEqual('note'); + }); +});