diff --git a/services/gunDB/contact-api/actions.js b/services/gunDB/contact-api/actions.js index 8fbd3521..b68ea423 100644 --- a/services/gunDB/contact-api/actions.js +++ b/services/gunDB/contact-api/actions.js @@ -8,7 +8,11 @@ const { Constants, Schema } = Common const { ErrorCode } = Constants -const LightningServices = require('../../../utils/lightningServices') +const { sendPaymentV2Invoice } = require('../../../utils/lightningServices/v2') + +/** + * @typedef {import('../../../utils/lightningServices/types').PaymentV2} PaymentV2 + */ const Getters = require('./getters') const Key = require('./key') @@ -903,20 +907,11 @@ const sendHRWithInitialMsg = async ( * @param {number} amount * @param {string} memo * @param {number} feeLimit - * @param {number=} maxParts - * @param {number=} timeoutSeconds * @throws {Error} If no response in less than 20 seconds from the recipient, or * lightning cannot find a route for the payment. - * @returns {Promise} The payment's preimage. + * @returns {Promise} The payment's preimage. */ -const sendPayment = async ( - to, - amount, - memo, - feeLimit, - maxParts = 3, - timeoutSeconds = 5 -) => { +const sendSpontaneousPayment = async (to, amount, memo, feeLimit) => { try { const SEA = require('../Mediator').mySEA const getUser = () => require('../Mediator').getUser() @@ -1046,69 +1041,27 @@ const sendPayment = async ( throw new Error(orderResponse.response) } - const { - services: { router } - } = LightningServices - - /** - * @typedef {object} SendErr - * @prop {string} details - */ - - /** - * Partial - * https://api.lightning.community/#sendpaymentv2 - * @typedef {object} SendResponse - * @prop {string} failure_reason - * @prop {string} payment_preimage - */ - logger.info('Will now send payment through lightning') - const sendPaymentV2Args = { - /** @type {string} */ - payment_request: orderResponse.response, - max_parts: maxParts, - timeout_seconds: timeoutSeconds, - no_inflight_updates: true, - fee_limit_sat: feeLimit - } - - const preimage = await new Promise((res, rej) => { - const sentPaymentStream = router.sendPaymentV2(sendPaymentV2Args) - /** - * @param {SendResponse} response - */ - const dataCB = response => { - logger.info('SendPayment Data:', response) - if (response.failure_reason !== 'FAILURE_REASON_NONE') { - rej(new Error(response.failure_reason)) - } else { - res(response.payment_preimage) - } - } - sentPaymentStream.on('data', dataCB) - /** - * - * @param {SendErr} err - */ - const errCB = err => { - logger.error('SendPayment Error:', err) - rej(err.details) - } - sentPaymentStream.on('error', errCB) + const payment = await sendPaymentV2Invoice({ + feeLimit, + payment_request: orderResponse.response }) if (Utils.successfulHandshakeAlreadyExists(to)) { await sendMessage( to, - Schema.encodeSpontaneousPayment(amount, memo || 'no memo', preimage), + Schema.encodeSpontaneousPayment( + amount, + memo || 'no memo', + payment.payment_preimage + ), require('../Mediator').getUser(), require('../Mediator').mySEA ) } - return preimage + return payment } catch (e) { logger.error('Error inside sendPayment()') logger.error(e) @@ -1116,6 +1069,21 @@ const sendPayment = async ( } } +/** + * Returns the preimage corresponding to the payment. + * @param {string} to + * @param {number} amount + * @param {string} memo + * @param {number} feeLimit + * @throws {Error} If no response in less than 20 seconds from the recipient, or + * lightning cannot find a route for the payment. + * @returns {Promise} The payment's preimage. + */ +const sendPayment = async (to, amount, memo, feeLimit) => { + const payment = await sendSpontaneousPayment(to, amount, memo, feeLimit) + return payment.payment_preimage +} + /** * @param {UserGUNNode} user * @returns {Promise} @@ -1632,5 +1600,6 @@ module.exports = { follow, unfollow, initWall, - sendMessageNew + sendMessageNew, + sendSpontaneousPayment } diff --git a/src/routes.js b/src/routes.js index 584d1944..dcc47eb8 100644 --- a/src/routes.js +++ b/src/routes.js @@ -29,6 +29,10 @@ const { const GunActions = require('../services/gunDB/contact-api/actions') const GunGetters = require('../services/gunDB/contact-api/getters') const GunKey = require('../services/gunDB/contact-api/key') +const { + sendPaymentV2Keysend, + sendPaymentV2Invoice +} = require('../utils/lightningServices/v2') const DEFAULT_MAX_NUM_ROUTES_TO_QUERY = 10 const SESSION_ID = uuid() @@ -1159,6 +1163,57 @@ module.exports = async ( }) }) + app.post('/api/lnd/unifiedTrx', async (req, res) => { + try { + const { type, amt, to, memo, feeLimit } = req.body + + if (type !== 'spont') { + return res.status(415).json({ + field: 'type', + errorMessage: `Only 'spont' payments supported via this endpoint for now.` + }) + } + + const amount = Number(amt) + + if (!isARealUsableNumber(amount)) { + return res.status(400).json({ + field: 'amt', + errorMessage: 'Not an usable number' + }) + } + + if (amount < 1) { + return res.status(400).json({ + field: 'amt', + errorMessage: 'Must be 1 or greater.' + }) + } + + if (!isARealUsableNumber(feeLimit)) { + return res.status(400).json({ + field: 'feeLimit', + errorMessage: 'Not an usable number' + }) + } + + if (feeLimit < 1) { + return res.status(400).json({ + field: 'feeLimit', + errorMessage: 'Must be 1 or greater.' + }) + } + + return res + .status(200) + .json(await GunActions.sendSpontaneousPayment(to, amt, memo, feeLimit)) + } catch (e) { + return res.status(500).json({ + errorMessage: e.message + }) + } + }) + // get lnd node payments list app.get('/api/lnd/listpayments', (req, res) => { const { lightning } = LightningServices.services @@ -1442,92 +1497,46 @@ module.exports = async ( }) // sendpayment - app.post('/api/lnd/sendpayment', (req, res) => { - const { router } = LightningServices.services + app.post('/api/lnd/sendpayment', async (req, res) => { // this is the recommended value from lightning labs - let paymentRequest = {} const { keysend, maxParts = 3, timeoutSeconds = 5, feeLimit } = req.body + if (!feeLimit) { - return res.status(500).json({ + return res.status(400).json({ errorMessage: 'please provide a "feeLimit" to the send payment request' }) } + if (keysend) { const { dest, amt, finalCltvDelta = 40 } = req.body if (!dest || !amt) { - return res.status(500).json({ + return res.status(400).json({ errorMessage: 'please provide "dest" and "amt" for keysend payments' }) } - const preimage = Crypto.randomBytes(32) - const r_hash = Crypto.createHash('sha256') - .update(preimage) - .digest() - //https://github.com/lightningnetwork/lnd/blob/master/record/experimental.go#L5:2 - //might break in future updates - const KeySendType = 5482373484 - //https://api.lightning.community/#featurebit - const TLV_ONION_REQ = 8 - paymentRequest = { - dest: Buffer.from(dest, 'hex'), + + const payment = await sendPaymentV2Keysend({ amt, - final_cltv_delta: finalCltvDelta, - dest_features: [TLV_ONION_REQ], - dest_custom_records: { - [KeySendType]: preimage - }, - payment_hash: r_hash, - max_parts: maxParts, - timeout_seconds: timeoutSeconds, - no_inflight_updates: true, - fee_limit_sat: feeLimit - } - } else { - const { payreq } = req.body + dest, + feeLimit, + finalCltvDelta, + maxParts, + timeoutSeconds + }) - paymentRequest = { - payment_request: payreq, - max_parts: maxParts, - timeout_seconds: timeoutSeconds, - no_inflight_updates: true, - fee_limit_sat: feeLimit - } - - if (req.body.amt) { - paymentRequest.amt = req.body.amt - } + return res.status(200).json(payment) } + const { payreq } = req.body - logger.info('Sending payment', paymentRequest) - const sentPayment = router.sendPaymentV2(paymentRequest) - sentPayment.on('data', response => { - logger.info('SendPayment Data:', response) - if (response.failure_reason !== 'FAILURE_REASON_NONE') { - res.status(500).json({ - errorMessage: response.failure_reason - }) - } else { - res.json(response) - } + const payment = await sendPaymentV2Invoice({ + feeLimit, + payment_request: payreq, + amt: req.body.amt, + max_parts: maxParts, + timeoutSeconds }) - sentPayment.on('status', status => { - logger.info('SendPayment Status:', status) - }) - - sentPayment.on('error', async err => { - logger.error('SendPayment Error:', err) - const health = await checkHealth() - if (health.LNDStatus.success) { - res.status(500).json({ - errorMessage: sanitizeLNDError(err.details) - }) - } else { - res.status(500) - res.json({ errorMessage: 'LND is down' }) - } - }) - //sentPayment.on('end', () => {}) + return res.status(200).json(payment) }) app.post('/api/lnd/trackpayment', (req, res) => { diff --git a/tsconfig.json b/tsconfig.json index 95c3ecbd..da9874a9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,5 @@ { - "include": ["./services/gunDB/**/*.*"], + "include": ["./services/gunDB/**/*.*", "./utils/lightningServices/**/*.*"], "compilerOptions": { /* Basic Options */ // "incremental": true, /* Enable incremental compilation */ diff --git a/utils/lightningServices/index.js b/utils/lightningServices/index.js new file mode 100644 index 00000000..a3555b28 --- /dev/null +++ b/utils/lightningServices/index.js @@ -0,0 +1 @@ +module.exports = require('./lightning-services') \ No newline at end of file diff --git a/utils/lightningServices.js b/utils/lightningServices/lightning-services.js similarity index 60% rename from utils/lightningServices.js rename to utils/lightningServices/lightning-services.js index a1a4ead1..a0631f1b 100644 --- a/utils/lightningServices.js +++ b/utils/lightningServices/lightning-services.js @@ -1,10 +1,13 @@ -const FS = require("../utils/fs"); -const lnrpc = require("../services/lnd/lightning"); - +/** + * @format + */ /** * @typedef {import('commander').Command} Command */ +const FS = require('../../utils/fs') +const lnrpc = require('../../services/lnd/lightning') + /** * @typedef {object} Config * @prop {boolean} useTLS @@ -43,9 +46,9 @@ class LightningServices { /** * @type {Config} */ - const newDefaults = require("../config/defaults")(program.mainnet) + const newDefaults = require('../../config/defaults')(program.mainnet) - this.defaults = newDefaults; + this.defaults = newDefaults this._config = { ...newDefaults, @@ -55,11 +58,11 @@ class LightningServices { lndHost: program.lndhost || newDefaults.lndHost, lndCertPath: program.lndCertPath || newDefaults.lndCertPath, macaroonPath: program.macaroonPath || newDefaults.macaroonPath - }; + } } isInitialized = () => { - return !!(this.lightning && this.walletUnlocker); + return !!(this.lightning && this.walletUnlocker) } get services() { @@ -67,11 +70,11 @@ class LightningServices { lightning: this.lightning, walletUnlocker: this.walletUnlocker, router: this.router - }; + } } get servicesData() { - return this.lnServicesData; + return this.lnServicesData } /** @@ -82,7 +85,9 @@ class LightningServices { return this._config } - throw new Error('Tried to access LightningServices.servicesConfig without setting defaults first.') + throw new Error( + 'Tried to access LightningServices.servicesConfig without setting defaults first.' + ) } get config() { @@ -90,7 +95,9 @@ class LightningServices { return this._config } - throw new Error('Tried to access LightningServices.config without setting defaults first.') + throw new Error( + 'Tried to access LightningServices.config without setting defaults first.' + ) } /** @@ -101,38 +108,38 @@ class LightningServices { return this._defaults } - throw new Error('Tried to access LightningServices.defaults without setting them first.') + throw new Error( + 'Tried to access LightningServices.defaults without setting them first.' + ) } init = async () => { - const { macaroonPath, lndHost, lndCertPath } = this.config; - const macaroonExists = await FS.access(macaroonPath); - const lnServices = await lnrpc( - { - lnrpcProtoPath: this.defaults.lndProto, - routerProtoPath: this.defaults.routerProto, - walletUnlockerProtoPath: this.defaults.walletUnlockerProto, - lndHost, - lndCertPath, - macaroonPath: macaroonExists ? macaroonPath : null - } - ); + const { macaroonPath, lndHost, lndCertPath } = this.config + const macaroonExists = await FS.access(macaroonPath) + const lnServices = await lnrpc({ + lnrpcProtoPath: this.defaults.lndProto, + routerProtoPath: this.defaults.routerProto, + walletUnlockerProtoPath: this.defaults.walletUnlockerProto, + lndHost, + lndCertPath, + macaroonPath: macaroonExists ? macaroonPath : null + }) if (!lnServices) { throw new Error(`Could not init lnServices`) } - const { lightning, walletUnlocker, router } = lnServices; - this.lightning = lightning; + const { lightning, walletUnlocker, router } = lnServices + this.lightning = lightning this.walletUnlocker = walletUnlocker - this.router = router; + this.router = router this.lnServicesData = { lndProto: this.defaults.lndProto, lndHost, lndCertPath, macaroonPath: macaroonExists ? macaroonPath : null - }; + } } } -const lightningServices = new LightningServices(); +const lightningServices = new LightningServices() -module.exports = lightningServices; +module.exports = lightningServices diff --git a/utils/lightningServices/types.ts b/utils/lightningServices/types.ts new file mode 100644 index 00000000..f8e77d81 --- /dev/null +++ b/utils/lightningServices/types.ts @@ -0,0 +1,108 @@ +/** + * @format + */ + +export interface PaymentV2 { + payment_hash: string + + creation_date: string + + payment_preimage: string + + value_sat: string + + value_msat: string + + payment_request: string + + status: 'UNKNOWN' | 'IN_FLIGHT' | 'SUCCEEDED' | 'FAILED' + + fee_sat: number + + fee_msat: number + + creation_time_ns: string + + payment_index: string + + failure_reason: + | 'FAILURE_REASON_NONE' + | 'FAILURE_REASON_TIMEOUT' + | 'FAILURE_REASON_NO_ROUTE' + | 'FAILURE_REASON_ERROR' + | 'FAILURE_REASON_INCORRECT_PAYMENT_DETAILS' + | 'FAILURE_REASON_INSUFFICIENT_BALANCE' +} + +enum FeatureBit { + DATALOSS_PROTECT_REQ = 0, + DATALOSS_PROTECT_OPT = 1, + INITIAL_ROUING_SYNC = 3, + UPFRONT_SHUTDOWN_SCRIPT_REQ = 4, + UPFRONT_SHUTDOWN_SCRIPT_OPT = 5, + GOSSIP_QUERIES_REQ = 6, + GOSSIP_QUERIES_OPT = 7, + TLV_ONION_REQ = 8, + TLV_ONION_OPT = 9, + EXT_GOSSIP_QUERIES_REQ = 10, + EXT_GOSSIP_QUERIES_OPT = 11, + STATIC_REMOTE_KEY_REQ = 12, + STATIC_REMOTE_KEY_OPT = 13, + PAYMENT_ADDR_REQ = 14, + PAYMENT_ADDR_OPT = 15, + MPP_REQ = 16, + MPP_OPT = 17 +} + +interface _SendPaymentV2Request { + dest: Buffer + /** + * Number of satoshis to send. The fields amt and amt_msat are mutually + * exclusive. + */ + amt: string + + /** + * The CLTV delta from the current height that should be used to set the + * timelock for the final hop. + */ + final_cltv_delta: number + + dest_features: FeatureBit[] + + dest_custom_records: Record + + /** + * The hash to use within the payment's HTLC. + */ + payment_hash: Buffer + + max_parts: number + + timeout_seconds: number + + no_inflight_updates: boolean + + payment_request: string + + fee_limit_sat: number +} + +export type SendPaymentV2Request = Partial<_SendPaymentV2Request> + +export interface SendPaymentKeysendParams { + amt: string + dest: string + feeLimit: number + finalCltvDelta?: number + maxParts?: number + timeoutSeconds?: number +} + +export interface SendPaymentInvoiceParams { + amt?: string + feeLimit: number + max_parts?: number + payment_request: string + timeoutSeconds?: number +} diff --git a/utils/lightningServices/v2.js b/utils/lightningServices/v2.js new file mode 100644 index 00000000..ee6204a9 --- /dev/null +++ b/utils/lightningServices/v2.js @@ -0,0 +1,343 @@ +/** + * @format + */ +const Crypto = require('crypto') +const logger = require('winston') + +const lightningServices = require('./lightning-services') +/** + * @typedef {import('./types').PaymentV2} PaymentV2 + * @typedef {import('./types').SendPaymentV2Request} SendPaymentV2Request + * @typedef {import('./types').SendPaymentInvoiceParams} SendPaymentInvoiceParams + * @typedef {import('./types').SendPaymentKeysendParams} SendPaymentKeysendParams + */ + +//https://github.com/lightningnetwork/lnd/blob/master/record/experimental.go#L5:2 +//might break in future updates +const KeySendType = 5482373484 +//https://api.lightning.community/#featurebit +const TLV_ONION_REQ = 8 + +/** + * @param {SendPaymentV2Request} sendPaymentRequest + * @returns {boolean} + */ +const isValidSendPaymentRequest = sendPaymentRequest => { + const { + amt, + dest, + dest_custom_records, + dest_features, + final_cltv_delta, + max_parts, + no_inflight_updates, + payment_hash, + timeout_seconds, + fee_limit_sat, + payment_request + } = sendPaymentRequest + + if (typeof amt !== 'undefined' && typeof amt !== 'string') { + return false + } + + if (typeof dest !== 'undefined' && !(dest instanceof Buffer)) { + return false + } + + if ( + typeof dest_custom_records !== 'undefined' && + typeof dest_custom_records !== 'object' + ) { + return false + } + + if ( + typeof dest_custom_records !== 'undefined' && + dest_custom_records === null + ) { + return false + } + + if ( + typeof dest_custom_records !== 'undefined' && + Object.keys(dest_custom_records).length === 0 + ) { + return false + } + + if (typeof dest_features !== 'undefined' && !Array.isArray(dest_features)) { + return false + } + + if (typeof dest_features !== 'undefined' && dest_features.length === 0) { + return false + } + + if ( + typeof final_cltv_delta !== 'undefined' && + typeof final_cltv_delta !== 'number' + ) { + return false + } + + if ( + typeof payment_hash !== 'undefined' && + !(payment_hash instanceof Buffer) + ) { + return false + } + + if (typeof max_parts !== 'undefined' && typeof max_parts !== 'number') { + return false + } + + if ( + typeof timeout_seconds !== 'undefined' && + typeof timeout_seconds !== 'number' + ) { + return false + } + + if ( + typeof no_inflight_updates !== 'undefined' && + typeof no_inflight_updates !== 'boolean' + ) { + return false + } + + if ( + typeof fee_limit_sat !== 'undefined' && + typeof fee_limit_sat !== 'number' + ) { + return false + } + + if ( + typeof payment_request !== 'undefined' && + typeof payment_request !== 'string' + ) { + return false + } + + return true +} + +/** + * @param {SendPaymentKeysendParams} sendPaymentKeysendParams + * @returns {boolean} + */ +const isValidSendPaymentKeysendParams = sendPaymentKeysendParams => { + const { + amt, + dest, + feeLimit, + finalCltvDelta, + maxParts, + timeoutSeconds + } = sendPaymentKeysendParams + + if (typeof amt !== 'string') { + return false + } + + if (typeof dest !== 'string') { + return false + } + + if (typeof feeLimit !== 'number') { + return false + } + + if ( + typeof finalCltvDelta !== 'undefined' && + typeof finalCltvDelta !== 'number' + ) { + return false + } + + if (typeof maxParts !== 'undefined' && typeof maxParts !== 'number') { + return false + } + + if ( + typeof timeoutSeconds !== 'undefined' && + typeof timeoutSeconds !== 'number' + ) { + return false + } + + return true +} + +/** + * @param {SendPaymentInvoiceParams} sendPaymentInvoiceParams + */ +const isValidSendPaymentInvoiceParams = sendPaymentInvoiceParams => { + const { + amt, + feeLimit, + max_parts, + payment_request, + timeoutSeconds + } = sendPaymentInvoiceParams + + // payment_request: string + // timeoutSeconds?: number + + if (typeof amt !== 'undefined' && typeof amt !== 'string') { + return false + } + + if (typeof feeLimit !== 'number') { + return false + } + + if (typeof max_parts !== 'undefined' && typeof max_parts !== 'number') { + return false + } + + if (typeof payment_request !== 'string') { + return false + } + + if ( + typeof timeoutSeconds !== 'undefined' && + typeof timeoutSeconds !== 'number' + ) { + return false + } + + return true +} + +/** + * aklssjdklasd + * @param {SendPaymentV2Request} sendPaymentRequest + * @returns {Promise} + */ +const sendPaymentV2 = sendPaymentRequest => { + const { + services: { router } + } = lightningServices + + if (!isValidSendPaymentRequest(sendPaymentRequest)) { + throw new TypeError( + `Invalid SendPaymentRequest: ${JSON.stringify(sendPaymentRequest)}` + ) + } + + /** + * @type {SendPaymentV2Request} + */ + const paymentRequest = { + ...sendPaymentRequest, + no_inflight_updates: true + } + + return new Promise((res, rej) => { + const stream = router.sendPaymentV2(paymentRequest) + + stream.on( + 'data', + /** + * @param {import("./types").PaymentV2} streamingPaymentV2 + */ streamingPaymentV2 => { + if (streamingPaymentV2.failure_reason !== 'FAILURE_REASON_NONE') { + rej(new Error(streamingPaymentV2.failure_reason)) + } else { + res(streamingPaymentV2) + } + } + ) + + // @ts-expect-error + stream.on('status', status => { + logger.info('SendPaymentV2 Status:', status) + }) + + stream.on( + 'error', + /** + * @param {{ details: any; }} err + */ err => { + logger.error('SendPaymentV2 Error:', err) + + rej(err.details || err) + } + ) + }) +} + +/** + * @param {SendPaymentKeysendParams} params + * @returns {Promise} + */ +const sendPaymentV2Keysend = params => { + const { + amt, + dest, + feeLimit, + finalCltvDelta, + maxParts, + timeoutSeconds + } = params + + if (!isValidSendPaymentKeysendParams(params)) { + throw new TypeError( + `Invalid SendPaymentKeysendParams: ${JSON.stringify(params)}` + ) + } + + const preimage = Crypto.randomBytes(32) + const r_hash = Crypto.createHash('sha256') + .update(preimage) + .digest() + + return sendPaymentV2({ + dest: Buffer.from(dest, 'hex'), + amt, + final_cltv_delta: finalCltvDelta, + dest_features: [TLV_ONION_REQ], + dest_custom_records: { + [KeySendType]: preimage + }, + payment_hash: r_hash, + max_parts: maxParts, + timeout_seconds: timeoutSeconds, + fee_limit_sat: feeLimit + }) +} + +/** + * @param {SendPaymentInvoiceParams} params + * @returns {Promise} + */ +const sendPaymentV2Invoice = params => { + const { + feeLimit, + payment_request, + amt, + max_parts = 3, + timeoutSeconds = 5 + } = params + + if (!isValidSendPaymentInvoiceParams(params)) { + throw new TypeError( + `Invalid SendPaymentInvoiceParams: ${JSON.stringify(params)}` + ) + } + + return sendPaymentV2({ + amt, + payment_request, + fee_limit_sat: feeLimit, + max_parts, + timeout_seconds: timeoutSeconds + }) +} + +module.exports = { + sendPaymentV2Keysend, + sendPaymentV2Invoice +}