Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ on:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: '17 3 * * *'
jobs:
test:
runs-on: ubuntu-latest
Expand All @@ -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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 .",
Expand All @@ -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": {
Expand All @@ -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",
Expand Down
22 changes: 22 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

130 changes: 130 additions & 0 deletions test/chains/stellar/properties.test.ts
Original file line number Diff line number Diff line change
@@ -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<number>(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);
});