diff --git a/payreq.d.ts b/payreq.d.ts index 515b614..2366152 100644 --- a/payreq.d.ts +++ b/payreq.d.ts @@ -57,7 +57,7 @@ export declare type TagsObject = { expire_time?: number; min_final_cltv_expiry?: number; fallback_address?: FallbackAddress; - routing_info?: RoutingInfo; + routing_info?: RoutingInfo[]; feature_bits?: FeatureBits; unknownTags?: UnknownTag[]; }; diff --git a/payreq.js b/payreq.js index f22147f..ddd5800 100644 --- a/payreq.js +++ b/payreq.js @@ -395,14 +395,20 @@ function purposeCommitEncoder (data) { return bech32.toWords(buffer) } -function tagsItems (tags, tagName) { +function tagsItem (tags, tagName) { const tag = tags.filter(item => item.tagName === tagName) const data = tag.length > 0 ? tag[0].data : null return data } +function tagsItems (tags, tagName) { + const tag = tags.filter(item => item.tagName === tagName) + const data = tag.length > 0 ? tag.map((t) => t.data) : null + return data +} + function tagsContainItem (tags, tagName) { - return tagsItems(tags, tagName) !== null + return tagsItem(tags, tagName) !== null } function orderKeys (unorderedObj, forDecode) { @@ -510,7 +516,7 @@ function sign (inputPayReqObj, inputPrivateKey) { let nodePublicKey, tagNodePublicKey // If there is a payee_node_key tag convert to buffer if (tagsContainItem(payReqObj.tags, TAGNAMES['19'])) { - tagNodePublicKey = hexToBuffer(tagsItems(payReqObj.tags, TAGNAMES['19'])) + tagNodePublicKey = hexToBuffer(tagsItem(payReqObj.tags, TAGNAMES['19'])) } // If there is payeeNodeKey attribute, convert to buffer if (payReqObj.payeeNodeKey) { @@ -610,7 +616,7 @@ function encode (inputData, addDefaults) { throw new Error('Payment request requires feature bits with at least payment secret support flagged if payment secret is included') } } else { - const fB = tagsItems(data.tags, TAGNAMES['5']) + const fB = tagsItem(data.tags, TAGNAMES['5']) if (!fB.payment_secret || (!fB.payment_secret.supported && !fB.payment_secret.required)) { throw new Error('Payment request requires feature bits with at least payment secret support flagged if payment secret is included') } @@ -631,7 +637,7 @@ function encode (inputData, addDefaults) { // If a description exists, check to make sure the buffer isn't greater than // 639 bytes long, since 639 * 8 / 5 = 1023 words (5 bit) when padded if (tagsContainItem(data.tags, TAGNAMES['13']) && - Buffer.from(tagsItems(data.tags, TAGNAMES['13']), 'utf8').length > 639) { + Buffer.from(tagsItem(data.tags, TAGNAMES['13']), 'utf8').length > 639) { throw new Error('Description is too long: Max length 639 bytes') } @@ -655,7 +661,7 @@ function encode (inputData, addDefaults) { let nodePublicKey, tagNodePublicKey // If there is a payee_node_key tag convert to buffer - if (tagsContainItem(data.tags, TAGNAMES['19'])) tagNodePublicKey = hexToBuffer(tagsItems(data.tags, TAGNAMES['19'])) + if (tagsContainItem(data.tags, TAGNAMES['19'])) tagNodePublicKey = hexToBuffer(tagsItem(data.tags, TAGNAMES['19'])) // If there is payeeNodeKey attribute, convert to buffer if (data.payeeNodeKey) nodePublicKey = hexToBuffer(data.payeeNodeKey) if (nodePublicKey && tagNodePublicKey && !tagNodePublicKey.equals(nodePublicKey)) { @@ -668,7 +674,7 @@ function encode (inputData, addDefaults) { let code, addressHash, address // If there is a fallback address tag we must check it is valid if (tagsContainItem(data.tags, TAGNAMES['9'])) { - const addrData = tagsItems(data.tags, TAGNAMES['9']) + const addrData = tagsItem(data.tags, TAGNAMES['9']) // Most people will just provide address so Hash and code will be undefined here address = addrData.address addressHash = addrData.addressHash @@ -714,37 +720,37 @@ function encode (inputData, addDefaults) { } // If there is route info tag, check that each route has all 4 necessary info - if (tagsContainItem(data.tags, TAGNAMES['3'])) { - const routingInfo = tagsItems(data.tags, TAGNAMES['3']) - routingInfo.forEach(route => { - if (route.pubkey === undefined || - route.short_channel_id === undefined || - route.fee_base_msat === undefined || - route.fee_proportional_millionths === undefined || - route.cltv_expiry_delta === undefined) { + const routingInfo = tagsItems(data.tags, TAGNAMES['3']) + routingInfo && routingInfo.forEach(route => { + route.forEach(hop => { + if (hop.pubkey === undefined || + hop.short_channel_id === undefined || + hop.fee_base_msat === undefined || + hop.fee_proportional_millionths === undefined || + hop.cltv_expiry_delta === undefined) { throw new Error('Routing info is incomplete') } - if (!secp256k1.publicKeyVerify(hexToBuffer(route.pubkey))) { + if (!secp256k1.publicKeyVerify(hexToBuffer(hop.pubkey))) { throw new Error('Routing info pubkey is not a valid pubkey') } - const shortId = hexToBuffer(route.short_channel_id) + const shortId = hexToBuffer(hop.short_channel_id) if (!(shortId instanceof Buffer) || shortId.length !== 8) { throw new Error('Routing info short channel id must be 8 bytes') } - if (typeof route.fee_base_msat !== 'number' || - Math.floor(route.fee_base_msat) !== route.fee_base_msat) { + if (typeof hop.fee_base_msat !== 'number' || + Math.floor(hop.fee_base_msat) !== hop.fee_base_msat) { throw new Error('Routing info fee base msat is not an integer') } - if (typeof route.fee_proportional_millionths !== 'number' || - Math.floor(route.fee_proportional_millionths) !== route.fee_proportional_millionths) { + if (typeof hop.fee_proportional_millionths !== 'number' || + Math.floor(hop.fee_proportional_millionths) !== hop.fee_proportional_millionths) { throw new Error('Routing info fee proportional millionths is not an integer') } - if (typeof route.cltv_expiry_delta !== 'number' || - Math.floor(route.cltv_expiry_delta) !== route.cltv_expiry_delta) { + if (typeof hop.cltv_expiry_delta !== 'number' || + Math.floor(hop.cltv_expiry_delta) !== hop.cltv_expiry_delta) { throw new Error('Routing info cltv expiry delta is not an integer') } }) - } + }) let prefix = 'ln' prefix += coinTypeObj.bech32 @@ -843,7 +849,7 @@ function encode (inputData, addDefaults) { if (sigWords) dataWords = dataWords.concat(sigWords) if (tagsContainItem(data.tags, TAGNAMES['6'])) { - data.timeExpireDate = data.timestamp + tagsItems(data.tags, TAGNAMES['6']) + data.timeExpireDate = data.timestamp + tagsItem(data.tags, TAGNAMES['6']) data.timeExpireDateString = new Date(data.timeExpireDate * 1000).toISOString() } data.timestampString = new Date(data.timestamp * 1000).toISOString() @@ -972,14 +978,14 @@ function decode (paymentRequest, network) { // be kind and provide an absolute expiration date. // good for logs if (tagsContainItem(tags, TAGNAMES['6'])) { - timeExpireDate = timestamp + tagsItems(tags, TAGNAMES['6']) + timeExpireDate = timestamp + tagsItem(tags, TAGNAMES['6']) timeExpireDateString = new Date(timeExpireDate * 1000).toISOString() } const toSign = Buffer.concat([Buffer.from(prefix, 'utf8'), Buffer.from(convert(wordsNoSig, 5, 8))]) const payReqHash = sha256(toSign) const sigPubkey = Buffer.from(secp256k1.ecdsaRecover(sigBuffer, recoveryFlag, payReqHash, true)) - if (tagsContainItem(tags, TAGNAMES['19']) && tagsItems(tags, TAGNAMES['19']) !== sigPubkey.toString('hex')) { + if (tagsContainItem(tags, TAGNAMES['19']) && tagsItem(tags, TAGNAMES['19']) !== sigPubkey.toString('hex')) { throw new Error('Lightning Payment Request signature pubkey does not match payee pubkey') } @@ -1011,18 +1017,26 @@ function decode (paymentRequest, network) { } function getTagsObject (tags) { - const result = {} + const result = { + [TAGNAMES['3']]: [] + } tags.forEach(tag => { if (tag.tagName === unknownTagName) { if (!result.unknownTags) { result.unknownTags = [] } result.unknownTags.push(tag.data) + } else if (tag.tagName === TAGNAMES['3']) { + result[tag.tagName].push(tag.data) } else { result[tag.tagName] = tag.data } }) + if (result[TAGNAMES['3']].length === 0) { + delete result[TAGNAMES['3']] + } + return result } diff --git a/test/index.js b/test/index.js index 54819ad..68f7158 100644 --- a/test/index.js +++ b/test/index.js @@ -141,7 +141,11 @@ fixtures.decode.valid.forEach((f) => { const data = decoded.tagsObject[key] const tagsData = decoded.tags.filter(item => item.tagName === key) t.assert(tagsData.length === 1) - t.same(data, tagsData[0].data) + if (key === 'routing_info') { + t.same(data, tagsData.map((t) => t.data)) + } else { + t.same(data, tagsData[0].data) + } }) t.end() @@ -323,3 +327,45 @@ tape('can encode and decode small timestamp', (t) => { t.same(reEncoded.paymentRequest, signedData.paymentRequest) t.end() }) + +tape('can decode multiple route hints', (t) => { + const decoded = lnpayreq.decode('lnbc21n1pnyc64fpp55h2xw2y9ygl80zh5zrwf3sz' + + 'skqshyvm2zzvrk7h7fxay3k0e7t9shp5jgmctxuhd' + + 'clx5w4cfgr70v362tc6zn4e9h8s3tllv2j0e970fw' + + 'ssxqyjw5qrzjqg6khjn0d0ed5vltq8jtw49fl3t42' + + '5hg0n7hvkynk70c8xplatfszyqsqyqqzqgpqyqqqq' + + 'lgqqqqqqgqjqrzjqtc63jrkql6ptj8j9sq9jvqzwa' + + 'v5rh4y3p5uugcfdte8kr8aes9kjyqsqyqqzqgpqyq' + + 'qqqlgqqqqqqgqjqcqzzssp5yc07wjjnc3t9w3w5sn' + + 'y6hmnq320hfqse7zsv4n3k97mujheekh7snp4qwxw' + + 'ehlkj9awmypclkf06agf3u64sy7e4p6uvkk8dfja8' + + '7st8rvtz9qypqsq50ne8s8dyc04a373fhc7mcllh5' + + 'ycsvj7mek8zg9w7h5kzez6u308m8ld22dggg4fysl' + + 'w67jjnd94hgml0w2dw4dq7n4623n0ce6hd9sqs9cxyt' + ) + t.assert(decoded.tagsObject.routing_info.length === 2) + t.assert(decoded.tagsObject.routing_info[0].length === 1) + t.assert(decoded.tagsObject.routing_info[1].length === 1) + t.end() +}) + +tape('can decode route hints with multiple hops', (t) => { + const decoded = lnpayreq.decode('lnbc21n1pnycmvgpp5eez89m3mmjtjxz99fv7nam' + + 'faagjecw9k7xvmmsd7dg0thay8jx5shp5jgmctxu' + + 'hdclx5w4cfgr70v362tc6zn4e9h8s3tllv2j0e97' + + '0fwssxqyjw5qr9yqg6khjn0d0ed5vltq8jtw49fl' + + '3t425hg0n7hvkynk70c8xplatfszyqsqyqqzqgpq' + + 'yqqqqlgqqqqqqgqjqpr2672da4l9k3navq7fd654' + + '879w42jap706ajcjwmelquc8l4dxqgszqqsqqgpq' + + 'ypqqqqraqqqqqqpqzgqcqzzssp5f2twpv6mkaygp' + + 'w0qpqytl3m0x76thftrw4gqzp05f084susptxpqn' + + 'p4qgkap5y72ewa6s08e65huc4fexryccla2906zm' + + 'txxwwd9dvvlrwq79qypqsquzmg28e4g06d59t5dz' + + 'hv7ms242w9uf8rkf4rsnf4495j27k6wmzqfgyvqy' + + 'he6alhhrtwg7djvpvfkf62wefatmnm6sutnwpkzj' + + 'd750qpyeman0' + ) + t.assert(decoded.tagsObject.routing_info.length === 1) + t.assert(decoded.tagsObject.routing_info[0].length === 2) + t.end() +})