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
57 changes: 57 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,63 @@ jobs:
- run: pnpm build
- run: pnpm test

bun:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- run: bun install --frozen-lockfile
- run: bun test
- run: bun run build

differential:
name: Differential (test-vectors vs vN-1)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: dorny/paths-filter@v3
id: filter
if: github.event_name == 'pull_request'
with:
filters: |
chains:
- 'src/chains/**'

- name: Determine whether to run
id: should-run
run: |
if [ "${{ github.event_name }}" != "pull_request" ] || [ "${{ steps.filter.outputs.chains }}" = "true" ]; then
echo "run=true" >> "$GITHUB_OUTPUT"
else
echo "run=false" >> "$GITHUB_OUTPUT"
fi

- uses: pnpm/action-setup@v4
if: steps.should-run.outputs.run == 'true'
with:
version: 10

- uses: actions/setup-node@v4
if: steps.should-run.outputs.run == 'true'
with:
node-version: 22
cache: pnpm

- name: Install dependencies
if: steps.should-run.outputs.run == 'true'
run: pnpm install --frozen-lockfile

- name: Build SDK
if: steps.should-run.outputs.run == 'true'
run: pnpm build

- name: Run differential harness
if: steps.should-run.outputs.run == 'true'
run: pnpm --filter @wraith-protocol/test-vectors differential

slow-tests:
name: Property fuzz (nightly)
runs-on: ubuntu-latest
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ node_modules/
dist/
*.tsbuildinfo
package-lock.json
packages/test-vectors/.differential/
.claude/
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ node_modules
reference
pnpm-lock.yaml
test/**/bench/**
.claude
41 changes: 41 additions & 0 deletions packages/test-vectors/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,47 @@ for (const [f, expected] of Object.entries(files)) {
"
```

## Differential Testing

`differential.ts` runs every vector in `vectors/stellar.json` through two builds of `@wraith-protocol/sdk` — a pinned previous-minor reference version and the current workspace tip — and diffs the outputs field by field. Semver numbers alone don't catch a change that silently alters cryptographic output for existing inputs; this does, by actually comparing behavior across versions instead of behavior against a fixed expectation.

```bash
pnpm --filter @wraith-protocol/test-vectors differential
```

### How it works

1. `differential.config.json` names the reference version (`referenceVersion`, vN-1) and the peer dependency range it needs. This is a plain config value, not auto-computed from semver, so a maintainer decides when to move the reference forward.
2. The script installs that reference version into a throwaway `.differential/reference/` directory (gitignored) with `npm install --ignore-scripts`, and runs `differential-runner.mjs` there — the same runner file, unmodified, also runs against the workspace's own build. Node's normal module resolution is what picks the SDK version in each case; the runner never hardcodes a path.
3. Every vector's `input` is fed through the corresponding SDK function (`deriveStealthKeys`, `generateStealthAddress`, `checkStealthAddress` + `deriveStealthPrivateScalar`, `signStellarTransaction`, `encodeStealthMetaAddress` + `decodeStealthMetaAddress`) on both sides, and the two output objects are compared key by key.
4. Any field that differs between the reference and the tip is a diff. A diff either matches an entry in `differences.json`, in which case it's a waived, expected difference, or it doesn't, in which case the run fails.

### Waiving an expected difference

Add an entry to `differences.json`:

```json
{
"waived": [
{
"category": "stealth_gen",
"field": "viewTag",
"reason": "Explain why this diff is expected and safe — reference the commit/PR that introduced it."
}
]
}
```

`category` and `field` match the diff's location (e.g. `stealth_gen.viewTag`, `scan_match.isMatch`). Every waiver **must** carry a non-empty `reason` — the script refuses to run (exit code 2) if it finds one without. A waiver with no matching diff is reported as stale but doesn't fail the run; clean those up when you notice them.

### Bumping the reference version

When a new minor of `@wraith-protocol/sdk` ships, update `referenceVersion` in `differential.config.json` to that new minor so the reference stays one minor behind the current line. If the move introduces new diffs that are intentional (a documented, deliberate behavior change), waive them with a reason; anything undocumented is exactly what this harness exists to catch.

### CI

The `differential` job in `.github/workflows/ci.yml` runs this on every pull request that touches `src/chains/**`.

---

## Consumption Examples
Expand Down
19 changes: 19 additions & 0 deletions packages/test-vectors/differences.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"waived": [
{
"category": "stealth_gen",
"field": "viewTag",
"reason": "generateStealthAddress's default view-tag computation moved from the legacy wraith:tag: prefix to the domain-separated wraith:stellar:view-tag:v2: prefix in commit f7def68 (PR #93, 2026-06-25), which landed before the 1.4.0 minor. The 1.3.0 reference still computes the legacy tag by default; the old scheme remains available explicitly via computeViewTag(). This is a real, already-shipped protocol change predating this harness — waived so the harness stays green on develop. New view-tag diffs beyond this one must not be silently added to this waiver."
},
{
"category": "scan_match",
"field": "isMatch",
"reason": "Direct consequence of the stealth_gen.viewTag change above: checkStealthAddress compares the caller-supplied viewTag against its own internal computation, so a v2-generated viewTag no longer matches the 1.3.0 reference's legacy-scheme comparison. Same root cause and same commit (f7def68, PR #93) as the stealth_gen.viewTag waiver."
},
{
"category": "scan_match",
"field": "stealthPubKey",
"reason": "checkStealthAddress only returns a non-null stealthPubKeyBytes when isMatch is true, so this is a downstream artifact of the scan_match.isMatch waiver above, not an independent discrepancy. Same root cause and commit (f7def68, PR #93)."
}
]
}
143 changes: 143 additions & 0 deletions packages/test-vectors/differential-runner.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
#!/usr/bin/env node
/**
* Runs every vector in vectors/stellar.json through whatever
* `@wraith-protocol/sdk/chains/stellar` resolves to from this script's own
* location, and prints the results as JSON on stdout.
*
* This file is executed unmodified in two different working directories by
* differential.ts: once against the workspace's own build (the "tip"), and
* once copied into a scratch install of the pinned reference version (the
* "reference"). Node's normal module resolution is what picks the SDK
* version — the script itself never hardcodes a path — so both runs exercise
* identical logic against whatever package version is actually installed.
*/

