bip-321 0.0.8 → 0.0.9
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/README.md +79 -11
- package/bun.lock +1 -0
- package/index.test.ts +365 -0
- package/index.ts +89 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -27,6 +27,18 @@ result.paymentMethods.forEach((method: PaymentMethod) => {
|
|
|
27
27
|
});
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
+
```typescript
|
|
31
|
+
import { encodeBIP321 } from "bip-321";
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const { uri } = encodeBIP321({ address: "bitcoin_address", label: "Label", message: "Message", amount: 0.5 });
|
|
35
|
+
|
|
36
|
+
// uri = bitcoin:bitcoin_address?label=Label&message=Message&amount=0.5
|
|
37
|
+
} catch (error) {
|
|
38
|
+
console.error(error)
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
30
42
|
## Installation
|
|
31
43
|
|
|
32
44
|
```bash
|
|
@@ -42,7 +54,7 @@ npm install bip-321
|
|
|
42
54
|
## Quick Start
|
|
43
55
|
|
|
44
56
|
```typescript
|
|
45
|
-
import { parseBIP321 } from "bip-321";
|
|
57
|
+
import { parseBIP321, encodeBIP321 } from "bip-321";
|
|
46
58
|
|
|
47
59
|
// Parse a simple Bitcoin address
|
|
48
60
|
const result = parseBIP321("bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa");
|
|
@@ -51,6 +63,15 @@ console.log(result.valid); // true
|
|
|
51
63
|
console.log(result.network); // "mainnet"
|
|
52
64
|
console.log(result.address); // "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"
|
|
53
65
|
console.log(result.paymentMethods); // Array of payment methods
|
|
66
|
+
|
|
67
|
+
// Encode a simple Bitcoin address
|
|
68
|
+
try {
|
|
69
|
+
const { uri } = encodeBIP321({ address: "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa" });
|
|
70
|
+
|
|
71
|
+
console.log(uri); // bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa
|
|
72
|
+
} catch (error) {
|
|
73
|
+
console.error(error)
|
|
74
|
+
}
|
|
54
75
|
```
|
|
55
76
|
|
|
56
77
|
## Validation Functions
|
|
@@ -140,9 +161,11 @@ result.paymentMethods.forEach((method) => {
|
|
|
140
161
|
### Lightning-Only Payment
|
|
141
162
|
|
|
142
163
|
```typescript
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
164
|
+
// Encode lightning-only payment (no on-chain address)
|
|
165
|
+
const { uri } = encodeBIP321({ lightning: "lnbc15u1p3xnhl2pp5jptserfk3zk4qy42tlucycrfwxhydvlemu9pqr93tuzlv9cc7g3s..." });
|
|
166
|
+
|
|
167
|
+
// Parse lightning-only payment
|
|
168
|
+
const result = parseBIP321(uri);
|
|
146
169
|
|
|
147
170
|
console.log(result.paymentMethods[0].type); // "lightning"
|
|
148
171
|
console.log(result.paymentMethods[0].network); // "mainnet"
|
|
@@ -151,18 +174,18 @@ console.log(result.paymentMethods[0].network); // "mainnet"
|
|
|
151
174
|
### Ark Payment
|
|
152
175
|
|
|
153
176
|
```typescript
|
|
154
|
-
//
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
177
|
+
// Encode Ark-only payment (no on-chain address)
|
|
178
|
+
const { uri } = encodeBIP321({ ark: "ark1pwh9vsmezqqpjy9akejayl2vvcse6he97rn40g84xrlvrlnhayuuyefrp9nse2yspqqjl5wpy" });
|
|
179
|
+
|
|
180
|
+
// Parse Ark payment
|
|
181
|
+
const result = parseBIP321(uri);
|
|
158
182
|
|
|
159
183
|
console.log(result.paymentMethods[0].type); // "ark"
|
|
160
184
|
console.log(result.paymentMethods[0].network); // "mainnet"
|
|
161
185
|
|
|
162
186
|
// Testnet Ark address
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
);
|
|
187
|
+
const { uri: testnetUri } = encodeBIP321({ ark: "tark1pm6sr0fpzqqpnzzwxf209kju4qavs4gtumxk30yv2u5ncrvtp72z34axcvrydtdqpqq5838km" });
|
|
188
|
+
const testnetResult = parseBIP321(testnetUri);
|
|
166
189
|
|
|
167
190
|
console.log(testnetResult.paymentMethods[0].network); // "testnet"
|
|
168
191
|
```
|
|
@@ -238,6 +261,51 @@ Parses a BIP-321 URI and returns detailed information about the payment request.
|
|
|
238
261
|
|
|
239
262
|
**Returns:** `BIP321ParseResult` object
|
|
240
263
|
|
|
264
|
+
### `encodeBIP321(params: BIP321EncodeParams): BIP321EncodeResult`
|
|
265
|
+
|
|
266
|
+
Encodes payment parameters into a BIP-321 URI string. Validates the generated URI before returning.
|
|
267
|
+
|
|
268
|
+
**Parameters:**
|
|
269
|
+
- `params` - The `BIP321EncodeParams` object containing payment details
|
|
270
|
+
|
|
271
|
+
**Returns:** `BIP321EncodeResult` object
|
|
272
|
+
|
|
273
|
+
**Throws:** `Error` if the generated URI is invalid or contains no valid payment methods
|
|
274
|
+
|
|
275
|
+
### `BIP321EncodeParams` Interface
|
|
276
|
+
|
|
277
|
+
```typescript
|
|
278
|
+
interface BIP321EncodeParams {
|
|
279
|
+
address?: string; // Main Bitcoin address
|
|
280
|
+
amount?: number; // Amount in BTC
|
|
281
|
+
label?: string; // Label for the recipient
|
|
282
|
+
message?: string; // Message describing the transaction
|
|
283
|
+
lightning?: string | string[]; // BOLT11 Lightning invoice(s)
|
|
284
|
+
lno?: string | string[]; // BOLT12 offer(s)
|
|
285
|
+
sp?: string | string[]; // Silent Payment address(es)
|
|
286
|
+
ark?: string | string[]; // Ark address(es)
|
|
287
|
+
bc?: string | string[]; // Mainnet address(es)
|
|
288
|
+
tb?: string | string[]; // Testnet address(es)
|
|
289
|
+
bcrt?: string | string[]; // Regtest address(es)
|
|
290
|
+
tbs?: string | string[]; // Signet address(es)
|
|
291
|
+
pop?: string; // Proof of payment callback URI
|
|
292
|
+
reqPop?: string; // Required proof of payment callback URI
|
|
293
|
+
optionalParams?: Record<string, string | string[]>; // Additional optional parameters
|
|
294
|
+
}
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
**Note:** `pop` and `reqPop` are mutually exclusive - only one can be provided.
|
|
298
|
+
|
|
299
|
+
### `BIP321EncodeResult` Interface
|
|
300
|
+
|
|
301
|
+
```typescript
|
|
302
|
+
interface BIP321EncodeResult extends BIP321ParseResult {
|
|
303
|
+
uri: string; // The encoded BIP-321 URI string
|
|
304
|
+
}
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
The result includes all fields from `BIP321ParseResult` plus the generated `uri` string.
|
|
308
|
+
|
|
241
309
|
### Validation Functions
|
|
242
310
|
|
|
243
311
|
The library exports individual validation functions for each payment method type:
|
package/bun.lock
CHANGED
package/index.test.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
getPaymentMethodsByNetwork,
|
|
5
5
|
getValidPaymentMethods,
|
|
6
6
|
formatPaymentMethodsSummary,
|
|
7
|
+
encodeBIP321,
|
|
7
8
|
} from "./index";
|
|
8
9
|
|
|
9
10
|
const TEST_DATA = {
|
|
@@ -599,3 +600,367 @@ describe("BIP-321 Parser", () => {
|
|
|
599
600
|
});
|
|
600
601
|
});
|
|
601
602
|
});
|
|
603
|
+
|
|
604
|
+
describe("BIP-321 Encoder", () => {
|
|
605
|
+
describe("Basic Encoding", () => {
|
|
606
|
+
test("encodes simple address", () => {
|
|
607
|
+
const result = encodeBIP321({ address: TEST_DATA.addresses.mainnet.p2pkh });
|
|
608
|
+
expect(result.valid).toBe(true);
|
|
609
|
+
expect(result.uri).toBe(`bitcoin:${TEST_DATA.addresses.mainnet.p2pkh}`);
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
test("encodes bech32 address", () => {
|
|
613
|
+
const result = encodeBIP321({ address: TEST_DATA.addresses.mainnet.bech32 });
|
|
614
|
+
expect(result.valid).toBe(true);
|
|
615
|
+
expect(result.uri).toBe(`bitcoin:${TEST_DATA.addresses.mainnet.bech32}`);
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
test("encodes taproot address", () => {
|
|
619
|
+
const result = encodeBIP321({ address: TEST_DATA.addresses.mainnet.taproot });
|
|
620
|
+
expect(result.valid).toBe(true);
|
|
621
|
+
expect(result.uri).toBe(`bitcoin:${TEST_DATA.addresses.mainnet.taproot}`);
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
test("encodes testnet address", () => {
|
|
625
|
+
const result = encodeBIP321({ address: TEST_DATA.addresses.testnet.bech32 });
|
|
626
|
+
expect(result.valid).toBe(true);
|
|
627
|
+
expect(result.network).toBe("testnet");
|
|
628
|
+
expect(result.uri).toBe(`bitcoin:${TEST_DATA.addresses.testnet.bech32}`);
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
test("encodes empty address with query params", () => {
|
|
632
|
+
const result = encodeBIP321({ lightning: TEST_DATA.lightning.mainnet });
|
|
633
|
+
expect(result.valid).toBe(true);
|
|
634
|
+
expect(result.uri).toBe(`bitcoin:?lightning=${TEST_DATA.lightning.mainnet}`);
|
|
635
|
+
});
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
describe("Query Parameters", () => {
|
|
639
|
+
test("encodes label parameter", () => {
|
|
640
|
+
const result = encodeBIP321({ address: TEST_DATA.addresses.mainnet.p2pkh, label: "bip321" });
|
|
641
|
+
expect(result.uri).toBe(`bitcoin:${TEST_DATA.addresses.mainnet.p2pkh}?label=bip321`);
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
test("encodes message parameter", () => {
|
|
645
|
+
const result = encodeBIP321({ address: TEST_DATA.addresses.mainnet.p2pkh, message: "bip321" });
|
|
646
|
+
expect(result.uri).toBe(`bitcoin:${TEST_DATA.addresses.mainnet.p2pkh}?message=bip321`);
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
test("encodes amount parameter", () => {
|
|
650
|
+
const result = encodeBIP321({ address: TEST_DATA.addresses.mainnet.p2pkh, amount: 20.3 });
|
|
651
|
+
expect(result.valid).toBe(true);
|
|
652
|
+
expect(result.amount).toBe(20.3);
|
|
653
|
+
expect(result.uri).toBe(`bitcoin:${TEST_DATA.addresses.mainnet.p2pkh}?amount=20.3`);
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
test("encodes zero amount", () => {
|
|
657
|
+
const result = encodeBIP321({ address: TEST_DATA.addresses.mainnet.p2pkh, amount: 0 });
|
|
658
|
+
expect(result.valid).toBe(true);
|
|
659
|
+
expect(result.amount).toBe(0);
|
|
660
|
+
expect(result.uri).toBe(`bitcoin:${TEST_DATA.addresses.mainnet.p2pkh}?amount=0`)
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
test("encodes multiple parameters", () => {
|
|
664
|
+
const result = encodeBIP321({
|
|
665
|
+
address: TEST_DATA.addresses.mainnet.p2pkh,
|
|
666
|
+
amount: 50,
|
|
667
|
+
label: "Luke-Jr",
|
|
668
|
+
message: "Donation for project xyz",
|
|
669
|
+
});
|
|
670
|
+
expect(result.valid).toBe(true);
|
|
671
|
+
expect(result.amount).toBe(50);
|
|
672
|
+
expect(result.label).toBe("Luke-Jr");
|
|
673
|
+
expect(result.message).toBe("Donation for project xyz");
|
|
674
|
+
expect(result.uri).toBe(`bitcoin:${TEST_DATA.addresses.mainnet.p2pkh}?amount=50&label=Luke-Jr&message=Donation%20for%20project%20xyz`);
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
test("encodes special characters in label", () => {
|
|
678
|
+
const result = encodeBIP321({ address: TEST_DATA.addresses.mainnet.p2pkh, label: "Test & Label" });
|
|
679
|
+
expect(result.valid).toBe(true);
|
|
680
|
+
expect(result.label).toBe("Test & Label");
|
|
681
|
+
expect(result.uri).toBe(`bitcoin:${TEST_DATA.addresses.mainnet.p2pkh}?label=Test%20%26%20Label`);
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
test("encodes special characters in message", () => {
|
|
685
|
+
const result = encodeBIP321({ address: TEST_DATA.addresses.mainnet.p2pkh, message: "Donation for project xyz" });
|
|
686
|
+
expect(result.valid).toBe(true);
|
|
687
|
+
expect(result.message).toBe("Donation for project xyz");
|
|
688
|
+
expect(result.uri).toBe(`bitcoin:${TEST_DATA.addresses.mainnet.p2pkh}?message=Donation%20for%20project%20xyz`);
|
|
689
|
+
});
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
describe("Lightning Invoice", () => {
|
|
693
|
+
test("encodes with single lightning invoice", () => {
|
|
694
|
+
const result = encodeBIP321({
|
|
695
|
+
address: TEST_DATA.addresses.mainnet.p2pkh,
|
|
696
|
+
lightning: TEST_DATA.lightning.mainnet,
|
|
697
|
+
});
|
|
698
|
+
expect(result.valid).toBe(true);
|
|
699
|
+
expect(result.paymentMethods.some((pm) => pm.type === "lightning")).toBe(true);
|
|
700
|
+
expect(result.uri).toBe(`bitcoin:${TEST_DATA.addresses.mainnet.p2pkh}?lightning=${TEST_DATA.lightning.mainnet}`);
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
test("encodes with multiple lightning invoices", () => {
|
|
704
|
+
const result = encodeBIP321({
|
|
705
|
+
lightning: [TEST_DATA.lightning.mainnet, TEST_DATA.lightning.mainnet],
|
|
706
|
+
});
|
|
707
|
+
expect(result.valid).toBe(true);
|
|
708
|
+
expect(result.paymentMethods.filter((pm) => pm.type === "lightning").length).toBe(2);
|
|
709
|
+
expect(result.uri).toBe(`bitcoin:?lightning=${TEST_DATA.lightning.mainnet}&lightning=${TEST_DATA.lightning.mainnet}`);
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
test("encodes lightning without address", () => {
|
|
713
|
+
const result = encodeBIP321({ lightning: TEST_DATA.lightning.mainnet });
|
|
714
|
+
expect(result.valid).toBe(true);
|
|
715
|
+
expect(result.uri).toBe(`bitcoin:?lightning=${TEST_DATA.lightning.mainnet}`);
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
test("encodes testnet lightning invoice", () => {
|
|
719
|
+
const result = encodeBIP321({ lightning: TEST_DATA.lightning.testnet });
|
|
720
|
+
expect(result.valid).toBe(true);
|
|
721
|
+
expect(result.paymentMethods[0]!.network).toBe("testnet");
|
|
722
|
+
expect(result.uri).toBe(`bitcoin:?lightning=${TEST_DATA.lightning.testnet}`);
|
|
723
|
+
});
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
describe("Alternative Payment Methods", () => {
|
|
727
|
+
test("encodes silent payment address", () => {
|
|
728
|
+
const result = encodeBIP321({ sp: TEST_DATA.silentPayment.mainnet });
|
|
729
|
+
expect(result.valid).toBe(true);
|
|
730
|
+
expect(result.paymentMethods[0]!.type).toBe("silent-payment");
|
|
731
|
+
expect(result.paymentMethods[0]!.network).toBe("mainnet");
|
|
732
|
+
expect(result.uri).toBe(`bitcoin:?sp=${TEST_DATA.silentPayment.mainnet}`);
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
test("encodes testnet silent payment address", () => {
|
|
736
|
+
const result = encodeBIP321({ sp: TEST_DATA.silentPayment.testnet });
|
|
737
|
+
expect(result.valid).toBe(true);
|
|
738
|
+
expect(result.paymentMethods[0]!.network).toBe("testnet");
|
|
739
|
+
expect(result.uri).toBe(`bitcoin:?sp=${TEST_DATA.silentPayment.testnet}`);
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
test("encodes multiple silent payment addresses", () => {
|
|
743
|
+
const result = encodeBIP321({
|
|
744
|
+
sp: [TEST_DATA.silentPayment.mainnet, TEST_DATA.silentPayment.mainnet],
|
|
745
|
+
});
|
|
746
|
+
expect(result.valid).toBe(true);
|
|
747
|
+
expect(result.paymentMethods.filter((pm) => pm.type === "silent-payment").length).toBe(2);
|
|
748
|
+
expect(result.uri).toBe(`bitcoin:?sp=${TEST_DATA.silentPayment.mainnet}&sp=${TEST_DATA.silentPayment.mainnet}`);
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
test("encodes Ark address", () => {
|
|
752
|
+
const result = encodeBIP321({ ark: TEST_DATA.ark.mainnet });
|
|
753
|
+
expect(result.valid).toBe(true);
|
|
754
|
+
expect(result.paymentMethods[0]!.type).toBe("ark");
|
|
755
|
+
expect(result.paymentMethods[0]!.network).toBe("mainnet");
|
|
756
|
+
expect(result.uri).toBe(`bitcoin:?ark=${TEST_DATA.ark.mainnet}`);
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
test("encodes testnet Ark address", () => {
|
|
760
|
+
const result = encodeBIP321({ ark: TEST_DATA.ark.testnet });
|
|
761
|
+
expect(result.valid).toBe(true);
|
|
762
|
+
expect(result.paymentMethods[0]!.network).toBe("testnet");
|
|
763
|
+
expect(result.uri).toBe(`bitcoin:?ark=${TEST_DATA.ark.testnet}`);
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
test("encodes BOLT12 offer", () => {
|
|
767
|
+
const result = encodeBIP321({ lno: "lno1qqqq02k20d" });
|
|
768
|
+
expect(result.valid).toBe(true);
|
|
769
|
+
expect(result.paymentMethods[0]!.type).toBe("offer");
|
|
770
|
+
expect(result.uri).toBe("bitcoin:?lno=lno1qqqq02k20d");
|
|
771
|
+
});
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
describe("Network-specific Addresses", () => {
|
|
775
|
+
test("encodes bc parameter", () => {
|
|
776
|
+
const result = encodeBIP321({ bc: TEST_DATA.addresses.mainnet.bech32 });
|
|
777
|
+
expect(result.valid).toBe(true);
|
|
778
|
+
expect(result.paymentMethods[0]!.network).toBe("mainnet");
|
|
779
|
+
expect(result.uri).toBe(`bitcoin:?bc=${TEST_DATA.addresses.mainnet.bech32}`);
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
test("encodes tb parameter", () => {
|
|
783
|
+
const result = encodeBIP321({ tb: TEST_DATA.addresses.testnet.bech32 });
|
|
784
|
+
expect(result.valid).toBe(true);
|
|
785
|
+
expect(result.paymentMethods[0]!.network).toBe("testnet");
|
|
786
|
+
expect(result.uri).toBe(`bitcoin:?tb=${TEST_DATA.addresses.testnet.bech32}`);
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
test("encodes bcrt parameter", () => {
|
|
790
|
+
const result = encodeBIP321({ bcrt: TEST_DATA.addresses.regtest.bech32 });
|
|
791
|
+
expect(result.valid).toBe(true);
|
|
792
|
+
expect(result.paymentMethods[0]!.network).toBe("regtest");
|
|
793
|
+
expect(result.uri).toBe(`bitcoin:?bcrt=${TEST_DATA.addresses.regtest.bech32}`);
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
test("encodes multiple bc addresses", () => {
|
|
797
|
+
const result = encodeBIP321({
|
|
798
|
+
bc: [TEST_DATA.addresses.mainnet.bech32, TEST_DATA.addresses.mainnet.taproot],
|
|
799
|
+
});
|
|
800
|
+
expect(result.valid).toBe(true);
|
|
801
|
+
expect(result.paymentMethods.length).toBe(2);
|
|
802
|
+
expect(result.uri).toBe(`bitcoin:?bc=${TEST_DATA.addresses.mainnet.bech32}&bc=${TEST_DATA.addresses.mainnet.taproot}`);
|
|
803
|
+
});
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
describe("Proof of Payment", () => {
|
|
807
|
+
test("encodes pop parameter", () => {
|
|
808
|
+
const result = encodeBIP321({
|
|
809
|
+
address: TEST_DATA.addresses.mainnet.p2pkh,
|
|
810
|
+
pop: "customapp:",
|
|
811
|
+
});
|
|
812
|
+
expect(result.valid).toBe(true);
|
|
813
|
+
expect(result.pop).toBeDefined();
|
|
814
|
+
expect(result.popRequired).toBe(false);
|
|
815
|
+
expect(result.uri).toBe(`bitcoin:${TEST_DATA.addresses.mainnet.p2pkh}?pop=customapp%3A`);
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
test("encodes req-pop parameter", () => {
|
|
819
|
+
const result = encodeBIP321({
|
|
820
|
+
address: TEST_DATA.addresses.mainnet.p2pkh,
|
|
821
|
+
reqPop: "customapp:",
|
|
822
|
+
});
|
|
823
|
+
expect(result.valid).toBe(true);
|
|
824
|
+
expect(result.pop).toBeDefined();
|
|
825
|
+
expect(result.popRequired).toBe(true);
|
|
826
|
+
expect(result.uri).toBe(`bitcoin:${TEST_DATA.addresses.mainnet.p2pkh}?req-pop=customapp%3A`);
|
|
827
|
+
});
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
describe("Optional Parameters", () => {
|
|
831
|
+
test("encodes custom optional parameters", () => {
|
|
832
|
+
const result = encodeBIP321({
|
|
833
|
+
address: TEST_DATA.addresses.mainnet.p2pkh,
|
|
834
|
+
optionalParams: { custom: "value" },
|
|
835
|
+
});
|
|
836
|
+
expect(result.valid).toBe(true);
|
|
837
|
+
expect(result.optionalParams.custom).toEqual(["value"]);
|
|
838
|
+
expect(result.uri).toBe(`bitcoin:${TEST_DATA.addresses.mainnet.p2pkh}?custom=value`);
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
test("encodes multiple custom optional parameters", () => {
|
|
842
|
+
const result = encodeBIP321({
|
|
843
|
+
address: TEST_DATA.addresses.mainnet.p2pkh,
|
|
844
|
+
optionalParams: { foo: "bar", baz: ["one", "two"] },
|
|
845
|
+
});
|
|
846
|
+
expect(result.valid).toBe(true);
|
|
847
|
+
expect(result.optionalParams.foo).toEqual(["bar"]);
|
|
848
|
+
expect(result.optionalParams.baz).toEqual(["one", "two"]);
|
|
849
|
+
expect(result.uri).toBe(`bitcoin:${TEST_DATA.addresses.mainnet.p2pkh}?foo=bar&baz=one&baz=two`);
|
|
850
|
+
});
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
describe("Combined Payment Methods", () => {
|
|
854
|
+
test("encodes address with lightning and silent payment", () => {
|
|
855
|
+
const result = encodeBIP321({
|
|
856
|
+
address: TEST_DATA.addresses.mainnet.p2pkh,
|
|
857
|
+
lightning: TEST_DATA.lightning.mainnet,
|
|
858
|
+
sp: TEST_DATA.silentPayment.mainnet,
|
|
859
|
+
});
|
|
860
|
+
expect(result.valid).toBe(true);
|
|
861
|
+
expect(result.paymentMethods.length).toBe(3);
|
|
862
|
+
expect(result.paymentMethods.some((pm) => pm.type === "onchain")).toBe(true);
|
|
863
|
+
expect(result.paymentMethods.some((pm) => pm.type === "lightning")).toBe(true);
|
|
864
|
+
expect(result.paymentMethods.some((pm) => pm.type === "silent-payment")).toBe(true);
|
|
865
|
+
expect(result.uri).toBe(`bitcoin:${TEST_DATA.addresses.mainnet.p2pkh}?lightning=${TEST_DATA.lightning.mainnet}&sp=${TEST_DATA.silentPayment.mainnet}`);
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
test("encodes all parameters together", () => {
|
|
869
|
+
const result = encodeBIP321({
|
|
870
|
+
address: TEST_DATA.addresses.mainnet.p2pkh,
|
|
871
|
+
amount: 0.5,
|
|
872
|
+
label: "Test",
|
|
873
|
+
message: "Payment",
|
|
874
|
+
lightning: TEST_DATA.lightning.mainnet,
|
|
875
|
+
sp: TEST_DATA.silentPayment.mainnet,
|
|
876
|
+
ark: TEST_DATA.ark.mainnet,
|
|
877
|
+
});
|
|
878
|
+
expect(result.valid).toBe(true);
|
|
879
|
+
expect(result.amount).toBe(0.5);
|
|
880
|
+
expect(result.label).toBe("Test");
|
|
881
|
+
expect(result.message).toBe("Payment");
|
|
882
|
+
expect(result.paymentMethods.length).toBe(4);
|
|
883
|
+
expect(result.uri).toBe(`bitcoin:${TEST_DATA.addresses.mainnet.p2pkh}?amount=0.5&label=Test&message=Payment&lightning=${TEST_DATA.lightning.mainnet}&sp=${TEST_DATA.silentPayment.mainnet}&ark=${TEST_DATA.ark.mainnet}`);
|
|
884
|
+
});
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
describe("Invalid Data", () => {
|
|
888
|
+
test("throws on invalid address", () => {
|
|
889
|
+
expect(() => encodeBIP321({ address: "invalid_bitcoin_address" })).toThrow();
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
test("throws on negative amount", () => {
|
|
893
|
+
expect(() => encodeBIP321({
|
|
894
|
+
address: TEST_DATA.addresses.mainnet.p2pkh,
|
|
895
|
+
amount: -1,
|
|
896
|
+
})).toThrow("Invalid amount format");
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
test("throws on NaN amount", () => {
|
|
900
|
+
expect(() => encodeBIP321({
|
|
901
|
+
address: TEST_DATA.addresses.mainnet.p2pkh,
|
|
902
|
+
amount: NaN,
|
|
903
|
+
})).toThrow("Invalid amount format");
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
test("throws on Infinity amount", () => {
|
|
907
|
+
expect(() => encodeBIP321({
|
|
908
|
+
address: TEST_DATA.addresses.mainnet.p2pkh,
|
|
909
|
+
amount: Infinity,
|
|
910
|
+
})).toThrow("Invalid amount format");
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
test("throws on invalid lightning invoice", () => {
|
|
914
|
+
expect(() => encodeBIP321({ lightning: "invalid_invoice" })).toThrow(/lightning/i);
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
test("throws on invalid silent payment address", () => {
|
|
918
|
+
expect(() => encodeBIP321({ sp: "sp1invalid" })).toThrow(/silent payment/i);
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
test("throws on invalid Ark address", () => {
|
|
922
|
+
expect(() => encodeBIP321({ ark: "ark1invalid" })).toThrow(/Ark/i);
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
test("throws on forbidden pop scheme", () => {
|
|
926
|
+
expect(() => encodeBIP321({
|
|
927
|
+
address: TEST_DATA.addresses.mainnet.p2pkh,
|
|
928
|
+
reqPop: "https://example.com",
|
|
929
|
+
})).toThrow(/Forbidden pop scheme/i);
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
test("throws on network mismatch in bc parameter", () => {
|
|
933
|
+
expect(() => encodeBIP321({ bc: TEST_DATA.addresses.testnet.bech32 })).toThrow(/network mismatch/i);
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
test("throws on empty params with no payment method", () => {
|
|
937
|
+
expect(() => encodeBIP321({ label: "test" })).toThrow("No valid payment methods found");
|
|
938
|
+
});
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
describe("Round-trip Encoding", () => {
|
|
942
|
+
test("encoded URI can be parsed back", () => {
|
|
943
|
+
const params = {
|
|
944
|
+
address: TEST_DATA.addresses.mainnet.p2pkh,
|
|
945
|
+
amount: 1.5,
|
|
946
|
+
label: "Test Label",
|
|
947
|
+
message: "Test Message",
|
|
948
|
+
};
|
|
949
|
+
const encoded = encodeBIP321(params);
|
|
950
|
+
expect(encoded.valid).toBe(true);
|
|
951
|
+
expect(encoded.address).toBe(params.address);
|
|
952
|
+
expect(encoded.amount).toBe(params.amount);
|
|
953
|
+
expect(encoded.label).toBe(params.label);
|
|
954
|
+
expect(encoded.message).toBe(params.message);
|
|
955
|
+
expect(encoded.uri).toBe(`bitcoin:${TEST_DATA.addresses.mainnet.p2pkh}?amount=1.5&label=Test%20Label&message=Test%20Message`);
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
test("encoded lightning URI can be parsed back", () => {
|
|
959
|
+
const encoded = encodeBIP321({ lightning: TEST_DATA.lightning.mainnet });
|
|
960
|
+
expect(encoded.valid).toBe(true);
|
|
961
|
+
expect(encoded.paymentMethods[0]!.type).toBe("lightning");
|
|
962
|
+
expect(encoded.paymentMethods[0]!.value).toBe(TEST_DATA.lightning.mainnet);
|
|
963
|
+
expect(encoded.uri).toBe(`bitcoin:?lightning=${TEST_DATA.lightning.mainnet}`);
|
|
964
|
+
});
|
|
965
|
+
});
|
|
966
|
+
});
|
package/index.ts
CHANGED
|
@@ -17,17 +17,19 @@ export {
|
|
|
17
17
|
validatePopUri,
|
|
18
18
|
};
|
|
19
19
|
|
|
20
|
+
export type Network = "mainnet" | "testnet" | "regtest" | "signet";
|
|
21
|
+
|
|
20
22
|
export interface PaymentMethod {
|
|
21
23
|
type: "onchain" | "lightning" | "offer" | "silent-payment" | "ark";
|
|
22
24
|
value: string;
|
|
23
|
-
network?:
|
|
25
|
+
network?: Network;
|
|
24
26
|
valid: boolean;
|
|
25
27
|
error?: string;
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
export interface BIP321ParseResult {
|
|
29
31
|
address?: string;
|
|
30
|
-
network?:
|
|
32
|
+
network?: Network;
|
|
31
33
|
amount?: number;
|
|
32
34
|
label?: string;
|
|
33
35
|
message?: string;
|
|
@@ -42,7 +44,7 @@ export interface BIP321ParseResult {
|
|
|
42
44
|
|
|
43
45
|
export function parseBIP321(
|
|
44
46
|
uri: string,
|
|
45
|
-
expectedNetwork?:
|
|
47
|
+
expectedNetwork?: Network,
|
|
46
48
|
): BIP321ParseResult {
|
|
47
49
|
const result: BIP321ParseResult = {
|
|
48
50
|
paymentMethods: [],
|
|
@@ -314,6 +316,90 @@ export function parseBIP321(
|
|
|
314
316
|
return result;
|
|
315
317
|
}
|
|
316
318
|
|
|
319
|
+
interface BIP321EncodeParamsBase {
|
|
320
|
+
address?: string;
|
|
321
|
+
amount?: number;
|
|
322
|
+
label?: string;
|
|
323
|
+
message?: string;
|
|
324
|
+
lightning?: string | string[];
|
|
325
|
+
lno?: string | string[];
|
|
326
|
+
sp?: string | string[];
|
|
327
|
+
ark?: string | string[];
|
|
328
|
+
bc?: string | string[];
|
|
329
|
+
tb?: string | string[];
|
|
330
|
+
bcrt?: string | string[];
|
|
331
|
+
tbs?: string | string[];
|
|
332
|
+
optionalParams?: Record<string, string | string[]>;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Make pop and reqPop mutually exclusive using discriminated union
|
|
336
|
+
export type BIP321EncodeParams = BIP321EncodeParamsBase &
|
|
337
|
+
(
|
|
338
|
+
| { pop?: string; reqPop?: never }
|
|
339
|
+
| { pop?: never; reqPop?: string }
|
|
340
|
+
| { pop?: never; reqPop?: never }
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
export type BIP321EncodeResult = BIP321ParseResult & { uri: string }
|
|
344
|
+
|
|
345
|
+
export function encodeBIP321(params: BIP321EncodeParams): BIP321EncodeResult {
|
|
346
|
+
const searchParams = new URLSearchParams();
|
|
347
|
+
|
|
348
|
+
const append = (key: string, value: string | string[] | undefined) => {
|
|
349
|
+
if (value === undefined) return;
|
|
350
|
+
const values = Array.isArray(value) ? value : [value];
|
|
351
|
+
for (const v of values) {
|
|
352
|
+
searchParams.append(key, v);
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
if (params.amount !== undefined) {
|
|
357
|
+
if (Number.isNaN(params.amount) || !Number.isFinite(params.amount) || params.amount < 0) {
|
|
358
|
+
throw new Error("Invalid amount format");
|
|
359
|
+
}
|
|
360
|
+
searchParams.append("amount", params.amount.toString());
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
append("label", params.label);
|
|
364
|
+
append("message", params.message);
|
|
365
|
+
|
|
366
|
+
if (params.pop !== undefined) {
|
|
367
|
+
searchParams.append("pop", params.pop);
|
|
368
|
+
} else if (params.reqPop !== undefined) {
|
|
369
|
+
searchParams.append("req-pop", params.reqPop);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
append("lightning", params.lightning);
|
|
373
|
+
append("lno", params.lno);
|
|
374
|
+
append("sp", params.sp);
|
|
375
|
+
append("ark", params.ark);
|
|
376
|
+
|
|
377
|
+
append("bc", params.bc);
|
|
378
|
+
append("tb", params.tb);
|
|
379
|
+
append("bcrt", params.bcrt);
|
|
380
|
+
append("tbs", params.tbs);
|
|
381
|
+
|
|
382
|
+
if (params.optionalParams) {
|
|
383
|
+
for (const [key, value] of Object.entries(params.optionalParams)) {
|
|
384
|
+
append(key, value);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const address = params.address ?? "";
|
|
389
|
+
|
|
390
|
+
// URLSearchParams encodes spaces as '+', but BIP-321 expects percent encoding
|
|
391
|
+
const query = searchParams.toString().replace(/\+/g, "%20");
|
|
392
|
+
const uri = `bitcoin:${address}${query ? `?${query}` : ""}`;
|
|
393
|
+
const parsed = parseBIP321(uri);
|
|
394
|
+
|
|
395
|
+
const hasValidPaymentMethod = parsed.paymentMethods.some((pm) => pm.valid);
|
|
396
|
+
if (!parsed.valid || !hasValidPaymentMethod) {
|
|
397
|
+
throw new Error(parsed.errors.join("; ") || "No valid payment methods");
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return { ...parsed, uri };
|
|
401
|
+
}
|
|
402
|
+
|
|
317
403
|
export function getPaymentMethodsByNetwork(
|
|
318
404
|
result: BIP321ParseResult,
|
|
319
405
|
): Record<string, PaymentMethod[]> {
|
package/package.json
CHANGED