diff --git a/src/validators/amountValidator.test.ts b/src/validators/amountValidator.test.ts deleted file mode 100644 index 8af3478..0000000 --- a/src/validators/amountValidator.test.ts +++ /dev/null @@ -1,267 +0,0 @@ -import assert from 'node:assert'; -import * as fc from 'fast-check'; -import { AmountValidator } from './amountValidator.js'; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -const STROOPS_PER_USDC = BigInt(10 ** AmountValidator.USDC_DECIMALS); -const MAX_STROOPS = BigInt(AmountValidator.MAX_AMOUNT) * STROOPS_PER_USDC; - -/** - * Convert a stroop count back to a canonical 7-decimal string. - * This is the inverse of toSmallestUnit and is guaranteed to produce - * an exactly-representable IEEE 754 double (since we derive the string - * from integer arithmetic, not from floating-point). - */ -function stroopsToCanonical(stroops: bigint): string { - const whole = stroops / STROOPS_PER_USDC; - const frac = stroops % STROOPS_PER_USDC; - return `${whole}.${String(frac).padStart(7, '0')}`; -} - -/** - * Arbitrary for valid canonical USDC amounts. - * Generated from stroop integers so the resulting string is always - * exactly representable as a float64 (no precision-loss rejections). - */ -const validStroopsArb = fc.bigInt({ min: 1n, max: MAX_STROOPS }); -const validAmountArb = validStroopsArb.map(stroopsToCanonical); - -// --------------------------------------------------------------------------- -// Unit tests – valid inputs -// --------------------------------------------------------------------------- - -describe('AmountValidator.validateUsdcAmount – valid inputs', () => { - it('accepts a typical amount', () => { - const r = AmountValidator.validateUsdcAmount('100.0000000'); - assert.strictEqual(r.valid, true); - assert.strictEqual(r.normalizedAmount, '100.0000000'); - }); - - it('accepts the smallest non-zero step (1 stroop)', () => { - const r = AmountValidator.validateUsdcAmount('0.0000001'); - assert.strictEqual(r.valid, true); - assert.strictEqual(r.normalizedAmount, '0.0000001'); - }); - - it('accepts the maximum allowed amount', () => { - const r = AmountValidator.validateUsdcAmount('1000000000.0000000'); - assert.strictEqual(r.valid, true); - assert.strictEqual(r.normalizedAmount, '1000000000.0000000'); - }); -}); - -// --------------------------------------------------------------------------- -// Unit tests – invalid inputs -// --------------------------------------------------------------------------- - -describe('AmountValidator.validateUsdcAmount – invalid inputs', () => { - // --- type guard --- - it('rejects non-string input', () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - assert.strictEqual(AmountValidator.validateUsdcAmount(100 as any).valid, false); - }); - - // --- zero / negative --- - it('rejects zero', () => { - const r = AmountValidator.validateUsdcAmount('0.0000000'); - assert.strictEqual(r.valid, false); - assert.strictEqual(r.error, 'Amount must be greater than zero'); - }); - - it('rejects negative amount', () => { - assert.strictEqual(AmountValidator.validateUsdcAmount('-1.0000000').valid, false); - }); - - // --- precision --- - it('rejects too few decimal places', () => { - assert.strictEqual(AmountValidator.validateUsdcAmount('100.00').valid, false); - }); - - it('rejects too many decimal places (8)', () => { - assert.strictEqual(AmountValidator.validateUsdcAmount('100.00000001').valid, false); - }); - - it('rejects no decimal point', () => { - assert.strictEqual(AmountValidator.validateUsdcAmount('100').valid, false); - }); - - // --- scientific notation --- - it('rejects scientific notation variants', () => { - for (const v of ['1e7', '1E7', '1e+7', '1e-7', '5.0e3', '1.0E+7', '1.23e5']) { - assert.strictEqual( - AmountValidator.validateUsdcAmount(v).valid, - false, - `expected invalid for "${v}"` - ); - } - }); - - // --- NaN / Infinity strings --- - it('rejects NaN and Infinity strings', () => { - for (const v of ['NaN', 'Infinity', '-Infinity', 'inf']) { - assert.strictEqual( - AmountValidator.validateUsdcAmount(v).valid, - false, - `expected invalid for "${v}"` - ); - } - }); - - // --- locale / whitespace / special chars --- - it('rejects locale-formatted and whitespace-padded strings', () => { - for (const v of [ - '1,000.0000000', - '1000,0000000', - '1.000,0000000', - '1000.0000000 ', - ' 1000.0000000', - '1_000.0000000', - ]) { - assert.strictEqual( - AmountValidator.validateUsdcAmount(v).valid, - false, - `expected invalid for "${v}"` - ); - } - }); - - it('rejects empty string', () => { - assert.strictEqual(AmountValidator.validateUsdcAmount('').valid, false); - }); - - it('rejects alphabetic input', () => { - assert.strictEqual(AmountValidator.validateUsdcAmount('abc.0000000').valid, false); - }); - - // --- over maximum --- - it('rejects amount exceeding 1 billion USDC', () => { - const r = AmountValidator.validateUsdcAmount('1000000001.0000000'); - assert.strictEqual(r.valid, false); - assert.match(r.error!, /maximum/i); - }); -}); - -// --------------------------------------------------------------------------- -// toSmallestUnit – bigint round-trip -// --------------------------------------------------------------------------- - -describe('AmountValidator.toSmallestUnit', () => { - it('converts 1.0000000 to 10_000_000n', () => { - assert.strictEqual(AmountValidator.toSmallestUnit('1.0000000'), 10_000_000n); - }); - - it('converts 0.0000001 to 1n (1 stroop)', () => { - assert.strictEqual(AmountValidator.toSmallestUnit('0.0000001'), 1n); - }); - - it('converts 100.0000000 to 1_000_000_000n', () => { - assert.strictEqual(AmountValidator.toSmallestUnit('100.0000000'), 1_000_000_000n); - }); - - it('throws on invalid input', () => { - assert.throws(() => AmountValidator.toSmallestUnit('1e7'), /Invalid amount/); - }); - - it('result is always a non-negative bigint', () => { - const stroops = AmountValidator.toSmallestUnit('0.0000001'); - assert.strictEqual(typeof stroops, 'bigint'); - assert.ok(stroops >= 0n); - }); -}); - -// --------------------------------------------------------------------------- -// Property-based tests (fast-check) -// --------------------------------------------------------------------------- - -describe('AmountValidator – property tests', () => { - it('all valid canonical amounts are accepted', () => { - fc.assert( - fc.property(validAmountArb, (amount) => { - return AmountValidator.validateUsdcAmount(amount).valid === true; - }), - { numRuns: 500 } - ); - }); - - it('normalizedAmount always equals the input for valid amounts', () => { - fc.assert( - fc.property(validAmountArb, (amount) => { - const r = AmountValidator.validateUsdcAmount(amount); - return r.normalizedAmount === amount; - }), - { numRuns: 500 } - ); - }); - - it('toSmallestUnit round-trips: stroop → canonical string → stroop', () => { - fc.assert( - fc.property(validStroopsArb, (stroops) => { - const amount = stroopsToCanonical(stroops); - return AmountValidator.toSmallestUnit(amount) === stroops; - }), - { numRuns: 500 } - ); - }); - - it('toSmallestUnit result is always a non-negative bigint', () => { - fc.assert( - fc.property(validAmountArb, (amount) => { - const stroops = AmountValidator.toSmallestUnit(amount); - return typeof stroops === 'bigint' && stroops >= 0n; - }), - { numRuns: 500 } - ); - }); - - it('scientific-notation strings are always rejected', () => { - // Build strings like "123e5", "4.5E+3" from integer mantissa + exponent. - const sciArb = fc - .tuple( - fc.integer({ min: 1, max: 999_999 }), - fc.integer({ min: 1, max: 9 }), - fc.constantFrom('e', 'E'), - fc.constantFrom('', '+', '-') - ) - .map(([mantissa, exp, e, sign]) => `${mantissa}${e}${sign}${exp}`); - - fc.assert( - fc.property(sciArb, (amount) => { - return AmountValidator.validateUsdcAmount(amount).valid === false; - }), - { numRuns: 300 } - ); - }); - - it('strings with more than 7 decimal places are always rejected', () => { - // 8-digit fractional part: pad an integer to 8 digits. - const overPrecisionArb = fc - .tuple( - fc.integer({ min: 0, max: 999 }), - fc.integer({ min: 0, max: 99_999_999 }) - ) - .map(([whole, frac]) => `${whole}.${String(frac).padStart(8, '0')}`); - - fc.assert( - fc.property(overPrecisionArb, (amount) => { - return AmountValidator.validateUsdcAmount(amount).valid === false; - }), - { numRuns: 300 } - ); - }); - - it('whitespace-padded strings are always rejected', () => { - const paddedArb = fc - .tuple(validAmountArb, fc.constantFrom(' ', '\t', '\n'), fc.boolean()) - .map(([amount, ws, prepend]) => (prepend ? `${ws}${amount}` : `${amount}${ws}`)); - - fc.assert( - fc.property(paddedArb, (amount) => { - return AmountValidator.validateUsdcAmount(amount).valid === false; - }), - { numRuns: 200 } - ); - }); -}); diff --git a/src/validators/amountValidator.ts b/src/validators/amountValidator.ts index c643140..9a9f564 100644 --- a/src/validators/amountValidator.ts +++ b/src/validators/amountValidator.ts @@ -1,6 +1,9 @@ +import { logger, getRequestId } from '../logger.js'; + export interface ValidationResult { valid: boolean; error?: string; + code?: string; normalizedAmount?: string; } @@ -18,16 +21,29 @@ export class AmountValidator { private static readonly MAX_STROOPS = BigInt(AmountValidator.MAX_AMOUNT) * BigInt(10 ** AmountValidator.USDC_DECIMALS); + /** + * Validates a USDC amount string and normalizes it. + * Implements boundary validation and returns a standardized error envelope on failure. + * Logs validation failures with correlation IDs for tracing. + * + * @param amount The USDC amount string to validate + * @returns ValidationResult with standard error code or normalized amount + */ static validateUsdcAmount(amount: string): ValidationResult { + const correlationId = getRequestId() ?? 'unknown'; + if (typeof amount !== 'string') { - return { valid: false, error: 'Amount must be a string' }; + logger.warn('[AmountValidator] Validation failed: Amount must be a string', { correlationId, amountType: typeof amount }); + return { valid: false, error: 'Amount must be a string', code: 'INVALID_AMOUNT_TYPE' }; } // Reject scientific notation and any non-canonical form before parsing. if (!this.AMOUNT_PATTERN.test(amount)) { + logger.warn('[AmountValidator] Validation failed: Invalid amount format', { correlationId, provided: amount }); return { valid: false, error: 'Amount must have exactly 7 decimal places (e.g., "100.0000000")', + code: 'INVALID_AMOUNT_FORMAT' }; } @@ -36,13 +52,16 @@ export class AmountValidator { const stroops = BigInt(whole) * BigInt(10 ** this.USDC_DECIMALS) + BigInt(frac); if (stroops <= 0n) { - return { valid: false, error: 'Amount must be greater than zero' }; + logger.warn('[AmountValidator] Validation failed: Amount is zero or negative', { correlationId, provided: amount }); + return { valid: false, error: 'Amount must be greater than zero', code: 'INVALID_AMOUNT_RANGE' }; } if (stroops > this.MAX_STROOPS) { + logger.warn('[AmountValidator] Validation failed: Amount exceeds maximum', { correlationId, provided: amount }); return { valid: false, error: 'Amount exceeds maximum limit of 1,000,000,000 USDC', + code: 'AMOUNT_EXCEEDS_MAXIMUM' }; } @@ -60,6 +79,8 @@ export class AmountValidator { static toSmallestUnit(amount: string): bigint { const result = this.validateUsdcAmount(amount); if (!result.valid || !result.normalizedAmount) { + const correlationId = getRequestId() ?? 'unknown'; + logger.error('[AmountValidator] Fatal validation error during conversion', { correlationId, error: result.error, code: result.code }); throw new Error(`Invalid amount: ${result.error}`); } const [whole, frac] = result.normalizedAmount.split('.'); diff --git a/tests/unit/amountValidator.property.test.ts b/tests/unit/amountValidator.property.test.ts new file mode 100644 index 0000000..eeadfa6 --- /dev/null +++ b/tests/unit/amountValidator.property.test.ts @@ -0,0 +1,399 @@ +/** + * @file amountValidator.property.test.ts + * + * Property-based tests for `AmountValidator` using fast-check. + * + * These tests complement the example-based unit tests in + * `src/validators/amountValidator.test.ts` by verifying *invariants* that + * must hold across the entire input domain rather than at hand-picked + * examples. + * + * Properties tested: + * 1. **Precision** – strings with ≠ 7 decimal digits are always rejected. + * 2. **Sign** – negative and zero amounts are always rejected. + * 3. **Scale** – amounts above the 1 billion USDC cap are rejected. + * 4. **Format** – scientific notation, whitespace, and locale + * separators are never accepted. + * 5. **Round-trip** – stroop → canonical string → stroop is lossless. + * 6. **Validity** – every generated canonical string is accepted. + * + * Configuration: + * - 100 runs per property (default). + * - fast-check's built-in shrinkage surfaces minimal counterexamples + * on failure. + * + * @see {@link ../../src/validators/amountValidator.ts} + */ + +import * as fc from 'fast-check'; +import { AmountValidator } from '../../src/validators/amountValidator.js'; + +// --------------------------------------------------------------------------- +// Constants & helpers +// --------------------------------------------------------------------------- + +/** Number of stroops in 1 USDC (10^7). */ +const STROOPS_PER_USDC = BigInt(10 ** AmountValidator.USDC_DECIMALS); + +/** Maximum stroop value that the validator should accept. */ +const MAX_STROOPS = + BigInt(AmountValidator.MAX_AMOUNT) * STROOPS_PER_USDC; + +/** + * Convert a stroop bigint back to its canonical 7-decimal USDC string. + * Uses pure integer arithmetic—no floating-point precision loss. + * + * @param stroops - A non-negative bigint stroop value. + * @returns A string of the form `".<7-digit-frac>"`. + */ +function stroopsToCanonical(stroops: bigint): string { + const whole = stroops / STROOPS_PER_USDC; + const frac = stroops % STROOPS_PER_USDC; + return `${whole}.${String(frac).padStart(AmountValidator.USDC_DECIMALS, '0')}`; +} + +// --------------------------------------------------------------------------- +// Arbitraries +// --------------------------------------------------------------------------- + +/** + * Arbitrary: valid stroop count ∈ [1, MAX_STROOPS]. + * + * Generating from stroops (instead of from float strings) guarantees + * every output is exactly representable and satisfies the canonical + * 7-decimal format. + */ +const validStroopsArb = fc.bigInt({ min: 1n, max: MAX_STROOPS }); + +/** Arbitrary: valid canonical USDC string derived from a stroop count. */ +const validAmountArb = validStroopsArb.map(stroopsToCanonical); + +/** + * Arbitrary: decimal count that is *not* 7 (range 0–15, excluding 7). + * Used to produce strings with wrong precision. + */ +const wrongDecimalCountArb = fc + .integer({ min: 0, max: 15 }) + .filter((n) => n !== AmountValidator.USDC_DECIMALS); + +/** Default run count – matches the acceptance criteria of 100 runs. */ +const NUM_RUNS = 100; + +// --------------------------------------------------------------------------- +// Properties +// --------------------------------------------------------------------------- + +describe('AmountValidator – property-based tests (fast-check)', () => { + // ----------------------------------------------------------------------- + // 1. Precision + // ----------------------------------------------------------------------- + + describe('Precision', () => { + it('strings with fewer or more than 7 decimal digits are rejected', () => { + // Build `"."` where `frac` has a length ≠ 7. + const wrongPrecisionArb = fc + .tuple( + fc.integer({ min: 0, max: 999_999 }), + wrongDecimalCountArb, + ) + .map(([whole, decimals]) => { + // Produce a fractional part of exactly `decimals` digits. + // For 0 decimals the string has no fractional part but still has + // the dot, ensuring the regex rejects it. + const frac = + decimals === 0 + ? '' + : String(Math.abs(whole) % 10 ** decimals).padStart(decimals, '0'); + return `${Math.abs(whole)}.${frac}`; + }); + + fc.assert( + fc.property(wrongPrecisionArb, (amount) => { + const result = AmountValidator.validateUsdcAmount(amount); + return result.valid === false; + }), + { numRuns: NUM_RUNS }, + ); + }); + + it('strings without a decimal point are rejected', () => { + const noDotArb = fc + .integer({ min: 1, max: 999_999_999 }) + .map(String); + + fc.assert( + fc.property(noDotArb, (amount) => { + return AmountValidator.validateUsdcAmount(amount).valid === false; + }), + { numRuns: NUM_RUNS }, + ); + }); + + it('valid canonical strings always have exactly 7 decimal digits', () => { + fc.assert( + fc.property(validAmountArb, (amount) => { + const result = AmountValidator.validateUsdcAmount(amount); + if (!result.valid || !result.normalizedAmount) return false; + const fracPart = result.normalizedAmount.split('.')[1]; + return fracPart !== undefined && fracPart.length === AmountValidator.USDC_DECIMALS; + }), + { numRuns: NUM_RUNS }, + ); + }); + }); + + // ----------------------------------------------------------------------- + // 2. Sign + // ----------------------------------------------------------------------- + + describe('Sign', () => { + it('negative amounts (prefixed with "-") are always rejected', () => { + // Take a valid amount and prepend a minus sign. + const negativeArb = validAmountArb.map((a) => `-${a}`); + + fc.assert( + fc.property(negativeArb, (amount) => { + return AmountValidator.validateUsdcAmount(amount).valid === false; + }), + { numRuns: NUM_RUNS }, + ); + }); + + it('explicit positive sign ("+") is always rejected', () => { + const plusArb = validAmountArb.map((a) => `+${a}`); + + fc.assert( + fc.property(plusArb, (amount) => { + return AmountValidator.validateUsdcAmount(amount).valid === false; + }), + { numRuns: NUM_RUNS }, + ); + }); + + it('zero amount ("0.0000000") is rejected', () => { + // Single deterministic check—zero is a boundary, not a distribution. + const result = AmountValidator.validateUsdcAmount('0.0000000'); + expect(result.valid).toBe(false); + expect(result.error).toMatch(/greater than zero/i); + }); + + it('valid amounts always produce a positive stroop value', () => { + fc.assert( + fc.property(validAmountArb, (amount) => { + const stroops = AmountValidator.toSmallestUnit(amount); + return typeof stroops === 'bigint' && stroops > 0n; + }), + { numRuns: NUM_RUNS }, + ); + }); + }); + + // ----------------------------------------------------------------------- + // 3. Scale + // ----------------------------------------------------------------------- + + describe('Scale', () => { + it('amounts above the 1 billion USDC cap are rejected', () => { + // Generate stroop values that exceed MAX_STROOPS. + const overMaxArb = fc + .bigInt({ min: MAX_STROOPS + 1n, max: MAX_STROOPS * 2n }) + .map(stroopsToCanonical); + + fc.assert( + fc.property(overMaxArb, (amount) => { + const result = AmountValidator.validateUsdcAmount(amount); + return result.valid === false && /maximum/i.test(result.error ?? ''); + }), + { numRuns: NUM_RUNS }, + ); + }); + + it('amounts at or below the cap are accepted', () => { + fc.assert( + fc.property(validAmountArb, (amount) => { + return AmountValidator.validateUsdcAmount(amount).valid === true; + }), + { numRuns: NUM_RUNS }, + ); + }); + + it('the exact maximum (1,000,000,000.0000000) is accepted', () => { + const result = AmountValidator.validateUsdcAmount('1000000000.0000000'); + expect(result.valid).toBe(true); + expect(result.normalizedAmount).toBe('1000000000.0000000'); + }); + + it('one stroop above the maximum is rejected', () => { + const oneOver = stroopsToCanonical(MAX_STROOPS + 1n); + const result = AmountValidator.validateUsdcAmount(oneOver); + expect(result.valid).toBe(false); + expect(result.error).toMatch(/maximum/i); + }); + }); + + // ----------------------------------------------------------------------- + // 4. Format rejection + // ----------------------------------------------------------------------- + + describe('Format rejection', () => { + it('scientific-notation strings are always rejected', () => { + const sciArb = fc + .tuple( + fc.integer({ min: 1, max: 999_999 }), + fc.integer({ min: 1, max: 9 }), + fc.constantFrom('e', 'E'), + fc.constantFrom('', '+', '-'), + ) + .map(([mantissa, exp, e, sign]) => `${mantissa}${e}${sign}${exp}`); + + fc.assert( + fc.property(sciArb, (amount) => { + return AmountValidator.validateUsdcAmount(amount).valid === false; + }), + { numRuns: NUM_RUNS }, + ); + }); + + it('whitespace-padded strings are always rejected', () => { + const paddedArb = fc + .tuple( + validAmountArb, + fc.constantFrom(' ', '\t', '\n', '\r'), + fc.boolean(), + ) + .map(([amount, ws, prepend]) => + prepend ? `${ws}${amount}` : `${amount}${ws}`, + ); + + fc.assert( + fc.property(paddedArb, (amount) => { + return AmountValidator.validateUsdcAmount(amount).valid === false; + }), + { numRuns: NUM_RUNS }, + ); + }); + + it('locale-separator strings (commas, underscores) are always rejected', () => { + // Insert a comma or underscore at a random position in the whole part. + const localeArb = fc + .tuple( + fc.integer({ min: 1_000, max: 999_999_999 }), + fc.constantFrom(',', '_'), + ) + .map(([n, sep]) => { + const s = String(n); + const pos = Math.max(1, Math.floor(s.length / 2)); + const withSep = s.slice(0, pos) + sep + s.slice(pos); + return `${withSep}.0000000`; + }); + + fc.assert( + fc.property(localeArb, (amount) => { + return AmountValidator.validateUsdcAmount(amount).valid === false; + }), + { numRuns: NUM_RUNS }, + ); + }); + + it('non-string inputs are rejected', () => { + // Exercise a variety of JS value types. + const nonStringArb = fc.oneof( + fc.integer(), + fc.double(), + fc.boolean(), + fc.constant(null), + fc.constant(undefined), + ); + + fc.assert( + fc.property(nonStringArb, (value) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = AmountValidator.validateUsdcAmount(value as any); + return result.valid === false; + }), + { numRuns: NUM_RUNS }, + ); + }); + }); + + // ----------------------------------------------------------------------- + // 5. Round-trip integrity + // ----------------------------------------------------------------------- + + describe('Round-trip integrity', () => { + it('stroop → canonical string → stroop is lossless', () => { + fc.assert( + fc.property(validStroopsArb, (stroops) => { + const canonical = stroopsToCanonical(stroops); + const roundTripped = AmountValidator.toSmallestUnit(canonical); + return roundTripped === stroops; + }), + { numRuns: NUM_RUNS }, + ); + }); + + it('normalizedAmount always equals the original valid input', () => { + fc.assert( + fc.property(validAmountArb, (amount) => { + const result = AmountValidator.validateUsdcAmount(amount); + return result.normalizedAmount === amount; + }), + { numRuns: NUM_RUNS }, + ); + }); + + it('toSmallestUnit result is always a positive bigint for valid amounts', () => { + fc.assert( + fc.property(validAmountArb, (amount) => { + const stroops = AmountValidator.toSmallestUnit(amount); + return typeof stroops === 'bigint' && stroops > 0n; + }), + { numRuns: NUM_RUNS }, + ); + }); + }); + + // ----------------------------------------------------------------------- + // 6. Counterexample shrinkage verification + // ----------------------------------------------------------------------- + + describe('Shrinkage', () => { + it('fast-check shrinks to a minimal counterexample on a forced failure', () => { + // We intentionally introduce a property that fails for amounts > 500 USDC + // and verify that fast-check's shrinkage produces a counterexample. + const threshold = 500n * STROOPS_PER_USDC; + + let shrunkCounterexample: string | undefined; + + try { + fc.assert( + fc.property(validAmountArb, (amount) => { + const stroops = AmountValidator.toSmallestUnit(amount); + // This will fail for any amount > 500 USDC. + return stroops <= threshold; + }), + { numRuns: NUM_RUNS }, + ); + } catch (err: unknown) { + // fast-check throws a `Property failed` error with a + // `counterexample` array on the error object. + if (err instanceof Error && 'counterexample' in err) { + const ce = (err as Error & { counterexample: unknown[] }).counterexample; + shrunkCounterexample = ce?.[0] as string; + } + } + + // Verify that a counterexample was produced and that shrinkage + // brought it close to the boundary (≤ 501 USDC is a reasonable + // shrink target). + expect(shrunkCounterexample).toBeDefined(); + const shrunkStroops = AmountValidator.toSmallestUnit(shrunkCounterexample!); + expect(shrunkStroops).toBeGreaterThan(threshold); + + // Shrinkage should bring the counterexample close to the 500 USDC + // boundary. We allow a generous margin of 1 USDC above threshold. + const onUsdcAbove = threshold + STROOPS_PER_USDC; + expect(shrunkStroops).toBeLessThanOrEqual(onUsdcAbove); + }); + }); +});