Lightning invoices
BOLT-11 Lightning invoices decoded: amount down to the millisatoshi, timestamp and expiry, payment hash and secret, route hints, fallback addresses, feature bits, and the payee's key recovered from the signature or checked against the `n` field. What the specification says must fail is refused with its reason; preimages and hashed descriptions can be checked.
Version 1.0.0 · 4 functions · 4 crates
Functions
What use lightning_invoice.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use ln_decode, ln_expired from lightning_invoice (Quillo 0.6).
ln_decode(invoice: str) → anyor an errorAn invoice decoded and its signature checked: { network, amount_msat, amount_sat, timestamp, expiry, expires_at, payment_hash, payment_secret, description, description_hash, payee, payee_recovered, min_final_cltv_expiry, metadata, fallbacks, routes, features, unknown_tags, signature }. amount_msat null means the payer chooses. Takes a lightning: prefix. Whatever BOLT-11 says must fail is an error value.
ln_expired(invoice: str, at: int) → anyor an errorWhether an invoice can still be paid: { expired, seconds_left }. at is seconds since 1970, or 0 for now.
ln_verify_preimage(invoice: str, preimage: str) → boolor an errorWhether a payment's preimage in hex is the one the invoice asks for: its SHA-256 equals the payment hash. It proves the invoice was paid.
ln_verify_description(invoice: str, description: str) → boolor an errorWhether a long description is the one an invoice's h field commits to. Error value if the invoice has a plain description instead.
Get it
$ quillo install lightning_invoice # lightning_invoice.qwer into this folder$ quillo install lightning_invoice --example # and its example, lightning_invoice.qweOr with curl:
$ curl -O https://quillolang.org/plugins/files/lightning_invoice.qwer$ curl -O https://quillolang.org/plugins/files/lightning_invoice.qweThen, in your program, all of it or only what you call:
use lightning_invoice.qweruse ln_decode, ln_expired from lightning_invoice // Quillo 0.6: only theseIt uses these crates, which cargo fetches the first time the plugin is built:
k256 = { version = "0.13", default-features = false, features = ["ecdsa", "arithmetic", "std"] }sha2 = "0.10"hex = "0.4"serde_json = { version = "1", features = ["preserve_order"] }Example
// BOLT-11 Lightning invoices: the specification's own examples decoded, each valid one with its// amount, tags and payee, and each invalid one refused for its reason. Nothing is paid, and no// node is asked: the signature alone says who wrote the invoice.//// quillo run lightning_invoice.qwe// then open http://localhost:8080/decode?invoice=lnbc..., /coffee, /tags, /signature, /expiry and /refuseduse "lightning_invoice.qwer"// From BOLT #11, "Examples" and "Examples of Invalid Invoices".let specs = { donation: "lnbc1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpl2pkx2ctnv5sxxmmwwd5kgetjypeh2ursdae8g6twvus8g6rfwvs8qun0dfjkxaq9qrsgq357wnc5r2ueh7ck6q93dj32dlqnls087fxdwk8qakdyafkq3yap9us6v52vjjsrvywa6rt52cm9r9zqt8r2t7mlcwspyetp5h2tztugp9lfyql", coffee: "lnbc2500u1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpu9qrsgquk0rl77nj30yxdy8j9vdx85fkpmdla2087ne0xh8nhedh8w27kyke0lp53ut353s06fv3qfegext0eh0ymjpf39tuven09sam30g4vgpfna3rh", nonsense: "lnbc2500u1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpquwpc4curk03c9wlrswe78q4eyqc7d8d0xqzpu9qrsgqhtjpauu9ur7fw2thcl4y9vfvh4m9wlfyz2gem29g5ghe2aak2pm3ps8fdhtceqsaagty2vph7utlgj48u0ged6a337aewvraedendscp573dxr", hashed: "lnbc20m1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqhp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqs9qrsgq7ea976txfraylvgzuxs8kgcw23ezlrszfnh8r6qtfpr6cxga50aj6txm9rxrydzd06dfeawfk6swupvz4erwnyutnjq7x39ymw6j38gp7ynn44", testnet: "lntb20m1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygshp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqfpp3x9et2e20v6pu37c5d9vax37wxq72un989qrsgqdj545axuxtnfemtpwkc45hx9d2ft7x04mt8q7y6t0k2dge9e7h8kpy9p34ytyslj3yu569aalz2xdk8xkd7ltxqld94u8h2esmsmacgpghe9k8", route: "lnbc20m1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqhp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqsfpp3qjmp7lwpagxun9pygexvgpjdc4jdj85fr9yq20q82gphp2nflc7jtzrcazrra7wwgzxqc8u7754cdlpfrmccae92qgzqvzq2ps8pqqqqqqpqqqqq9qqqvpeuqafqxu92d8lr6fvg0r5gv0heeeqgcrqlnm6jhphu9y00rrhy4grqszsvpcgpy9qqqqqqgqqqqq7qqzq9qrsgqdfjcdk6w3ak5pca9hwfwfh63zrrz06wwfya0ydlzpgzxkn5xagsqz7x9j4jwe7yj7vaf2k9lqsdk45kts2fd0fkr28am0u4w95tt2nsq76cqw0", p2sh: "lnbc20m1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygshp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqfppj3a24vwu6r8ejrss3axul8rxldph2q7z99qrsgqz6qsgww34xlatfj6e3sngrwfy3ytkt29d2qttr8qz2mnedfqysuqypgqex4haa2h8fx3wnypranf3pdwyluftwe680jjcfp438u82xqphf75ym", p2wpkh: "lnbc20m1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygshp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqfppqw508d6qejxtdg4y5r3zarvary0c5xw7k9qrsgqt29a0wturnys2hhxpner2e3plp6jyj8qx7548zr2z7ptgjjc7hljm98xhjym0dg52sdrvqamxdezkmqg4gdrvwwnf0kv2jdfnl4xatsqmrnsse", routes2: "lnbc9678785340p1pwmna7lpp5gc3xfm08u9qy06djf8dfflhugl6p7lgza6dsjxq454gxhj9t7a0sd8dgfkx7cmtwd68yetpd5s9xar0wfjn5gpc8qhrsdfq24f5ggrxdaezqsnvda3kkum5wfjkzmfqf3jkgem9wgsyuctwdus9xgrcyqcjcgpzgfskx6eqf9hzqnteypzxz7fzypfhg6trddjhygrcyqezcgpzfysywmm5ypxxjemgw3hxjmn8yptk7untd9hxwg3q2d6xjcmtv4ezq7pqxgsxzmnyyqcjqmt0wfjjq6t5v4khxsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygsxqyjw5qcqp2rzjq0gxwkzc8w6323m55m4jyxcjwmy7stt9hwkwe2qxmy8zpsgg7jcuwz87fcqqeuqqqyqqqqlgqqqqn3qq9q9qrsgqrvgkpnmps664wgkp43l22qsgdw4ve24aca4nymnxddlnp8vh9v2sdxlu5ywdxefsfvm0fq3sesf08uf6q9a2ke0hc9j6z6wlxg5z5kqpu2v9wz", metadata: "lnbc10m1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdp9wpshjmt9de6zqmt9w3skgct5vysxjmnnd9jx2mq8q8a04uqsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9q2gqqqqqqsgq7hf8he7ecf7n4ffphs6awl9t6676rrclv9ckg3d3ncn7fct63p6s365duk5wrk202cfy3aj5xnnp5gs3vrdvruverwwq7yzhkf5a3xqpd05wjc", high_s: "lnbc1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpl2pkx2ctnv5sxxmmwwd5kgetjypeh2ursdae8g6twvus8g6rfwvs8qun0dfjkxaq9qrsgq357wnc5r2ueh7ck6q93dj32dlqnls087fxdwk8qakdyafkq3yap2r09nt4ndd0unm3z9u5t48y6ucv4r5sg7lk98c77ctvjczkspk5qprc90gx", bad_feature: "lnbc25m1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5vdhkven9v5sxyetpdeessp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9q4psqqqqqqqqqqqqqqqqsgqtqyx5vggfcsll4wu246hz02kp85x4katwsk9639we5n5yngc3yhqkm35jnjw4len8vrnqnf5ejh0mzj9n3vz2px97evektfm2l6wqccp3y7372", bad_n: "lnbc25m1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5vdhkven9v5sxyetpdeessp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9q5sqqqqqqqqqqqqqqqqsgqnp5qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqf9dt6rzhc3ynq3u7axf4xc3zaw3fy2kyqal6n86nh5j3lhgcutdjzhvswudd5l8ay9acgrrpd2e6vw9sdqxq6dzq983sgt7dpxk5wacqacdc2y", no_hash: "lnbc25m1pvjluezdq5vdhkven9v5sxyetpdeessp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9q5sqqqqqqqqqqqqqqqqsgqjy08jd52dtj25pefkh0enc54lxtjtr8wnum2msrq2d8uztp9vhhpccq93ljpff0kkjgzkzcp2czlyenjphf6upazjlc8uxw7za0ynusqrfhg8u", two_hashes: "lnbc1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqpp5lllllllllllllllllllllllllllllllllllllllllllllllllllsdpyv36hqmrfvdshgefqwpshjmt9de6zq6rpwd5qsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9g3f93cqturay6zk2fyfcmeflphlzew9wfq0n5nf9hqnlwxthtzqcljcuurljyd2vngkya5hndakf33ghly97qm5nc3umj7jep22nfsq3nr0w8", bad_checksum: "lnbc2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpquwpc4curk03c9wlrswe78q4eyqc7d8d0xqzpuyk0sg5g70me25alkluzd2x62aysf2pyy8edtjeevuv4p2d5p76r4zkmneet7uvyakky2zr4cusd45tftc9c5fh0nnqpnl2jfll544esqchsrnt", no_one: "pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpquwpc4curk03c9wlrswe78q4eyqc7d8d0xqzpuyk0sg5g70me25alkluzd2x62aysf2pyy8edtjeevuv4p2d5p76r4zkmneet7uvyakky2zr4cusd45tftc9c5fh0nnqpnl2jfll544esqchsrny", mixed: "LNBC2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpquwpc4curk03c9wlrswe78q4eyqc7d8d0xqzpuyk0sg5g70me25alkluzd2x62aysf2pyy8edtjeevuv4p2d5p76r4zkmneet7uvyakky2zr4cusd45tftc9c5fh0nnqpnl2jfll544esqchsrny", no_recover: "lnbc2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpusp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9qrsgqwgt7mcn5yqw3yx0w94pswkpq6j9uh6xfqqqtsk4tnarugeektd4hg5975x9am52rz4qskukxdmjemg92vvqz8nvmsye63r5ykel43pgz7zq0g2", short: "lnbc1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpl2pkx2ctnv5sxxmmwwd5kgetjypeh2ursdae8g6na6hlh", bad_multiplier: "lnbc2500x1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpusp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9qrsgqrrzc4cvfue4zp3hggxp47ag7xnrlr8vgcmkjxk3j5jqethnumgkpqp23z9jclu3v0a7e0aruz366e9wqdykw6dxhdzcjjhldxq0w6wgqcnu43j", sub_msat: "lnbc2500000001p1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpusp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9qrsgq0lzc236j96a95uv0m3umg28gclm5lqxtqqwk32uuk4k6673k6n5kfvx3d2h8s295fad45fdhmusm8sjudfhlf6dcsxmfvkeywmjdkxcp99202x", no_secret: "lnbc20m1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqhp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqs9qrsgq7ea976txfraylvgzuxs8kgcw23ezlrszfnh8r6qtfpr6cxga50aj6txm9rxrydzd06dfeawfk6swupvz4erwnyutnjq7x39ymw6j38gp49qdkj", high_s_n: "lnbc25m1p70xwfzpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpl2pkx2ctnv5sxxmmwwd5kgetjypeh2ursdae8g6twvus8g6rfwvs8qun0dfjkxaqnp4q0n326hr8v9zprg8gsvezcch06gfaqqhde2aj730yg0durunfhv66sp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9qrsgqsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygsp5cfzp9ugllvk03rltd6hvndxj26ux6gcxc5azyxk060rj9tzghct5zvjlps76gx8wpq5yuu79688k8gnm2c0al6v608s96l0xzrrlqqwnzxmu", features99: "lnbc25m1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5vdhkven9v5sxyetpdeessp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9q5sqqqqqqqqqqqqqqqqsgq2a25dxl5hrntdtn6zvydt7d66hyzsyhqs4wdynavys42xgl6sgx9c4g7me86a27t07mdtfry458rtjr0v92cnmswpsjscgt2vcse3sgpz3uapa",}route GET /decode ? { invoice: str } { let i = ln_decode(invoice) if is_err(i) { reply 422 i.error } i}route GET /coffee = ln_decode(specs.coffee)?route GET /tags = { donation: ln_decode(specs.donation)?.amount_msat, nonsense: ln_decode(specs.nonsense)?.description, hashed: ln_decode(specs.hashed)?.description_hash, testnet: ln_decode(specs.testnet)?.fallbacks, p2sh: ln_decode(specs.p2sh)?.fallbacks, p2wpkh: ln_decode(specs.p2wpkh)?.fallbacks, routes: ln_decode(specs.route)?.routes, pico: ln_decode(specs.routes2)?.amount_msat, metadata: ln_decode(specs.metadata)?.metadata, features: ln_decode(specs.features99)?.features,}route GET /signature = { recovered: ln_decode(specs.donation)?.payee, was_recovered: ln_decode(specs.donation)?.payee_recovered, high_s: ln_decode(specs.high_s)?.signature.high_s, high_s_payee: ln_decode(specs.high_s)?.payee, upper: ln_decode(upper(specs.donation))?.payment_hash, scheme: ln_decode("lightning:" + specs.coffee)?.amount_sat,}route GET /expiry = { within: ln_expired(specs.coffee, 1496314690)?, after: ln_expired(specs.coffee, 1496314800)?, today: ln_expired(specs.coffee, 0)?.expired, preimage: ln_verify_preimage(specs.coffee, "0000000000000000000000000000000000000000000000000000000000000000")?, short: ln_verify_preimage(specs.coffee, "00").error, description: ln_verify_description(specs.hashed, "One piece of chocolate cake, one icecream cone, one pickle, one slice of swiss cheese, one slice of salami, one lollypop, one piece of cherry pie, one sausage, one cupcake, and one slice of watermelon")?, not_hashed: ln_verify_description(specs.coffee, "x").error,}route GET /refused = { bad_feature: ln_decode(specs.bad_feature).error, bad_n: ln_decode(specs.bad_n).error, no_hash: ln_decode(specs.no_hash).error, two_hashes: ln_decode(specs.two_hashes).error, no_secret: ln_decode(specs.no_secret).error, bad_checksum: ln_decode(specs.bad_checksum).error, no_one: ln_decode(specs.no_one).error, mixed: ln_decode(specs.mixed).error, no_recover: ln_decode(specs.no_recover).error, short: ln_decode(specs.short).error, bad_multiplier: ln_decode(specs.bad_multiplier).error, sub_msat: ln_decode(specs.sub_msat).error, high_s_n: ln_decode(specs.high_s_n).error,}$ quillo run lightning_invoice.qwe$ curl 'localhost:8080/coffee'$ curl 'localhost:8080/tags'$ curl 'localhost:8080/signature'$ curl 'localhost:8080/expiry'$ curl 'localhost:8080/refused'Source
//! BOLT-11 Lightning invoices decoded and checked: amount, timestamp, expiry, every tag with its route hints, fallback addresses and feature bits, and the signature verified or the payee's key recovered from it, by the specification's rules for what must fail.//! version: 1.0.0//!//! [dependencies]//! k256 = { version = "0.13", default-features = false, features = ["ecdsa", "arithmetic", "std"] }//! sha2 = "0.10"//! hex = "0.4"//! serde_json = { version = "1", features = ["preserve_order"] }use k256::ecdsa::signature::hazmat::PrehashVerifier;use k256::ecdsa::{RecoveryId, Signature, VerifyingKey};use serde_json::{json, Map, Value};use sha2::{Digest, Sha256};use std::time::{SystemTime, UNIX_EPOCH};const B32: &[u8; 32] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";const B58: &[u8; 58] = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";fn polymod(vals: impl Iterator<Item = u8>) -> u32 { let mut chk: u32 = 1; for v in vals { let top = chk >> 25; chk = (chk & 0x1ffffff) << 5 ^ v as u32; for (i, g) in [0x3b6a57b2u32, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3].iter().enumerate() { if top >> i & 1 == 1 { chk ^= g; } } } chk}fn hrp_expand(hrp: &str) -> Vec<u8> { let mut v: Vec<u8> = hrp.bytes().map(|b| b >> 5).collect(); v.push(0); v.extend(hrp.bytes().map(|b| b & 31)); v}fn bech32_encode(hrp: &str, data: &[u8], constant: u32) -> String { let sum = polymod(hrp_expand(hrp).into_iter().chain(data.iter().copied()).chain([0u8; 6])) ^ constant; let mut s = format!("{}1", hrp); s.extend(data.iter().map(|d| B32[*d as usize] as char)); s.extend((0..6).map(|i| B32[(sum >> (5 * (5 - i)) & 31) as usize] as char)); s}fn base58check(version: u8, payload: &[u8]) -> String { let mut b = vec![version]; b.extend_from_slice(payload); let c = Sha256::digest(Sha256::digest(&b)); b.extend_from_slice(&c[..4]); let mut digits: Vec<u8> = vec![]; for x in &b { let mut carry = *x as u32; for d in digits.iter_mut() { let v = ((*d as u32) << 8) + carry; *d = (v % 58) as u8; carry = v / 58; } while carry > 0 { digits.push((carry % 58) as u8); carry /= 58; } } let mut s = "1".repeat(b.iter().take_while(|x| **x == 0).count()); s.extend(digits.iter().rev().map(|d| B58[*d as usize] as char)); s}// Five-bit words as bytes: `pad` keeps a last short byte, as the signed data does; without it// the leftover bits are dropped, as in tagged fields.fn to_bytes(words: &[u8], pad: bool) -> Vec<u8> { let (mut acc, mut bits, mut out) = (0u32, 0u32, vec![]); for v in words { acc = (acc << 5 | *v as u32) & 0xffff; bits += 5; if bits >= 8 { bits -= 8; out.push((acc >> bits & 255) as u8); } } if pad && bits > 0 { out.push((acc << (8 - bits) & 255) as u8); } out}fn uint(words: &[u8]) -> u64 { words.iter().fold(0u64, |a, w| a << 5 | *w as u64)}struct Hrp { network: &'static str, msat: Option<u128>,}fn read_hrp(hrp: &str) -> Result<Hrp, String> { let rest = hrp.strip_prefix("ln").ok_or(format!("`{}` does not start with `ln`", hrp))?; let (network, amt) = if let Some(r) = rest.strip_prefix("bcrt") { ("regtest", r) } else if let Some(r) = rest.strip_prefix("bc") { ("mainnet", r) } else if let Some(r) = rest.strip_prefix("tbs") { ("signet", r) } else if let Some(r) = rest.strip_prefix("tb") { ("testnet", r) } else { return Err(format!("`{}` is no currency this reader knows: lnbc, lntb, lntbs and lnbcrt", hrp)); }; if amt.is_empty() { return Ok(Hrp { network, msat: None }); } let (num, mult) = match amt.chars().last() { Some(c) if c.is_ascii_digit() => (amt, None), Some(c) => (&amt[..amt.len() - 1], Some(c)), None => (amt, None), }; if num.is_empty() || !num.bytes().all(|b| b.is_ascii_digit()) { return Err(format!("the amount `{}` is not a number with a multiplier", amt)); } if num.starts_with('0') { return Err(format!("the amount `{}` starts with a zero", amt)); } let n: u128 = num.parse().map_err(|_| format!("the amount `{}` is too large", amt))?; // In picobitcoin: 1 BTC = 10^12 pico; a millisatoshi is 10 pico. let scale: u128 = match mult { None => 1_000_000_000_000, Some('m') => 1_000_000_000, Some('u') => 1_000_000, Some('n') => 1_000, Some('p') => 1, Some(c) => return Err(format!("`{}` is not a multiplier; there are m, u, n and p", c)), }; let pico = n.checked_mul(scale).ok_or(format!("the amount `{}` is too large", amt))?; if pico % 10 != 0 { return Err(format!("the amount `{}` is not a whole number of millisatoshis: with `p` its last digit must be 0", amt)); } Ok(Hrp { network, msat: Some(pico / 10) })}fn fallback(version: u8, program: &[u8], network: &str) -> Value { let hrp = match network { "mainnet" => "bc", "regtest" => "bcrt", _ => "tb", }; let (pkh, sh) = if network == "mainnet" { (0x00, 0x05) } else { (0x6f, 0xc4) }; let address = match version { 17 if program.len() == 20 => base58check(pkh, program), 18 if program.len() == 20 => base58check(sh, program), 0..=16 => { let mut d = vec![version]; let (mut acc, mut bits) = (0u32, 0u32); for b in program { acc = (acc << 8 | *b as u32) & 0xffff; bits += 8; while bits >= 5 { bits -= 5; d.push((acc >> bits & 31) as u8); } } if bits > 0 { d.push((acc << (5 - bits) & 31) as u8); } bech32_encode(hrp, &d, if version == 0 { 1 } else { 0x2bc830a3 }) } _ => String::new(), }; json!({ "version": version, "hash": hex::encode(program), "address": address })}const FEATURES: &[(usize, &str)] = &[(8, "var_onion_optin"), (14, "payment_secret"), (16, "basic_mpp"), (24, "option_route_blinding"), (48, "option_payment_metadata")];fn features(words: &[u8]) -> (Value, Option<usize>) { let mut bits = vec![]; let total = words.len() * 5; for (i, w) in words.iter().enumerate() { for b in 0..5 { if w >> b & 1 == 1 { bits.push(total - 5 * (i + 1) + b); } } } bits.sort(); let name = |b: usize| FEATURES.iter().find(|f| f.0 == b & !1).map(|f| f.1); let required: Vec<Value> = bits.iter().filter(|b| *b % 2 == 0).map(|b| json!(name(*b).map(String::from).unwrap_or(format!("bit {}", b)))).collect(); let optional: Vec<Value> = bits.iter().filter(|b| *b % 2 == 1).map(|b| json!(name(*b).map(String::from).unwrap_or(format!("bit {}", b)))).collect(); let unknown = bits.iter().find(|b| *b % 2 == 0 && name(**b).is_none()).copied(); (json!({ "bits": bits, "required": required, "optional": optional }), unknown)}fn decode_invoice(input: &str) -> Result<Value, String> { let t = input.trim(); let t = t.strip_prefix("lightning:").or(t.strip_prefix("LIGHTNING:")).unwrap_or(t); if t.bytes().any(|b| b.is_ascii_lowercase()) && t.bytes().any(|b| b.is_ascii_uppercase()) { return Err("the invoice mixes capitals and small letters".into()); } let low = t.to_ascii_lowercase(); let at = low.rfind('1').ok_or("the invoice has no `1` after its prefix")?; let hrp = &low[..at]; if !hrp.starts_with("ln") { return Err("a BOLT-11 invoice starts with `ln`".into()); } let mut words = vec![]; for (i, c) in low[at + 1..].bytes().enumerate() { words.push(B32.iter().position(|x| *x == c).ok_or(format!("`{}` at position {} is not in Bech32", c as char, at + 2 + i))? as u8); } if words.len() < 7 + 104 + 6 { return Err(format!("the invoice is too short: {} characters of data, with room for no signature", words.len())); } if polymod(hrp_expand(hrp).into_iter().chain(words.iter().copied())) != 1 { return Err("the invoice fails its Bech32 checksum".into()); } let h = read_hrp(hrp)?; let words = &words[..words.len() - 6]; let body = &words[..words.len() - 104]; let sig = to_bytes(&words[words.len() - 104..], false); let timestamp = uint(&body[..7]); let mut o = Map::new(); let (mut payment_hash, mut secret, mut description, mut desc_hash, mut payee) = (None, None, None, None, None); let (mut expiry, mut cltv, mut metadata) = (3600u64, 18u64, Value::Null); let (mut falls, mut routes, mut feat, mut unknown_tags) = (vec![], vec![], Value::Null, vec![]); let mut i = 7; while i < body.len() { if i + 3 > body.len() { return Err("a tagged field runs past the end of the invoice".into()); } let tag = body[i]; let len = uint(&body[i + 1..i + 3]) as usize; i += 3; if i + len > body.len() { return Err("a tagged field runs past the end of the invoice".into()); } let f = &body[i..i + len]; i += len; let name = B32[tag as usize] as char; let fixed = |want: usize| if len == want { Ok(hex::encode(to_bytes(f, false))) } else { Err(format!("the `{}` field is {} long; it must be {}", name, len, want)) }; let once = |seen: bool| if seen { Err(format!("there are two `{}` fields; there must be one", name)) } else { Ok(()) }; let minimal = || if f.first() == Some(&0) { Err(format!("the `{}` field has leading zeros", name)) } else { Ok(()) }; match name { 'p' => { once(payment_hash.is_some())?; payment_hash = Some(fixed(52)?); } 's' => { once(secret.is_some())?; secret = Some(fixed(52)?); } 'h' => { once(desc_hash.is_some())?; desc_hash = Some(fixed(52)?); } 'n' => { once(payee.is_some())?; payee = Some(fixed(53)?); } 'd' => { once(description.is_some())?; description = Some(String::from_utf8(to_bytes(f, false)).map_err(|_| "the description is not valid UTF-8".to_string())?); } 'm' => metadata = json!(hex::encode(to_bytes(f, false))), 'x' => { minimal()?; expiry = uint(f); } 'c' => { minimal()?; cltv = uint(f); } '9' => { minimal()?; let (v, unknown) = features(f); if let Some(b) = unknown { return Err(format!("the invoice requires unknown feature bit {}", b)); } feat = v; } 'f' => { if f.is_empty() || f[0] > 18 { continue; // An unknown version is skipped, as the specification says. } falls.push(fallback(f[0], &to_bytes(&f[1..], false), h.network)); } 'r' => { let b = to_bytes(f, false); let mut hops = vec![]; for e in b.chunks_exact(51) { let scid = u64::from_be_bytes(e[33..41].try_into().unwrap()); hops.push(json!({ "pubkey": hex::encode(&e[..33]), "short_channel_id": format!("{}x{}x{}", scid >> 40, scid >> 16 & 0xffffff, scid & 0xffff), "fee_base_msat": u32::from_be_bytes(e[41..45].try_into().unwrap()), "fee_proportional_millionths": u32::from_be_bytes(e[45..49].try_into().unwrap()), "cltv_expiry_delta": u16::from_be_bytes(e[49..51].try_into().unwrap()), })); } routes.push(Value::Array(hops)); } _ => unknown_tags.push(json!({ "tag": name.to_string(), "length": len })), } } let payment_hash = payment_hash.ok_or("the invoice has no `p` field (payment hash)")?; let secret = secret.ok_or("the invoice has no `s` field (payment secret)")?; if description.is_some() == desc_hash.is_some() { return Err(if description.is_some() { "the invoice has both a description and its hash".to_string() } else { "the invoice has neither a description nor its hash".to_string() }); } // The signature covers the human part as text, then the data as bytes, padded with zeros. let mut signed = hrp.as_bytes().to_vec(); signed.extend(to_bytes(body, true)); let digest: [u8; 32] = Sha256::digest(&signed).into(); let raw = Signature::from_slice(&sig[..64]).map_err(|_| "the signature's r or s is out of range".to_string())?; let rec = sig[64]; if rec > 3 { return Err(format!("the signature's recovery id is {}; it is 0 to 3", rec)); } let high = raw.normalize_s(); let recovered = payee.is_none(); let key = match &payee { Some(n) => { if high.is_some() { return Err("the signature has a high S with an `n` field, which must fail".into()); } let k = VerifyingKey::from_sec1_bytes(&hex::decode(n).unwrap_or_default()).map_err(|_| "the `n` field is not a public key".to_string())?; k.verify_prehash(&digest, &raw).map_err(|_| "the signature does not match the `n` field's key".to_string())?; n.clone() } None => { let (s, id) = match high { Some(low) => (low, rec ^ 1), None => (raw, rec), }; let id = RecoveryId::from_byte(id).ok_or("bad recovery id")?; let k = VerifyingKey::recover_from_prehash(&digest, &s, id).map_err(|_| "no public key can be recovered from the signature".to_string())?; hex::encode(k.to_encoded_point(true).as_bytes()) } }; o.insert("network".into(), json!(h.network)); o.insert("amount_msat".into(), json!(h.msat.map(|m| m as u64))); o.insert("amount_sat".into(), json!(h.msat.map(|m| (m / 1000) as u64))); o.insert("timestamp".into(), json!(timestamp)); o.insert("expiry".into(), json!(expiry)); o.insert("expires_at".into(), json!(timestamp + expiry)); o.insert("payment_hash".into(), json!(payment_hash)); o.insert("payment_secret".into(), json!(secret)); o.insert("description".into(), json!(description)); o.insert("description_hash".into(), json!(desc_hash)); o.insert("payee".into(), json!(key)); o.insert("payee_recovered".into(), json!(recovered)); o.insert("min_final_cltv_expiry".into(), json!(cltv)); o.insert("metadata".into(), metadata); o.insert("fallbacks".into(), Value::Array(falls)); o.insert("routes".into(), Value::Array(routes)); o.insert("features".into(), feat); o.insert("unknown_tags".into(), Value::Array(unknown_tags)); o.insert("signature".into(), json!({ "r": hex::encode(&sig[..32]), "s": hex::encode(&sig[32..64]), "recovery": rec, "high_s": high.is_some() })); Ok(Value::Object(o))}/// An invoice decoded and its signature checked: { network, amount_msat, amount_sat, timestamp, expiry, expires_at, payment_hash, payment_secret, description, description_hash, payee, payee_recovered, min_final_cltv_expiry, metadata, fallbacks, routes, features, unknown_tags, signature }. `amount_msat` null means the payer chooses. Takes a `lightning:` prefix. Whatever BOLT-11 says must fail is an error value.pub fn ln_decode(invoice: &str) -> Result<Value, String> { decode_invoice(invoice)}fn now() -> u64 { SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)}/// Whether an invoice can still be paid: { expired, seconds_left }. `at` is seconds since 1970, or 0 for now.pub fn ln_expired(invoice: &str, at: i64) -> Result<Value, String> { let i = decode_invoice(invoice)?; let t = if at > 0 { at as u64 } else { now() }; let end = i["expires_at"].as_u64().unwrap_or(0); Ok(json!({ "expired": t >= end, "seconds_left": end.saturating_sub(t) }))}/// Whether a payment's preimage in hex is the one the invoice asks for: its SHA-256 equals the payment hash. It proves the invoice was paid.pub fn ln_verify_preimage(invoice: &str, preimage: &str) -> Result<bool, String> { let i = decode_invoice(invoice)?; let p = hex::decode(preimage.trim()).map_err(|_| "the preimage is not hex".to_string())?; if p.len() != 32 { return Err(format!("a preimage is 32 bytes; this is {}", p.len())); } Ok(hex::encode(Sha256::digest(&p)) == i["payment_hash"].as_str().unwrap_or(""))}/// Whether a long description is the one an invoice's `h` field commits to. Error value if the invoice has a plain description instead.pub fn ln_verify_description(invoice: &str, description: &str) -> Result<bool, String> { let i = decode_invoice(invoice)?; let h = i["description_hash"].as_str().ok_or("the invoice has a plain description, not a hash")?; Ok(hex::encode(Sha256::digest(description.as_bytes())) == h)}Related
bitcoin_address.qwer · 1.0.0Bitcoin addresses checked with their real checksums, Base58Check, Bech32 and Bech32m, and told apart: P2PKH, P2SH, SegWit v0, Taproot, the network and the output script. Addresses made from a public key or hash, and BIP-21 payment links read and written, amounts exact to the satoshi and unknown `req-` parameters refused.3 views · 4 downloadsEthereum addresses eth_address.qwer · 1.0.0Ethereum without a node: EIP-55 checksums checked and written, addresses from public keys in any spelling, Keccak-256, personal_sign messages signed, recovered and verified for sign-in with a wallet, and EIP-681 payment URIs read and built, `2.014e18` amounts kept exact. secp256k1 and Keccak in pure Rust.2 views · 5 downloadsThread 0 posts
On Lightning invoices: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.