@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.
- package/dist/chain.d.ts +13 -0
- package/dist/chain.js +13 -0
- package/dist/contracts.config.test.d.ts +18 -0
- package/dist/contracts.config.test.js +220 -0
- package/dist/contracts.d.ts +169 -29
- package/dist/contracts.js +188 -31
- package/dist/contracts.test.js +134 -7
- package/dist/deposit.d.ts +50 -0
- package/dist/deposit.js +45 -0
- package/dist/events.d.ts +2 -2
- package/dist/events.js +20 -0
- package/dist/payment.currency.test.d.ts +11 -0
- package/dist/payment.currency.test.js +134 -0
- package/dist/payment.d.ts +149 -23
- package/dist/payment.js +170 -29
- package/dist/payment.rails.test.d.ts +25 -0
- package/dist/payment.rails.test.js +213 -0
- package/dist/payment.test.js +73 -51
- package/dist/rateInput.d.ts +163 -0
- package/dist/rateInput.js +173 -0
- package/dist/rateInput.test.d.ts +1 -0
- package/dist/rateInput.test.js +318 -0
- package/dist/tokens.d.ts +15068 -15037
- package/dist/tokens.js +37 -8
- package/dist/tokens.test.js +11 -0
- package/dist/transports.d.ts +86 -8
- package/dist/transports.js +126 -10
- package/dist/transports.test.js +131 -5
- package/dist/units.d.ts +9 -0
- package/dist/units.js +9 -0
- package/package.json +5 -1
|
@@ -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
|
+
});
|