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