@bcts/uniform-resources 1.0.0-alpha.13 → 1.0.0-alpha.15

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bcts/uniform-resources",
3
- "version": "1.0.0-alpha.13",
3
+ "version": "1.0.0-alpha.15",
4
4
  "type": "module",
5
5
  "description": "Blockchain Commons Uniform Resources (UR) for TypeScript",
6
6
  "license": "BSD-2-Clause-Patent",
@@ -44,7 +44,6 @@
44
44
  ],
45
45
  "scripts": {
46
46
  "build": "tsdown",
47
- "dev": "tsdown --watch",
48
47
  "test": "vitest run",
49
48
  "test:watch": "vitest",
50
49
  "lint": "eslint 'src/**/*.ts' 'tests/**/*.ts'",
@@ -67,18 +66,19 @@
67
66
  "node": ">=18.0.0"
68
67
  },
69
68
  "dependencies": {
70
- "@bcts/dcbor": "^1.0.0-alpha.13"
69
+ "@bcts/crypto": "^1.0.0-alpha.15",
70
+ "@bcts/dcbor": "^1.0.0-alpha.15"
71
71
  },
72
72
  "devDependencies": {
73
73
  "@bcts/eslint": "^0.1.0",
74
74
  "@bcts/tsconfig": "^0.1.0",
75
75
  "@eslint/js": "^9.39.2",
76
- "@types/node": "^25.0.3",
76
+ "@types/node": "^25.0.6",
77
77
  "@types/pako": "^2.0.4",
78
78
  "eslint": "^9.39.2",
79
79
  "prettier": "^3.7.4",
80
80
  "ts-node": "^10.9.2",
81
- "tsdown": "^0.18.3",
81
+ "tsdown": "^0.18.4",
82
82
  "typedoc": "^0.28.15",
83
83
  "typescript": "^5.9.3",
84
84
  "vitest": "^4.0.16"
package/src/fountain.ts CHANGED
@@ -71,6 +71,11 @@ export function xorBytes(a: Uint8Array, b: Uint8Array): Uint8Array {
71
71
  * This uses a seeded Xoshiro256** PRNG to deterministically select fragments,
72
72
  * ensuring encoder and decoder agree without explicit coordination.
73
73
  *
74
+ * The algorithm matches the BC-UR reference implementation:
75
+ * 1. For pure parts (seqNum <= seqLen), return single fragment index
76
+ * 2. For mixed parts, use weighted sampling to choose degree
77
+ * 3. Shuffle all indices and take the first 'degree' indices
78
+ *
74
79
  * @param seqNum - The sequence number (1-based)
75
80
  * @param seqLen - Total number of pure fragments
76
81
  * @param checksum - CRC32 checksum of the message
@@ -86,43 +91,18 @@ export function chooseFragments(seqNum: number, seqLen: number, checksum: number
86
91
  const seed = createSeed(checksum, seqNum);
87
92
  const rng = new Xoshiro256(seed);
88
93
 
89
- // Choose degree (number of fragments to mix)
90
- // Uses a simplified soliton distribution
91
- const degree = chooseDegree(rng, seqLen);
94
+ // Choose degree using weighted sampler (1/k distribution)
95
+ const degree = rng.chooseDegree(seqLen);
92
96
 
93
- // Choose which fragments to include
94
- const indices = new Set<number>();
95
- while (indices.size < degree) {
96
- const index = rng.nextInt(0, seqLen);
97
- indices.add(index);
97
+ // Create array of all indices [0, 1, 2, ..., seqLen-1]
98
+ const allIndices: number[] = [];
99
+ for (let i = 0; i < seqLen; i++) {
100
+ allIndices.push(i);
98
101
  }
99
102
 
100
- return Array.from(indices).sort((a, b) => a - b);
101
- }
102
-
103
- /**
104
- * Chooses the degree (number of fragments to mix) using a simplified
105
- * robust soliton distribution.
106
- *
107
- * This ensures good coverage of fragments for efficient decoding.
108
- */
109
- function chooseDegree(rng: Xoshiro256, seqLen: number): number {
110
- // Use a simplified distribution that tends toward lower degrees
111
- // but can occasionally include more fragments
112
- const r = rng.nextDouble();
113
-
114
- // Probability distribution favoring lower degrees
115
- // Based on robust soliton distribution
116
- if (r < 0.5) {
117
- return 1;
118
- } else if (r < 0.75) {
119
- return 2;
120
- } else if (r < 0.9) {
121
- return Math.min(3, seqLen);
122
- } else {
123
- // Higher degrees are less common but help with convergence
124
- return Math.min(rng.nextInt(4, seqLen + 1), seqLen);
125
- }
103
+ // Shuffle all indices and take the first 'degree' indices
104
+ const shuffled = rng.shuffled(allIndices);
105
+ return shuffled.slice(0, degree);
126
106
  }
127
107
 
128
108
  /**
@@ -264,7 +244,7 @@ export class FountainDecoder {
264
244
  const indices = chooseFragments(part.seqNum, this.seqLen, this.checksum);
265
245
 
266
246
  if (indices.length === 1) {
267
- // Pure fragment
247
+ // Pure fragment (or degree-1 mixed that acts like pure)
268
248
  const index = indices[0];
269
249
  if (!this.pureFragments.has(index)) {
270
250
  this.pureFragments.set(index, part.data);
@@ -1,4 +1,4 @@
1
- import { decodeCbor } from "@bcts/dcbor";
1
+ import { decodeCbor, MajorType, type Cbor } from "@bcts/dcbor";
2
2
  import { InvalidSchemeError, InvalidTypeError, UnexpectedTypeError, URError } from "./error.js";
3
3
  import { UR } from "./ur.js";
4
4
  import { URType } from "./ur-type.js";
@@ -118,27 +118,43 @@ export class MultipartDecoder {
118
118
 
119
119
  /**
120
120
  * Decodes a multipart UR's fountain part data.
121
+ *
122
+ * The multipart body is a CBOR array: [seqNum, seqLen, messageLen, checksum, data]
121
123
  */
122
124
  private _decodeFountainPart(partInfo: MultipartInfo): FountainPart {
123
- // Decode bytewords
124
- const rawData = decodeBytewords(partInfo.encodedData, BytewordsStyle.Minimal);
125
+ // Decode bytewords to get CBOR data
126
+ const cborData = decodeBytewords(partInfo.encodedData, BytewordsStyle.Minimal);
125
127
 
126
- if (rawData.length < 8) {
127
- throw new URError("Invalid multipart data: too short");
128
- }
128
+ // Decode the CBOR array
129
+ const decoded = decodeCbor(cborData);
129
130
 
130
- // Extract metadata
131
- const messageLen =
132
- ((rawData[0] << 24) | (rawData[1] << 16) | (rawData[2] << 8) | rawData[3]) >>> 0;
131
+ // The decoded value should be an array with 5 elements
132
+ if (decoded.type !== MajorType.Array) {
133
+ throw new URError("Invalid multipart data: expected CBOR array");
134
+ }
133
135
 
134
- const checksum =
135
- ((rawData[4] << 24) | (rawData[5] << 16) | (rawData[6] << 8) | rawData[7]) >>> 0;
136
+ const items = decoded.value as Cbor[];
137
+ if (items.length !== 5) {
138
+ throw new URError(`Invalid multipart data: expected 5 elements, got ${items.length}`);
139
+ }
136
140
 
137
- const data = rawData.slice(8);
141
+ // Extract the fields: [seqNum, seqLen, messageLen, checksum, data]
142
+ const seqNum = Number(items[0].value);
143
+ const seqLen = Number(items[1].value);
144
+ const messageLen = Number(items[2].value);
145
+ const checksum = Number(items[3].value);
146
+ const data = items[4].value as Uint8Array;
147
+
148
+ // Verify seqNum and seqLen match the URL path values
149
+ if (seqNum !== partInfo.seqNum || seqLen !== partInfo.seqLen) {
150
+ throw new URError(
151
+ `Multipart metadata mismatch: URL says ${partInfo.seqNum}-${partInfo.seqLen}, CBOR says ${seqNum}-${seqLen}`,
152
+ );
153
+ }
138
154
 
139
155
  return {
140
- seqNum: partInfo.seqNum,
141
- seqLen: partInfo.seqLen,
156
+ seqNum,
157
+ seqLen,
142
158
  messageLen,
143
159
  checksum,
144
160
  data,
@@ -2,6 +2,7 @@ import type { UR } from "./ur.js";
2
2
  import { URError } from "./error.js";
3
3
  import { FountainEncoder, type FountainPart } from "./fountain.js";
4
4
  import { encodeBytewords, BytewordsStyle } from "./utils.js";
5
+ import { cbor } from "@bcts/dcbor";
5
6
 
6
7
  /**
7
8
  * Encodes a UR as multiple parts using fountain codes.
@@ -96,28 +97,14 @@ export class MultipartEncoder {
96
97
  }
97
98
 
98
99
  /**
99
- * Encodes part metadata and data into bytes for bytewords encoding.
100
+ * Encodes part metadata and data as CBOR for bytewords encoding.
101
+ * Format: CBOR array [seqNum, seqLen, messageLen, checksum, data]
100
102
  */
101
103
  private _encodePartData(part: FountainPart): Uint8Array {
102
- // Simple encoding: messageLen (4 bytes) + checksum (4 bytes) + data
103
- const result = new Uint8Array(8 + part.data.length);
104
+ // Create CBOR array with 5 elements: [seqNum, seqLen, messageLen, checksum, data]
105
+ const cborArray = cbor([part.seqNum, part.seqLen, part.messageLen, part.checksum, part.data]);
104
106
 
105
- // Message length (big-endian)
106
- result[0] = (part.messageLen >>> 24) & 0xff;
107
- result[1] = (part.messageLen >>> 16) & 0xff;
108
- result[2] = (part.messageLen >>> 8) & 0xff;
109
- result[3] = part.messageLen & 0xff;
110
-
111
- // Checksum (big-endian)
112
- result[4] = (part.checksum >>> 24) & 0xff;
113
- result[5] = (part.checksum >>> 16) & 0xff;
114
- result[6] = (part.checksum >>> 8) & 0xff;
115
- result[7] = part.checksum & 0xff;
116
-
117
- // Fragment data
118
- result.set(part.data, 8);
119
-
120
- return result;
107
+ return cborArray.toData();
121
108
  }
122
109
 
123
110
  /**
package/src/utils.ts CHANGED
@@ -615,7 +615,7 @@ export function encodeBytemojisIdentifier(data: Uint8Array): string {
615
615
  export enum BytewordsStyle {
616
616
  /** Full 4-letter words separated by spaces */
617
617
  Standard = "standard",
618
- /** Full 4-letter words without separators */
618
+ /** Full 4-letter words separated by hyphens (URI-safe) */
619
619
  Uri = "uri",
620
620
  /** First and last character only (minimal) - used by UR encoding */
621
621
  Minimal = "minimal",
@@ -712,6 +712,7 @@ export function encodeBytewords(
712
712
  case BytewordsStyle.Standard:
713
713
  return words.join(" ");
714
714
  case BytewordsStyle.Uri:
715
+ return words.join("-");
715
716
  case BytewordsStyle.Minimal:
716
717
  return words.join("");
717
718
  }
@@ -741,19 +742,15 @@ export function decodeBytewords(
741
742
  break;
742
743
  }
743
744
  case BytewordsStyle.Uri: {
744
- // 4-character words with no separator
745
- if (lowercased.length % 4 !== 0) {
746
- throw new Error("Invalid URI bytewords length");
747
- }
748
- bytes = [];
749
- for (let i = 0; i < lowercased.length; i += 4) {
750
- const word = lowercased.slice(i, i + 4);
745
+ // 4-character words separated by hyphens
746
+ const words = lowercased.split("-");
747
+ bytes = words.map((word) => {
751
748
  const index = BYTEWORDS_MAP.get(word);
752
749
  if (index === undefined) {
753
750
  throw new Error(`Invalid byteword: ${word}`);
754
751
  }
755
- bytes.push(index);
756
- }
752
+ return index;
753
+ });
757
754
  break;
758
755
  }
759
756
  case BytewordsStyle.Minimal: {
package/src/xoshiro.ts CHANGED
@@ -5,8 +5,11 @@
5
5
  * for deterministic fragment selection in fountain codes.
6
6
  *
7
7
  * Reference: https://prng.di.unimi.it/
8
+ * BC-UR Reference: https://github.com/nicklockwood/fountain-codes
8
9
  */
9
10
 
11
+ import { sha256 } from "@bcts/crypto";
12
+
10
13
  const MAX_UINT64 = BigInt("0xffffffffffffffff");
11
14
 
12
15
  /**
@@ -28,25 +31,33 @@ export class Xoshiro256 {
28
31
  private s: [bigint, bigint, bigint, bigint];
29
32
 
30
33
  /**
31
- * Creates a new Xoshiro256** instance from a seed.
34
+ * Creates a new Xoshiro256** instance from a 32-byte seed.
32
35
  *
33
- * The seed is hashed using SHA-256 to initialize the state.
34
- * For consistent results across encoder/decoder, use the same seed.
36
+ * The seed must be exactly 32 bytes (256 bits). The bytes are interpreted
37
+ * using the BC-UR reference algorithm: each 8-byte chunk is read as
38
+ * big-endian then stored as little-endian for the state.
35
39
  *
36
- * @param seed - The seed bytes (any length)
40
+ * @param seed - The seed bytes (must be exactly 32 bytes)
37
41
  */
38
42
  constructor(seed: Uint8Array) {
39
- // Hash the seed using a simple hash function
40
- // In production, you'd use SHA-256 here
41
- const hash = this.hashSeed(seed);
42
-
43
- // Initialize the 4x64-bit state from the hash
44
- this.s = [
45
- this.bytesToBigInt(hash.slice(0, 8)),
46
- this.bytesToBigInt(hash.slice(8, 16)),
47
- this.bytesToBigInt(hash.slice(16, 24)),
48
- this.bytesToBigInt(hash.slice(24, 32)),
49
- ];
43
+ if (seed.length !== 32) {
44
+ throw new Error(`Seed must be 32 bytes, got ${seed.length}`);
45
+ }
46
+
47
+ // BC-UR reference implementation:
48
+ // For each 8-byte chunk, read as big-endian u64, then convert to little-endian bytes
49
+ // This effectively swaps the byte order within each 8-byte segment
50
+ const s: [bigint, bigint, bigint, bigint] = [0n, 0n, 0n, 0n];
51
+ for (let i = 0; i < 4; i++) {
52
+ // Read 8 bytes as big-endian u64
53
+ let v = 0n;
54
+ for (let n = 0; n < 8; n++) {
55
+ v = (v << 8n) | BigInt(seed[8 * i + n] ?? 0);
56
+ }
57
+ s[i] = v;
58
+ }
59
+
60
+ this.s = s;
50
61
  }
51
62
 
52
63
  /**
@@ -59,47 +70,6 @@ export class Xoshiro256 {
59
70
  return instance;
60
71
  }
61
72
 
62
- /**
63
- * Simple hash function for seeding.
64
- * This is a basic implementation - in production use SHA-256.
65
- */
66
- private hashSeed(seed: Uint8Array): Uint8Array {
67
- // Simple hash expansion using CRC32-like operations
68
- const result = new Uint8Array(32);
69
-
70
- if (seed.length === 0) {
71
- return result;
72
- }
73
-
74
- // Expand seed to 32 bytes using a simple mixing function
75
- for (let i = 0; i < 32; i++) {
76
- let hash = 0;
77
- for (const byte of seed) {
78
- hash = (hash * 31 + byte + i) >>> 0;
79
- }
80
- // Mix the hash further
81
- hash ^= hash >>> 16;
82
- hash = (hash * 0x85ebca6b) >>> 0;
83
- hash ^= hash >>> 13;
84
- hash = (hash * 0xc2b2ae35) >>> 0;
85
- hash ^= hash >>> 16;
86
- result[i] = hash & 0xff;
87
- }
88
-
89
- return result;
90
- }
91
-
92
- /**
93
- * Converts 8 bytes to a 64-bit BigInt (little-endian).
94
- */
95
- private bytesToBigInt(bytes: Uint8Array): bigint {
96
- let result = 0n;
97
- for (let i = 7; i >= 0; i--) {
98
- result = (result << 8n) | BigInt(bytes[i] ?? 0);
99
- }
100
- return result;
101
- }
102
-
103
73
  /**
104
74
  * Generates the next 64-bit random value.
105
75
  */
@@ -121,19 +91,21 @@ export class Xoshiro256 {
121
91
 
122
92
  /**
123
93
  * Generates a random double in [0, 1).
94
+ * Matches BC-UR reference: self.next() as f64 / (u64::MAX as f64 + 1.0)
124
95
  */
125
96
  nextDouble(): number {
126
- // Use the upper 53 bits for double precision
127
97
  const value = this.next();
128
- return Number(value >> 11n) / Number(1n << 53n);
98
+ // u64::MAX as f64 + 1.0 = 18446744073709551616.0
99
+ return Number(value) / 18446744073709551616.0;
129
100
  }
130
101
 
131
102
  /**
132
- * Generates a random integer in [low, high).
103
+ * Generates a random integer in [low, high] (inclusive).
104
+ * Matches BC-UR reference: (self.next_double() * ((high - low + 1) as f64)) as u64 + low
133
105
  */
134
106
  nextInt(low: number, high: number): number {
135
- const range = high - low;
136
- return low + Math.floor(this.nextDouble() * range);
107
+ const range = high - low + 1;
108
+ return Math.floor(this.nextDouble() * range) + low;
137
109
  }
138
110
 
139
111
  /**
@@ -153,28 +125,150 @@ export class Xoshiro256 {
153
125
  }
154
126
  return result;
155
127
  }
128
+
129
+ /**
130
+ * Shuffles items by repeatedly picking random indices.
131
+ * Matches BC-UR reference implementation.
132
+ */
133
+ shuffled<T>(items: T[]): T[] {
134
+ const source = [...items];
135
+ const shuffled: T[] = [];
136
+ while (source.length > 0) {
137
+ const index = this.nextInt(0, source.length - 1);
138
+ const item = source.splice(index, 1)[0];
139
+ if (item !== undefined) {
140
+ shuffled.push(item);
141
+ }
142
+ }
143
+ return shuffled;
144
+ }
145
+
146
+ /**
147
+ * Chooses the degree (number of fragments to mix) using a weighted sampler.
148
+ * Uses the robust soliton distribution with weights [1/1, 1/2, 1/3, ..., 1/n].
149
+ * Matches BC-UR reference implementation.
150
+ */
151
+ chooseDegree(seqLen: number): number {
152
+ // Create weights: [1/1, 1/2, 1/3, ..., 1/seqLen]
153
+ const weights: number[] = [];
154
+ for (let i = 1; i <= seqLen; i++) {
155
+ weights.push(1.0 / i);
156
+ }
157
+
158
+ // Use Vose's alias method for weighted sampling
159
+ const sampler = new WeightedSampler(weights);
160
+ return sampler.next(this) + 1; // 1-indexed degree
161
+ }
156
162
  }
157
163
 
158
164
  /**
159
- * Creates a seed for the Xoshiro PRNG from message checksum and sequence number.
165
+ * Weighted sampler using Vose's alias method.
166
+ * Allows O(1) sampling from a discrete probability distribution.
167
+ */
168
+ class WeightedSampler {
169
+ private readonly aliases: number[];
170
+ private readonly probs: number[];
171
+
172
+ constructor(weights: number[]) {
173
+ const n = weights.length;
174
+ if (n === 0) {
175
+ throw new Error("Weights array cannot be empty");
176
+ }
177
+
178
+ // Normalize weights
179
+ const sum = weights.reduce((a, b) => a + b, 0);
180
+ if (sum <= 0) {
181
+ throw new Error("Weights must sum to a positive value");
182
+ }
183
+
184
+ const normalized = weights.map((w) => (w * n) / sum);
185
+
186
+ // Initialize alias table
187
+ this.aliases = Array.from<number>({ length: n }).fill(0);
188
+ this.probs = Array.from<number>({ length: n }).fill(0);
189
+
190
+ // Partition into small and large
191
+ const small: number[] = [];
192
+ const large: number[] = [];
193
+
194
+ for (let i = n - 1; i >= 0; i--) {
195
+ if (normalized[i] < 1.0) {
196
+ small.push(i);
197
+ } else {
198
+ large.push(i);
199
+ }
200
+ }
201
+
202
+ // Build the alias table
203
+ while (small.length > 0 && large.length > 0) {
204
+ const a = small.pop();
205
+ const g = large.pop();
206
+ if (a === undefined || g === undefined) break;
207
+ this.probs[a] = normalized[a] ?? 0;
208
+ this.aliases[a] = g;
209
+ const normalizedG = normalized[g] ?? 0;
210
+ const normalizedA = normalized[a] ?? 0;
211
+ normalized[g] = normalizedG + normalizedA - 1.0;
212
+ if (normalized[g] !== undefined && normalized[g] < 1.0) {
213
+ small.push(g);
214
+ } else {
215
+ large.push(g);
216
+ }
217
+ }
218
+
219
+ while (large.length > 0) {
220
+ const g = large.pop();
221
+ if (g === undefined) break;
222
+ this.probs[g] = 1.0;
223
+ }
224
+
225
+ while (small.length > 0) {
226
+ const a = small.pop();
227
+ if (a === undefined) break;
228
+ this.probs[a] = 1.0;
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Sample from the distribution.
234
+ */
235
+ next(rng: Xoshiro256): number {
236
+ const r1 = rng.nextDouble();
237
+ const r2 = rng.nextDouble();
238
+ const n = this.probs.length;
239
+ const i = Math.floor(n * r1);
240
+ if (r2 < this.probs[i]) {
241
+ return i;
242
+ } else {
243
+ return this.aliases[i];
244
+ }
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Creates a Xoshiro256 PRNG instance from message checksum and sequence number.
250
+ *
251
+ * This creates an 8-byte seed by concatenating seqNum and checksum (both in
252
+ * big-endian), then hashes it with SHA-256 to get the 32-byte seed for Xoshiro.
160
253
  *
161
- * This ensures that both encoder and decoder produce the same random sequence
162
- * for a given message and part number.
254
+ * This matches the BC-UR reference implementation.
163
255
  */
164
256
  export function createSeed(checksum: number, seqNum: number): Uint8Array {
165
- const seed = new Uint8Array(8);
257
+ // Create 8-byte seed: seqNum (big-endian) || checksum (big-endian)
258
+ const seed8 = new Uint8Array(8);
166
259
 
167
- // Pack checksum (4 bytes, big-endian)
168
- seed[0] = (checksum >>> 24) & 0xff;
169
- seed[1] = (checksum >>> 16) & 0xff;
170
- seed[2] = (checksum >>> 8) & 0xff;
171
- seed[3] = checksum & 0xff;
260
+ // seqNum in big-endian (bytes 0-3)
261
+ seed8[0] = (seqNum >>> 24) & 0xff;
262
+ seed8[1] = (seqNum >>> 16) & 0xff;
263
+ seed8[2] = (seqNum >>> 8) & 0xff;
264
+ seed8[3] = seqNum & 0xff;
172
265
 
173
- // Pack seqNum (4 bytes, big-endian)
174
- seed[4] = (seqNum >>> 24) & 0xff;
175
- seed[5] = (seqNum >>> 16) & 0xff;
176
- seed[6] = (seqNum >>> 8) & 0xff;
177
- seed[7] = seqNum & 0xff;
266
+ // checksum in big-endian (bytes 4-7)
267
+ seed8[4] = (checksum >>> 24) & 0xff;
268
+ seed8[5] = (checksum >>> 16) & 0xff;
269
+ seed8[6] = (checksum >>> 8) & 0xff;
270
+ seed8[7] = checksum & 0xff;
178
271
 
179
- return seed;
272
+ // Hash with SHA-256 to get 32 bytes
273
+ return sha256(seed8);
180
274
  }