bitcoin-decoder 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2026 Psycarlo
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,19 @@
1
+ ![bitcoin-decoder](https://github.com/Psycarlo/bitcoin-decoder/blob/main/hero.png)
2
+
3
+ ## Installation
4
+
5
+ ```bash
6
+ npm install bitcoin-decoder
7
+ ```
8
+
9
+ ## Quick Start
10
+
11
+ ```typescript
12
+ import { decode } from 'bitcoin-decoder'
13
+
14
+ const result = decode('1111111111111111111114oLvT2')
15
+ ```
16
+
17
+ ## License
18
+
19
+ Released under the **MIT** license.
@@ -0,0 +1,9 @@
1
+ import type { DecodedData, Input } from './types';
2
+ declare function decode(input: Input): DecodedData;
3
+ export { decode };
4
+ export type { DecodedData, Destination, Metadata, Network, ParsedLNAddress, WellKnown } from './types';
5
+ export { wellKnown } from './utils/lightning-address';
6
+ declare const _default: {
7
+ decode: typeof decode;
8
+ };
9
+ export default _default;
package/dist/index.js ADDED
@@ -0,0 +1,162 @@
1
+ import { DecodeError } from './types';
2
+ import { ARK_PREFIXES, ark } from './utils/ark';
3
+ import { bip321 } from './utils/bip-321';
4
+ import { BITCOIN_ADDRESS_PREFIXES, bitcoin } from './utils/bitcoin';
5
+ import { bolt11 } from './utils/bolt11';
6
+ import { bolt12 } from './utils/bolt12';
7
+ import { lightningAddress } from './utils/lightning-address';
8
+ import { LNURL_PREFIX, lnurl } from './utils/lnurl';
9
+ import { getNetwork } from './utils/network';
10
+ const BIP321_PREFIX = 'bitcoin:';
11
+ const LIGHTNING_PREFIX = 'lightning:';
12
+ const BOLT11_PREFIXES = ['lnbcrt', 'lntbs', 'lnbc', 'lntb'];
13
+ const BOLT12_PREFIXES = ['lnbcrto1', 'lnto1', 'lno1'];
14
+ function toDestination(pd) {
15
+ if (pd.type === 'bitcoin-address') {
16
+ return {
17
+ destination: pd.value,
18
+ type: pd.type,
19
+ protocol: pd.protocol,
20
+ addressType: pd.addressType
21
+ };
22
+ }
23
+ return {
24
+ destination: pd.value,
25
+ type: pd.type,
26
+ protocol: pd.protocol
27
+ };
28
+ }
29
+ function decodeBolt11(input, invoice) {
30
+ const parsedDestination = bolt11(invoice);
31
+ const destination = toDestination(parsedDestination.destination);
32
+ return {
33
+ input,
34
+ destination,
35
+ destinations: [destination],
36
+ network: getNetwork(invoice) || 'unknown',
37
+ metadata: parsedDestination.metadata
38
+ };
39
+ }
40
+ function decodeBolt12(input, offer) {
41
+ const parsedDestination = bolt12(offer);
42
+ const destination = toDestination(parsedDestination.destination);
43
+ return {
44
+ input,
45
+ destination,
46
+ destinations: [destination],
47
+ network: getNetwork(offer) || 'unknown',
48
+ metadata: parsedDestination.metadata
49
+ };
50
+ }
51
+ function decodeLightningAddress(input) {
52
+ const parsedDestination = lightningAddress(input);
53
+ const destination = toDestination(parsedDestination.destination);
54
+ return {
55
+ input,
56
+ destination,
57
+ destinations: [destination],
58
+ network: 'unknown'
59
+ };
60
+ }
61
+ function decodeLnurl(input) {
62
+ const parsedDestination = lnurl(input);
63
+ const destination = toDestination(parsedDestination.destination);
64
+ return {
65
+ input,
66
+ destination,
67
+ destinations: [destination],
68
+ network: 'unknown'
69
+ };
70
+ }
71
+ function decodeArk(input) {
72
+ const parsedDestination = ark(input);
73
+ const destination = toDestination(parsedDestination.destination);
74
+ return {
75
+ input,
76
+ destination,
77
+ destinations: [destination],
78
+ network: getNetwork(input) || 'unknown'
79
+ };
80
+ }
81
+ function decodeBip321(input) {
82
+ const parsedDestinations = bip321(input);
83
+ const first = parsedDestinations[0];
84
+ if (!first) {
85
+ throw new DecodeError(`No supported payment methods in BIP-321 URI: ${input}`, 'NO_PAYMENT_METHODS');
86
+ }
87
+ const destination = toDestination(first.destination);
88
+ const destinations = parsedDestinations.map((pd) => toDestination(pd.destination));
89
+ return {
90
+ input,
91
+ destination,
92
+ destinations,
93
+ network: getNetwork(destination.destination) || 'unknown',
94
+ metadata: first.metadata
95
+ };
96
+ }
97
+ function decodeBitcoin(input) {
98
+ const parsedDestination = bitcoin(input);
99
+ const destination = toDestination(parsedDestination.destination);
100
+ return {
101
+ input,
102
+ destination,
103
+ destinations: [destination],
104
+ network: getNetwork(input) || 'unknown'
105
+ };
106
+ }
107
+ function decodeInput(input) {
108
+ const lowerInput = input.toLowerCase();
109
+ if (lowerInput.startsWith(BIP321_PREFIX)) {
110
+ return decodeBip321(input);
111
+ }
112
+ if (lowerInput.startsWith(LIGHTNING_PREFIX)) {
113
+ return decodeBolt11(input, input.slice(LIGHTNING_PREFIX.length));
114
+ }
115
+ if (BOLT11_PREFIXES.some((prefix) => lowerInput.startsWith(prefix))) {
116
+ return decodeBolt11(input, input);
117
+ }
118
+ if (BOLT12_PREFIXES.some((prefix) => lowerInput.startsWith(prefix))) {
119
+ return decodeBolt12(input, input);
120
+ }
121
+ if (lowerInput.startsWith(LNURL_PREFIX)) {
122
+ return decodeLnurl(input);
123
+ }
124
+ if (ARK_PREFIXES.some((prefix) => lowerInput.startsWith(prefix))) {
125
+ return decodeArk(input);
126
+ }
127
+ if (input.includes('@')) {
128
+ return decodeLightningAddress(input);
129
+ }
130
+ if (BITCOIN_ADDRESS_PREFIXES.some((prefix) => lowerInput.startsWith(prefix))) {
131
+ return decodeBitcoin(input);
132
+ }
133
+ throw new DecodeError(`Unknown input format: ${input}`, 'UNKNOWN_FORMAT');
134
+ }
135
+ function getErrorCode(error) {
136
+ if (error instanceof DecodeError) {
137
+ return {
138
+ valid: false,
139
+ input: '',
140
+ errorMessage: error.message,
141
+ errorCode: error.code
142
+ };
143
+ }
144
+ const message = error instanceof Error ? error.message : String(error);
145
+ return {
146
+ valid: false,
147
+ input: '',
148
+ errorMessage: message,
149
+ errorCode: 'UNKNOWN_FORMAT'
150
+ };
151
+ }
152
+ function decode(input) {
153
+ try {
154
+ return { valid: true, ...decodeInput(input) };
155
+ }
156
+ catch (error) {
157
+ return { ...getErrorCode(error), input };
158
+ }
159
+ }
160
+ export { decode };
161
+ export { wellKnown } from './utils/lightning-address';
162
+ export default { decode };
@@ -0,0 +1,69 @@
1
+ export type Input = string;
2
+ /** Bitcoin network
3
+ *
4
+ * `testnet` here includes bitcoin testnet, signet, and regtest.
5
+ */
6
+ export type Network = 'mainnet' | 'testnet' | 'unknown';
7
+ export type Protocol = 'on-chain' | 'lightning' | 'ark';
8
+ export type Metadata = {
9
+ /** Amount in sats */
10
+ amount?: number;
11
+ description?: string;
12
+ };
13
+ export type DestinationType = 'bitcoin-address' | 'bolt11' | 'bolt12' | 'lnurl' | 'lnaddress' | 'ark-address';
14
+ export type BitcoinAddressType = 'p2pkh' | 'p2sh' | 'p2wpkh' | 'p2wsh' | 'p2tr';
15
+ export type Destination = {
16
+ destination: string;
17
+ type: 'bitcoin-address';
18
+ protocol: Protocol;
19
+ addressType: BitcoinAddressType;
20
+ } | {
21
+ destination: string;
22
+ type: Exclude<DestinationType, 'bitcoin-address'>;
23
+ protocol: Protocol;
24
+ };
25
+ export type ErrorCode = 'UNKNOWN_FORMAT' | 'INVALID_HRP' | 'INVALID_VERSION' | 'EMPTY_ADDRESS' | 'PAYLOAD_TOO_SHORT' | 'INVALID_CHECKSUM' | 'INVALID_BOLT12' | 'INVALID_BIP321' | 'INVALID_LNADDRESS' | 'INVALID_LNURL' | 'NO_PAYMENT_METHODS';
26
+ export type ParsedLNAddress = {
27
+ username: string;
28
+ domain: string;
29
+ };
30
+ export type WellKnown = {
31
+ callback: string;
32
+ minSendable: number;
33
+ maxSendable: number;
34
+ commentAllowed?: number;
35
+ metadata?: string;
36
+ };
37
+ type DecodedSuccess = {
38
+ valid: true;
39
+ /** Raw text passed by the user */
40
+ input: Input;
41
+ /** All parsed destinations */
42
+ destinations: Destination[];
43
+ /** Shortcut primary destination for convenience. Lightning > Ark > On-chain */
44
+ readonly destination: Destination;
45
+ /** Bitcoin network associated with the destination(s) */
46
+ network: Network;
47
+ /** Most relevant metadata */
48
+ metadata?: Metadata;
49
+ };
50
+ type DecodedError = {
51
+ valid: false;
52
+ /** Raw text passed by the user */
53
+ input: Input;
54
+ errorMessage: string;
55
+ errorCode: ErrorCode;
56
+ };
57
+ export type DecodedData = DecodedSuccess | DecodedError;
58
+ export declare class DecodeError extends Error {
59
+ code: ErrorCode;
60
+ constructor(message: string, code: ErrorCode);
61
+ }
62
+ type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never;
63
+ export type ParsedDestination = {
64
+ destination: DistributiveOmit<Destination, 'destination'> & {
65
+ value: string;
66
+ };
67
+ metadata?: Metadata;
68
+ };
69
+ export {};
@@ -0,0 +1,7 @@
1
+ export class DecodeError extends Error {
2
+ code;
3
+ constructor(message, code) {
4
+ super(message);
5
+ this.code = code;
6
+ }
7
+ }
@@ -0,0 +1,4 @@
1
+ import type { Input, ParsedDestination } from '../types';
2
+ declare const ARK_PREFIXES: string[];
3
+ declare function ark(input: Input): ParsedDestination;
4
+ export { ark, ARK_PREFIXES };
@@ -0,0 +1,43 @@
1
+ import { bech32m } from '@scure/base';
2
+ import { DecodeError } from '../types';
3
+ const ARK_PREFIXES = ['ark', 'tark'];
4
+ const VALID_VERSIONS = [0, 1];
5
+ const MIN_PAYLOAD_LENGTH = 4;
6
+ function validate(input) {
7
+ let prefix;
8
+ let words;
9
+ try {
10
+ const decoded = bech32m.decode(input, 1023);
11
+ prefix = decoded.prefix;
12
+ words = decoded.words;
13
+ }
14
+ catch {
15
+ throw new DecodeError('Invalid bech32m checksum', 'INVALID_CHECKSUM');
16
+ }
17
+ if (!ARK_PREFIXES.includes(prefix)) {
18
+ throw new DecodeError(`Invalid HRP: ${prefix}`, 'INVALID_HRP');
19
+ }
20
+ const [version] = words;
21
+ if (version === undefined) {
22
+ throw new DecodeError('Empty address', 'EMPTY_ADDRESS');
23
+ }
24
+ if (!VALID_VERSIONS.includes(version)) {
25
+ throw new DecodeError(`Unknown version: ${version}`, 'INVALID_VERSION');
26
+ }
27
+ const payloadBits = (words.length - 1) * 5;
28
+ const payloadBytes = Math.floor(payloadBits / 8);
29
+ if (payloadBytes < MIN_PAYLOAD_LENGTH) {
30
+ throw new DecodeError('Payload too short', 'PAYLOAD_TOO_SHORT');
31
+ }
32
+ }
33
+ function ark(input) {
34
+ validate(input);
35
+ return {
36
+ destination: {
37
+ value: input,
38
+ protocol: 'ark',
39
+ type: 'ark-address'
40
+ }
41
+ };
42
+ }
43
+ export { ark, ARK_PREFIXES };
@@ -0,0 +1,3 @@
1
+ import type { Input, ParsedDestination } from '../types';
2
+ declare function bip321(input: Input): ParsedDestination[];
3
+ export { bip321 };
@@ -0,0 +1,56 @@
1
+ import { parseBIP321 } from 'bip-321';
2
+ import { ark } from './ark';
3
+ import { bitcoin } from './bitcoin';
4
+ import { bolt11 } from './bolt11';
5
+ import { bolt12 } from './bolt12';
6
+ const SATS_PER_BTC = 100_000_000;
7
+ /** Priority order: Lightning (0) > Ark (1) > On-chain (2) */
8
+ const PROTOCOL_PRIORITY = {
9
+ lightning: 0,
10
+ offer: 0,
11
+ ark: 1,
12
+ onchain: 2
13
+ };
14
+ const SUPPORTED_TYPES = new Set(Object.keys(PROTOCOL_PRIORITY));
15
+ function isSupportedType(type) {
16
+ return SUPPORTED_TYPES.has(type);
17
+ }
18
+ function getMetadata(result) {
19
+ const amount = result.amount
20
+ ? Math.round(result.amount * SATS_PER_BTC)
21
+ : undefined;
22
+ const description = result.message ?? result.label;
23
+ if (amount === undefined && description === undefined) {
24
+ return undefined;
25
+ }
26
+ return { amount, description };
27
+ }
28
+ function mapPaymentMethod(paymentMethod, metadata) {
29
+ if (paymentMethod.type === 'lightning') {
30
+ return bolt11(paymentMethod.value);
31
+ }
32
+ if (paymentMethod.type === 'offer') {
33
+ return bolt12(paymentMethod.value);
34
+ }
35
+ if (paymentMethod.type === 'ark') {
36
+ return ark(paymentMethod.value);
37
+ }
38
+ const parsed = bitcoin(paymentMethod.value);
39
+ return { ...parsed, metadata };
40
+ }
41
+ function parse(result) {
42
+ const metadata = getMetadata(result);
43
+ return result.paymentMethods
44
+ .filter((method) => (method.valid || method.type === 'offer') &&
45
+ isSupportedType(method.type))
46
+ .sort((a, b) => PROTOCOL_PRIORITY[a.type] - PROTOCOL_PRIORITY[b.type])
47
+ .map((method) => mapPaymentMethod(method, metadata));
48
+ }
49
+ function bip321(input) {
50
+ const result = parseBIP321(input);
51
+ if (!result.valid) {
52
+ throw new Error(`Invalid BIP-321 URI: ${result.errors.join(', ')}`);
53
+ }
54
+ return parse(result);
55
+ }
56
+ export { bip321 };
@@ -0,0 +1,4 @@
1
+ import type { Input, ParsedDestination } from '../types';
2
+ declare const BITCOIN_ADDRESS_PREFIXES: string[];
3
+ declare function bitcoin(input: Input): ParsedDestination;
4
+ export { bitcoin, BITCOIN_ADDRESS_PREFIXES };
@@ -0,0 +1,124 @@
1
+ import { sha256 } from '@noble/hashes/sha2.js';
2
+ import { bech32, bech32m, createBase58check } from '@scure/base';
3
+ import { DecodeError } from '../types';
4
+ const b58c = createBase58check(sha256);
5
+ const SEGWIT_HRPS = ['bc', 'tb', 'bcrt'];
6
+ const BITCOIN_ADDRESS_PREFIXES = [
7
+ 'bc1',
8
+ 'tb1',
9
+ 'bcrt1',
10
+ '1',
11
+ '3',
12
+ 'm',
13
+ 'n',
14
+ '2'
15
+ ];
16
+ const P2PKH_VERSIONS = new Set([0x00, 0x6f]);
17
+ const P2SH_VERSIONS = new Set([0x05, 0xc4]);
18
+ function getAddressType(version) {
19
+ if (P2PKH_VERSIONS.has(version)) {
20
+ return 'p2pkh';
21
+ }
22
+ if (P2SH_VERSIONS.has(version)) {
23
+ return 'p2sh';
24
+ }
25
+ throw new DecodeError(`Unknown version byte: ${version}`, 'INVALID_VERSION');
26
+ }
27
+ function validateLegacy(input) {
28
+ let decoded;
29
+ try {
30
+ decoded = b58c.decode(input);
31
+ }
32
+ catch {
33
+ throw new DecodeError('Invalid base58check checksum', 'INVALID_CHECKSUM');
34
+ }
35
+ if (decoded.length !== 21) {
36
+ throw new DecodeError('Invalid address length', 'PAYLOAD_TOO_SHORT');
37
+ }
38
+ const version = decoded[0];
39
+ if (version === undefined) {
40
+ throw new DecodeError('Empty address', 'EMPTY_ADDRESS');
41
+ }
42
+ return getAddressType(version);
43
+ }
44
+ function isValidHrp(prefix) {
45
+ return SEGWIT_HRPS.includes(prefix);
46
+ }
47
+ function getSegwitAddressType(version, programLength) {
48
+ if (version === 0) {
49
+ return programLength === 20 ? 'p2wpkh' : 'p2wsh';
50
+ }
51
+ return 'p2tr';
52
+ }
53
+ function validateSegwit(input) {
54
+ const lowerInput = input.toLowerCase();
55
+ // Try bech32 (witness version 0)
56
+ try {
57
+ const decoded = bech32.decode(lowerInput, 90);
58
+ if (!isValidHrp(decoded.prefix)) {
59
+ throw new DecodeError(`Invalid HRP: ${decoded.prefix}`, 'INVALID_HRP');
60
+ }
61
+ const [version] = decoded.words;
62
+ if (version !== 0) {
63
+ throw new DecodeError('Witness v0 must use bech32 encoding', 'INVALID_VERSION');
64
+ }
65
+ const data = bech32.fromWords(decoded.words.slice(1));
66
+ if (data.length !== 20 && data.length !== 32) {
67
+ throw new DecodeError(`Invalid witness program length: ${data.length}`, 'PAYLOAD_TOO_SHORT');
68
+ }
69
+ return getSegwitAddressType(version, data.length);
70
+ }
71
+ catch (error) {
72
+ if (error instanceof DecodeError) {
73
+ throw error;
74
+ }
75
+ }
76
+ // Try bech32m (witness version 1+)
77
+ try {
78
+ const decoded = bech32m.decode(lowerInput, 90);
79
+ if (!isValidHrp(decoded.prefix)) {
80
+ throw new DecodeError(`Invalid HRP: ${decoded.prefix}`, 'INVALID_HRP');
81
+ }
82
+ const [version] = decoded.words;
83
+ if (version === undefined || version < 1 || version > 16) {
84
+ throw new DecodeError(`Invalid witness version: ${version}`, 'INVALID_VERSION');
85
+ }
86
+ const data = bech32m.fromWords(decoded.words.slice(1));
87
+ if (version === 1) {
88
+ if (data.length !== 32) {
89
+ throw new DecodeError(`Invalid witness program length: ${data.length}`, 'PAYLOAD_TOO_SHORT');
90
+ }
91
+ return getSegwitAddressType(version, data.length);
92
+ }
93
+ // Future witness versions (2-16): program must be 2-40 bytes
94
+ if (data.length < 2 || data.length > 40) {
95
+ throw new DecodeError(`Invalid witness program length: ${data.length}`, 'PAYLOAD_TOO_SHORT');
96
+ }
97
+ return getSegwitAddressType(version, data.length);
98
+ }
99
+ catch (error) {
100
+ if (error instanceof DecodeError) {
101
+ throw error;
102
+ }
103
+ throw new DecodeError('Invalid bech32/bech32m checksum', 'INVALID_CHECKSUM');
104
+ }
105
+ }
106
+ function validate(input) {
107
+ const lowerInput = input.toLowerCase();
108
+ if (SEGWIT_HRPS.some((hrp) => lowerInput.startsWith(`${hrp}1`))) {
109
+ return validateSegwit(input);
110
+ }
111
+ return validateLegacy(input);
112
+ }
113
+ function bitcoin(input) {
114
+ const addressType = validate(input);
115
+ return {
116
+ destination: {
117
+ value: input,
118
+ protocol: 'on-chain',
119
+ type: 'bitcoin-address',
120
+ addressType
121
+ }
122
+ };
123
+ }
124
+ export { bitcoin, BITCOIN_ADDRESS_PREFIXES };
@@ -0,0 +1,3 @@
1
+ import type { Input, ParsedDestination } from '../types';
2
+ declare function bolt11(input: Input): ParsedDestination;
3
+ export { bolt11 };
@@ -0,0 +1,23 @@
1
+ import { decode } from 'light-bolt11-decoder';
2
+ function getSection(decodedInvoice, name) {
3
+ return decodedInvoice.sections.find((section) => section.name === name);
4
+ }
5
+ function parse(decodedInvoice) {
6
+ const amount = getSection(decodedInvoice, 'amount');
7
+ const description = getSection(decodedInvoice, 'description');
8
+ return {
9
+ destination: {
10
+ value: decodedInvoice.paymentRequest,
11
+ protocol: 'lightning',
12
+ type: 'bolt11'
13
+ },
14
+ metadata: {
15
+ amount: amount ? Number(amount.value) / 1000 : undefined,
16
+ description: description?.value
17
+ }
18
+ };
19
+ }
20
+ function bolt11(input) {
21
+ return parse(decode(input));
22
+ }
23
+ export { bolt11 };
@@ -0,0 +1,3 @@
1
+ import type { Input, ParsedDestination } from '../types';
2
+ declare function bolt12(input: Input): ParsedDestination;
3
+ export { bolt12 };
@@ -0,0 +1,170 @@
1
+ import { bech32, hex, utf8 } from '@scure/base';
2
+ const BECH32_ALPHABET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
3
+ // BOLT12 TLV types from the spec
4
+ const TLV_TYPES = {
5
+ 2: 'offer_chains',
6
+ 4: 'offer_metadata',
7
+ 6: 'offer_currency',
8
+ 8: 'offer_amount',
9
+ 10: 'description',
10
+ 12: 'offer_features',
11
+ 14: 'offer_absolute_expiry',
12
+ 16: 'offer_paths',
13
+ 18: 'issuer',
14
+ 20: 'offer_quantity_max',
15
+ 22: 'node_id',
16
+ 80: 'invreq_metadata',
17
+ 82: 'invreq_payer_id',
18
+ 160: 'invoice_paths',
19
+ 162: 'invoice_blindedpay',
20
+ 164: 'invoice_created_at',
21
+ 166: 'invoice_relative_expiry',
22
+ 168: 'invoice_payment_hash',
23
+ 170: 'invoice_amount',
24
+ 172: 'invoice_fallbacks',
25
+ 174: 'invoice_features',
26
+ 176: 'invoice_node_id',
27
+ 240: 'signature'
28
+ };
29
+ /** Read a BigSize integer (variable-length encoding from the Lightning spec). */
30
+ function readBigSize(data, offset) {
31
+ const first = data[offset];
32
+ if (first === undefined) {
33
+ throw new Error('Unexpected end of data');
34
+ }
35
+ if (first < 0xfd) {
36
+ return [first, offset + 1];
37
+ }
38
+ if (first === 0xfd) {
39
+ return [(data[offset + 1] ?? 0) * 256 + (data[offset + 2] ?? 0), offset + 3];
40
+ }
41
+ if (first === 0xfe) {
42
+ return [
43
+ (data[offset + 1] ?? 0) * 16_777_216 +
44
+ (data[offset + 2] ?? 0) * 65_536 +
45
+ (data[offset + 3] ?? 0) * 256 +
46
+ (data[offset + 4] ?? 0),
47
+ offset + 5
48
+ ];
49
+ }
50
+ throw new Error('BigSize value too large');
51
+ }
52
+ function parseValue(type, value) {
53
+ const name = TLV_TYPES[type];
54
+ switch (name) {
55
+ case 'description':
56
+ case 'issuer':
57
+ case 'offer_currency':
58
+ return utf8.encode(value);
59
+ case 'node_id':
60
+ case 'offer_metadata':
61
+ case 'invreq_metadata':
62
+ case 'invreq_payer_id':
63
+ case 'invoice_payment_hash':
64
+ case 'invoice_node_id':
65
+ case 'signature':
66
+ return hex.encode(value);
67
+ case 'offer_amount':
68
+ case 'offer_quantity_max':
69
+ case 'offer_absolute_expiry':
70
+ case 'invoice_created_at':
71
+ case 'invoice_relative_expiry':
72
+ case 'invoice_amount': {
73
+ let n = 0;
74
+ for (const byte of value) {
75
+ n = n * 256 + byte;
76
+ }
77
+ return n;
78
+ }
79
+ case 'offer_chains': {
80
+ const chains = [];
81
+ for (let i = 0; i < value.length; i += 32) {
82
+ chains.push(hex.encode(value.slice(i, i + 32)));
83
+ }
84
+ return chains;
85
+ }
86
+ default:
87
+ return hex.encode(value);
88
+ }
89
+ }
90
+ /**
91
+ * Decode bech32 data without checksum verification.
92
+ * BOLT12 offers use bech32 encoding but omit the checksum per spec,
93
+ * which causes standard bech32 decoders to reject them.
94
+ */
95
+ function decodeBech32NoChecksum(str) {
96
+ const lower = str.toLowerCase();
97
+ const sepIndex = lower.lastIndexOf('1');
98
+ if (sepIndex < 1) {
99
+ throw new Error('Invalid bech32 string');
100
+ }
101
+ const dataStr = lower.slice(sepIndex + 1);
102
+ const words = [];
103
+ for (const char of dataStr) {
104
+ const idx = BECH32_ALPHABET.indexOf(char);
105
+ if (idx === -1) {
106
+ throw new Error(`Invalid bech32 character: ${char}`);
107
+ }
108
+ words.push(idx);
109
+ }
110
+ return words;
111
+ }
112
+ function decode(offerRequest) {
113
+ if (typeof offerRequest !== 'string') {
114
+ throw new Error('Offer request must be a string');
115
+ }
116
+ const lower = offerRequest.toLowerCase();
117
+ if (!lower.startsWith('ln')) {
118
+ throw new Error('Invalid BOLT12 offer request');
119
+ }
120
+ // Try standard bech32 decode first (handles offers with valid checksum),
121
+ // then fall back to no-checksum decode (e.g. Phoenix wallet offers)
122
+ let words;
123
+ const decoded = bech32.decodeUnsafe(offerRequest, Number.MAX_SAFE_INTEGER);
124
+ if (decoded) {
125
+ words = Array.from(decoded.words);
126
+ }
127
+ else {
128
+ words = decodeBech32NoChecksum(offerRequest);
129
+ }
130
+ // Convert 5-bit words to bytes
131
+ const data = bech32.fromWordsUnsafe(words);
132
+ if (!data) {
133
+ throw new Error('Failed to convert bech32 words to bytes');
134
+ }
135
+ // Parse byte-level TLV records (BigSize type + BigSize length + value)
136
+ const sections = [];
137
+ let offset = 0;
138
+ while (offset < data.length) {
139
+ const [type, afterType] = readBigSize(data, offset);
140
+ const [length, afterLength] = readBigSize(data, afterType);
141
+ const value = data.slice(afterLength, afterLength + length);
142
+ offset = afterLength + length;
143
+ const name = TLV_TYPES[type] ?? `unknown_${type}`;
144
+ sections.push({
145
+ name,
146
+ value: parseValue(type, value)
147
+ });
148
+ }
149
+ return { offerRequest, sections };
150
+ }
151
+ function getSection(decodedOffer, name) {
152
+ return decodedOffer.sections.find((section) => section.name === name);
153
+ }
154
+ function parse(decodedOffer) {
155
+ const description = getSection(decodedOffer, 'description');
156
+ return {
157
+ destination: {
158
+ value: decodedOffer.offerRequest,
159
+ protocol: 'lightning',
160
+ type: 'bolt12'
161
+ },
162
+ metadata: {
163
+ description: typeof description?.value === 'string' ? description.value : undefined
164
+ }
165
+ };
166
+ }
167
+ function bolt12(input) {
168
+ return parse(decode(input));
169
+ }
170
+ export { bolt12 };
@@ -0,0 +1,5 @@
1
+ import type { Input, ParsedDestination, ParsedLNAddress, WellKnown } from '../types';
2
+ declare function parse(input: Input): ParsedLNAddress;
3
+ declare function lightningAddress(input: Input): ParsedDestination;
4
+ declare function wellKnown(lnaddress: string, needsParse?: boolean): Promise<WellKnown | false>;
5
+ export { lightningAddress, parse, wellKnown };
@@ -0,0 +1,65 @@
1
+ import { DecodeError } from '../types';
2
+ const BASE_URL = 'https://';
3
+ const headers = { 'Content-Type': 'application/json' };
4
+ function parse(input) {
5
+ const atIndex = input.indexOf('@');
6
+ if (atIndex < 1) {
7
+ throw new DecodeError('Invalid lightning address format', 'INVALID_LNADDRESS');
8
+ }
9
+ const username = input.slice(0, atIndex);
10
+ const domain = input.slice(atIndex + 1);
11
+ if (!domain.includes('.')) {
12
+ throw new DecodeError('Invalid lightning address format', 'INVALID_LNADDRESS');
13
+ }
14
+ return { username, domain };
15
+ }
16
+ function lightningAddress(input) {
17
+ parse(input);
18
+ return {
19
+ destination: {
20
+ value: input,
21
+ protocol: 'lightning',
22
+ type: 'lnaddress'
23
+ }
24
+ };
25
+ }
26
+ async function wellKnown(lnaddress, needsParse = true) {
27
+ let url = lnaddress;
28
+ if (needsParse) {
29
+ try {
30
+ const parsed = parse(lnaddress);
31
+ url = `${BASE_URL}${parsed.domain}/.well-known/lnurlp/${parsed.username}`;
32
+ }
33
+ catch {
34
+ return false;
35
+ }
36
+ }
37
+ try {
38
+ const response = await fetch(url, {
39
+ headers,
40
+ method: 'GET'
41
+ });
42
+ const json = (await response.json());
43
+ const { callback, minSendable, maxSendable, commentAllowed, metadata } = json;
44
+ if (!callback) {
45
+ return false;
46
+ }
47
+ if (!minSendable) {
48
+ return false;
49
+ }
50
+ if (!maxSendable) {
51
+ return false;
52
+ }
53
+ return {
54
+ callback,
55
+ minSendable,
56
+ maxSendable,
57
+ commentAllowed,
58
+ metadata
59
+ };
60
+ }
61
+ catch {
62
+ return false;
63
+ }
64
+ }
65
+ export { lightningAddress, parse, wellKnown };
@@ -0,0 +1,4 @@
1
+ import type { Input, ParsedDestination } from '../types';
2
+ declare const LNURL_PREFIX = "lnurl";
3
+ declare function lnurl(input: Input): ParsedDestination;
4
+ export { lnurl, LNURL_PREFIX };
@@ -0,0 +1,32 @@
1
+ import { bech32 } from '@scure/base';
2
+ import { DecodeError } from '../types';
3
+ const LNURL_PREFIX = 'lnurl';
4
+ function lnurl(input) {
5
+ let words;
6
+ try {
7
+ const decoded = bech32.decode(input, 2000);
8
+ if (decoded.prefix !== LNURL_PREFIX) {
9
+ throw new DecodeError(`Invalid HRP: expected "${LNURL_PREFIX}", got "${decoded.prefix}"`, 'INVALID_LNURL');
10
+ }
11
+ words = decoded.words;
12
+ }
13
+ catch (error) {
14
+ if (error instanceof DecodeError) {
15
+ throw error;
16
+ }
17
+ throw new DecodeError('Invalid LNURL encoding', 'INVALID_LNURL');
18
+ }
19
+ const bytes = bech32.fromWords(words);
20
+ const url = new TextDecoder().decode(new Uint8Array(bytes));
21
+ if (!url.startsWith('https://')) {
22
+ throw new DecodeError('LNURL does not contain a valid URL', 'INVALID_LNURL');
23
+ }
24
+ return {
25
+ destination: {
26
+ value: input,
27
+ protocol: 'lightning',
28
+ type: 'lnurl'
29
+ }
30
+ };
31
+ }
32
+ export { lnurl, LNURL_PREFIX };
@@ -0,0 +1,3 @@
1
+ import type { Input, Network } from '../types';
2
+ declare function getNetwork(input: Input): Network | undefined;
3
+ export { getNetwork };
@@ -0,0 +1,48 @@
1
+ const MAINNET_ADDRESS_PREFIXES = ['bc1p', 'bc1q', 'bc1', '1', '3'];
2
+ const TESTNET_ADDRESS_PREFIXES = [
3
+ 'bcrt1q',
4
+ 'bcrt1p',
5
+ 'tb1q',
6
+ 'tb1p',
7
+ 'm',
8
+ 'n',
9
+ '2'
10
+ ];
11
+ const MAINNET_BOLT11_PREFIXES = ['lnbc'];
12
+ const TESTNET_BOLT11_PREFIXES = ['lnbcrt', 'lntbs', 'lntb'];
13
+ const MAINNET_BOLT12_PREFIXES = ['lno1'];
14
+ const TESTNET_BOLT12_PREFIXES = ['lnbcrto1', 'lnto1'];
15
+ const MAINNET_ARK_PREFIXES = ['ark'];
16
+ const TESTNET_ARK_PREFIXES = ['tark'];
17
+ function startsWithAny(input, prefixes) {
18
+ return prefixes.some((prefix) => input.startsWith(prefix));
19
+ }
20
+ function getNetwork(input) {
21
+ const lowerInput = input.toLowerCase();
22
+ if (startsWithAny(lowerInput, TESTNET_ADDRESS_PREFIXES)) {
23
+ return 'testnet';
24
+ }
25
+ if (startsWithAny(lowerInput, MAINNET_ADDRESS_PREFIXES)) {
26
+ return 'mainnet';
27
+ }
28
+ if (startsWithAny(lowerInput, TESTNET_BOLT11_PREFIXES)) {
29
+ return 'testnet';
30
+ }
31
+ if (startsWithAny(lowerInput, MAINNET_BOLT11_PREFIXES)) {
32
+ return 'mainnet';
33
+ }
34
+ if (startsWithAny(lowerInput, TESTNET_BOLT12_PREFIXES)) {
35
+ return 'testnet';
36
+ }
37
+ if (startsWithAny(lowerInput, MAINNET_BOLT12_PREFIXES)) {
38
+ return 'mainnet';
39
+ }
40
+ if (startsWithAny(lowerInput, TESTNET_ARK_PREFIXES)) {
41
+ return 'testnet';
42
+ }
43
+ if (startsWithAny(lowerInput, MAINNET_ARK_PREFIXES)) {
44
+ return 'mainnet';
45
+ }
46
+ return undefined;
47
+ }
48
+ export { getNetwork };
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "bitcoin-decoder",
3
+ "version": "0.1.0",
4
+ "description": "Decode bitcoin QR codes, URIs, and raw strings",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "source": "./src/index.ts",
9
+ "types": "./dist/index.d.ts",
10
+ "import": "./dist/index.js",
11
+ "default": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist"
16
+ ],
17
+ "author": {
18
+ "name": "psycarlo",
19
+ "email": "psycarlo1@gmail.com",
20
+ "url": "https://psycarlo.com"
21
+ },
22
+ "scripts": {
23
+ "build": "tsc -p tsconfig.build.json",
24
+ "test": "bun test",
25
+ "type-check": "tsc --noEmit",
26
+ "check": "ultracite check",
27
+ "fix": "ultracite fix",
28
+ "prepublishOnly": "bun run build"
29
+ },
30
+ "dependencies": {
31
+ "@noble/hashes": "^2.0.1",
32
+ "@scure/base": "^2.0.0",
33
+ "bip-321": "^0.0.10",
34
+ "light-bolt11-decoder": "^3.2.0"
35
+ },
36
+ "devDependencies": {
37
+ "@biomejs/biome": "2.4.0",
38
+ "@types/bun": "^1.3.9",
39
+ "typescript": "^5.9.3",
40
+ "ultracite": "7.2.3"
41
+ },
42
+ "keywords": [
43
+ "bitcoin",
44
+ "decode",
45
+ "on-chain",
46
+ "lightning",
47
+ "ark",
48
+ "bip-21",
49
+ "bip-321",
50
+ "uri"
51
+ ],
52
+ "license": "MIT",
53
+ "repository": {
54
+ "type": "git",
55
+ "url": "https://github.com/Psycarlo/bitcoin-decode"
56
+ },
57
+ "bugs": {
58
+ "email": "psycarlo1@gmail.com",
59
+ "url": "https://github.com/Psycarlo/bitcoin-decode/issues"
60
+ },
61
+ "homepage": "https://github.com/Psycarlo/bitcoin-decode#readme",
62
+ "workspaces": [
63
+ "website"
64
+ ]
65
+ }