diff --git a/bolt11/address_version.js b/bolt11/address_version.js new file mode 100644 index 00000000..73c22d29 --- /dev/null +++ b/bolt11/address_version.js @@ -0,0 +1,37 @@ +const {networks} = require('bitcoinjs-lib'); + +const {p2pkh} = require('./conf/address_versions'); +const {p2sh} = require('./conf/address_versions'); + +/** Address version + + { + network: + [prefix]: + version: + } + + @throws + + + @returns + { + version: + } +*/ +module.exports = ({network, prefix, version}) => { + if (!!prefix) { + return {version}; + } + + switch (version) { + case networks[network].pubKeyHash: + return {version: p2pkh}; + + case networks[network].scriptHash: + return {version: p2sh}; + + default: + throw new Error('UnexpectedVersionToDeriveBoltOnChainAddressVersion'); + } +}; diff --git a/bolt11/chain_address_as_words.js b/bolt11/chain_address_as_words.js new file mode 100644 index 00000000..78e0c774 --- /dev/null +++ b/bolt11/chain_address_as_words.js @@ -0,0 +1,28 @@ +const chainAddressDetails = require('./chain_address_details'); +const hexAsWords = require('./hex_as_words'); + +/** Convert chain address to bech32 words + + { + address: + network: + } + + @returns + { + words: [] + } +*/ +module.exports = ({address, network}) => { + if (!address) { + throw new Error('ExpectedAddressToGetWordsForChainAddress'); + } + + if (!network) { + throw new Error('ExpectedNetworkToGetWordsForChainAddress'); + } + + const {hash, version} = chainAddressDetails({address, network}); + + return {words: [version].concat(hexAsWords({hex: hash}).words)}; +}; diff --git a/bolt11/chain_address_details.js b/bolt11/chain_address_details.js new file mode 100644 index 00000000..b54ca497 --- /dev/null +++ b/bolt11/chain_address_details.js @@ -0,0 +1,48 @@ +const {address} = require('bitcoinjs-lib'); +const {networks} = require('bitcoinjs-lib'); + +const addressVersion = require('./address_version'); + +const base58 = n => { try { return address.fromBase58Check(n); } catch (e) {}}; +const bech32 = n => { try { return address.fromBech32(n); } catch (e) {}}; + +/** Derive chain address details + + { + address: + network: + } + + @throws + on invalid chain address + + @returns + { + hash:
+ version: + } +*/ +module.exports = ({address, network}) => { + if (!address) { + throw new Error('ExpectedAddressToDeriveChainAddressDetails'); + } + + if (!network || !networks[network]) { + throw new Error('ExpectedNetworkToDeriveChainAddressDetails'); + } + + const details = base58(address) || bech32(address); + + // Exit early: address does not parse as a bech32 or base58 address + if (!details) { + throw new Error('ExpectedValidAddressToDeriveChainDetails'); + } + + const {prefix} = details; + const {version} = details; + + return { + hash: (details.data || details.hash).toString('hex'), + version: addressVersion({network, prefix, version}).version, + }; +}; diff --git a/bolt11/conf/multipliers.json b/bolt11/conf/multipliers.json new file mode 100644 index 00000000..325583e8 --- /dev/null +++ b/bolt11/conf/multipliers.json @@ -0,0 +1,24 @@ +{ + "multipliers": [ + { + "letter": "", + "value": "100000000000" + }, + { + "letter": "m", + "value": "100000000" + }, + { + "letter": "n", + "value": "100" + }, + { + "letter": "p", + "value": "10" + }, + { + "letter": "u", + "value": "100000" + } + ] +} diff --git a/bolt11/create_signed_request.js b/bolt11/create_signed_request.js new file mode 100644 index 00000000..495f4ada --- /dev/null +++ b/bolt11/create_signed_request.js @@ -0,0 +1,82 @@ +const {createHash} = require('crypto'); + +const {encode} = require('bech32'); +const {recover} = require('secp256k1'); + +const hexAsWords = require('./hex_as_words'); +const wordsAsBuffer = require('./words_as_buffer'); + +const {isArray} = Array; +const {MAX_SAFE_INTEGER} = Number; +const padding = '0'; +const recoveryFlags = [0, 1, 2, 3]; + +/** Assemble a signed payment request + + { + destination: + hrp: + signature: + tags: [] + } + + @throws + + + @returns + { + request: + } +*/ +module.exports = ({destination, hrp, signature, tags}) => { + if (!destination) { + throw new Error('ExpectedDestinationForSignedPaymentRequest'); + } + + if (!hrp) { + throw new Error('ExpectedHrpForSignedPaymentRequest'); + } + + if (!signature) { + throw new Error('ExpectedRequestSignatureForSignedPaymentRequest'); + } + + try { + hexAsWords({hex: signature}); + } catch (err) { + throw new Error('ExpectedValidSignatureHexForSignedPaymentRequest'); + } + + if (!isArray(tags)) { + throw new Error('ExpectedRequestTagsForSignedPaymentRequest'); + } + + const preimage = Buffer.concat([ + Buffer.from(hrp, 'ascii'), + wordsAsBuffer({words: tags}), + ]); + + const hash = createHash('sha256').update(preimage).digest(); + + const destinationKey = Buffer.from(destination, 'hex'); + const sig = Buffer.from(signature, 'hex'); + + // Find the recovery flag that works for this signature + const recoveryFlag = recoveryFlags.find(flag => { + try { + return recover(hash, sig, flag, true).equals(destinationKey); + } catch (err) { + return false; + } + }); + + if (recoveryFlag === undefined) { + throw new Error('ExpectedValidSignatureForSignedPaymentRequest'); + } + + const sigWords = hexAsWords({hex: signature + padding + recoveryFlag}).words; + + const words = [].concat(tags).concat(sigWords); + + return {request: encode(hrp, words, MAX_SAFE_INTEGER)}; +}; diff --git a/bolt11/create_unsigned_request.js b/bolt11/create_unsigned_request.js new file mode 100644 index 00000000..e58709d1 --- /dev/null +++ b/bolt11/create_unsigned_request.js @@ -0,0 +1,209 @@ +const {createHash} = require('crypto'); + +const {crypto} = require('bitcoinjs-lib'); +const {flatten} = require('lodash'); + +const chainAddressAsWords = require('./chain_address_as_words'); +const currencyCodes = require('./conf/bech32_currency_codes'); +const descriptionAsWords = require('./description_as_words'); +const hexAsWords = require('./hex_as_words'); +const hopAsHex = require('./hop_as_hex'); +const mtokensAsHrp = require('./mtokens_as_hrp'); +const numberAsWords = require('./number_as_words'); +const taggedFields = require('./conf/tagged_fields'); +const wordsAsBuffer = require('./words_as_buffer'); + +const decBase = 10; +const defaultExpireMs = 1e3 * 60 * 60 * 24; +const {floor} = Math; +const {keys} = Object; +const maxDescriptionLen = 639; +const msPerSec = 1e3; +const mtokPerTok = 1e3; +const {now} = Date; +const {parse} = Date; +const {sha256} = crypto; + +/** Create an unsigned payment request + + { + [chain_addresses]: [] + [cltv_delta]: + [created_at]: + [description]: + [description_hash]: + destination: + [expires_at]: + id: + [mtokens]: (can exceed Number limit) + network: + [routes]: [[{ + [base_fee_mtokens]: + [channel]: + [cltv_delta]: + [fee_rate]: + public_key: + }]] + [tokens]: (note: can differ from mtokens) + } + + @returns + { + hash: + hrp: + tags: [] + } +*/ +module.exports = args => { + if (!args.description && !args.description_hash) { + throw new Error('ExpectedPaymentDescriptionOrDescriptionHashForPayReq'); + } + + if (Buffer.byteLength(args.description || '', 'utf8') > maxDescriptionLen) { + throw new Error('ExpectedPaymentDescriptionWithinDescriptionByteLimit'); + } + + if (!args.id) { + throw new Error('ExpectedPaymentHashWhenEncodingPaymentRequest'); + } + + const createdAt = floor((parse(args.created_at) || now()) / msPerSec); + + const defaultExpiresAt = new Date(createdAt + defaultExpireMs).toISOString(); + + const expiresAt = args.expires_at || defaultExpiresAt; + + const expiresAtEpochTime = floor(parse(expiresAt) / msPerSec); + + const currencyPrefix = keys(currencyCodes) + .map(code => ({code, network: currencyCodes[code]})) + .find(({network}) => network === args.network); + + if (!currencyPrefix) { + throw new Error('ExpectedKnownNetworkToEncodePaymentRequest'); + } + + const createdAtWords = numberAsWords({number: floor(createdAt)}).words; + const mtokens = args.mtokens || args.tokens * mtokPerTok; + + const hrp = `ln${currencyPrefix.code}${mtokensAsHrp({mtokens}).hrp}`; + + const fieldWords = flatten(keys(taggedFields).map(field => { + switch (taggedFields[field].label) { + case 'description': + return { + field, + words: descriptionAsWords({description: args.description}).words, + } + + case 'description_hash': + if (!args.description_hash) { + return {}; + } + + return { + field, + words: hexAsWords({hex: args.description_hash}).words, + }; + + case 'destination_public_key': + return {}; + + return { + field, + words: hexAsWords({hex: args.destination}).words, + }; + + case 'expiry': + if (!args.expires_at) { + return {}; + } + + return { + field, + words: numberAsWords({number: expiresAtEpochTime - createdAt}).words, + }; + + case 'fallback_address': + if (!args.chain_addresses) { + return {}; + } + + return args.chain_addresses.map(address => ({ + field, + words: chainAddressAsWords({address, network: args.network}).words, + })); + + case 'min_final_cltv_expiry': + return {}; + + return { + field, + words: numberAsWords({number: args.cltv_delta}).words, + }; + + case 'payment_hash': + return { + field, + words: hexAsWords({hex: args.id}).words, + }; + + case 'routing': + if (!args.routes) { + return {}; + } + + return args.routes.map(route => { + let pubKeyCursor; + + const paths = route.map(hop => { + if (!hop.channel) { + pubKeyCursor = hop.public_key; + + return; + } + + const {hex} = hopAsHex({ + base_fee_mtokens: hop.base_fee_mtokens, + channel: hop.channel, + cltv_delta: hop.cltv_delta, + fee_rate: hop.fee_rate, + public_key: pubKeyCursor, + }); + + pubKeyCursor = hop.public_key; + + return hex; + }); + + const {words} = hexAsWords({hex: paths.filter(n => !!n).join('')}); + + return {field, words}; + }); + + default: + throw new Error('UnexpectedTaggedFieldType'); + } + })); + + const tagWords = fieldWords.filter(n => !!n.words).map(({field, words}) => { + const typeWord = [parseInt(field, decBase)]; + + const dataLengthWords = numberAsWords({number: words.length}).words; + + const dataLengthPadded = [0].concat(dataLengthWords).slice(-2); + + return [].concat(typeWord).concat(dataLengthPadded).concat(words); + }); + + const allTags = flatten(createdAtWords.concat(tagWords)); + + const preimage = Buffer.concat([ + Buffer.from(hrp, 'ascii'), + wordsAsBuffer({words: allTags}), + ]); + + const hash = createHash('sha256').update(preimage).digest().toString('hex'); + + return {hash, hrp, tags: allTags}; +}; diff --git a/bolt11/description_as_words.js b/bolt11/description_as_words.js new file mode 100644 index 00000000..ea98233a --- /dev/null +++ b/bolt11/description_as_words.js @@ -0,0 +1,22 @@ +const {toWords} = require('bech32'); + +const encoding = 'utf8'; + +/** Description string as words + + { + [description]: + } + + @returns + { + [words]: [] + } +*/ +module.exports = ({description}) => { + if (!description) { + return {}; + } + + return {words: toWords(Buffer.from(description, encoding))}; +}; diff --git a/bolt11/hex_as_words.js b/bolt11/hex_as_words.js new file mode 100644 index 00000000..b55c1c35 --- /dev/null +++ b/bolt11/hex_as_words.js @@ -0,0 +1,22 @@ +const {toWords} = require('bech32'); + +const encoding = 'hex'; + +/** Hex data as bech32 words + + { + [hex]: + } + + @returns + { + words: [] + } +*/ +module.exports = ({hex}) => { + if (!hex) { + return {}; + } + + return {words: toWords(Buffer.from(hex, encoding))}; +}; diff --git a/bolt11/hop_as_hex.js b/bolt11/hop_as_hex.js new file mode 100644 index 00000000..9f08e0a2 --- /dev/null +++ b/bolt11/hop_as_hex.js @@ -0,0 +1,54 @@ +const BN = require('bn.js'); +const {rawChanId} = require('bolt07'); + +const endian = 'be'; + +/** Hop as raw hop hint hex data + + { + base_fee_mtokens: + channel: + cltv_delta: + fee_rate: + public_key: + } + + @throws + + + @returns + { + hex: + } +*/ +module.exports = args => { + if (!args.base_fee_mtokens) { + throw new Error('ExpectedBaseFeeMillitokensToConvertHopToHex'); + } + + if (!args.channel) { + throw new Error('ExpectedChannelToConvertHopToHex'); + } + + if (!args.cltv_delta) { + throw new Error('ExpectedCltvDeltaToConvertHopToHex'); + } + + if (args.fee_rate === undefined) { + throw new Error('ExpectedHopFeeRateToConvertHopToHex'); + } + + if (!args.public_key) { + throw new Error('ExpectedHopPublicKeyToConvertHopToHex'); + } + + const encoded = Buffer.concat([ + Buffer.from(args.public_key, 'hex'), + Buffer.from(rawChanId({channel: args.channel}).id, 'hex'), + new BN(args.base_fee_mtokens).toArrayLike(Buffer, endian, 4), + new BN(args.fee_rate).toArrayLike(Buffer, endian, 4), + new BN(args.cltv_delta).toArrayLike(Buffer, endian, 2), + ]); + + return {hex: encoded.toString('hex')}; +}; diff --git a/bolt11/mtokens_as_hrp.js b/bolt11/mtokens_as_hrp.js new file mode 100644 index 00000000..88a34be6 --- /dev/null +++ b/bolt11/mtokens_as_hrp.js @@ -0,0 +1,28 @@ +const {multipliers} = require('./conf/multipliers'); + +/** Get Tokens as the Human Readable Part of a BOLT11 payment request + + { + [mtokens]: // default: 0 + } + + @returns via cbk + { + hrp: + } +*/ +module.exports = ({mtokens}) => { + if (!mtokens) { + return {hrp: ''}; + } + + const amount = BigInt(mtokens); + + const [hrp] = multipliers + .map(({letter, value}) => ({letter, value: BigInt(value)})) + .filter(({value}) => !(amount % value)) + .map(({letter, value}) => `${amount / value}${letter}`) + .sort((a, b) => a.length - b.length); + + return {hrp}; +}; diff --git a/bolt11/number_as_words.js b/bolt11/number_as_words.js new file mode 100644 index 00000000..1e5fc4ce --- /dev/null +++ b/bolt11/number_as_words.js @@ -0,0 +1,35 @@ +const boundary = Math.pow(2, 5); +const {floor} = Math; + +/** Number as bech32 words + + { + [number]: + } + + @returns + { + [words]: [] + } +*/ +module.exports = ({number}) => { + if (number === undefined) { + return {}; + } + + let cursor = floor(number); + + if (!cursor) { + return {words: [[].length]}; + } + + const words = []; + + while (!!cursor) { + words.unshift(cursor & (boundary - [cursor].length)); + + cursor = floor(cursor / boundary); + } + + return {words}; +}; diff --git a/test/backups/test_backups_from_snapshot.js b/test/backups/test_backups_from_snapshot.js new file mode 100644 index 00000000..31eaa568 --- /dev/null +++ b/test/backups/test_backups_from_snapshot.js @@ -0,0 +1,103 @@ +const {test} = require('tap'); + +const {backupsFromSnapshot} = require('./../../backups'); + +const tests = [ + { + args: { + single_chan_backups: { + chan_backups: [{ + chan_backup: Buffer.from('00', 'hex'), + chan_point: { + funding_txid_bytes: Buffer.from('01', 'hex'), + output_index: 2, + }, + }], + }, + multi_chan_backup: { + chan_points: [{ + funding_txid_bytes: Buffer.from('01', 'hex'), + output_index: 2, + }], + multi_chan_backup: Buffer.from('03', 'hex'), + }, + }, + description: 'Backups snapshot converts to backups', + expected: { + backup: '03', + channel_backup: '00', + transaction_id: '01', + transaction_vout: 2, + }, + }, + { + args: { + multi_chan_backup: { + chan_points: [{ + funding_txid_bytes: Buffer.from('01', 'hex'), + output_index: 2, + }], + multi_chan_backup: Buffer.from('03', 'hex'), + }, + }, + description: 'Backups missing single channel backups', + expected: {err: [503, 'ExpectedChannelBackupsInBackupsResponse']}, + }, + { + args: { + single_chan_backups: {}, + multi_chan_backup: { + chan_points: [{ + funding_txid_bytes: Buffer.from('01', 'hex'), + output_index: 2, + }], + multi_chan_backup: Buffer.from('03', 'hex'), + }, + }, + description: 'Backups missing single channel backups array', + expected: {err: [503, 'ExpectedChannelBackupsInBackupsResponse']}, + }, + { + args: { + single_chan_backups: { + chan_backups: [{ + chan_backup: Buffer.from('00', 'hex'), + chan_point: { + funding_txid_bytes: Buffer.from('01', 'hex'), + output_index: 2, + }, + }], + }, + }, + description: 'Backups missing multiple channel backups', + expected: {err: [503, 'ExpectedMultiChannelBackupInSnapshot']}, + }, +]; + +tests.forEach(({args, description, expected}) => { + return test(description, ({end, equal}) => { + return backupsFromSnapshot(args, (err, res) => { + const [errCode, errMessage] = err || []; + + if (!!expected.err) { + const [expectedCode, expectedMessage] = expected.err; + + equal(errCode, expectedCode, 'Error code as expected'); + equal(errMessage, expectedMessage, 'Error message as expected'); + + return end(); + } + + const {backup, channels} = res; + + const [channel] = channels; + + equal(backup, expected.backup, 'Backup as expected'); + equal(channel.backup, expected.channel_backup, 'Chan backup returned'); + equal(channel.transaction_id, expected.transaction_id, 'Chan tx id'); + equal(channel.transaction_vout, expected.transaction_vout, 'Chan vout'); + + return end(); + }); + }); +}); diff --git a/test/bolt11/test_create_unsigned_request.js b/test/bolt11/test_create_unsigned_request.js new file mode 100644 index 00000000..9d714729 --- /dev/null +++ b/test/bolt11/test_create_unsigned_request.js @@ -0,0 +1,265 @@ +const {sign} = require('secp256k1'); + +const {test} = require('tap'); + +const {createSignedRequest} = require('./../../'); +const {createUnsignedRequest} = require('./../../'); +const {parsePaymentRequest} = require('./../../'); +const wordsAsBuffer = require('./../../bolt11/words_as_buffer'); + +const bufFromHex = hex => Buffer.from(hex, 'hex'); + +const tests = [ + { + args: { + created_at: '2017-06-01T10:57:38.000Z', + description_hash: '3925b6f67e2c340036ed12093dd44e0368df1b6ea26c53dbe4811f58fd5db8c1', + id: '0001020304050607080900010203040506070809000102030405060708090102', + mtokens: '2000000000', + network: 'bitcoin', + }, + description: 'Test creating a regular unsigned request', + expected: { + data: '0b25fe64410d00004080c1014181c20240004080c1014181c20240004080c1014181c202404082e1a1c92db7b3f161a001b7689049eea2701b46f8db7513629edf2408fac7eaedc60800', + hash: 'b6025e8a10539dddbcbe6840a9650707ae3f147b8dcfda338561ada710508916', + hrp: 'lnbc20m', + }, + verify: { + destination: '03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad', + private_key: 'e126f68f7eafcc8b74f54d269fe206be715000f94dac067d1c04a8ca3b2db734', + }, + }, + { + args: { + created_at: '2017-06-01T10:57:38.000Z', + description: 'Please consider supporting this project', + id: '0001020304050607080900010203040506070809000102030405060708090102', + network: 'bitcoin', + }, + description: 'Test creating a donation payment request', + expected: { + data: '0b25fe64410d00004080c1014181c20240004080c1014181c20240004080c1014181c202404081a1fa83632b0b9b29031b7b739b4b232b91039bab83837b93a34b733903a3434b990383937b532b1ba0', + hash: 'c3d4e83f646fa79a393d75277b1d858db1d1f7ab7137dcb7835db2ecd518e1c9', + hrp: 'lnbc', + }, + verify: { + destination: '03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad', + private_key: 'e126f68f7eafcc8b74f54d269fe206be715000f94dac067d1c04a8ca3b2db734', + }, + }, + { + args: { + created_at: '2017-06-01T10:57:38.000Z', + description: '1 cup coffee', + expires_at: '2017-06-01T10:58:38.000Z', + id: '0001020304050607080900010203040506070809000102030405060708090102', + mtokens: '250000000', + network: 'bitcoin', + }, + description: 'Payment request with expiration time', + expected: { + data: '0b25fe64410d00004080c1014181c20240004080c1014181c20240004080c1014181c202404080c01078d050c4818dd5c0818dbd9999959400', + hash: '41545c21535123568d875cf108983e97323857e05d302e8c0c8091540f496b6e', + hrp: 'lnbc2500u', + }, + verify: { + destination: '03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad', + private_key: 'e126f68f7eafcc8b74f54d269fe206be715000f94dac067d1c04a8ca3b2db734', + }, + }, + { + args: { + created_at: '2017-06-01T10:57:38.000Z', + description: 'ナンセンス 1杯', + expires_at: '2017-06-01T10:58:38.000Z', + id: '0001020304050607080900010203040506070809000102030405060708090102', + network: 'bitcoin', + tokens: 250000, + }, + description: 'Payment request with utf8 characters in description', + expected: { + data: '0b25fe64410d00004080c1014181c20240004080c1014181c20240004080c1014181c202404080c01078d0838e0e2b8e0ecf8e0aef8e0ecf8e0ae480c79a76bc', + hash: 'a66929bfdba1ef8480f41f0a509646d100971a1b1ef074ecc2ad283ef872fb78', + hrp: 'lnbc2500u', + }, + verify: { + destination: '03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad', + private_key: 'e126f68f7eafcc8b74f54d269fe206be715000f94dac067d1c04a8ca3b2db734', + }, + }, + { + args: { + created_at: '2017-06-01T10:57:38.000Z', + description_hash: '3925b6f67e2c340036ed12093dd44e0368df1b6ea26c53dbe4811f58fd5db8c1', + id: '0001020304050607080900010203040506070809000102030405060708090102', + mtokens: '2000000000', + network: 'bitcoin', + }, + description: 'Payment request with hash of list of items', + expected: { + data: '0b25fe64410d00004080c1014181c20240004080c1014181c20240004080c1014181c202404082e1a1c92db7b3f161a001b7689049eea2701b46f8db7513629edf2408fac7eaedc60800', + hash: 'b6025e8a10539dddbcbe6840a9650707ae3f147b8dcfda338561ada710508916', + hrp: 'lnbc20m', + }, + verify: { + destination: '03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad', + private_key: 'e126f68f7eafcc8b74f54d269fe206be715000f94dac067d1c04a8ca3b2db734', + }, + }, + { + args: { + chain_addresses: ['mk2QpYatsKicvFVuTAQLBryyccRXMUaGHP'], + created_at: '2017-06-01T10:57:38.000Z', + description_hash: '3925b6f67e2c340036ed12093dd44e0368df1b6ea26c53dbe4811f58fd5db8c1', + id: '0001020304050607080900010203040506070809000102030405060708090102', + mtokens: '2000000000', + network: 'testnet', + }, + description: 'Payment request with a fallback address', + expected: { + data: '0b25fe64410d00004080c1014181c20240004080c1014181c20240004080c1014181c202404081210c4c5cad5953d9a0f23ec51a5674d1f38c0f2b9329ee1a1c92db7b3f161a001b7689049eea2701b46f8db7513629edf2408fac7eaedc6080', + hash: '8a70928b442c583b3c2206bd72c2edf3bbe514444134285d6abbc6e98c552d92', + hrp: 'lntb20m', + }, + verify: { + destination: '03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad', + private_key: 'e126f68f7eafcc8b74f54d269fe206be715000f94dac067d1c04a8ca3b2db734', + }, + }, + { + args: { + chain_addresses: ['1RustyRX2oai4EYYDpQGWvEL62BBGqN9T'], + created_at: '2017-06-01T10:57:38.000Z', + description_hash: '3925b6f67e2c340036ed12093dd44e0368df1b6ea26c53dbe4811f58fd5db8c1', + id: '0001020304050607080900010203040506070809000102030405060708090102', + network: 'bitcoin', + routes: [[ + { + public_key: '029e03a901b85534ff1e92c43c74431f7ce72046060fcf7a95c37e148f78c77255', + }, + { + base_fee_mtokens: '1', + channel: '66051x263430x1800', + cltv_delta: 3, + fee_rate: 20, + public_key: '03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad', + }, + ]], + tokens: 2000000, + }, + description: 'Payment request with routing hints', + expected: { + data: '0b25fe64410d00004080c1014181c20240004080c1014181c20240004080c1014181c202404080629014f01d480dc2a9a7f8f49621e3a218fbe7390230307e7bd4ae1bf0a47bc63b92a8081018202830384000000008000000a0001890862096c3efb83d41b9328488c99880c9b8ac9b23d1370d0e496dbd9f8b0d000dbb44824f751380da37c6dba89b14f6f92047d63f576e3040', + hash: '215f17ac50e01dbcce070aa15d9ebd29088c7e7e37eb8f6ae071770ca40dd47b', + hrp: 'lnbc20m', + }, + verify: { + destination: '03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad', + private_key: 'e126f68f7eafcc8b74f54d269fe206be715000f94dac067d1c04a8ca3b2db734', + }, + }, + { + args: { + chain_addresses: ['3EktnHQD7RiAE6uzMj2ZifT9YgRrkSgzQX'], + created_at: '2017-06-01T10:57:38.000Z', + description_hash: '3925b6f67e2c340036ed12093dd44e0368df1b6ea26c53dbe4811f58fd5db8c1', + id: '0001020304050607080900010203040506070809000102030405060708090102', + network: 'bitcoin', + tokens: 2000000, + }, + description: 'Payment request with p2sh', + expected: { + data: '0b25fe64410d00004080c1014181c20240004080c1014181c20240004080c1014181c202404081210ca3d5558ee6867cc870847a6e7ce337da1ba81e116e1a1c92db7b3f161a001b7689049eea2701b46f8db7513629edf2408fac7eaedc6080', + hash: 'aff3372fd3da5db4d6cc7cd757c5ad2804ab76f7eb68a79617e4f9edf329cdb3', + hrp: 'lnbc20m', + }, + verify: { + destination: '03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad', + private_key: 'e126f68f7eafcc8b74f54d269fe206be715000f94dac067d1c04a8ca3b2db734', + }, + }, + { + args: { + chain_addresses: ['bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4'], + created_at: '2017-06-01T10:57:38.000Z', + description_hash: '3925b6f67e2c340036ed12093dd44e0368df1b6ea26c53dbe4811f58fd5db8c1', + id: '0001020304050607080900010203040506070809000102030405060708090102', + network: 'bitcoin', + tokens: 2000000, + }, + description: 'Payment request with p2wpkh', + expected: { + data: '0b25fe64410d00004080c1014181c20240004080c1014181c20240004080c1014181c20240408121081d479dba066465b515250711746ce8c8fc50cef5ae1a1c92db7b3f161a001b7689049eea2701b46f8db7513629edf2408fac7eaedc6080', + hash: '53c4634f904ae9e1888e5158d6b7e352c4352cb2e43bf60019352baa471551cd', + hrp: 'lnbc20m', + }, + verify: { + destination: '03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad', + private_key: 'e126f68f7eafcc8b74f54d269fe206be715000f94dac067d1c04a8ca3b2db734', + }, + }, + { + args: { + chain_addresses: ['bc1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3'], + created_at: '2017-06-01T10:57:38.000Z', + description_hash: '3925b6f67e2c340036ed12093dd44e0368df1b6ea26c53dbe4811f58fd5db8c1', + id: '0001020304050607080900010203040506070809000102030405060708090102', + network: 'bitcoin', + tokens: 2000000, + }, + description: 'Payment request with p2wsh', + expected: { + data: '0b25fe64410d00004080c1014181c20240004080c1014181c20240004080c1014181c20240408121a80618c50f0531459a012f46480cd5b684db26159e335349e86e318ca581240c9882e1a1c92db7b3f161a001b7689049eea2701b46f8db7513629edf2408fac7eaedc60800', + hash: '827b34dedbcebfbead318c8ca5d0fdeffb47e79261035980b059dcf66ae83ade', + hrp: 'lnbc20m', + }, + verify: { + destination: '03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad', + private_key: 'e126f68f7eafcc8b74f54d269fe206be715000f94dac067d1c04a8ca3b2db734', + }, + }, +]; + +tests.forEach(({args, description, expected, verify}) => { + return test(description, ({deepIs, end, equal}) => { + const {hash, hrp, tags} = createUnsignedRequest(args); + + const data = wordsAsBuffer({words: tags}).toString('hex'); + + equal(data, expected.data, 'Tags calculated for payment request'); + equal(hash, expected.hash, 'Hash calculated for payment request'); + equal(hrp, expected.hrp, 'Hrp calculated for payment request'); + + const {signature} = sign(bufFromHex(hash), bufFromHex(verify.private_key)); + + const {request} = createSignedRequest({ + hrp, + tags, + destination: verify.destination, + signature: signature.toString('hex'), + }); + + const parsed = parsePaymentRequest({request}); + + deepIs(parsed.chain_addresses, args.chain_addresses, 'Expected fallbacks'); + equal(parsed.cltv_delta, args.cltv_delta || 9, 'Request cltv is expected'); + equal(parsed.created_at, args.created_at, 'Request create_at is expected'); + equal(parsed.description, args.description, 'Req description expected'); + equal(parsed.description_hash, args.description_hash, 'Got Desc hash'); + equal(parsed.destination, verify.destination, 'Destination key expected'); + + if (!!args.mtokens) { + equal(parsed.mtokens, args.mtokens, 'Payment request mtokens expected'); + } + + if (!!args.routes) { + deepIs(parsed.routes, args.routes, 'Payment request routes as expected'); + } + + if (!!args.tokens) { + equal(parsed.tokens, args.tokens, 'Payment request tokens as expected'); + } + + return end(); + }); +}); diff --git a/test/bolt11/test_mtokens_as_hrp.js b/test/bolt11/test_mtokens_as_hrp.js new file mode 100644 index 00000000..f2420773 --- /dev/null +++ b/test/bolt11/test_mtokens_as_hrp.js @@ -0,0 +1,49 @@ +const {test} = require('tap'); + +const mtokensAsHrp = require('./../../bolt11/mtokens_as_hrp'); + +const tests = [ + { + args: {mtokens: '1000'}, description: 'Test nano tokens', expected: '10n', + }, + { + args: {mtokens: '10000'}, description: 'Test more nano', expected: '100n', + }, + { + args: {mtokens: '100000'}, description: 'Test micro', expected: '1u', + }, + { + args: {mtokens: '1', description: 'Test pico', expected: '10p'}, + }, + { + args: {mtokens: '100000000'}, description: 'Test milli', expected: '1m', + }, + { + args: {mtokens: '100000000000'}, description: 'Test btc', expected: '1', + }, + { + args: {mtokens: '123456789000'}, + description: 'Test extended nano', + expected: '1234567890n', + }, + { + args: {mtokens: '123450000000'}, + description: 'Test extended micro', + expected: '1234500u', + }, + { + args: {mtokens: '123400000000'}, + description: 'Test extended milli', + expected: '1234m', + }, +]; + +tests.forEach(({args, description, expected}) => { + return test(description, ({end, equal}) => { + const {hrp} = mtokensAsHrp(args); + + equal(hrp, expected, 'Hrp derived from mtokens'); + + return end(); + }); +});