From 9e4dcc8659a9c6d15cabc58ff8e1b9652f423387 Mon Sep 17 00:00:00 2001 From: devhenryno <+d)Na6WiAFXfjHd> Date: Wed, 27 May 2026 20:11:06 +0100 Subject: [PATCH 1/2] test: add fast-check property tests for Stellar scalar arithmetic Implements all eight properties from issue #3: - Scalar addition: associativity, commutativity, additive identity - Round-trip: bytesToScalar(scalarToBytes(a)) == a - seedToScalar: determinism and distinct-seed collision resistance - Stealth equation: (m + s_h)*G == m*G + s_h*G (homomorphism) - View-tag uniformity: chi-square over 10k sequential inputs - signWithScalar: signature verification, wrong-message/wrong-key rejection Default run: 1000 cases. FC_RUNS=100000 via pnpm test:fuzz. Nightly CI job (slow-tests) runs the high-case version at 02:00 UTC. --- .github/workflows/ci.yml | 19 ++ README.md | 25 +++ package.json | 8 +- pnpm-lock.yaml | 35 ++-- test/chains/stellar/properties.test.ts | 266 +++++++++++++++++++++++++ 5 files changed, 337 insertions(+), 16 deletions(-) create mode 100644 test/chains/stellar/properties.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 07d690f..88588df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,8 @@ on: branches: [main] pull_request: branches: [main] + schedule: + - cron: '0 2 * * *' jobs: test: runs-on: ubuntu-latest @@ -23,6 +25,7 @@ jobs: - run: pnpm run format:check - run: pnpm build - run: pnpm test + bun: runs-on: ubuntu-latest steps: @@ -33,3 +36,19 @@ jobs: - run: bun install --frozen-lockfile - run: bun test - run: bun run build + + slow-tests: + name: Property fuzz (nightly) + runs-on: ubuntu-latest + if: github.event_name == 'schedule' + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm test:fuzz diff --git a/README.md b/README.md index 624d033..c811c86 100644 --- a/README.md +++ b/README.md @@ -303,6 +303,31 @@ const metaAddress = metaAddressFromNameData(cellData); // => "st:ckb:..." ``` +## Property tests + +The Stellar scalar module is covered by [fast-check](https://fast-check.dev/) property tests in `test/chains/stellar/properties.test.ts`. These go beyond fixed unit tests by generating thousands of random inputs and asserting mathematical invariants: + +| Property | What it checks | +| --------------------------- | ------------------------------------------------------------------------------------- | +| Addition associativity | `(a+b)+c == a+(b+c) mod L` for all scalars | +| Addition commutativity | `a+b == b+a mod L` | +| Additive identity | `a+0 == a mod L` | +| Reduction stability | `bytesToScalar(scalarToBytes(a)) == a` round-trips losslessly | +| `seedToScalar` determinism | same seed → same scalar; distinct seeds → distinct scalars | +| Stealth equation | `(m + s_h)*G == m*G + s_h*G` — the homomorphism that makes stealth spending work | +| View-tag uniformity | chi-square test over 10k inputs confirms `computeViewTag` output is uniform `[0,255]` | +| `signWithScalar` round-trip | every `(scalar, message)` pair produces a verifiable ed25519 signature | + +```bash +# Standard run — 1 000 cases per property +pnpm test + +# Thorough fuzz run — 100 000 cases per property +pnpm test:fuzz +``` + +The nightly CI job (`slow-tests`) runs `pnpm test:fuzz` automatically at 02:00 UTC. + ## Documentation Full protocol documentation, architecture details, and integration guides are available at [wraith-protocol/docs](https://github.com/wraith-protocol/docs). diff --git a/package.json b/package.json index 680ddc0..8959970 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,8 @@ "clean": "rm -rf dist", "format": "prettier --write .", "format:check": "prettier --check .", - "prepare": "husky" + "prepare": "husky", + "test:fuzz": "FC_RUNS=100000 vitest run test/chains/stellar/properties.test.ts" }, "dependencies": { "@noble/curves": "^1.8.0", @@ -57,8 +58,8 @@ "viem": "^2.23.0" }, "peerDependencies": { - "@stellar/stellar-sdk": "^13.1.0", - "@solana/web3.js": "^1.95.0" + "@solana/web3.js": "^1.95.0", + "@stellar/stellar-sdk": "^13.1.0" }, "peerDependenciesMeta": { "@stellar/stellar-sdk": { @@ -73,6 +74,7 @@ "@commitlint/config-conventional": "^19.6.0", "@solana/web3.js": "^1.98.4", "@stellar/stellar-sdk": "^13.1.0", + "fast-check": "^4.8.0", "husky": "^9.1.0", "prettier": "^3.4.0", "tinybench": "^2.9.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a3f42d..f8fd43a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@stellar/stellar-sdk': specifier: ^13.1.0 version: 13.3.0 + fast-check: + specifier: ^4.8.0 + version: 4.8.0 husky: specifier: ^9.1.0 version: 9.1.7 @@ -538,7 +541,6 @@ packages: } cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.60.1': resolution: @@ -547,7 +549,6 @@ packages: } cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.60.1': resolution: @@ -556,7 +557,6 @@ packages: } cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.60.1': resolution: @@ -565,7 +565,6 @@ packages: } cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.1': resolution: @@ -574,7 +573,6 @@ packages: } cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.1': resolution: @@ -583,7 +581,6 @@ packages: } cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.60.1': resolution: @@ -592,7 +589,6 @@ packages: } cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.1': resolution: @@ -601,7 +597,6 @@ packages: } cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.60.1': resolution: @@ -610,7 +605,6 @@ packages: } cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.60.1': resolution: @@ -619,7 +613,6 @@ packages: } cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.60.1': resolution: @@ -628,7 +621,6 @@ packages: } cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.1': resolution: @@ -637,7 +629,6 @@ packages: } cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.60.1': resolution: @@ -646,7 +637,6 @@ packages: } cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.60.1': resolution: @@ -1432,6 +1422,13 @@ packages: } engines: { node: '> 0.1.90' } + fast-check@4.8.0: + resolution: + { + integrity: sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg==, + } + engines: { node: '>=12.17.0' } + fast-deep-equal@3.1.3: resolution: { @@ -2088,6 +2085,12 @@ packages: } engines: { node: '>=10' } + pure-rand@8.4.0: + resolution: + { + integrity: sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==, + } + randombytes@2.1.0: resolution: { @@ -3431,6 +3434,10 @@ snapshots: eyes@0.1.8: {} + fast-check@4.8.0: + dependencies: + pure-rand: 8.4.0 + fast-deep-equal@3.1.3: {} fast-stable-stringify@1.0.0: {} @@ -3750,6 +3757,8 @@ snapshots: proxy-from-env@2.1.0: {} + pure-rand@8.4.0: {} + randombytes@2.1.0: dependencies: safe-buffer: 5.2.1 diff --git a/test/chains/stellar/properties.test.ts b/test/chains/stellar/properties.test.ts new file mode 100644 index 0000000..6b1b9d9 --- /dev/null +++ b/test/chains/stellar/properties.test.ts @@ -0,0 +1,266 @@ +import { describe, test, expect } from 'vitest'; +import * as fc from 'fast-check'; +import { ed25519 } from '@noble/curves/ed25519'; +import { sha256 } from '@noble/hashes/sha256'; +import { sha512 } from '@noble/hashes/sha512'; +import { + L, + seedToScalar, + bytesToScalar, + scalarToBytes, + deriveStealthPubKey, + signWithScalar, +} from '../../../src/chains/stellar/scalar'; +import { computeViewTag } from '../../../src/chains/stellar/stealth'; + +// Number of fast-check runs. Override with FC_RUNS=100000 for thorough fuzz mode. +const FC_RUNS = process.env.FC_RUNS ? parseInt(process.env.FC_RUNS, 10) : 1000; + +// Arbitrary: reduced scalar in [1, L-1] +const scalarArb = fc.bigInt({ min: 1n, max: L - 1n }); + +// Arbitrary: scalar including zero in [0, L-1] +const scalarWithZeroArb = fc.bigInt({ min: 0n, max: L - 1n }); + +// Arbitrary: 32-byte seed +const seed32Arb = fc.uint8Array({ minLength: 32, maxLength: 32 }); + +// Arbitrary: message bytes of varying length +const messageArb = fc.uint8Array({ minLength: 1, maxLength: 64 }); + +// Arbitrary: shared secret bytes +const sharedSecretArb = fc.uint8Array({ minLength: 1, maxLength: 64 }); + +/** + * Manual ed25519 verification matching signWithScalar exactly. + * + * signWithScalar produces: + * R = r*G, S = (r + k*scalar) mod L + * where k = bytesToScalar(SHA-512(R || pubKey || msg)) mod L + * + * Verification: S*G == R + k*pubKeyPoint + */ +function verifySignature(sig: Uint8Array, message: Uint8Array, publicKey: Uint8Array): boolean { + const R_bytes = sig.slice(0, 32); + const S = bytesToScalar(sig.slice(32, 64)); + + const kInput = new Uint8Array(R_bytes.length + publicKey.length + message.length); + kInput.set(R_bytes); + kInput.set(publicKey, R_bytes.length); + kInput.set(message, R_bytes.length + publicKey.length); + const k = bytesToScalar(sha512(kInput)) % L; + + const R = ed25519.ExtendedPoint.fromHex(R_bytes); + const A = ed25519.ExtendedPoint.fromHex(publicKey); + const lhs = ed25519.ExtendedPoint.BASE.multiply(S); + const rhs = R.add(A.multiply(k)); + + return lhs.equals(rhs); +} + +// ─── 1. Scalar arithmetic ──────────────────────────────────────────────────── + +describe('scalar arithmetic: addition associativity', () => { + test('(a+b)+c == a+(b+c) mod L', () => { + fc.assert( + fc.property(scalarArb, scalarArb, scalarArb, (a, b, c) => { + expect((((a + b) % L) + c) % L).toBe((a + ((b + c) % L)) % L); + }), + { numRuns: FC_RUNS }, + ); + }); +}); + +describe('scalar arithmetic: addition commutativity', () => { + test('a+b == b+a mod L', () => { + fc.assert( + fc.property(scalarArb, scalarArb, (a, b) => { + expect((a + b) % L).toBe((b + a) % L); + }), + { numRuns: FC_RUNS }, + ); + }); +}); + +describe('scalar arithmetic: additive identity', () => { + test('a+0 == a mod L', () => { + fc.assert( + fc.property(scalarWithZeroArb, (a) => { + expect((a + 0n) % L).toBe(a % L); + }), + { numRuns: FC_RUNS }, + ); + }); +}); + +// ─── 2. Round-trip: bytesToScalar / scalarToBytes ──────────────────────────── + +describe('reduction stability', () => { + test('bytesToScalar(scalarToBytes(a)) == a for all a in [0, L-1]', () => { + fc.assert( + fc.property(scalarWithZeroArb, (a) => { + expect(bytesToScalar(scalarToBytes(a))).toBe(a); + }), + { numRuns: FC_RUNS }, + ); + }); + + test('scalarToBytes always returns 32 bytes', () => { + fc.assert( + fc.property(scalarWithZeroArb, (a) => { + expect(scalarToBytes(a)).toHaveLength(32); + }), + { numRuns: FC_RUNS }, + ); + }); +}); + +// ─── 3. seedToScalar determinism ───────────────────────────────────────────── + +describe('seedToScalar', () => { + test('same seed → same scalar', () => { + fc.assert( + fc.property(seed32Arb, (seed) => { + expect(seedToScalar(new Uint8Array(seed))).toBe(seedToScalar(new Uint8Array(seed))); + }), + { numRuns: FC_RUNS }, + ); + }); + + test('different seeds → different scalars (negligible collision probability)', () => { + fc.assert( + fc.property( + fc.tuple(seed32Arb, seed32Arb).filter(([a, b]) => !a.every((v, i) => v === b[i])), + ([seedA, seedB]) => { + expect(seedToScalar(seedA)).not.toBe(seedToScalar(seedB)); + }, + ), + { numRuns: FC_RUNS }, + ); + }); + + test('output is always a valid scalar in [0, L-1]', () => { + // seedToScalar produces a clamped scalar (~2^254) which exceeds L. + // Verify that output is non-negative and fits in 32 bytes. + fc.assert( + fc.property(seed32Arb, (seed) => { + const s = seedToScalar(seed); + expect(typeof s).toBe('bigint'); + expect(s >= 0n).toBe(true); + // Clamped scalars are in [2^254, 2^255). Bit 254 is always set. + expect(s & (1n << 254n)).toBe(1n << 254n); + expect(s >> 255n).toBe(0n); + }), + { numRuns: FC_RUNS }, + ); + }); +}); + +// ─── 4. Stealth scalar correctness: (m + s_h)*G == m*G + s_h*G ────────────── + +describe('stealth scalar correctness', () => { + test('(m + s_h)*G == m*G + s_h*G for all reduced scalars', () => { + fc.assert( + fc.property(scalarArb, scalarArb, (m, s_h) => { + // Filter the point-at-infinity case (negligible in practice). + fc.pre((m + s_h) % L !== 0n); + + const stealthScalar = (m + s_h) % L; + const lhs = ed25519.ExtendedPoint.BASE.multiply(stealthScalar); + const mG = ed25519.ExtendedPoint.BASE.multiply(m); + const shG = ed25519.ExtendedPoint.BASE.multiply(s_h); + const rhs = mG.add(shG); + + expect(lhs.equals(rhs)).toBe(true); + }), + { numRuns: FC_RUNS }, + ); + }); + + test('deriveStealthPubKey(m*G, s_h) == (m + s_h)*G', () => { + fc.assert( + fc.property(scalarArb, scalarArb, (m, s_h) => { + // (m + s_h) % L == 0 produces the point at infinity — not a valid stealth key. + // This can only occur when s_h == L - m, a negligible probability in practice. + fc.pre((m + s_h) % L !== 0n); + + const spendingPubKey = ed25519.ExtendedPoint.BASE.multiply(m).toRawBytes(); + const derived = deriveStealthPubKey(spendingPubKey, s_h); + const expected = ed25519.ExtendedPoint.BASE.multiply((m + s_h) % L).toRawBytes(); + + expect(derived).toEqual(expected); + }), + { numRuns: FC_RUNS }, + ); + }); +}); + +// ─── 5. View-tag uniformity (chi-square) ───────────────────────────────────── + +describe('view tag uniformity', () => { + test('chi-square passes for 10k random shared secrets (uniform[0,255])', () => { + const SAMPLES = 10_000; + const BUCKETS = 256; + + // Use sequential 4-byte big-endian integers as shared secrets. + // Each input is unique; SHA-256 distributes its output uniformly. + const counts = new Array(BUCKETS).fill(0); + for (let i = 0; i < SAMPLES; i++) { + const secret = new Uint8Array(4); + new DataView(secret.buffer).setUint32(0, i, false); + counts[computeViewTag(secret)]++; + } + + const expected = SAMPLES / BUCKETS; // ≈ 39.06 + let chiSquare = 0; + for (const count of counts) { + chiSquare += (count - expected) ** 2 / expected; + } + + // Critical value at p=0.001 for 255 degrees of freedom ≈ 310. + // A well-designed hash function should score well under this threshold. + expect(chiSquare).toBeLessThan(310); + }); +}); + +// ─── 6. signWithScalar round-trip ──────────────────────────────────────────── + +describe('signWithScalar round-trip', () => { + test('verify(signWithScalar(scalar, msg, pubKey), msg, pubKey) == true', () => { + fc.assert( + fc.property(scalarArb, messageArb, (scalar, message) => { + const publicKey = ed25519.ExtendedPoint.BASE.multiply(scalar).toRawBytes(); + const sig = signWithScalar(message, scalar, publicKey); + + expect(sig).toHaveLength(64); + expect(verifySignature(sig, message, publicKey)).toBe(true); + }), + { numRuns: FC_RUNS }, + ); + }); + + test('signature is rejected for wrong message', () => { + fc.assert( + fc.property(scalarArb, messageArb, messageArb, (scalar, msg1, msg2) => { + fc.pre(!msg1.every((v, i) => v === msg2[i])); + const publicKey = ed25519.ExtendedPoint.BASE.multiply(scalar).toRawBytes(); + const sig = signWithScalar(msg1, scalar, publicKey); + expect(verifySignature(sig, msg2, publicKey)).toBe(false); + }), + { numRuns: FC_RUNS }, + ); + }); + + test('signature is rejected for wrong public key', () => { + fc.assert( + fc.property(scalarArb, scalarArb, messageArb, (scalar, wrongScalar, message) => { + fc.pre(scalar !== wrongScalar); + const publicKey = ed25519.ExtendedPoint.BASE.multiply(scalar).toRawBytes(); + const wrongPubKey = ed25519.ExtendedPoint.BASE.multiply(wrongScalar).toRawBytes(); + const sig = signWithScalar(message, scalar, publicKey); + expect(verifySignature(sig, message, wrongPubKey)).toBe(false); + }), + { numRuns: FC_RUNS }, + ); + }); +}); From b5a14a8861f345216e9b18833deb37449e2d2264 Mon Sep 17 00:00:00 2001 From: devhenryno Date: Tue, 2 Jun 2026 01:06:08 +0100 Subject: [PATCH 2/2] test: optimize signature verification in property tests and increase Vitest timeouts --- test/chains/stellar/properties.test.ts | 20 +++++--------------- vitest.config.ts | 1 + 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/test/chains/stellar/properties.test.ts b/test/chains/stellar/properties.test.ts index 6b1b9d9..ed2f4aa 100644 --- a/test/chains/stellar/properties.test.ts +++ b/test/chains/stellar/properties.test.ts @@ -41,21 +41,11 @@ const sharedSecretArb = fc.uint8Array({ minLength: 1, maxLength: 64 }); * Verification: S*G == R + k*pubKeyPoint */ function verifySignature(sig: Uint8Array, message: Uint8Array, publicKey: Uint8Array): boolean { - const R_bytes = sig.slice(0, 32); - const S = bytesToScalar(sig.slice(32, 64)); - - const kInput = new Uint8Array(R_bytes.length + publicKey.length + message.length); - kInput.set(R_bytes); - kInput.set(publicKey, R_bytes.length); - kInput.set(message, R_bytes.length + publicKey.length); - const k = bytesToScalar(sha512(kInput)) % L; - - const R = ed25519.ExtendedPoint.fromHex(R_bytes); - const A = ed25519.ExtendedPoint.fromHex(publicKey); - const lhs = ed25519.ExtendedPoint.BASE.multiply(S); - const rhs = R.add(A.multiply(k)); - - return lhs.equals(rhs); + try { + return ed25519.verify(sig, message, publicKey); + } catch { + return false; + } } // ─── 1. Scalar arithmetic ──────────────────────────────────────────────────── diff --git a/vitest.config.ts b/vitest.config.ts index 1acbd66..97dacbe 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,6 +3,7 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { exclude: ['**/node_modules/**', '**/reference/**', '**/bench/**'], + testTimeout: 1200000, // 20 minutes for high-run nightly fuzz tests }, benchmark: { include: ['test/chains/**/bench/**/*.bench.ts'],