@provex/utils 1.9.2 → 1.10.0-rc.20260912192040.fdbb8fc

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.
@@ -0,0 +1,173 @@
1
+ /**
2
+ * @fileoverview Parsing, keystroke handling and plausibility checks for
3
+ * maker-entered conversion rates.
4
+ *
5
+ * ## Why this module exists
6
+ *
7
+ * A conversion rate is typed as text and stored on chain as an 18-decimal
8
+ * bigint. Ten production deposits were created with rates inflated by exactly
9
+ * `10 ** (decimal places typed)` — `1.029` became `1029`, `1.15` became `115`,
10
+ * `1.2` became `12` — because the deposit-creation form held the rate in
11
+ * component state as a bigint and re-derived the displayed text from it on
12
+ * every keystroke. The half-finished string `"1."` parses to the same bigint as
13
+ * `"1"`, so the formatter rendered it back as `"1"` and the decimal separator
14
+ * the maker had just typed was erased. Every later digit then landed in the
15
+ * whole-number part.
16
+ *
17
+ * Three deposits are still live on PulseChain priced a thousand times above
18
+ * market, which no buyer will ever fill.
19
+ *
20
+ * ## The rule this module enforces
21
+ *
22
+ * The maker's raw text is the single source of truth while editing, and it is
23
+ * converted to a bigint exactly once, at submit. Nothing re-derives the text
24
+ * from the number. {@link nextRateInputValue} is the whole of the editing
25
+ * transition, and it is a pure string-to-string function precisely so that
26
+ * "type it one character at a time" and "paste it in one go" cannot diverge —
27
+ * see the fuzz tests in `rateInput.test.ts`, which assert that equivalence over
28
+ * generated input.
29
+ *
30
+ * ## Separator policy
31
+ *
32
+ * Digits and a single separator are accepted, and **every separator is a
33
+ * decimal separator**. A comma is normalised to a period, so a maker on a
34
+ * locale that writes `0,978` and one that writes `0.978` both get 0.978.
35
+ * Group separators are deliberately *not* accepted: a rate never needs
36
+ * grouping to be typed, and accepting it reintroduces the exact ambiguity that
37
+ * makes `1,029` unreadable — one thousand and twenty-nine, or one point oh two
38
+ * nine? Rejecting is safe because it is loud; guessing is not.
39
+ *
40
+ * @see {@link parseRateInput} for the text-to-bigint conversion
41
+ * @see {@link nextRateInputValue} for the editing transition
42
+ */
43
+ import { parseUnits as viemParseUnits } from 'viem';
44
+ /**
45
+ * Decimal places a conversion rate carries on chain. Rates are always
46
+ * 18-decimal fixed point regardless of the fiat currency's own precision — the
47
+ * currency's `decimals` field describes how the rate is *displayed*, never how
48
+ * it is stored.
49
+ */
50
+ export const RATE_DECIMALS = 18;
51
+ /** Digits plus the two accepted decimal separators, and nothing else. */
52
+ const ACCEPTED_CHARACTERS = /^[0-9.,]*$/;
53
+ /** Matches every decimal separator, in either locale convention. */
54
+ const SEPARATORS = /[.,]/g;
55
+ /**
56
+ * Count decimal separators in a candidate string, treating period and comma
57
+ * alike.
58
+ *
59
+ * @param candidate - Text to inspect
60
+ * @returns Number of separator characters present
61
+ */
62
+ const countSeparators = (candidate) => (candidate.match(SEPARATORS) ?? []).length;
63
+ /**
64
+ * Normalise a candidate rate string, or reject it.
65
+ *
66
+ * Accepts a partially-typed value — `''`, `'1'`, `'1.'` and `'1.0'` are all
67
+ * legal states on the way to `'1.02'` — because this runs on every keystroke.
68
+ * Completeness is checked at submit by {@link parseRateInput}, not here.
69
+ *
70
+ * @param candidate - Text the input element now holds
71
+ * @returns The candidate with any comma rewritten to a period, or `null` if it
72
+ * is not a legal partial rate
73
+ *
74
+ * @example
75
+ * ```ts
76
+ * normalizeRateInput('1,029') // '1.029' — comma is a decimal separator
77
+ * normalizeRateInput('1.') // '1.' — legal mid-typing
78
+ * normalizeRateInput('1.2.3') // null — ambiguous
79
+ * normalizeRateInput('1e3') // null — not a digit or separator
80
+ * ```
81
+ */
82
+ export const normalizeRateInput = (candidate) => {
83
+ if (candidate === '')
84
+ return '';
85
+ if (!ACCEPTED_CHARACTERS.test(candidate))
86
+ return null;
87
+ if (countSeparators(candidate) > 1)
88
+ return null;
89
+ return candidate.replace(',', '.');
90
+ };
91
+ /**
92
+ * Next value of the rate field, given the text the input element now holds.
93
+ *
94
+ * This is the entire editing transition. It is pure and string-to-string: the
95
+ * value is never routed through a bigint and back, which is what erased the
96
+ * decimal separator and inflated ten production deposits. An illegal candidate
97
+ * leaves the field on its previous value, which is how a keystroke is refused.
98
+ *
99
+ * @param options - Current field value and the candidate replacing it
100
+ * @returns The value the field should now hold
101
+ *
102
+ * @example
103
+ * ```ts
104
+ * nextRateInputValue({ current: '1', candidate: '1,' }) // '1.'
105
+ * nextRateInputValue({ current: '1.2', candidate: '1.2.' }) // '1.2' — refused
106
+ * ```
107
+ */
108
+ export const nextRateInputValue = ({ current, candidate }) => {
109
+ const normalized = normalizeRateInput(candidate);
110
+ return normalized === null ? current : normalized;
111
+ };
112
+ /**
113
+ * Convert maker-entered rate text into an 18-decimal bigint.
114
+ *
115
+ * Call this once, at submit. Calling it on every keystroke and rendering the
116
+ * result back into the field is the defect this module exists to prevent.
117
+ *
118
+ * @param candidate - Raw text the maker entered
119
+ * @returns Parsed rate, or the reason it could not be parsed
120
+ *
121
+ * @example
122
+ * ```ts
123
+ * parseRateInput('0.978') // { status: 'parsed', value: 978000000000000000n, ... }
124
+ * parseRateInput('0,978') // identical — comma is a decimal separator
125
+ * parseRateInput('978') // { status: 'parsed', value: 978000000000000000000n, ... }
126
+ * parseRateInput('') // { status: 'failed', reason: 'empty' }
127
+ * ```
128
+ */
129
+ export const parseRateInput = (candidate) => {
130
+ const trimmed = candidate.trim();
131
+ if (trimmed === '')
132
+ return { status: 'failed', reason: 'empty' };
133
+ if (!ACCEPTED_CHARACTERS.test(trimmed))
134
+ return { status: 'failed', reason: 'unsupportedCharacter' };
135
+ if (countSeparators(trimmed) > 1)
136
+ return { status: 'failed', reason: 'multipleDecimalSeparators' };
137
+ const [wholePart = '', fractionalPart = ''] = trimmed.replace(',', '.').split('.');
138
+ if (wholePart === '' && fractionalPart === '')
139
+ return { status: 'failed', reason: 'missingDigits' };
140
+ if (fractionalPart.length > RATE_DECIMALS)
141
+ return { status: 'failed', reason: 'tooManyDecimalPlaces' };
142
+ const canonicalText = `${wholePart === '' ? '0' : wholePart}.${fractionalPart === '' ? '0' : fractionalPart}`;
143
+ const value = viemParseUnits(canonicalText, RATE_DECIMALS);
144
+ if (value <= 0n)
145
+ return { status: 'failed', reason: 'notPositive' };
146
+ return { status: 'parsed', value, canonicalText };
147
+ };
148
+ /**
149
+ * Rates at or above this many fiat units per token are held back for the maker
150
+ * to confirm.
151
+ *
152
+ * This is a deliberately crude backstop, not a market check. It exists because
153
+ * every rate corrupted in production landed far above it (12, 115, 978, 1020,
154
+ * 1029, 1099) while every legitimate rate observed on chain sat near 1 — the
155
+ * highest genuine one being roughly 18, for Mexican pesos. A handful of
156
+ * currencies do trade in the thousands per dollar, so this asks rather than
157
+ * refuses; the maker can confirm and proceed.
158
+ */
159
+ export const RATE_CONFIRMATION_THRESHOLD = 100n * 10n ** BigInt(RATE_DECIMALS);
160
+ /**
161
+ * Whether a parsed rate is large enough that the maker should confirm it before
162
+ * the deposit is created.
163
+ *
164
+ * @param options - The parsed rate, as an 18-decimal bigint
165
+ * @returns `true` when the rate warrants an explicit confirmation
166
+ *
167
+ * @example
168
+ * ```ts
169
+ * needsRateConfirmation({ value: parseUnits('1.029', 18) }) // false
170
+ * needsRateConfirmation({ value: parseUnits('1029', 18) }) // true
171
+ * ```
172
+ */
173
+ export const needsRateConfirmation = ({ value }) => value >= RATE_CONFIRMATION_THRESHOLD;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,318 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { parseUnits } from 'viem';
4
+ import { RATE_DECIMALS, RATE_CONFIRMATION_THRESHOLD, normalizeRateInput, nextRateInputValue, parseRateInput, needsRateConfirmation, } from './rateInput.js';
5
+ /**
6
+ * Replay a string into the rate field one character at a time, exactly as the
7
+ * input element does: each keystroke offers the whole field's new text as the
8
+ * candidate.
9
+ *
10
+ * The defect this module exists to prevent lived here and nowhere else — the
11
+ * old form re-derived the field's text from a bigint on every keystroke, so
12
+ * typing `0.978` left-to-right produced 978 while pasting the same string
13
+ * produced 0.978. Every property below that compares these two functions is
14
+ * guarding that specific divergence.
15
+ */
16
+ const typeSequentially = (text) => {
17
+ let current = '';
18
+ for (const character of text) {
19
+ current = nextRateInputValue({ current, candidate: current + character });
20
+ }
21
+ return current;
22
+ };
23
+ /** Paste the whole string in a single change, the way a clipboard drop lands. */
24
+ const pasteWhole = (text) => nextRateInputValue({ current: '', candidate: text });
25
+ /**
26
+ * Deterministic pseudo-random generator so a fuzz failure is reproducible from
27
+ * the seed printed in the assertion message rather than vanishing on re-run.
28
+ */
29
+ const createRandom = (seed) => {
30
+ let state = seed >>> 0;
31
+ return () => {
32
+ state = (state + 0x6d2b79f5) >>> 0;
33
+ let drawn = Math.imul(state ^ (state >>> 15), 1 | state);
34
+ drawn = (drawn + Math.imul(drawn ^ (drawn >>> 7), 61 | drawn)) ^ drawn;
35
+ return ((drawn ^ (drawn >>> 14)) >>> 0) / 4294967296;
36
+ };
37
+ };
38
+ const pickInteger = (random, lowest, highest) => lowest + Math.floor(random() * (highest - lowest + 1));
39
+ const buildDigits = (random, count) => Array.from({ length: count }, () => String(pickInteger(random, 0, 9))).join('');
40
+ /** A legal rate: digits, optionally one separator, at most 18 decimal places. */
41
+ const buildLegalRate = (random) => {
42
+ const wholePart = buildDigits(random, pickInteger(random, 0, 6));
43
+ const fractionalPart = buildDigits(random, pickInteger(random, 0, RATE_DECIMALS));
44
+ const separator = random() < 0.5 ? '.' : ',';
45
+ const hasSeparator = fractionalPart.length > 0 || random() < 0.3;
46
+ // A bare separator carries no digits and is rejected by design; give it one.
47
+ const safeWholePart = wholePart === '' && fractionalPart === '' ? '1' : wholePart;
48
+ const text = hasSeparator
49
+ ? `${safeWholePart}${separator}${fractionalPart}`
50
+ : safeWholePart;
51
+ return { text, wholePart: safeWholePart, fractionalPart: hasSeparator ? fractionalPart : '', separator };
52
+ };
53
+ const FUZZ_ITERATIONS = 2000;
54
+ describe('parseRateInput — production regressions', () => {
55
+ // The four rates still live or recently live on PulseChain, each the result
56
+ // of a decimal separator being eaten mid-typing. The left column is what the
57
+ // maker meant; the right is what reached the escrow.
58
+ const corrupted = [
59
+ ['0.978', '978'],
60
+ ['1.029', '1029'],
61
+ ['1.020', '1020'],
62
+ ['1.099', '1099'],
63
+ ['1.15', '115'],
64
+ ['1.2', '12'],
65
+ ];
66
+ for (const [intended, corruptedText] of corrupted) {
67
+ it(`typing "${intended}" yields ${intended}, never ${corruptedText}`, () => {
68
+ const typed = typeSequentially(intended);
69
+ const result = parseRateInput(typed);
70
+ assert.equal(result.status, 'parsed');
71
+ assert.ok(result.status === 'parsed');
72
+ assert.equal(result.value, parseUnits(intended, RATE_DECIMALS), `typing "${intended}" must produce ${intended}`);
73
+ assert.notEqual(result.value, parseUnits(corruptedText, RATE_DECIMALS), `typing "${intended}" must never produce ${corruptedText} — this is the bug that ` +
74
+ 'priced three PulseChain deposits a thousand times above market');
75
+ });
76
+ }
77
+ it('reaches the same value whether the maker types or pastes', () => {
78
+ for (const [intended] of corrupted) {
79
+ assert.equal(typeSequentially(intended), pasteWhole(intended), `"${intended}" diverged between typing and pasting`);
80
+ }
81
+ });
82
+ it('treats a comma decimal separator as the maker intends', () => {
83
+ // A maker on a euro-zone locale types 0,978 and means 0.978.
84
+ assert.equal(typeSequentially('0,978'), '0.978');
85
+ const result = parseRateInput('0,978');
86
+ assert.ok(result.status === 'parsed');
87
+ assert.equal(result.value, parseUnits('0.978', RATE_DECIMALS));
88
+ });
89
+ });
90
+ describe('parseRateInput — accepted values', () => {
91
+ it('parses a whole number', () => {
92
+ const result = parseRateInput('978');
93
+ assert.ok(result.status === 'parsed');
94
+ assert.equal(result.value, parseUnits('978', RATE_DECIMALS));
95
+ });
96
+ it('parses a value with no whole part', () => {
97
+ const result = parseRateInput('.5');
98
+ assert.ok(result.status === 'parsed');
99
+ assert.equal(result.value, parseUnits('0.5', RATE_DECIMALS));
100
+ });
101
+ it('parses a value with a trailing separator', () => {
102
+ const result = parseRateInput('1.');
103
+ assert.ok(result.status === 'parsed');
104
+ assert.equal(result.value, parseUnits('1', RATE_DECIMALS));
105
+ });
106
+ it('preserves full 18-decimal precision', () => {
107
+ const text = `0.${'1'.repeat(RATE_DECIMALS)}`;
108
+ const result = parseRateInput(text);
109
+ assert.ok(result.status === 'parsed');
110
+ assert.equal(result.value, parseUnits(text, RATE_DECIMALS));
111
+ });
112
+ it('ignores surrounding whitespace', () => {
113
+ const result = parseRateInput(' 1.02 ');
114
+ assert.ok(result.status === 'parsed');
115
+ assert.equal(result.value, parseUnits('1.02', RATE_DECIMALS));
116
+ });
117
+ });
118
+ describe('parseRateInput — rejected values', () => {
119
+ const rejections = [
120
+ ['', 'empty'],
121
+ [' ', 'empty'],
122
+ ['1e3', 'unsupportedCharacter'],
123
+ ['-1.5', 'unsupportedCharacter'],
124
+ ['1 000', 'unsupportedCharacter'],
125
+ ['abc', 'unsupportedCharacter'],
126
+ // Group separators are refused rather than guessed at: "1,234.56" cannot be
127
+ // told apart from a locale writing one point two three four.
128
+ ['1,234.56', 'multipleDecimalSeparators'],
129
+ ['1.2.3', 'multipleDecimalSeparators'],
130
+ ['1,029,000', 'multipleDecimalSeparators'],
131
+ ['.', 'missingDigits'],
132
+ [',', 'missingDigits'],
133
+ [`0.${'1'.repeat(RATE_DECIMALS + 1)}`, 'tooManyDecimalPlaces'],
134
+ ['0', 'notPositive'],
135
+ ['0.0', 'notPositive'],
136
+ ['0.000', 'notPositive'],
137
+ ];
138
+ for (const [text, reason] of rejections) {
139
+ it(`rejects ${JSON.stringify(text)} as ${reason}`, () => {
140
+ const result = parseRateInput(text);
141
+ assert.ok(result.status === 'failed', `expected ${JSON.stringify(text)} to be refused`);
142
+ assert.equal(result.reason, reason);
143
+ });
144
+ }
145
+ it('refuses a zero rate, which no buyer could ever fill', () => {
146
+ // Guards intent, not shape: a deposit priced at zero is not "free", it is
147
+ // unfillable, so it must never reach the escrow.
148
+ const result = parseRateInput('0');
149
+ assert.ok(result.status === 'failed');
150
+ assert.equal(result.reason, 'notPositive');
151
+ });
152
+ });
153
+ describe('fuzz — the separator is never eaten', () => {
154
+ it('parsed value always equals the digits scaled by the decimal places typed', () => {
155
+ const random = createRandom(0x51ee7);
156
+ for (let iteration = 0; iteration < FUZZ_ITERATIONS; iteration += 1) {
157
+ const { text, wholePart, fractionalPart } = buildLegalRate(random);
158
+ const result = parseRateInput(text);
159
+ if (result.status !== 'parsed')
160
+ continue;
161
+ // The whole point: digits are placed by how many decimals were typed. If a
162
+ // separator is ever swallowed, this lands 10**fractionalPart.length high.
163
+ const digits = `${wholePart}${fractionalPart}`;
164
+ const expected = BigInt(digits === '' ? '0' : digits)
165
+ * 10n ** BigInt(RATE_DECIMALS - fractionalPart.length);
166
+ assert.equal(result.value, expected, `iteration ${iteration}: ${JSON.stringify(text)} parsed to ${result.value}, expected ${expected}`);
167
+ }
168
+ });
169
+ it('never inflates a fractional rate to its digits-only reading', () => {
170
+ const random = createRandom(0xea7e2);
171
+ for (let iteration = 0; iteration < FUZZ_ITERATIONS; iteration += 1) {
172
+ const { text, wholePart, fractionalPart } = buildLegalRate(random);
173
+ if (fractionalPart.length === 0)
174
+ continue;
175
+ const result = parseRateInput(text);
176
+ if (result.status !== 'parsed')
177
+ continue;
178
+ const digitsOnly = BigInt(`${wholePart}${fractionalPart}` || '0') * 10n ** BigInt(RATE_DECIMALS);
179
+ assert.notEqual(result.value, digitsOnly, `iteration ${iteration}: ${JSON.stringify(text)} was inflated to its digits-only reading`);
180
+ assert.ok(result.value < digitsOnly, `iteration ${iteration}: ${JSON.stringify(text)} must be smaller than its digits-only reading`);
181
+ }
182
+ });
183
+ });
184
+ describe('fuzz — typing equals pasting', () => {
185
+ it('holds for generated legal rates', () => {
186
+ const random = createRandom(0x7ab1e);
187
+ for (let iteration = 0; iteration < FUZZ_ITERATIONS; iteration += 1) {
188
+ const { text } = buildLegalRate(random);
189
+ assert.equal(typeSequentially(text), pasteWhole(text), `iteration ${iteration}: ${JSON.stringify(text)} diverged between typing and pasting`);
190
+ }
191
+ });
192
+ it('holds for the parsed value, not just the displayed text', () => {
193
+ const random = createRandom(0xba5ed);
194
+ for (let iteration = 0; iteration < FUZZ_ITERATIONS; iteration += 1) {
195
+ const { text } = buildLegalRate(random);
196
+ const typed = parseRateInput(typeSequentially(text));
197
+ const pasted = parseRateInput(pasteWhole(text));
198
+ assert.deepEqual(typed, pasted, `iteration ${iteration}: ${JSON.stringify(text)} parsed differently when typed than when pasted`);
199
+ }
200
+ });
201
+ it('holds when a comma is retyped as a period', () => {
202
+ const random = createRandom(0xc0ffee);
203
+ for (let iteration = 0; iteration < FUZZ_ITERATIONS; iteration += 1) {
204
+ const { text } = buildLegalRate(random);
205
+ const withComma = text.replace('.', ',');
206
+ const withPeriod = text.replace(',', '.');
207
+ assert.deepEqual(parseRateInput(withComma), parseRateInput(withPeriod), `iteration ${iteration}: ${JSON.stringify(withComma)} and ${JSON.stringify(withPeriod)} disagreed`);
208
+ }
209
+ });
210
+ });
211
+ describe('fuzz — totality and idempotence over arbitrary input', () => {
212
+ const CHARACTER_POOL = '0123456789.,eE+- \t\u0660\u06F5\u4E07\u200B/*abcXYZ%$\u20AC\u00A3';
213
+ const buildJunk = (random) => Array.from({ length: pickInteger(random, 0, 24) }, () => CHARACTER_POOL[pickInteger(random, 0, CHARACTER_POOL.length - 1)]).join('');
214
+ it('parseRateInput never throws, whatever it is handed', () => {
215
+ const random = createRandom(0x1a5cf);
216
+ for (let iteration = 0; iteration < FUZZ_ITERATIONS; iteration += 1) {
217
+ const junk = buildJunk(random);
218
+ assert.doesNotThrow(() => parseRateInput(junk), `iteration ${iteration}: parseRateInput threw on ${JSON.stringify(junk)}`);
219
+ }
220
+ });
221
+ it('nextRateInputValue always leaves the field holding a legal partial value', () => {
222
+ const random = createRandom(0x1e9a1);
223
+ for (let iteration = 0; iteration < FUZZ_ITERATIONS; iteration += 1) {
224
+ const junk = buildJunk(random);
225
+ let current = '';
226
+ for (const character of junk) {
227
+ current = nextRateInputValue({ current, candidate: current + character });
228
+ assert.equal(normalizeRateInput(current), current, `iteration ${iteration}: field held illegal value ${JSON.stringify(current)}`);
229
+ }
230
+ }
231
+ });
232
+ it('normalizeRateInput is idempotent', () => {
233
+ const random = createRandom(0x1d3a9);
234
+ for (let iteration = 0; iteration < FUZZ_ITERATIONS; iteration += 1) {
235
+ const once = normalizeRateInput(buildJunk(random));
236
+ if (once === null)
237
+ continue;
238
+ assert.equal(normalizeRateInput(once), once, `iteration ${iteration}: normalising ${JSON.stringify(once)} twice changed it`);
239
+ }
240
+ });
241
+ it('canonical text re-parses to the identical value', () => {
242
+ const random = createRandom(0xca90a);
243
+ for (let iteration = 0; iteration < FUZZ_ITERATIONS; iteration += 1) {
244
+ const { text } = buildLegalRate(random);
245
+ const first = parseRateInput(text);
246
+ if (first.status !== 'parsed')
247
+ continue;
248
+ const second = parseRateInput(first.canonicalText);
249
+ assert.ok(second.status === 'parsed');
250
+ assert.equal(second.value, first.value, `iteration ${iteration}: ${JSON.stringify(text)} did not survive a round trip through its canonical form`);
251
+ }
252
+ });
253
+ });
254
+ describe('fuzz — ordering', () => {
255
+ it('appending a decimal digit never changes the whole-number part', () => {
256
+ const random = createRandom(0x0d3e1);
257
+ for (let iteration = 0; iteration < FUZZ_ITERATIONS; iteration += 1) {
258
+ const wholePart = buildDigits(random, pickInteger(random, 1, 4));
259
+ const fractionalPart = buildDigits(random, pickInteger(random, 0, 6));
260
+ const shorter = parseRateInput(`${wholePart}.${fractionalPart}`);
261
+ const longer = parseRateInput(`${wholePart}.${fractionalPart}${pickInteger(random, 0, 9)}`);
262
+ if (shorter.status !== 'parsed' || longer.status !== 'parsed')
263
+ continue;
264
+ const scale = 10n ** BigInt(RATE_DECIMALS);
265
+ assert.equal(longer.value / scale, shorter.value / scale, `iteration ${iteration}: adding a decimal to ${wholePart}.${fractionalPart} moved the whole-number part`);
266
+ }
267
+ });
268
+ it('a longer whole-number part always parses larger', () => {
269
+ const random = createRandom(0x10b9e);
270
+ for (let iteration = 0; iteration < FUZZ_ITERATIONS; iteration += 1) {
271
+ const wholePart = buildDigits(random, pickInteger(random, 1, 4)).replace(/^0+/, '') || '1';
272
+ const shorter = parseRateInput(wholePart);
273
+ const longer = parseRateInput(`${wholePart}0`);
274
+ assert.ok(shorter.status === 'parsed' && longer.status === 'parsed');
275
+ assert.ok(longer.value > shorter.value, `iteration ${iteration}: ${wholePart}0 did not exceed ${wholePart}`);
276
+ }
277
+ });
278
+ });
279
+ describe('needsRateConfirmation', () => {
280
+ it('passes the rates makers legitimately set', () => {
281
+ for (const text of ['0.70', '0.78', '0.895', '0.92', '1.02', '1.029', '18.07']) {
282
+ const result = parseRateInput(text);
283
+ assert.ok(result.status === 'parsed');
284
+ assert.equal(needsRateConfirmation({ value: result.value }), false, `${text} is a rate seen on chain and must not be questioned`);
285
+ }
286
+ });
287
+ it('questions the corrupted rates that a magnitude check can reach', () => {
288
+ for (const text of ['115', '978', '1020', '1029', '1099']) {
289
+ const result = parseRateInput(text);
290
+ assert.ok(result.status === 'parsed');
291
+ assert.equal(needsRateConfirmation({ value: result.value }), true, `${text} corrupted a real deposit and must be questioned`);
292
+ }
293
+ });
294
+ it('cannot reach a corrupted rate that lands below the threshold', () => {
295
+ // Deposit 180 reached the escrow at 12.0 — the maker meant 1.2 and lost one
296
+ // decimal place rather than three. No magnitude threshold can catch this
297
+ // without also questioning genuine rates: Mexican pesos trade near 18, so a
298
+ // threshold low enough to flag 12 would flag real liquidity as suspect.
299
+ //
300
+ // This is why the backstop is not the fix. The parser is: 1.2 can no longer
301
+ // become 12, so this case is prevented rather than merely questioned.
302
+ const result = parseRateInput('12');
303
+ assert.ok(result.status === 'parsed');
304
+ assert.equal(needsRateConfirmation({ value: result.value }), false);
305
+ const mexicanPesoRate = parseRateInput('18.07');
306
+ assert.ok(mexicanPesoRate.status === 'parsed');
307
+ assert.equal(needsRateConfirmation({ value: mexicanPesoRate.value }), false);
308
+ // The real guarantee: typing 1.2 produces 1.2, so 12 never arises.
309
+ assert.equal(typeSequentially('1.2'), '1.2');
310
+ const typed = parseRateInput(typeSequentially('1.2'));
311
+ assert.ok(typed.status === 'parsed');
312
+ assert.equal(typed.value, parseUnits('1.2', RATE_DECIMALS));
313
+ });
314
+ it('asks rather than refuses at the threshold boundary', () => {
315
+ assert.equal(needsRateConfirmation({ value: RATE_CONFIRMATION_THRESHOLD }), true);
316
+ assert.equal(needsRateConfirmation({ value: RATE_CONFIRMATION_THRESHOLD - 1n }), false);
317
+ });
318
+ });