bip-321 0.0.7 → 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 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
- const result = parseBIP321(
144
- "bitcoin:?lightning=lnbc15u1p3xnhl2pp5jptserfk3zk4qy42tlucycrfwxhydvlemu9pqr93tuzlv9cc7g3s..."
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
- // Mainnet Ark address
155
- const result = parseBIP321(
156
- "bitcoin:?ark=ark1pwh9vsmezqqpjy9akejayl2vvcse6he97rn40g84xrlvrlnhayuuyefrp9nse2yspqqjl5wpy"
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 testnetResult = parseBIP321(
164
- "bitcoin:?ark=tark1pm6sr0fpzqqpnzzwxf209kju4qavs4gtumxk30yv2u5ncrvtp72z34axcvrydtdqpqq5838km"
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
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "lockfileVersion": 1,
3
+ "configVersion": 0,
3
4
  "workspaces": {
4
5
  "": {
5
6
  "name": "bip-321",
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?: "mainnet" | "testnet" | "regtest" | "signet";
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?: "mainnet" | "testnet" | "regtest" | "signet";
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?: "mainnet" | "testnet" | "regtest" | "signet",
47
+ expectedNetwork?: Network,
46
48
  ): BIP321ParseResult {
47
49
  const result: BIP321ParseResult = {
48
50
  paymentMethods: [],
@@ -232,7 +234,12 @@ export function parseBIP321(
232
234
  const decodedValue = decodeURIComponent(value);
233
235
  const validation = validateBitcoinAddress(decodedValue);
234
236
  const networkMatches =
235
- validation.valid && validation.network === expectedNetwork;
237
+ validation.valid &&
238
+ (validation.network === expectedNetwork ||
239
+ // Testnet and signet are interchangeable for onchain
240
+ (validation.network === "testnet" &&
241
+ expectedNetwork === "signet") ||
242
+ (validation.network === "signet" && expectedNetwork === "testnet"));
236
243
 
237
244
  result.paymentMethods.push({
238
245
  type: "onchain",
@@ -274,12 +281,16 @@ export function parseBIP321(
274
281
  for (const method of result.paymentMethods) {
275
282
  if (method.network && method.network !== expectedNetwork) {
276
283
  // For Ark and Silent Payments, testnet covers testnet/signet/regtest
284
+ // For onchain, testnet and signet are interchangeable
277
285
  const isTestnetCompatible =
278
- (method.type === "ark" || method.type === "silent-payment") &&
279
- method.network === "testnet" &&
280
- (expectedNetwork === "testnet" ||
281
- expectedNetwork === "signet" ||
282
- expectedNetwork === "regtest");
286
+ ((method.type === "ark" || method.type === "silent-payment") &&
287
+ method.network === "testnet" &&
288
+ (expectedNetwork === "testnet" ||
289
+ expectedNetwork === "signet" ||
290
+ expectedNetwork === "regtest")) ||
291
+ (method.type === "onchain" &&
292
+ ((method.network === "testnet" && expectedNetwork === "signet") ||
293
+ (method.network === "signet" && expectedNetwork === "testnet")));
283
294
 
284
295
  if (!isTestnetCompatible) {
285
296
  result.errors.push(
@@ -305,6 +316,90 @@ export function parseBIP321(
305
316
  return result;
306
317
  }
307
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
+
308
403
  export function getPaymentMethodsByNetwork(
309
404
  result: BIP321ParseResult,
310
405
  ): Record<string, PaymentMethod[]> {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bip-321",
3
- "version": "0.0.7",
3
+ "version": "0.0.9",
4
4
  "description": "A TypeScript/JavaScript library for parsing BIP-321 Bitcoin URI scheme with support for multiple payment methods",
5
5
  "type": "module",
6
6
  "main": "./index.ts",