Skip to content

Commit

Permalink
chore: allow multiple route hints
Browse files Browse the repository at this point in the history
  • Loading branch information
bufo24 committed May 21, 2024
1 parent 1b71125 commit 20a7566
Show file tree
Hide file tree
Showing 3 changed files with 101 additions and 39 deletions.
2 changes: 1 addition & 1 deletion payreq.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
};
Expand Down
90 changes: 53 additions & 37 deletions payreq.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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')
}
Expand All @@ -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')
}

Expand All @@ -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)) {
Expand All @@ -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
Expand Down Expand Up @@ -717,32 +723,34 @@ function encode (inputData, addDefaults) {
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) {
throw new Error('Routing info is incomplete')
}
if (!secp256k1.publicKeyVerify(hexToBuffer(route.pubkey))) {
throw new Error('Routing info pubkey is not a valid pubkey')
}
const shortId = hexToBuffer(route.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) {
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) {
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) {
throw new Error('Routing info cltv expiry delta is not an integer')
}
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(hop.pubkey))) {
throw new Error('Routing info pubkey is not a valid pubkey')
}
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 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 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 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')
}
})
})
}

Expand Down Expand Up @@ -843,7 +851,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()
Expand Down Expand Up @@ -972,14 +980,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')
}

Expand Down Expand Up @@ -1011,18 +1019,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
}

Expand Down
48 changes: 47 additions & 1 deletion test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
})

0 comments on commit 20a7566

Please sign in to comment.