diff --git a/.eslintrc.json b/.eslintrc.json index 8bd8ef46..bfcdeedb 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -62,7 +62,10 @@ "no-shadow": "off", // We're usually throwing objects throughout the API to allow for more detailed error messages - "no-throw-literal": "off" + "no-throw-literal": "off", + + // lightning has sync methods and this rule bans them + "no-sync": "off" }, "parser": "babel-eslint", "env": { diff --git a/services/gunDB/Mediator/index.js b/services/gunDB/Mediator/index.js index be27bf87..79b671cf 100644 --- a/services/gunDB/Mediator/index.js +++ b/services/gunDB/Mediator/index.js @@ -154,6 +154,7 @@ const authenticate = async (alias, pass) => { } // move this to a subscription; implement off() ? todo API.Jobs.onAcceptedRequests(user, mySEA) + API.Jobs.onOrders(user, gun, mySEA) return user._.sea.pub } @@ -177,6 +178,7 @@ const authenticate = async (alias, pass) => { throw new Error(ack.err) } else if (typeof ack.sea === 'object') { API.Jobs.onAcceptedRequests(user, mySEA) + API.Jobs.onOrders(user, gun, mySEA) const mySec = await mySEA.secret(user._.sea.epub, user._.sea) if (typeof mySec !== 'string') { @@ -294,6 +296,7 @@ class Mediator { this.sendHRWithInitialMsg ) socket.on(Action.SEND_MESSAGE, this.sendMessage) + socket.on(Action.SEND_PAYMENT, this.sendPayment) socket.on(Action.SET_AVATAR, this.setAvatar) socket.on(Action.SET_DISPLAY_NAME, this.setDisplayName) @@ -561,6 +564,39 @@ class Mediator { } } + /** + * @param {Readonly<{ uuid: string, recipientPub: string, amount: number, memo: string, token: string }>} reqBody + */ + sendPayment = async reqBody => { + try { + const { recipientPub, amount, memo, token } = reqBody + + await throwOnInvalidToken(token) + + await API.Actions.sendPayment( + recipientPub, + amount, + memo, + gun, + user, + mySEA + ) + + this.socket.emit(Action.SEND_PAYMENT, { + ok: true, + msg: null, + origBody: reqBody + }) + } catch (err) { + console.log(err) + this.socket.emit(Action.SEND_PAYMENT, { + ok: false, + msg: err.message, + origBody: reqBody + }) + } + } + /** * @param {Readonly<{ avatar: string|null , token: string }>} body */ @@ -915,6 +951,7 @@ const register = async (alias, pass) => { return authenticate(alias, pass).then(async pub => { await API.Actions.setDisplayName('anon' + pub.slice(0, 8), user) await API.Actions.generateHandshakeAddress(user) + await API.Actions.generateOrderAddress(user) return pub }) } diff --git a/services/gunDB/action-constants.js b/services/gunDB/action-constants.js index ac17c7a1..440e7bcf 100644 --- a/services/gunDB/action-constants.js +++ b/services/gunDB/action-constants.js @@ -5,6 +5,7 @@ const Actions = { SEND_HANDSHAKE_REQUEST: "SEND_HANDSHAKE_REQUEST", SEND_HANDSHAKE_REQUEST_WITH_INITIAL_MSG: "SEND_HANDSHAKE_REQUEST_WITH_INITIAL_MSG", SEND_MESSAGE: "SEND_MESSAGE", + SEND_PAYMENT: "SEND_PAYMENT", SET_AVATAR: "SET_AVATAR", SET_DISPLAY_NAME: "SET_DISPLAY_NAME" }; diff --git a/services/gunDB/contact-api/actions.js b/services/gunDB/contact-api/actions.js index 9fe5300c..b6b7de2b 100644 --- a/services/gunDB/contact-api/actions.js +++ b/services/gunDB/contact-api/actions.js @@ -2,7 +2,11 @@ * @format */ const uuidv1 = require('uuid/v1') + +const LightningServices = require('../../../utils/lightningServices') + const ErrorCode = require('./errorCode') +const Getters = require('./getters') const Key = require('./key') const Utils = require('./utils') const { isHandshakeRequest } = require('./schema') @@ -15,6 +19,8 @@ const { isHandshakeRequest } = require('./schema') * @typedef {import('./schema').Message} Message * @typedef {import('./schema').Outgoing} Outgoing * @typedef {import('./schema').PartialOutgoing} PartialOutgoing + * @typedef {import('./schema').Order} Order + * @typedef {import('./SimpleGUN').Ack} Ack */ /** @@ -408,18 +414,8 @@ const sendHandshakeRequest = async (recipientPublicKey, gun, user, SEA) => { console.log('sendHR() -> before mySecret') const mySecret = await SEA.secret(user._.sea.epub, user._.sea) - if (typeof mySecret !== 'string') { - throw new TypeError( - "sendHandshakeRequest() -> typeof mySecret !== 'string'" - ) - } console.log('sendHR() -> before ourSecret') const ourSecret = await SEA.secret(recipientEpub, user._.sea) - if (typeof ourSecret !== 'string') { - throw new TypeError( - "sendHandshakeRequest() -> typeof ourSecret !== 'string'" - ) - } // check if successful handshake is present @@ -660,7 +656,65 @@ const sendMessage = async (recipientPublicKey, body, user, SEA) => { .get(Key.MESSAGES) .set(newMessage, ack => { if (ack.err) { - rej(ack.err) + rej(new Error(ack.err)) + } else { + res() + } + }) + }) +} + +/** + * @param {string} recipientPub + * @param {string} msgID + * @param {UserGUNNode} user + * @param {ISEA} SEA + * @returns {Promise} + */ +const deleteMessage = async (recipientPub, msgID, user, SEA) => { + if (!user.is) { + throw new Error(ErrorCode.NOT_AUTH) + } + + if (typeof recipientPub !== 'string') { + throw new TypeError( + `expected recipientPublicKey to be an string, but instead got: ${typeof recipientPub}` + ) + } + + if (recipientPub.length === 0) { + throw new TypeError( + 'expected recipientPublicKey to be an string of length greater than zero' + ) + } + + if (typeof msgID !== 'string') { + throw new TypeError( + `expected msgID to be an string, instead got: ${typeof msgID}` + ) + } + + if (msgID.length === 0) { + throw new TypeError( + 'expected msgID to be an string of length greater than zero' + ) + } + + const outgoingID = await Utils.recipientToOutgoingID(recipientPub, user, SEA) + + if (outgoingID === null) { + throw new Error(`Could not fetch an outgoing id for user: ${recipientPub}`) + } + + return new Promise((res, rej) => { + user + .get(Key.OUTGOINGS) + .get(outgoingID) + .get(Key.MESSAGES) + .get(msgID) + .put(null, ack => { + if (ack.err) { + rej(new Error(ack.err)) } else { res() } @@ -783,6 +837,158 @@ const sendHRWithInitialMsg = async ( await sendMessage(recipientPublicKey, initialMsg, user, SEA) } +/** + * @param {string} to + * @param {number} amount + * @param {string} memo + * @param {GUNNode} gun + * @param {UserGUNNode} user + * @param {ISEA} SEA + * @throws {Error} If no response in less than 20 seconds from the recipient, or + * lightning cannot find a route for the payment. + * @returns {Promise} + */ +const sendPayment = async (to, amount, memo, gun, user, SEA) => { + if (!user.is) { + throw new Error(ErrorCode.NOT_AUTH) + } + + const recipientEpub = await Utils.pubToEpub(to) + const ourSecret = await SEA.secret(recipientEpub, user._.sea) + + if (amount < 1) { + throw new RangeError('Amount must be at least 1 sat.') + } + + /** @type {Order} */ + const order = { + amount: await SEA.encrypt(amount.toString(), ourSecret), + from: user._.sea.pub, + memo: await SEA.encrypt(memo || 'no memo', ourSecret), + timestamp: Date.now() + } + + const currOrderAddress = await Getters.currentOrderAddress(to) + + order.timestamp = Date.now() + + /** @type {string} */ + const orderID = await new Promise((res, rej) => { + const ord = gun + .get(Key.ORDER_NODES) + .get(currOrderAddress) + .set(order, ack => { + if (ack.err) { + rej( + new Error( + `Error writing order to order node: ${currOrderAddress} for pub: ${to}: ${ack.err}` + ) + ) + } else { + res(ord._.get) + } + }) + }) + + const bob = gun.user(to) + + /** @type {string} */ + const invoice = await Promise.race([ + new Promise((res, rej) => { + bob + .get(Key.ORDER_TO_RESPONSE) + .get(orderID) + .on(data => { + if (typeof data !== 'string') { + rej( + `Expected order response from pub ${to} to be an string, instead got: ${typeof data}` + ) + } else { + res(data) + } + }) + }), + new Promise((_, rej) => { + setTimeout(() => { + rej(new Error(ErrorCode.ORDER_NOT_ANSWERED_IN_TIME)) + }, 20000) + }) + ]) + + const decInvoice = await SEA.decrypt(invoice, ourSecret) + + const { + services: { lightning } + } = LightningServices + + /** + * @typedef {object} SendErr + * @prop {string} details + */ + + /** + * Partial + * https://api.lightning.community/#grpc-response-sendresponse-2 + * @typedef {object} SendResponse + * @prop {string|null} payment_error + * @prop {any[]|null} payment_route + */ + + await new Promise((resolve, rej) => { + lightning.sendPaymentSync( + { + payment_request: decInvoice + }, + (/** @type {SendErr=} */ err, /** @type {SendResponse} */ res) => { + if (err) { + rej(new Error(err.details)) + } else if (res) { + if (res.payment_error) { + rej( + new Error( + `sendPaymentSync error response: ${JSON.stringify(res)}` + ) + ) + } else if (!res.payment_route) { + rej( + new Error( + `sendPaymentSync no payment route response: ${JSON.stringify( + res + )}` + ) + ) + } else { + resolve() + } + } else { + rej(new Error('no error or response received from sendPaymentSync')) + } + } + ) + }) +} + +/** + * @param {UserGUNNode} user + * @returns {Promise} + */ +const generateOrderAddress = user => + new Promise((res, rej) => { + if (!user.is) { + throw new Error(ErrorCode.NOT_AUTH) + } + + const address = uuidv1() + + user.get(Key.CURRENT_ORDER_ADDRESS).put(address, ack => { + if (ack.err) { + rej(new Error(ack.err)) + } else { + res() + } + }) + }) + module.exports = { INITIAL_MSG, __createOutgoingFeed, @@ -791,8 +997,11 @@ module.exports = { blacklist, generateHandshakeAddress, sendHandshakeRequest, + deleteMessage, sendMessage, sendHRWithInitialMsg, setAvatar, - setDisplayName + setDisplayName, + sendPayment, + generateOrderAddress } diff --git a/services/gunDB/contact-api/errorCode.js b/services/gunDB/contact-api/errorCode.js index eddc4f84..c3785d4a 100644 --- a/services/gunDB/contact-api/errorCode.js +++ b/services/gunDB/contact-api/errorCode.js @@ -41,3 +41,5 @@ exports.ALREADY_REQUESTED_HANDSHAKE = 'ALREADY_REQUESTED_HANDSHAKE' exports.STALE_HANDSHAKE_ADDRESS = 'STALE_HANDSHAKE_ADDRESS' exports.TIMEOUT_ERR = 'TIMEOUT_ERR' + +exports.ORDER_NOT_ANSWERED_IN_TIME = 'ORDER_NOT_ANSWERED_IN_TIME' diff --git a/services/gunDB/contact-api/getters.js b/services/gunDB/contact-api/getters.js new file mode 100644 index 00000000..fa704e37 --- /dev/null +++ b/services/gunDB/contact-api/getters.js @@ -0,0 +1,16 @@ +const Key = require('./key') +const Utils = require('./utils') + +/** + * @param {string} pub + * @returns {Promise} + */ +exports.currentOrderAddress = async (pub) => { + const currAddr = await Utils.tryAndWait((gun) => gun.user(pub).get(Key.CURRENT_ORDER_ADDRESS).then()) + + if (typeof currAddr !== 'string') { + throw new TypeError('Expected user.currentOrderAddress to be an string') + } + + return currAddr +} \ No newline at end of file diff --git a/services/gunDB/contact-api/jobs/index.js b/services/gunDB/contact-api/jobs/index.js new file mode 100644 index 00000000..55c993e1 --- /dev/null +++ b/services/gunDB/contact-api/jobs/index.js @@ -0,0 +1,18 @@ +/** + * @format + * Jobs are subscriptions to events that perform actions (write to GUN) on + * response to certain ways events can happen. These tasks need to be fired up + * at app launch otherwise certain features won't work as intended. Tasks should + * ideally be idempotent, that is, if they were to be fired up after a certain + * amount of time after app launch, everything should work as intended. For this + * to work, special care has to be put into how these respond to events. These + * tasks accept factories that are homonymous to the events on this same module. + */ + +const onAcceptedRequests = require('./onAcceptedRequests') +const onOrders = require('./onOrders') + +module.exports = { + onAcceptedRequests, + onOrders +} diff --git a/services/gunDB/contact-api/jobs.js b/services/gunDB/contact-api/jobs/onAcceptedRequests.js similarity index 80% rename from services/gunDB/contact-api/jobs.js rename to services/gunDB/contact-api/jobs/onAcceptedRequests.js index a6ec48dd..34888ca9 100644 --- a/services/gunDB/contact-api/jobs.js +++ b/services/gunDB/contact-api/jobs/onAcceptedRequests.js @@ -1,24 +1,15 @@ /** - * @prettier - * Taks are subscriptions to events that perform actions (write to GUN) on - * response to certain ways events can happen. These tasks need to be fired up - * at app launch otherwise certain features won't work as intended. Tasks should - * ideally be idempotent, that is, if they were to be fired up after a certain - * amount of time after app launch, everything should work as intended. For this - * to work, special care has to be put into how these respond to events. These - * tasks could be hardcoded inside events but then they wouldn't be easily - * auto-testable. These tasks accept factories that are homonymous to the events - * on the same + * @format */ -const ErrorCode = require('./errorCode') -const Key = require('./key') -const Schema = require('./schema') -const Utils = require('./utils') +const ErrorCode = require('../errorCode') +const Key = require('../key') +const Schema = require('../schema') +const Utils = require('../utils') /** - * @typedef {import('./SimpleGUN').GUNNode} GUNNode - * @typedef {import('./SimpleGUN').ISEA} ISEA - * @typedef {import('./SimpleGUN').UserGUNNode} UserGUNNode + * @typedef {import('../SimpleGUN').GUNNode} GUNNode + * @typedef {import('../SimpleGUN').ISEA} ISEA + * @typedef {import('../SimpleGUN').UserGUNNode} UserGUNNode */ /** @@ -148,6 +139,4 @@ const onAcceptedRequests = async (user, SEA) => { }) } -module.exports = { - onAcceptedRequests -} +module.exports = onAcceptedRequests diff --git a/services/gunDB/contact-api/jobs/onOrders.js b/services/gunDB/contact-api/jobs/onOrders.js new file mode 100644 index 00000000..3db6e350 --- /dev/null +++ b/services/gunDB/contact-api/jobs/onOrders.js @@ -0,0 +1,116 @@ +/** + * @format + */ + +const LightningServices = require('../../../../utils/lightningServices') + +const ErrorCode = require('../errorCode') +const Key = require('../key') +const Schema = require('../schema') +const Utils = require('../utils') + +/** + * @typedef {import('../SimpleGUN').GUNNode} GUNNode + * @typedef {import('../SimpleGUN').ListenerData} ListenerData + * @typedef {import('../SimpleGUN').ISEA} ISEA + * @typedef {import('../SimpleGUN').UserGUNNode} UserGUNNode + */ + +let currentOrderAddr = '' + +/** + * @param {string} addr + * @param {UserGUNNode} user + * @param {ISEA} SEA + * @returns {(order: ListenerData, orderID: string) => void} + */ +const listenerForAddr = (addr, user, SEA) => async (order, orderID) => { + try { + if (addr !== currentOrderAddr) { + return + } + + if (!Schema.isOrder(order)) { + throw new Error(`Expected an order instead got: ${JSON.stringify(order)}`) + } + + const orderToResponse = user.get(Key.ORDER_TO_RESPONSE) + + if (await orderToResponse.get(orderID).then()) { + return + } + + const senderEpub = await Utils.pubToEpub(order.from) + const secret = await SEA.secret(senderEpub, user._.sea) + + const amount = Number(await SEA.decrypt(order.amount, secret)) + const memo = await SEA.decrypt(order.memo, secret) + + /** + * @type {string} + */ + const invoice = await new Promise((resolve, rej) => { + const { + services: { lightning } + } = LightningServices + + lightning.addInvoice( + { + expiry: 36000, + memo, + value: amount + }, + ( + /** @type {any} */ error, + /** @type {{ payment_request: string }} */ response + ) => { + if (error) { + rej(error) + } else { + resolve(response.payment_request) + } + } + ) + }) + + const encInvoice = await SEA.encrypt(invoice, secret) + + orderToResponse.get(orderID).put(encInvoice, ack => { + if (ack.err) { + console.error(`error saving order response: ${ack.err}`) + } + }) + } catch (err) { + console.error('error inside onOrders:') + console.error(err) + } +} + +/** + * @param {UserGUNNode} user + * @param {GUNNode} gun + * @param {ISEA} SEA + * @throws {Error} NOT_AUTH + * @returns {void} + */ +const onOrders = (user, gun, SEA) => { + if (!user.is) { + throw new Error(ErrorCode.NOT_AUTH) + } + + user.get(Key.CURRENT_ORDER_ADDRESS).on(addr => { + if (typeof addr !== 'string') { + throw new TypeError('Expected current order address to be an string') + } + + currentOrderAddr = addr + + gun + .get(Key.ORDER_NODES) + .get(addr) + .map() + .on(listenerForAddr(currentOrderAddr, user, SEA)) + }) +} + +module.exports = onOrders diff --git a/services/gunDB/contact-api/key.js b/services/gunDB/contact-api/key.js index 1aa3f8a0..52d7de8b 100644 --- a/services/gunDB/contact-api/key.js +++ b/services/gunDB/contact-api/key.js @@ -24,3 +24,9 @@ exports.DISPLAY_NAME = 'displayName' * Maps user to the last request sent to them. */ exports.USER_TO_LAST_REQUEST_SENT = 'USER_TO_LAST_REQUEST_SENT' + +exports.CURRENT_ORDER_ADDRESS = 'currentOrderAddress' + +exports.ORDER_NODES = 'orderNodes' + +exports.ORDER_TO_RESPONSE = 'orderToResponse' diff --git a/services/gunDB/contact-api/schema.js b/services/gunDB/contact-api/schema.js index 929534c9..9a7d57bf 100644 --- a/services/gunDB/contact-api/schema.js +++ b/services/gunDB/contact-api/schema.js @@ -1,5 +1,5 @@ /** - * @prettier + * @format */ /** * @typedef {object} HandshakeRequest @@ -316,7 +316,6 @@ exports.isPartialOutgoing = item => { } /** - * * @param {any} item * @returns {item is Outgoing} */ @@ -337,3 +336,41 @@ exports.isOutgoing = item => { return typeof obj.with === 'string' && messagesAreMessages } + +/** + * @typedef {object} Order + * @prop {string} from Public key of sender. + * @prop {string} amount Encrypted + * @prop {string} memo Encrypted + * @prop {number} timestamp + */ + +/** + * @param {any} item + * @returns {item is Order} + */ +exports.isOrder = item => { + if (typeof item !== 'object') { + return false + } + + if (item === null) { + return false + } + + const obj = /** @type {Order} */ (item) + + if (typeof obj.amount !== 'string') { + return false + } + + if (typeof obj.from !== 'string') { + return false + } + + if (typeof obj.memo !== 'string') { + return false + } + + return typeof obj.timestamp === 'number' +}