-
Notifications
You must be signed in to change notification settings - Fork 7
/
split.ts
206 lines (167 loc) · 6.31 KB
/
split.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
/**
* (c) Copyright 2024 by Coinkite Inc. This file is in the public domain.
*
* Splitting of data and encoding as BBQr QR codes.
*/
import { FILETYPES, HEADER_LEN } from './consts';
import { FileType, SplitOptions, SplitResult, Version } from './types';
import {
base64ToBytes,
encodeData,
fileToBytes,
hexToBytes,
intToBase36,
looksLikePsbt,
validateSplitOptions,
versionToChars,
} from './utils';
function numQRNeeded(version: Version, length: number, splitMod: number) {
const baseCap = versionToChars(version) - HEADER_LEN;
// adjust capacity to be a multiple of splitMod
const adjustedCap = baseCap - (baseCap % splitMod);
const estimatedCount = Math.ceil(length / adjustedCap);
if (estimatedCount === 1) {
// if it fits in one QR, we're done
return { count: 1, perEach: length };
}
// the total capacity of our estimated count
// all but the last QR need to use adjusted capacity to ensure proper split
const estimatedCap = (estimatedCount - 1) * adjustedCap + baseCap;
return {
count: estimatedCap >= length ? estimatedCount : estimatedCount + 1,
perEach: adjustedCap,
};
}
function findBestVersion(length: number, splitMod: number, opts: Required<SplitOptions>) {
const options: { version: Version; count: number; perEach: number }[] = [];
for (let version = opts.minVersion; version <= opts.maxVersion; version++) {
const { count, perEach } = numQRNeeded(version, length, splitMod);
if (opts.minSplit <= count && count <= opts.maxSplit) {
options.push({ version, count, perEach });
}
}
if (!options.length) {
throw new Error('Cannot make it fit');
}
// pick smallest number of QR, lowest version
options.sort((a, b) => a.count - b.count || a.version - b.version);
return options[0];
}
/**
* Converts the input bytes into a series of QR codes, ensuring that the most efficient QR code
* version is used.
*
* NOTE: When the 'Z' (Zlib) encoding is selected, it is possible that the actual used encoding
* will be '2' (Base32) in case Zlib compression does not reduce the size of the output.
*
* @param raw The input bytes to split and encode.
* @param fileType The file type to use. Refer to BBQr spec.
*
* @param opts An optional SplitOptions object.
* @param opts.encoding The Encoding to use. Defaults to 'Z'.
* @param opts.minSplit The minimum number of QR codes to use. Defaults to 1.
* @param opts.maxSplit The maximum number of QR codes to use. Defaults to 1295.
* @param opts.minVersion The minimum QR code version to use. Defaults to 5.
* @param opts.maxVersion The maximum QR code version to use. Defaults to 40.
*
* @returns An object containing the version of the QR codes, their string parts, and the actual encoding used.
*/
export function splitQRs(
raw: Uint8Array,
fileType: FileType,
opts: SplitOptions = {}
): SplitResult {
if (!FILETYPES.has(fileType)) {
throw new Error(`Invalid value for fileType: ${fileType}`);
}
const validatedOpts = validateSplitOptions(opts);
const { encoding: actualEncoding, encoded, splitMod } = encodeData(raw, validatedOpts.encoding);
const { version, count, perEach } = findBestVersion(encoded.length, splitMod, validatedOpts);
const parts: string[] = [];
for (let n = 0, offset = 0; offset < encoded.length; n++, offset += perEach) {
parts.push(
`B$${actualEncoding}${fileType}` +
intToBase36(count) +
intToBase36(n) +
encoded.slice(offset, offset + perEach)
);
}
return { version, parts, encoding: actualEncoding };
}
/**
* Takes a given given input (Uint8Array, File, or string) and detects its FileType.
* PSBTs and Bitcoin transactions are supported in raw binary, Base64, or hex format.
*
* @param input - The input to detect the FileType of.
* @returns A Promise that resolves to an object containing the FileType and raw data.
*/
export async function detectFileType(
input: File | Uint8Array | string
): Promise<{ fileType: FileType; raw: Uint8Array }> {
// keep references to both raw and decoded versions of the input to run checks on
let raw: Uint8Array | undefined = undefined;
let decoded: string | undefined = undefined;
if (input instanceof File) {
// convert a File to Uint8Array so we have access to the raw bytes
input = await fileToBytes(input);
}
if (input instanceof Uint8Array) {
// we got binary, see if we recognize it
raw = input;
if (looksLikePsbt(input)) {
console.debug('Detected type "P" from binary input');
return { fileType: 'P', raw };
}
if (raw[0] === 0x01 || raw[0] === 0x02) {
console.debug('Detected type "T" from binary input');
return { fileType: 'T', raw };
}
// otherwise, try to decode as text (could be contents of a file)
try {
decoded = new TextDecoder('utf-8', { fatal: true }).decode(raw);
} catch (err) {
// not text, so fall back to generic binary
console.debug('Detected type "B" from binary input');
return { fileType: 'B', raw };
}
} else if (typeof input === 'string') {
decoded = input;
} else {
throw new Error('Invalid input - must be a File, Uint8Array or string');
}
const trimmed = decoded.trim();
if (/^70736274ff[0-9A-Fa-f]+$/.test(trimmed)) {
// PSBT in hex format
console.debug('Detected type "P" from hex input');
return { fileType: 'P', raw: hexToBytes(trimmed) };
}
if (/^0[1,2]000000[0-9A-Fa-f]+$/.test(trimmed)) {
// Transaction in hex format
console.debug('Detected type "T" from hex input');
return { fileType: 'T', raw: hexToBytes(trimmed) };
}
if (/^[A-Za-z0-9+/=]+$/.test(trimmed)) {
// looks like base64 - could be PSBT or transaction
const bytes = base64ToBytes(decoded);
if (looksLikePsbt(bytes)) {
console.debug('Detected type "P" from base64 input');
return { fileType: 'P', raw: bytes };
}
if (bytes[0] === 0x01 || bytes[0] === 0x02) {
console.debug('Detected type "T" from base64 input');
return { fileType: 'T', raw: bytes };
}
}
// ensure we have raw bytes for the next step
raw = raw ?? new TextEncoder().encode(decoded);
try {
JSON.parse(decoded);
console.debug('Detected type "J"');
return { fileType: 'J', raw };
} catch (err) {
// not JSON - fall back to generic Unicode
console.debug('Detected type "U"');
return { fileType: 'U', raw };
}
}
// EOF