function hexToBytesLocal(hex) {
const clean = hex.length % 2 === 0 ? hex : `0${hex}`;
const out = new Uint8Array(clean.length / 2);
for (let i = 0; i < out.length; i++) {
out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
}
return out;
}

function bytesToHexLocal(bytes) {
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}

async function run(vectorsPath) {
const { readFileSync } = await import('fs');
const vectors = JSON.parse(readFileSync(vectorsPath, 'utf8'));
const sdk = await import('@wraith-protocol/sdk/chains/stellar');

const results = {
key_derivation: [],
stealth_gen: [],
scan_match: [],
signing: [],
encoding: [],
};

for (const v of vectors.key_derivation) {
try {
const sig = hexToBytesLocal(v.input.signature);
const keys = sdk.deriveStealthKeys(sig);
results.key_derivation.push({
spendingKey: bytesToHexLocal(keys.spendingKey),
viewingKey: bytesToHexLocal(keys.viewingKey),
spendingScalar: keys.spendingScalar.toString(),
spendingPubKey: bytesToHexLocal(keys.spendingPubKey),
viewingPubKey: bytesToHexLocal(keys.viewingPubKey),
});
} catch (err) {
results.key_derivation.push({ __error: String(err && err.message ? err.message : err) });
}
}

for (const v of vectors.stealth_gen) {
try {
const spendPub = hexToBytesLocal(v.input.spendingPubKey);
const viewPub = hexToBytesLocal(v.input.viewingPubKey);
const ephSeed = hexToBytesLocal(v.input.ephemeralSeed);
const gen = sdk.generateStealthAddress(spendPub, viewPub, ephSeed);
results.stealth_gen.push({
stealthAddress: gen.stealthAddress,
ephemeralPubKey: bytesToHexLocal(gen.ephemeralPubKey),
viewTag: gen.viewTag,
stealthPubKey: gen.stealthPubKey ? bytesToHexLocal(gen.stealthPubKey) : null,
});
} catch (err) {
results.stealth_gen.push({ __error: String(err && err.message ? err.message : err) });
}
}

for (const v of vectors.scan_match) {
try {
const ephPub = hexToBytesLocal(v.input.ephemeralPubKey);
const viewingKey = hexToBytesLocal(v.input.viewingKey);
const spendingPubKey = hexToBytesLocal(v.input.spendingPubKey);
const spendingScalar = BigInt(v.input.spendingScalar);

const check = sdk.checkStealthAddress(ephPub, viewingKey, spendingPubKey, v.input.viewTag);
const stealthPrivateScalar = sdk.deriveStealthPrivateScalar(
spendingScalar,
viewingKey,
ephPub,
);

results.scan_match.push({
isMatch: check.isMatch,
stealthPrivateScalar: stealthPrivateScalar.toString(),
stealthPubKey:
check.stealthPubKeyBytes !== null && check.stealthPubKeyBytes !== undefined
? bytesToHexLocal(check.stealthPubKeyBytes)
: null,
});
} catch (err) {
results.scan_match.push({ __error: String(err && err.message ? err.message : err) });
}
}

for (const v of vectors.signing) {
try {
const txHash = hexToBytesLocal(v.input.transactionHash);
const stealthScalar = BigInt(v.input.stealthScalar);
const stealthPubKey = hexToBytesLocal(v.input.stealthPubKey);
const sig = sdk.signStellarTransaction(txHash, stealthScalar, stealthPubKey);
results.signing.push({ signature: bytesToHexLocal(sig) });
} catch (err) {
results.signing.push({ __error: String(err && err.message ? err.message : err) });
}
}

for (const v of vectors.encoding) {
try {
const spendingPubKey = hexToBytesLocal(v.input.spendingPubKey);
const viewingPubKey = hexToBytesLocal(v.input.viewingPubKey);
const metaAddress = sdk.encodeStealthMetaAddress(spendingPubKey, viewingPubKey);
const decoded = sdk.decodeStealthMetaAddress(metaAddress);
results.encoding.push({
metaAddress,
decodedSpendingPubKey: bytesToHexLocal(decoded.spendingPubKey),
decodedViewingPubKey: bytesToHexLocal(decoded.viewingPubKey),
});
} catch (err) {
results.encoding.push({ __error: String(err && err.message ? err.message : err) });
}
}

process.stdout.write(JSON.stringify(results));
}

const vectorsPath = process.argv[2];
if (!vectorsPath) {
console.error('usage: differential-runner.mjs <path-to-vectors.json>');
process.exit(2);
}

run(vectorsPath).catch((err) => {
console.error(err);
process.exit(1);
});
7 changes: 7 additions & 0 deletions packages/test-vectors/differential.config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"$comment": "Pins the published npm version treated as vN-1 for the differential harness (differential.ts). Bump referenceVersion by hand whenever a new minor of @wraith-protocol/sdk ships, so it always tracks one minor behind the current line rather than being auto-detected.",
"referenceVersion": "1.3.0",
"referencePeerDependencies": {
"@stellar/stellar-sdk": "^13.1.0"
}
}
Loading