diff --git a/package-lock.json b/package-lock.json index 08b4ca4..3494bb2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "lucide-vue-next": "^0.468.0", "luxon": "^3.5.0", "radix-vue": "^1.9.11", + "secp256k1": "^5.0.1", "tailwind-merge": "^2.5.5", "tailwindcss-animate": "^1.0.7", "vee-validate": "^4.14.7", @@ -32,6 +33,7 @@ "@types/jsdom": "^21.1.7", "@types/luxon": "^3.4.2", "@types/node": "^22.9.3", + "@types/secp256k1": "^4.0.6", "@vitejs/plugin-vue": "^5.2.1", "@vitest/eslint-plugin": "1.1.10", "@vue/eslint-config-prettier": "^10.1.0", @@ -1915,6 +1917,15 @@ "undici-types": "~6.20.0" } }, + "node_modules/@types/secp256k1": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@types/secp256k1/-/secp256k1-4.0.6.tgz", + "integrity": "sha512-hHxJU6PAEUn0TP4S/ZOzuTUvJWuZ6eIKeNKb5RBpODvSl6hp1Wrw4s7ATY50rklRCScUDpHzVA/DQdSjJ3UoYQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/sinonjs__fake-timers": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.1.tgz", @@ -3136,6 +3147,20 @@ "secp256k1": "^4.0.2" } }, + "node_modules/bolt11/node_modules/secp256k1": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/secp256k1/-/secp256k1-4.0.4.tgz", + "integrity": "sha512-6JfvwvjUOn8F/jUoBY2Q1v5WY5XS+rj8qSe0v8Y4ezH4InLgTEeOOPQsRll9OV429Pvo6BCHGavIyJfr3TAhsw==", + "hasInstallScript": true, + "dependencies": { + "elliptic": "^6.5.7", + "node-addon-api": "^5.0.0", + "node-gyp-build": "^4.2.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -7148,9 +7173,9 @@ } }, "node_modules/secp256k1": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/secp256k1/-/secp256k1-4.0.4.tgz", - "integrity": "sha512-6JfvwvjUOn8F/jUoBY2Q1v5WY5XS+rj8qSe0v8Y4ezH4InLgTEeOOPQsRll9OV429Pvo6BCHGavIyJfr3TAhsw==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/secp256k1/-/secp256k1-5.0.1.tgz", + "integrity": "sha512-lDFs9AAIaWP9UCdtWrotXWWF9t8PWgQDcxqgAnpM9rMqxb3Oaq2J0thzPVSxBwdJgyQtkU/sYtFtbM1RSt/iYA==", "hasInstallScript": true, "dependencies": { "elliptic": "^6.5.7", diff --git a/package.json b/package.json index 8edee20..a054991 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "lucide-vue-next": "^0.468.0", "luxon": "^3.5.0", "radix-vue": "^1.9.11", + "secp256k1": "^5.0.1", "tailwind-merge": "^2.5.5", "tailwindcss-animate": "^1.0.7", "vee-validate": "^4.14.7", @@ -40,6 +41,7 @@ "@types/jsdom": "^21.1.7", "@types/luxon": "^3.4.2", "@types/node": "^22.9.3", + "@types/secp256k1": "^4.0.6", "@vitejs/plugin-vue": "^5.2.1", "@vitest/eslint-plugin": "1.1.10", "@vue/eslint-config-prettier": "^10.1.0", diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 5146e4d..66bc2d7 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,3 +1,5 @@ +import * as secp256k1 from 'secp256k1' + import { type ClassValue, clsx } from 'clsx' import { twMerge } from 'tailwind-merge' @@ -5,6 +7,7 @@ import { onBeforeMount, reactive } from 'vue' import { useClipboard } from '@vueuse/core' import type { LightningReceipt } from '@/types/index' +import type { DecodedInvoice } from 'light-bolt11-decoder' export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) @@ -49,3 +52,94 @@ export function useForm() { form, } } + +export function strToHex(str: string) { + return str + .split('') + .map((x) => x.charCodeAt(0).toString(16)) + .join('') +} + +const hexToArrayBuffer = (hexString: string) => { + const bytes = new Uint8Array(hexString.length / 2) + for (let i = 0; i < bytes.length; i++) { + bytes[i] = parseInt(hexString.substr(i * 2, 2), 16) + } + return bytes.buffer +} + +export function byteArrayToHexString(byteArray: Uint8Array) { + return Array.prototype.map + .call(byteArray, function (byte) { + return ('0' + (byte & 0xff).toString(16)).slice(-2) + }) + .join('') +} + +export function bech32To8BitArray(str: string) { + const bech32CharValues = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l' + const int5Array = str.split('').map((char) => bech32CharValues.indexOf(char)) + + let count = 0 + let buffer = 0 + const byteArray = [] + + int5Array.forEach((value) => { + buffer = (buffer << 5) + value + count += 5 + + while (count >= 8) { + byteArray.push((buffer >> (count - 8)) & 255) + count -= 8 + } + }) + + if (count > 0) { + byteArray.push((buffer << (8 - count)) & 255) + } + + return Uint8Array.from(byteArray) +} + +export async function getPubkeyFromSignature(decoded: DecodedInvoice) { + const signature = decoded.sections.find((section) => section.name === 'signature') + + if (!signature || !signature.letters || !signature.value) { + return null + } + + const prefixSections = ['lightning_network', 'coin_network', 'amount'] + + const prefix = decoded.sections + .filter((section) => prefixSections.includes(section.name)) + .map((section) => { + if ('letters' in section) return section.letters + }) + .join('') + + if (!prefix) { + return null + } + + const separator = decoded.sections.find((section) => section.name === 'separator')?.letters + + if (!separator) { + return null + } + + const splitInvoice = decoded.paymentRequest.split(prefix + separator) + + const data = splitInvoice[1].split(signature.letters)[0] + + const signingData = strToHex(prefix) + byteArrayToHexString(bech32To8BitArray(data)) + + const hash = await crypto.subtle.digest('SHA-256', hexToArrayBuffer(signingData)) + + const recoveryId = parseInt(signature.value.slice(-2), 16); + const signatureValue = signature.value.slice(0, -2) + const sigParsed = hexToArrayBuffer(signatureValue) + + const sigPubkey = secp256k1.ecdsaRecover(new Uint8Array(sigParsed), recoveryId, new Uint8Array(hash), true) + + return byteArrayToHexString(sigPubkey) +} diff --git a/src/pages/HomeView.vue b/src/pages/HomeView.vue index 402a487..c951217 100644 --- a/src/pages/HomeView.vue +++ b/src/pages/HomeView.vue @@ -5,7 +5,7 @@ import bolt11 from 'light-bolt11-decoder' // import { Duration } from 'luxon' import { Icon } from '@iconify/vue' -import { useForm } from '@/lib/utils' +import { useForm, getPubkeyFromSignature } from '@/lib/utils' import { Card, CardHeader, @@ -33,6 +33,8 @@ const isVerified = ref(false) const { form } = useForm() +const payeePubKey = ref(""); + const decodedInvoice = computed(() => { try { const decoded = bolt11.decode(form.invoice) @@ -55,10 +57,12 @@ const decodedInvoice = computed(() => { amount: Math.floor(Number(amount) / 1000), description, paymentHash, + decoded } // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (error) { + console.error(error) return null } }) @@ -68,6 +72,7 @@ watchEffect(async () => { if (decodedInvoice.value) { isPaid.value = await checkPaymentProof() isVerified.value = true + payeePubKey.value = await getPubkeyFromSignature(decodedInvoice.value.decoded) || ""; } }) @@ -156,6 +161,23 @@ function formatLong(text: string) { + + Payee Pub Key + + {{ formatLong(payeePubKey) }} + + + + + + Status