diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a3d24b1..81a4642 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,8 @@ on: branches: [main] pull_request: branches: [main] + schedule: + - cron: '17 3 * * *' jobs: test: runs-on: ubuntu-latest @@ -20,3 +22,17 @@ jobs: - run: pnpm run format:check - run: pnpm build - run: pnpm test + slow-tests: + if: github.event_name == 'schedule' + runs-on: ubuntu-latest + 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 run test:fuzz diff --git a/README.md b/README.md index fe0dc15..a485caf 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,17 @@ pnpm add @wraith-protocol/sdk `@stellar/stellar-sdk` and `@solana/web3.js` are optional peer dependencies — only required if you import their respective chain modules. +## Property Tests + +The Stellar scalar arithmetic has property-based coverage for modular addition, scalar byte round-trips, deterministic seed derivation, stealth public-key equations, view-tag distribution, and `signWithScalar` verification. + +```bash +pnpm test:properties +pnpm test:fuzz +``` + +`test:properties` runs the default 1,000 generated cases per property. `test:fuzz` raises the same properties to 100,000 cases and is also scheduled in CI as the nightly `slow-tests` job. + ## Entry Points | Import | Purpose | diff --git a/package.json b/package.json index 5a0d713..2f55b5d 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,8 @@ "scripts": { "build": "tsup", "test": "vitest run", + "test:properties": "vitest run test/chains/stellar/properties.test.ts", + "test:fuzz": "WRAITH_FUZZ_RUNS=100000 vitest run test/chains/stellar/properties.test.ts", "test:watch": "vitest", "clean": "rm -rf dist", "format": "prettier --write .", @@ -47,8 +49,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": { @@ -63,6 +65,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", "tsup": "^8.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 64ca1e8..812273b 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 @@ -1429,6 +1432,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: { @@ -2085,6 +2095,12 @@ packages: } engines: { node: '>=10' } + pure-rand@8.4.0: + resolution: + { + integrity: sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==, + } + randombytes@2.1.0: resolution: { @@ -3428,6 +3444,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: {} @@ -3747,6 +3767,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..f3c43d3 --- /dev/null +++ b/test/chains/stellar/properties.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, test } from 'vitest'; +import fc from 'fast-check'; +import { ed25519 } from '@noble/curves/ed25519'; +import { sha256 } from '@noble/hashes/sha256'; +import { computeViewTag } from '../../../src/chains/stellar/stealth'; +import { + L, + bytesToScalar, + deriveStealthPubKey, + scalarToBytes, + seedToScalar, + signWithScalar, +} from '../../../src/chains/stellar/scalar'; + +const configuredRuns = Number(process.env.WRAITH_FUZZ_RUNS ?? '1000'); +const propertyRuns = Number.isFinite(configuredRuns) && configuredRuns > 0 ? configuredRuns : 1000; +const propertyOptions = { numRuns: propertyRuns }; +const scalarArbitrary = fc.bigInt({ min: 1n, max: L - 1n }); +const seedArbitrary = fc.uint8Array({ minLength: 32, maxLength: 32 }); +const messageArbitrary = fc.uint8Array({ minLength: 0, maxLength: 256 }); + +function addMod(a: bigint, b: bigint) { + return (a + b) % L; +} + +function publicKeyFromScalar(scalar: bigint) { + return ed25519.ExtendedPoint.BASE.multiply(scalar).toRawBytes(); +} + +function deterministicSecret(index: number) { + const input = new Uint8Array(4); + new DataView(input.buffer).setUint32(0, index, true); + return sha256(input); +} + +describe('Stellar scalar property tests', () => { + test('addition is associative modulo L', () => { + fc.assert( + fc.property(scalarArbitrary, scalarArbitrary, scalarArbitrary, (a, b, c) => { + expect(addMod(addMod(a, b), c)).toBe(addMod(a, addMod(b, c))); + }), + propertyOptions, + ); + }); + + test('addition is commutative modulo L', () => { + fc.assert( + fc.property(scalarArbitrary, scalarArbitrary, (a, b) => { + expect(addMod(a, b)).toBe(addMod(b, a)); + }), + propertyOptions, + ); + }); + + test('zero is the additive identity modulo L', () => { + fc.assert( + fc.property(scalarArbitrary, (a) => { + expect(addMod(a, 0n)).toBe(a); + }), + propertyOptions, + ); + }); + + test('scalar byte encoding round-trips valid reduced scalars', () => { + fc.assert( + fc.property(scalarArbitrary, (a) => { + expect(bytesToScalar(scalarToBytes(a))).toBe(a); + }), + propertyOptions, + ); + }); + + test('seedToScalar is deterministic and seed-sensitive', () => { + fc.assert( + fc.property(seedArbitrary, seedArbitrary, (seedA, seedB) => { + expect(seedToScalar(seedA)).toBe(seedToScalar(seedA)); + + if (!Buffer.from(seedA).equals(Buffer.from(seedB))) { + expect(seedToScalar(seedA)).not.toBe(seedToScalar(seedB)); + } + }), + propertyOptions, + ); + }); + + test('stealth scalar point equation holds', () => { + fc.assert( + fc.property(scalarArbitrary, scalarArbitrary, (m, sharedHashScalar) => { + fc.pre(addMod(m, sharedHashScalar) !== 0n); + + const spendingPubKey = publicKeyFromScalar(m); + const stealthPubKey = deriveStealthPubKey(spendingPubKey, sharedHashScalar); + const expectedPubKey = publicKeyFromScalar(addMod(m, sharedHashScalar)); + + expect(stealthPubKey).toEqual(expectedPubKey); + }), + propertyOptions, + ); + }, 20_000); + + test('view tags are uniform enough across deterministic shared-secret samples', () => { + const sampleSize = 10_000; + const bucketCount = 256; + const expected = sampleSize / bucketCount; + const buckets = new Array(bucketCount).fill(0); + + for (let i = 0; i < sampleSize; i++) { + buckets[computeViewTag(deterministicSecret(i))] += 1; + } + + const chiSquare = buckets.reduce( + (sum, observed) => sum + (observed - expected) ** 2 / expected, + 0, + ); + + expect(chiSquare).toBeLessThan(330); + }); + + test('signWithScalar signatures verify against the matching public key', () => { + fc.assert( + fc.property(scalarArbitrary, messageArbitrary, (scalar, message) => { + const publicKey = publicKeyFromScalar(scalar); + const signature = signWithScalar(message, scalar, publicKey); + + expect(ed25519.verify(signature, message, publicKey)).toBe(true); + }), + propertyOptions, + ); + }, 20_000); +});