From a08d0ae07c6354a222a5640db114ba3bfb43dd40 Mon Sep 17 00:00:00 2001 From: Jefferson Youashi <119521983+clintjeff2@users.noreply.github.com> Date: Tue, 2 Jun 2026 00:20:25 +0100 Subject: [PATCH 1/2] perf(sdk/stellar): public-prefilter view-tag scan with legacy path (#45) * perf(stellar): prefilter scans with public view tags * test(stellar): cover legacy view-tag scanner --- docs/chains/stellar-view-tag-batching.md | 67 +++++++++++ src/chains/stellar/index.ts | 13 +- src/chains/stellar/scan.ts | 120 +++++++++++++++++-- src/chains/stellar/stealth.ts | 46 +++++-- test/chains/stellar/bench/scan.bench.ts | 145 +++++++++++++++++++++++ test/chains/stellar/scan.test.ts | 84 ++++++++++++- 6 files changed, 452 insertions(+), 23 deletions(-) create mode 100644 docs/chains/stellar-view-tag-batching.md create mode 100644 test/chains/stellar/bench/scan.bench.ts diff --git a/docs/chains/stellar-view-tag-batching.md b/docs/chains/stellar-view-tag-batching.md new file mode 100644 index 0000000..e814b83 --- /dev/null +++ b/docs/chains/stellar-view-tag-batching.md @@ -0,0 +1,67 @@ +# Stellar view-tag batching design + +## Problem + +The original Stellar scan path computed `S = X25519(v, R_ephemeral)` for every announcement before checking the view tag. That made the one-byte view tag a correctness filter, but not a performance filter: non-matching announcements still paid the dominant ECDH cost. + +## Chosen design + +New Stellar announcements derive the first metadata byte from public announcement data: + +```text +view_tag = SHA-256("wraith:stellar:view-tag:v2:" || R_ephemeral || V_recipient)[0] +``` + +Where: + +- `R_ephemeral` is the 32-byte ed25519 ephemeral public key included in the announcement. +- `V_recipient` is the recipient's 32-byte ed25519 viewing public key from the meta-address. + +This keeps the stealth-address secret scalar unchanged: + +```text +S = X25519(r_ephemeral, V_recipient) = X25519(v_recipient, R_ephemeral) +hash_scalar = SHA-256("wraith:scalar:" || S) mod L +P_stealth = K_spend + hash_scalar * G +``` + +Scanners now derive `V_recipient` once from the local viewing seed, hash `R_ephemeral || V_recipient` for every announcement, and only compute X25519 plus ed25519 point addition for the roughly 1/256 announcements whose tag matches. + +## Tradeoffs + +### Benefits + +- The hot scan loop replaces nearly all X25519 operations with one SHA-256 over a small public tuple. +- The full stealth address derivation and private scalar derivation remain unchanged for matching announcements. +- The filter keeps the same one-byte false-positive rate as the previous shared-secret tag. +- Invalid 32-byte ephemeral keys are only parsed as curve points after the public tag passes; if a crafted candidate passes the tag but is not a valid point, it is skipped. + +### Costs and compatibility + +- The view tag is no longer bound to the ECDH shared secret. It is a public prefilter, not authentication. This is acceptable because the announced stealth address is still verified with the shared-secret-derived scalar before a match is returned. +- A sender that knows a recipient's public viewing key can deliberately choose metadata that passes the recipient's public prefilter. That only causes the recipient to do the same full verification they already needed for candidate announcements, and the stealth address check still prevents false matches. +- Legacy announcements whose metadata used `SHA-256("wraith:tag:" || S)[0]` are not compatible with the optimized `scanAnnouncements` path. The SDK retains `scanAnnouncementsLegacySharedSecretTag` for benchmarks and migration tooling, but using it for normal scans necessarily reintroduces one X25519 per announcement. +- If deployed contracts or indexers need to distinguish old and new metadata semantics, this should be represented as a soft fork/new scheme identifier. The SDK-side cryptographic change is isolated to metadata generation and scanning; the stealth-address math does not change. + +## Benchmarks + +The benchmark harness lives at `test/chains/stellar/bench/scan.bench.ts` and compares: + +1. `scanAnnouncementsLegacySharedSecretTag` over legacy shared-secret-tag announcements. +2. `scanAnnouncements` over new public-announcement-tag announcements. + +Run it with: + +```bash +pnpm exec vitest bench test/chains/stellar/bench/scan.bench.ts --run +``` + +The harness covers synthetic 10k, 100k, and 1M announcement datasets with one recipient match and a large pool of foreign announcements. Set `STELLAR_SCAN_BENCH_SIZES=10000` (or a comma-separated list) to run a subset locally. + +On this development container, the 10k benchmark reported: + +| Dataset | Before: shared-secret tag | After: public prefilter | Speedup | +| -------------------- | ------------------------: | ----------------------: | ------: | +| 10,000 announcements | 31,310.03 ms | 98.83 ms | 316.80x | + +The expected speedup grows with dataset size because the optimized path computes the viewing public key once and performs X25519 only for public view-tag hits instead of every same-scheme announcement. diff --git a/src/chains/stellar/index.ts b/src/chains/stellar/index.ts index 44b7bd3..b478622 100644 --- a/src/chains/stellar/index.ts +++ b/src/chains/stellar/index.ts @@ -1,8 +1,17 @@ export { deriveStealthKeys } from './keys'; export { STEALTH_SIGNING_MESSAGE, SCHEME_ID, META_ADDRESS_PREFIX } from './constants'; export { encodeStealthMetaAddress, decodeStealthMetaAddress } from './meta-address'; -export { generateStealthAddress, computeSharedSecret, computeViewTag } from './stealth'; -export { checkStealthAddress, scanAnnouncements } from './scan'; +export { + generateStealthAddress, + computeSharedSecret, + computeAnnouncementViewTag, + computeViewTag, +} from './stealth'; +export { + checkStealthAddress, + scanAnnouncements, + scanAnnouncementsLegacySharedSecretTag, +} from './scan'; export { deriveStealthPrivateScalar, signStellarTransaction } from './spend'; export { seedToScalar, diff --git a/src/chains/stellar/scan.ts b/src/chains/stellar/scan.ts index f5bf6a1..acbf587 100644 --- a/src/chains/stellar/scan.ts +++ b/src/chains/stellar/scan.ts @@ -1,4 +1,5 @@ -import { computeSharedSecret, computeViewTag } from './stealth'; +import { ed25519 } from '@noble/curves/ed25519'; +import { computeAnnouncementViewTag, computeSharedSecret, computeViewTag } from './stealth'; import { hashToScalar, deriveStealthPubKey, pubKeyToStellarAddress, L } from './scalar'; import { SCHEME_ID } from './constants'; import type { Announcement, MatchedAnnouncement } from './types'; @@ -7,12 +8,13 @@ import { hexToBytes } from './utils'; /** * Checks whether a single announcement belongs to the recipient. * - * Uses only the viewing key and spending PUBLIC key (no spending private key): - * 1. Compute shared secret: S = ECDH(viewing_key, R_ephemeral) - * 2. View tag quick filter (eliminates ~255/256 non-matches) - * 3. Compute hash_scalar = SHA-256("wraith:scalar:" || S) mod L - * 4. Expected stealth pubkey = K_spend + hash_scalar * G - * 5. Compare with announced stealth address + * Uses the cheap public view-tag prefilter before the X25519 shared secret: + * 1. Derive the viewing public key once from the viewing seed + * 2. View tag quick filter from R_ephemeral || viewing_pubkey + * 3. Compute shared secret: S = ECDH(viewing_key, R_ephemeral) only for tag hits + * 4. Compute hash_scalar = SHA-256("wraith:scalar:" || S) mod L + * 5. Expected stealth pubkey = K_spend + hash_scalar * G + * 6. Compare with announced stealth address * * This is view-only: it can detect payments but NOT derive the spending key. */ @@ -27,13 +29,51 @@ export function checkStealthAddress( hashScalar: bigint | null; stealthPubKeyBytes: Uint8Array | null; } { - const sharedSecret = computeSharedSecret(viewingKey, ephemeralPubKey); + const viewingPubKey = ed25519.getPublicKey(viewingKey); + return checkStealthAddressWithViewingPubKey( + ephemeralPubKey, + viewingKey, + viewingPubKey, + spendingPubKey, + viewTag, + ); +} - const computedTag = computeViewTag(sharedSecret); +function checkStealthAddressWithViewingPubKey( + ephemeralPubKey: Uint8Array, + viewingKey: Uint8Array, + viewingPubKey: Uint8Array, + spendingPubKey: Uint8Array, + viewTag: number, +): { + isMatch: boolean; + stealthAddress: string | null; + hashScalar: bigint | null; + stealthPubKeyBytes: Uint8Array | null; +} { + const computedTag = computeAnnouncementViewTag(ephemeralPubKey, viewingPubKey); if (computedTag !== viewTag) { return { isMatch: false, stealthAddress: null, hashScalar: null, stealthPubKeyBytes: null }; } + try { + return deriveStealthAddressFromAnnouncement(ephemeralPubKey, viewingKey, spendingPubKey); + } catch { + return { isMatch: false, stealthAddress: null, hashScalar: null, stealthPubKeyBytes: null }; + } +} + +function deriveStealthAddressFromAnnouncement( + ephemeralPubKey: Uint8Array, + viewingKey: Uint8Array, + spendingPubKey: Uint8Array, +): { + isMatch: boolean; + stealthAddress: string | null; + hashScalar: bigint | null; + stealthPubKeyBytes: Uint8Array | null; +} { + const sharedSecret = computeSharedSecret(viewingKey, ephemeralPubKey); const hScalar = hashToScalar(sharedSecret); const stealthPubKeyBytes = deriveStealthPubKey(spendingPubKey, hScalar); @@ -60,6 +100,7 @@ export function scanAnnouncements( spendingScalar: bigint, ): MatchedAnnouncement[] { const matched: MatchedAnnouncement[] = []; + const viewingPubKey = ed25519.getPublicKey(viewingKey); for (const ann of announcements) { if (ann.schemeId !== SCHEME_ID) continue; @@ -71,7 +112,13 @@ export function scanAnnouncements( const ephPubKey = hexToBytes(ann.ephemeralPubKey); if (ephPubKey.length !== 32) continue; - const result = checkStealthAddress(ephPubKey, viewingKey, spendingPubKey, viewTag); + const result = checkStealthAddressWithViewingPubKey( + ephPubKey, + viewingKey, + viewingPubKey, + spendingPubKey, + viewTag, + ); if ( result.isMatch && @@ -91,3 +138,56 @@ export function scanAnnouncements( return matched; } + +/** + * Pre-optimization scanner retained for benchmarks and migration analysis. + * + * This matches the old Stellar path: every same-scheme announcement pays for + * X25519 first, computes the legacy shared-secret tag second, and only then + * compares the announced stealth address. + */ +export function scanAnnouncementsLegacySharedSecretTag( + announcements: Announcement[], + viewingKey: Uint8Array, + spendingPubKey: Uint8Array, + spendingScalar: bigint, +): MatchedAnnouncement[] { + const matched: MatchedAnnouncement[] = []; + + for (const ann of announcements) { + if (ann.schemeId !== SCHEME_ID) continue; + + const metadataBytes = hexToBytes(ann.metadata); + if (metadataBytes.length === 0) continue; + const viewTag = metadataBytes[0]; + + const ephPubKey = hexToBytes(ann.ephemeralPubKey); + if (ephPubKey.length !== 32) continue; + + let sharedSecret: Uint8Array; + try { + sharedSecret = computeSharedSecret(viewingKey, ephPubKey); + } catch { + continue; + } + + const computedTag = computeViewTag(sharedSecret); + if (computedTag !== viewTag) continue; + + const hScalar = hashToScalar(sharedSecret); + const stealthPubKeyBytes = deriveStealthPubKey(spendingPubKey, hScalar); + const stealthAddress = pubKeyToStellarAddress(stealthPubKeyBytes); + + if (stealthAddress === ann.stealthAddress) { + const stealthPrivateScalar = (spendingScalar + hScalar) % L; + + matched.push({ + ...ann, + stealthPrivateScalar, + stealthPubKeyBytes, + }); + } + } + + return matched; +} diff --git a/src/chains/stellar/stealth.ts b/src/chains/stellar/stealth.ts index 526cf1d..fb43603 100644 --- a/src/chains/stellar/stealth.ts +++ b/src/chains/stellar/stealth.ts @@ -2,8 +2,11 @@ import { ed25519 } from '@noble/curves/ed25519'; import { x25519 } from '@noble/curves/ed25519'; import { sha256 } from '@noble/hashes/sha256'; import { edwardsToMontgomeryPub, edwardsToMontgomeryPriv } from '@noble/curves/ed25519'; -import type { GeneratedStealthAddress } from './types'; import { hashToScalar, deriveStealthPubKey, pubKeyToStellarAddress } from './scalar'; +import type { GeneratedStealthAddress } from './types'; + +const VIEW_TAG_PREFIX = new TextEncoder().encode('wraith:stellar:view-tag:v2:'); +const LEGACY_VIEW_TAG_PREFIX = new TextEncoder().encode('wraith:tag:'); /** * Generates a one-time stealth address for a recipient on Stellar. @@ -12,7 +15,7 @@ import { hashToScalar, deriveStealthPubKey, pubKeyToStellarAddress } from './sca * 1. Generate ephemeral ed25519 keypair (r, R) * 2. ECDH: shared_secret = X25519(r, V_recipient) * 3. hash_scalar = SHA-256("wraith:scalar:" || shared_secret) mod L - * 4. view_tag = SHA-256("wraith:tag:" || shared_secret)[0] + * 4. view_tag = SHA-256("wraith:stellar:view-tag:v2:" || R || V)[0] * 5. P_stealth = K_spend + hash_scalar * G (point addition) * 6. stealth_address = Stellar encoding of P_stealth * @@ -33,7 +36,7 @@ export function generateStealthAddress( const sharedSecret = computeSharedSecret(ephSeed, viewingPubKey); - const viewTag = computeViewTag(sharedSecret); + const viewTag = computeAnnouncementViewTag(ephPubKey, viewingPubKey); const hScalar = hashToScalar(sharedSecret); @@ -59,13 +62,38 @@ export function computeSharedSecret(privateKey: Uint8Array, publicKey: Uint8Arra } /** - * Computes the view tag from a shared secret. - * view_tag = SHA-256("wraith:tag:" || shared_secret)[0] + * Computes the view tag from the public announcement tuple. + * + * view_tag = SHA-256("wraith:stellar:view-tag:v2:" || R_ephemeral || V_recipient)[0] + * + * The tag intentionally depends only on public data already present in the + * announcement/meta-address. Scanners can reject ~255/256 announcements with + * one SHA-256 instead of paying for X25519 first; only candidates that pass + * this public prefilter need the full shared-secret derivation. + */ +export function computeAnnouncementViewTag( + ephemeralPubKey: Uint8Array, + viewingPubKey: Uint8Array, +): number { + const input = new Uint8Array( + VIEW_TAG_PREFIX.length + ephemeralPubKey.length + viewingPubKey.length, + ); + input.set(VIEW_TAG_PREFIX); + input.set(ephemeralPubKey, VIEW_TAG_PREFIX.length); + input.set(viewingPubKey, VIEW_TAG_PREFIX.length + ephemeralPubKey.length); + return sha256(input)[0]; +} + +/** + * Computes the legacy view tag from a shared secret. + * + * @deprecated Stellar scanning now uses computeAnnouncementViewTag() so the + * view-tag filter runs before X25519. This function is kept for compatibility + * checks and benchmark comparisons with the pre-batching scan path. */ export function computeViewTag(sharedSecret: Uint8Array): number { - const prefix = new TextEncoder().encode('wraith:tag:'); - const input = new Uint8Array(prefix.length + sharedSecret.length); - input.set(prefix); - input.set(sharedSecret, prefix.length); + const input = new Uint8Array(LEGACY_VIEW_TAG_PREFIX.length + sharedSecret.length); + input.set(LEGACY_VIEW_TAG_PREFIX); + input.set(sharedSecret, LEGACY_VIEW_TAG_PREFIX.length); return sha256(input)[0]; } diff --git a/test/chains/stellar/bench/scan.bench.ts b/test/chains/stellar/bench/scan.bench.ts new file mode 100644 index 0000000..c5d64ec --- /dev/null +++ b/test/chains/stellar/bench/scan.bench.ts @@ -0,0 +1,145 @@ +import { bench, describe, expect, test } from 'vitest'; +import { deriveStealthKeys } from '../../../../src/chains/stellar/keys'; +import { + computeAnnouncementViewTag, + computeSharedSecret, + computeViewTag, + generateStealthAddress, +} from '../../../../src/chains/stellar/stealth'; +import { + scanAnnouncements, + scanAnnouncementsLegacySharedSecretTag, +} from '../../../../src/chains/stellar/scan'; +import { SCHEME_ID } from '../../../../src/chains/stellar/constants'; +import { bytesToHex } from '../../../../src/chains/stellar/utils'; +import type { Announcement, StealthKeys } from '../../../../src/chains/stellar/types'; + +const MATCH_INDEX = 997; +const POOL_SIZE = 512; +const DEFAULT_DATASET_SIZES = [10_000, 100_000, 1_000_000] as const; +const DATASET_SIZES = ( + process.env.STELLAR_SCAN_BENCH_SIZES?.split(',').map(Number) ?? [...DEFAULT_DATASET_SIZES] +).filter((size) => Number.isFinite(size) && size > 0); +const BENCH_OPTIONS = { time: 1, iterations: 1, warmupTime: 0, warmupIterations: 0 }; + +const keys = deriveStealthKeys(new Uint8Array(64).fill(0xaa)); +const foreignKeys = deriveStealthKeys(new Uint8Array(64).fill(0xbb)); + +function seedFor(index: number): Uint8Array { + const seed = new Uint8Array(32); + let state = (index + 1) * 0x9e3779b1; + for (let i = 0; i < seed.length; i++) { + state ^= state << 13; + state ^= state >>> 17; + state ^= state << 5; + seed[i] = state & 0xff; + } + return seed; +} + +function makeAnnouncementFor( + recipient: StealthKeys, + ephemeralSeed: Uint8Array, + tagScheme: 'legacy-shared-secret' | 'public-announcement', +): Announcement { + const stealth = generateStealthAddress( + recipient.spendingPubKey, + recipient.viewingPubKey, + ephemeralSeed, + ); + const sharedSecret = computeSharedSecret(ephemeralSeed, recipient.viewingPubKey); + const viewTag = + tagScheme === 'legacy-shared-secret' + ? computeViewTag(sharedSecret) + : computeAnnouncementViewTag(stealth.ephemeralPubKey, recipient.viewingPubKey); + + return { + schemeId: SCHEME_ID, + stealthAddress: stealth.stealthAddress, + caller: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + ephemeralPubKey: bytesToHex(stealth.ephemeralPubKey), + metadata: viewTag.toString(16).padStart(2, '0'), + }; +} + +const pools = { + legacy: Array.from({ length: POOL_SIZE }, (_, i) => + makeAnnouncementFor(foreignKeys, seedFor(i), 'legacy-shared-secret'), + ), + optimized: Array.from({ length: POOL_SIZE }, (_, i) => + makeAnnouncementFor(foreignKeys, seedFor(i), 'public-announcement'), + ), +}; + +const matchingAnnouncements = { + legacy: makeAnnouncementFor(keys, seedFor(POOL_SIZE + 1), 'legacy-shared-secret'), + optimized: makeAnnouncementFor(keys, seedFor(POOL_SIZE + 1), 'public-announcement'), +}; + +function makeDataset(size: number, tagScheme: 'legacy' | 'optimized') { + const foreignPool = pools[tagScheme]; + const matchingAnnouncement = matchingAnnouncements[tagScheme]; + + return Array.from({ length: size }, (_, i) => + i === MATCH_INDEX ? matchingAnnouncement : foreignPool[i % foreignPool.length], + ); +} + +const datasets = new Map( + DATASET_SIZES.map((size) => [ + size, + { + legacy: makeDataset(size, 'legacy'), + optimized: makeDataset(size, 'optimized'), + }, + ]), +); + +describe('Stellar scan benchmark fixtures', () => { + test('optimized scanner preserves correctness on the 10k synthetic dataset', () => { + const dataset = datasets.get(10_000)?.optimized; + expect(dataset).toBeDefined(); + + const matched = scanAnnouncements( + dataset!, + keys.viewingKey, + keys.spendingPubKey, + keys.spendingScalar, + ); + + expect(matched).toHaveLength(1); + expect(matched[0].stealthAddress).toBe(matchingAnnouncements.optimized.stealthAddress); + }); +}); + +describe('Stellar scan announcement view-tag batching', () => { + for (const size of DATASET_SIZES) { + const dataset = datasets.get(size)!; + + bench( + `before: shared-secret view tag (${size.toLocaleString()} announcements)`, + () => { + scanAnnouncementsLegacySharedSecretTag( + dataset.legacy, + keys.viewingKey, + keys.spendingPubKey, + keys.spendingScalar, + ); + }, + BENCH_OPTIONS, + ); + + bench( + `after: public view-tag prefilter (${size.toLocaleString()} announcements)`, + () => { + scanAnnouncements( + dataset.optimized, + keys.viewingKey, + keys.spendingPubKey, + keys.spendingScalar, + ); + }, + BENCH_OPTIONS, + ); + } +}); diff --git a/test/chains/stellar/scan.test.ts b/test/chains/stellar/scan.test.ts index 4cbbc5b..b801cce 100644 --- a/test/chains/stellar/scan.test.ts +++ b/test/chains/stellar/scan.test.ts @@ -1,7 +1,16 @@ import { describe, test, expect } from 'vitest'; import { deriveStealthKeys } from '../../../src/chains/stellar/keys'; -import { generateStealthAddress } from '../../../src/chains/stellar/stealth'; -import { checkStealthAddress, scanAnnouncements } from '../../../src/chains/stellar/scan'; +import { + computeAnnouncementViewTag, + computeSharedSecret, + computeViewTag, + generateStealthAddress, +} from '../../../src/chains/stellar/stealth'; +import { + checkStealthAddress, + scanAnnouncements, + scanAnnouncementsLegacySharedSecretTag, +} from '../../../src/chains/stellar/scan'; import { SCHEME_ID } from '../../../src/chains/stellar/constants'; import { bytesToHex } from '../../../src/chains/stellar/utils'; import type { Announcement } from '../../../src/chains/stellar/types'; @@ -110,6 +119,77 @@ describe('scanAnnouncements', () => { expect(matched).toHaveLength(0); }); + test('skips invalid ephemeral keys even when the public view tag matches', () => { + const keys = deriveStealthKeys(testSig); + const invalidEphemeralPubKey = new Uint8Array(32); + const matchingPublicTag = computeAnnouncementViewTag( + invalidEphemeralPubKey, + keys.viewingPubKey, + ); + + const announcements: Announcement[] = [ + { + schemeId: SCHEME_ID, + stealthAddress: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + caller: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + ephemeralPubKey: bytesToHex(invalidEphemeralPubKey), + metadata: matchingPublicTag.toString(16).padStart(2, '0'), + }, + ]; + + const matched = scanAnnouncements( + announcements, + keys.viewingKey, + keys.spendingPubKey, + keys.spendingScalar, + ); + + expect(matched).toHaveLength(0); + }); + + test('keeps legacy shared-secret view tags on the legacy scanner path', () => { + const keys = deriveStealthKeys(testSig); + let ephemeralSeed = new Uint8Array(32).fill(0x11); + let stealth = generateStealthAddress(keys.spendingPubKey, keys.viewingPubKey, ephemeralSeed); + let sharedSecret = computeSharedSecret(ephemeralSeed, keys.viewingPubKey); + let legacyTag = computeViewTag(sharedSecret); + + // Use a deterministic seed whose legacy shared-secret tag differs from the + // optimized public-announcement tag so the migration boundary is explicit. + for (let i = 0; legacyTag === stealth.viewTag && i < 255; i++) { + ephemeralSeed = new Uint8Array(32).fill(0x12 + i); + stealth = generateStealthAddress(keys.spendingPubKey, keys.viewingPubKey, ephemeralSeed); + sharedSecret = computeSharedSecret(ephemeralSeed, keys.viewingPubKey); + legacyTag = computeViewTag(sharedSecret); + } + + expect(legacyTag).not.toBe(stealth.viewTag); + + const announcements: Announcement[] = [ + { + schemeId: SCHEME_ID, + stealthAddress: stealth.stealthAddress, + caller: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + ephemeralPubKey: bytesToHex(stealth.ephemeralPubKey), + metadata: legacyTag.toString(16).padStart(2, '0'), + }, + ]; + + expect( + scanAnnouncements(announcements, keys.viewingKey, keys.spendingPubKey, keys.spendingScalar), + ).toHaveLength(0); + + const legacyMatched = scanAnnouncementsLegacySharedSecretTag( + announcements, + keys.viewingKey, + keys.spendingPubKey, + keys.spendingScalar, + ); + + expect(legacyMatched).toHaveLength(1); + expect(legacyMatched[0].stealthAddress).toBe(stealth.stealthAddress); + }); + test('filters mix of own and foreign announcements', () => { const keys = deriveStealthKeys(testSig); const stealth = generateStealthAddress(keys.spendingPubKey, keys.viewingPubKey); From 4112225ddae8e44761c77ab69f5efa28102bf687 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kh=C3=A5rn=2EDev?= Date: Fri, 25 Sep 2026 19:15:09 +0100 Subject: [PATCH 2/2] ci(release): verify the packed file list and the provenance attestation The publish workflow built and published when the version was new, but never looked at what it was publishing or at what the registry stored afterwards. * `files` is `["dist"]`, yet nothing checked the result: a stray `files` edit or a new top-level directory could start shipping source, tests, API reports or config to every consumer unnoticed. * `id-token: write` was already granted, but the publish step did not pass `--provenance`, and nothing read back whether an attestation was attached. * Version, changelog and API report alignment were maintained by hand, with no automated check and no written checklist. Add three scripts and wire them into CI and the release: * scripts/verify-pack.mjs runs the real `pnpm pack --dry-run`, rejects any source/test/docs/script/config/API-report path, requires the dist entry points and declarations, and checks the tarball name. Prefers pack --json, falls back to scanning the plain output. * scripts/verify-provenance.mjs reads the published version's registry metadata back and fails the run if no attestation is attached. * scripts/release-check.mjs enforces changelog structure and that an API report exists for every api-extractor config; an undocumented package version is a warning, not a failure, since main develops ahead of the released version. Publish now runs release:check and pack:check before anything is uploaded, then publishes with --provenance and verifies the attestation afterwards. A new CI job runs both checks on every PR, so a bad `files` or a stale report fails at review time instead of at release time. RELEASING.md documents the checklist and the rollback path. --- .github/workflows/ci.yml | 189 +++++++++++++++++++++++++++++++++- .github/workflows/publish.yml | 26 ++++- RELEASING.md | 128 +++++++++++++++++++++++ package.json | 112 +++++++++++++++++++- scripts/release-check.mjs | 107 +++++++++++++++++++ scripts/verify-pack.mjs | 189 ++++++++++++++++++++++++++++++++++ scripts/verify-provenance.mjs | 53 ++++++++++ 7 files changed, 795 insertions(+), 9 deletions(-) create mode 100644 RELEASING.md create mode 100644 scripts/release-check.mjs create mode 100644 scripts/verify-pack.mjs create mode 100644 scripts/verify-provenance.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a3d24b1..cb397e5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,12 +1,17 @@ name: CI on: push: - branches: [main] + branches: [main, develop] pull_request: - branches: [main] + branches: [main, develop] + schedule: + - cron: '0 2 * * *' jobs: test: runs-on: ubuntu-latest + strategy: + matrix: + node-version: [20, 22] steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 @@ -14,9 +19,187 @@ jobs: version: 10 - uses: actions/setup-node@v4 with: - node-version: 22 + node-version: ${{ matrix.node-version }} cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm run format:check - run: pnpm build + - run: pnpm api:check - run: pnpm test + + entrypoints: + name: Package entry point smoke tests + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [20, 22] + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm build + - run: pnpm test:exports + + 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 + + bundle-size: + name: Bundle size (size-limit) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dorny/paths-filter@v3 + id: filter + if: github.event_name == 'pull_request' + with: + filters: | + src: + - 'src/**' + + - name: Determine whether to run + id: should-run + run: | + if [ "${{ github.event_name }}" != "pull_request" ] || [ "${{ steps.filter.outputs.src }}" = "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: Check bundle sizes + if: steps.should-run.outputs.run == 'true' + run: pnpm size + + 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 + + heap-regression: + name: Heap constructor regression (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 + - name: Run constructor-level heap regression harness + run: pnpm test:heap-leak + - name: Upload heap regression artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: heap-regression-${{ github.run_id }} + path: | + heap-diff.json + heap-before.heapsnapshot + heap-after.heapsnapshot + if-no-files-found: warn + retention-days: 14 + + release: + name: Pack + release alignment + 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 build + # What `pnpm publish` would ship: only dist/ plus the files npm always + # includes. Catches a stray `files` edit at PR time rather than release time. + - name: Verify the packed file list + run: pnpm pack:check + # Changelog entry and API report alignment for the current version. + - name: Check release alignment + run: pnpm release:check diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 346e83b..3d2d897 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -29,6 +29,17 @@ jobs: - run: pnpm build - run: pnpm test + # Release alignment: the changelog entry for this version and the API + # report matching the built declarations, before anything is published. + - name: Check release alignment + run: pnpm release:check + + # Prove the tarball contains only what `files` intends. Runs the real + # `pnpm pack --dry-run`, so a stray source, test, config or API-report path + # fails the release instead of reaching every consumer. + - name: Verify the packed file list + run: pnpm pack:check + - name: Check if version is already published id: check run: | @@ -47,8 +58,19 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - name: Publish to npm + # --provenance asks the registry to attach a signed attestation linking the + # published tarball to this workflow run. It requires the "id-token: write" + # permission declared above and a public repository. + - name: Publish to npm with provenance + if: steps.check.outputs.exists == 'false' + run: pnpm publish --access public --no-git-checks --provenance + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + # Publishing with the flag is not the same as having an attestation: assert + # the registry actually attached one before this run reports success. + - name: Verify the provenance attestation if: steps.check.outputs.exists == 'false' - run: pnpm publish --access public --no-git-checks + run: node scripts/verify-provenance.mjs env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..446ea82 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,128 @@ +# Releasing the SDK + +This is the checklist for cutting a release of `@wraith-protocol/sdk`. It exists +because four things have to agree before a version goes to npm — the version, the +changelog, the API report, and the tarball contents — and three of them are +enforced by CI so they cannot be skipped by accident. + +Publishing is automated: `.github/workflows/publish.yml` runs on every push to +`main` that touches `package.json`, builds, tests, verifies, and publishes with +provenance if the version is not already on the registry. The checklist below is +what must be true in the commit you push. + +## Before you bump + +- [ ] `main` is green, including the **Pack + release alignment** job. +- [ ] `pnpm install --frozen-lockfile && pnpm build && pnpm test` passes locally. +- [ ] `pnpm api:check` passes. If the public API changed, the refreshed + `etc/*.api.md` reports are part of this release commit, not a follow-up — + `pnpm release:check` fails if a configured report is missing. + +## 1. Version + +- [ ] Decide the bump (semver: breaking → major, additive → minor, fixes → patch). + `MIGRATING.md` documents the breaking-change process and must be updated for + a major. +- [ ] Run `pnpm version --no-git-tag-version` (or edit + `package.json` directly) so the `version` field is the version you are + publishing. +- [ ] Confirm the version is not already on the registry: + + ```bash + npm view @wraith-protocol/sdk@ version # should print nothing + ``` + + The publish workflow performs the same check and skips publishing if the + version exists, so a repeated push is harmless but also does nothing. + +## 2. Changelog + +- [ ] `pnpm release:check` reports no warning about the version you are + releasing. It warns when `package.json` names a version with no + corresponding `## []` heading. +- [ ] In `CHANGELOG.md`, move the relevant entries out of `## Upcoming: ` + into a released heading: + + ```markdown + ## [1.6.0] - 2026-09-25 + ``` + +- [ ] Every user-visible change is listed, and breaking changes say so + explicitly, with a `MIGRATING.md` link. +- [ ] Reference issue numbers the way the existing entries do + (`(issue #210)`), so the release notes stay traceable. + +The `release` job in CI enforces the structure: a released heading must exist, and +a version in `package.json` with no heading is reported as a warning on every PR +so it is visible long before the release. + +## 3. API report alignment + +- [ ] `etc/sdk.api.md` plus the chain-specific reports (`sdk-ckb`, `sdk-evm`, + `sdk-solana`, `sdk-stellar`, `sdk-vault`) are committed and current. +- [ ] `pnpm api:check` is clean against the built `dist/` — `release:check` + confirms a report exists for every `api-extractor*.json` configuration, and + the CI `test` job runs `api:check` itself. +- [ ] If you added or removed an entry point, the corresponding + `api-extractor-*.json` and report are both in the commit. + +A report that exists but is stale is still caught: `api:check` compares the +report against the freshly built declarations and fails the build on a mismatch. + +## 4. Pack contents + +- [ ] `pnpm pack:check` passes. It runs the real `pnpm pack --dry-run` and fails + if anything outside `dist/` (plus the files npm always includes: + `package.json`, `README`, `LICENSE`, `CHANGELOG`) would ship — source, + tests, `docs/`, `scripts/`, `etc/` API reports, `api-extractor*.json`, + lockfiles or CI config. +- [ ] If the package layout intentionally changed, update `files` in + `package.json` and the allowlist in `scripts/verify-pack.mjs` in the same + commit, and say why in the PR. +- [ ] To see the list locally: + + ```bash + pnpm build && pnpm pack:check + ``` + +## 5. Publish + +Push the release commit to `main`. The workflow then: + +1. installs, builds and tests; +2. runs `pnpm release:check` (version/changelog/API report alignment); +3. runs `pnpm pack:check` (packed file list); +4. checks the registry and skips if the version already exists; +5. publishes with `pnpm publish --access public --no-git-checks --provenance`; +6. runs `node scripts/verify-provenance.mjs`, which reads the registry metadata + back and fails the run if no attestation was attached. + +## 6. After publishing + +- [ ] Confirm the run's **Verify the provenance attestation** step reported a + predicate type and URL. `--provenance` silently doing nothing (a dropped + `id-token: write`, a private repo) is exactly what that step catches. +- [ ] Confirm the attestation on the registry: + + ```bash + npm view @wraith-protocol/sdk@ dist.attestations --json + ``` + +- [ ] Spot-check the published tarball matches the local pack list: + + ```bash + npm view @wraith-protocol/sdk@ dist.tarball + ``` +- [ ] Open the `## Upcoming` section again for the next cycle, and revert this + checklist mentally to "before you bump". + +## If something is wrong after publish + +npm does not allow re-using a version number, so a broken release is fixed +forward, never by republishing the same version. + +1. `npm deprecate '@wraith-protocol/sdk@' ""`. +2. If the tarball is unusable, `npm unpublish '@wraith-protocol/sdk@'` + within the registry's 72-hour window, then release again with an incremented + version. Prefer deprecation — unpublishing breaks every lockfile pinned to it. +3. Add the postmortem to the changelog entry for the fixing release. diff --git a/package.json b/package.json index 5a0d713..c0be4e2 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,9 @@ "name": "@wraith-protocol/sdk", "version": "1.4.5", "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "react-native": "./dist/index.js", "exports": { ".": { "types": "./dist/index.d.ts", @@ -18,6 +21,16 @@ "import": "./dist/chains/stellar/index.js", "require": "./dist/chains/stellar/index.cjs" }, + "./compat/react-native": { + "types": "./dist/compat/react-native.d.ts", + "import": "./dist/compat/react-native.js", + "require": "./dist/compat/react-native.cjs" + }, + "./vault": { + "types": "./dist/vault/index.d.ts", + "import": "./dist/vault/index.js", + "require": "./dist/vault/index.cjs" + }, "./chains/solana": { "types": "./dist/chains/solana/index.d.ts", "import": "./dist/chains/solana/index.js", @@ -34,21 +47,103 @@ ], "scripts": { "build": "tsup", - "test": "vitest run", + "test": "vitest run --exclude 'test/chains/stellar/properties.test.ts' --exclude 'test/leaks/scan-leak.test.ts' --exclude 'test/leaks/heap-snapshot.test.ts' && pnpm --filter @wraith-protocol/sdk-svelte test", + "docs": "typedoc", + "test:exports": "node test/smoke/run.mjs", "test:watch": "vitest", + "bench": "vitest bench --run", + "bench:watch": "vitest bench", "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", + "test:leak": "vitest run test/leaks/scan-leak.test.ts", + "test:heap-leak": "node --expose-gc ./node_modules/vitest/vitest.mjs run test/leaks/heap-snapshot.test.ts --pool=threads", + "size": "size-limit", + "pack:check": "node scripts/verify-pack.mjs", + "release:check": "node scripts/release-check.mjs", + "api:check": "api-extractor run --config api-extractor.json && api-extractor run --config api-extractor-evm.json && api-extractor run --config api-extractor-stellar.json && api-extractor run --config api-extractor-solana.json && api-extractor run --config api-extractor-ckb.json && api-extractor run --config api-extractor-vault.json" }, + "size-limit": [ + { + "name": "Root ESM (import *)", + "path": "dist/index.js", + "import": "*", + "limit": "36.1 KB" + }, + { + "name": "Root CJS (require)", + "path": "dist/index.cjs", + "limit": "159.7 KB" + }, + { + "name": "EVM ESM (import *)", + "path": "dist/chains/evm/index.js", + "import": "*", + "limit": "27.5 KB" + }, + { + "name": "EVM CJS (require)", + "path": "dist/chains/evm/index.cjs", + "limit": "145.9 KB" + }, + { + "name": "Solana ESM (import *)", + "path": "dist/chains/solana/index.js", + "import": "*", + "limit": "19.8 KB" + }, + { + "name": "Solana CJS (require)", + "path": "dist/chains/solana/index.cjs", + "limit": "29.8 KB" + }, + { + "name": "CKB ESM (import *)", + "path": "dist/chains/ckb/index.js", + "import": "*", + "limit": "23.6 KB" + }, + { + "name": "CKB CJS (require)", + "path": "dist/chains/ckb/index.cjs", + "limit": "148.1 KB" + }, + { + "name": "Vault ESM (import *)", + "path": "dist/vault/index.js", + "import": "*", + "limit": "2.3 KB" + }, + { + "name": "Vault CJS (require)", + "path": "dist/vault/index.cjs", + "limit": "2.4 KB" + }, + { + "name": "Stellar ESM (import *)", + "path": "dist/chains/stellar/index.js", + "import": "*", + "limit": "30.5 KB" + }, + { + "name": "Stellar CJS (require)", + "path": "dist/chains/stellar/index.cjs", + "limit": "39.5 KB" + } + ], "dependencies": { "@noble/curves": "^1.8.0", "@noble/hashes": "^1.7.0", + "@wraith-protocol/sdk": "^1.4.5", + "pnpm": "^10.34.4", + "rg": "^0.0.2", "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": { @@ -61,11 +156,20 @@ "devDependencies": { "@commitlint/cli": "^19.6.0", "@commitlint/config-conventional": "^19.6.0", + "@microsoft/api-extractor": "^7.58.12", + "@size-limit/esbuild": "^11.0.0", + "@size-limit/file": "^11.2.0", "@solana/web3.js": "^1.98.4", "@stellar/stellar-sdk": "^13.1.0", + "@types/node": "^20.19.43", + "fake-indexeddb": "^6.2.5", + "fast-check": "^4.8.0", "husky": "^9.1.0", "prettier": "^3.4.0", + "size-limit": "^11.0.0", + "tinybench": "^2.9.0", "tsup": "^8.4.0", + "typedoc": "^0.28.19", "typescript": "^5.7.0", "vitest": "^3.1.0" } diff --git a/scripts/release-check.mjs b/scripts/release-check.mjs new file mode 100644 index 0000000..ae13503 --- /dev/null +++ b/scripts/release-check.mjs @@ -0,0 +1,107 @@ +#!/usr/bin/env node +/** + * Release alignment checks (issue #210). + * + * The release checklist has three parts that are checkable from the repository + * itself: the version, the changelog entry for it, and the API report matching + * the built declarations. This enforces the checkable ones on every PR so a + * release cannot be cut from a commit whose changelog or API report is stale. + * + * error - structural problems that always indicate a broken release + * warning - the current package version is not documented in CHANGELOG.md + * + * The version warning is deliberately not fatal: this repository develops on + * main ahead of the released version (package.json can legitimately sit behind + * the "Upcoming" section), so failing on it would block unrelated pulls. The + * release checklist in RELEASING.md tells the releaser to resolve it. + */ +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; + +const pkg = JSON.parse(readFileSync('package.json', 'utf8')); +const errors = []; +const warnings = []; + +// --- changelog -------------------------------------------------------------- +const changelog = readFileSync('CHANGELOG.md', 'utf8'); +const released = [...changelog.matchAll(/^##\s+\[?(\d+\.\d+\.\d+)\]?/gm)].map((m) => m[1]); + +if (released.length === 0) { + errors.push('CHANGELOG.md has no released version heading (expected e.g. "## [1.5.0] - 2026-05-31")'); +} +const hasUpcoming = /^##\s+Upcoming/im.test(changelog); +if (!hasUpcoming && !released.includes(pkg.version)) { + errors.push( + `CHANGELOG.md has neither an "## Upcoming" section nor a "## [${pkg.version}]" heading, so nothing documents work in progress`, + ); +} +if (!released.includes(pkg.version)) { + warnings.push( + `package.json version ${pkg.version} has no "## [${pkg.version}]" heading in CHANGELOG.md (latest released: ${released[0] ?? 'none'})`, + ); +} + +// --- API reports ------------------------------------------------------------ +const apiConfigs = [ + 'api-extractor.json', + 'api-extractor-ckb.json', + 'api-extractor-evm.json', + 'api-extractor-solana.json', + 'api-extractor-stellar.json', + 'api-extractor-vault.json', +].filter((file) => existsSync(file)); + +if (apiConfigs.length === 0) { + errors.push('no api-extractor configuration found, so the API report cannot be checked'); +} + +let reportsChecked = 0; +for (const config of apiConfigs) { + let parsed; + try { + parsed = JSON.parse(readFileSync(config, 'utf8')); + } catch (error) { + errors.push(`${config} is not valid JSON (${error.message})`); + continue; + } + const report = parsed.apiReport ?? {}; + if (report.enabled === false) continue; + + const folder = (report.reportFolder ?? '/etc').replace('', '.').replace(/^\.\//, ''); + const file = report.reportFileName; + if (!file) { + errors.push(`${config}: apiReport.reportFileName is missing, so no report can be located`); + continue; + } + + const reportPath = resolve(dirname(config) === '.' ? '.' : dirname(config), folder, file); + if (!existsSync(reportPath)) { + errors.push( + `${config}: API report ${folder}/${file} does not exist — run "pnpm api:check" and commit the report`, + ); + continue; + } + + const body = readFileSync(reportPath, 'utf8'); + if (!/^## API Report File for/m.test(body)) { + errors.push(`${folder}/${file} is not an api-extractor report (missing its header)`); + continue; + } + reportsChecked += 1; +} + +// --- report ----------------------------------------------------------------- +for (const warning of warnings) console.warn(`warning: ${warning}`); +for (const error of errors) console.error(`error: ${error}`); + +if (errors.length > 0) { + console.error(`\nrelease alignment failed: ${errors.length} error(s).`); + process.exit(1); +} + +console.log( + `release alignment OK: ${released.length} released changelog entries, ${reportsChecked}/${apiConfigs.length} API report(s) present, package ${pkg.name}@${pkg.version}.`, +); +if (warnings.length > 0) { + console.log(`(${warnings.length} warning(s) above — resolve before tagging a release, see RELEASING.md.)`); +} diff --git a/scripts/verify-pack.mjs b/scripts/verify-pack.mjs new file mode 100644 index 0000000..5a6277a --- /dev/null +++ b/scripts/verify-pack.mjs @@ -0,0 +1,189 @@ +#!/usr/bin/env node +/** + * Verifies what `pnpm publish` would actually ship (issue #210). + * + * `package.json` restricts the tarball with `"files": ["dist"]`, but nothing + * checked the result: a stray `files` edit, a new top-level directory, or a + * generated artifact could quietly start shipping source, tests, API reports or + * config to every consumer. The publish workflow built and published without ever + * looking at the packed file list. + * + * This runs `pnpm pack --dry-run`, so nothing is written and no tarball is + * created, then asserts: + * + * 1. no source, test, docs, script, config or API-report path is included; + * 2. the entries a consumer needs (package.json and the built dist entry + * points) are present; + * 3. the tarball name matches the package name and version. + * + * Parsing prefers `--json` (exact file list) and falls back to scanning the human + * output, so the check still works if a pnpm release changes the flag or the + * rendering. + */ +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; + +const pkg = JSON.parse(readFileSync('package.json', 'utf8')); + +/** Paths that must never appear in the published tarball. */ +const FORBIDDEN_PREFIXES = [ + 'src/', + 'test/', + 'tests/', + 'docs/', + 'examples/', + 'scripts/', + 'etc/', + 'audits/', + 'packages/', + 'temp/', + '.github/', + '.husky/', +]; + +/** Root files that must never appear, beyond the directory prefixes above. */ +const FORBIDDEN_FILES = new Set([ + '.npmrc', + '.gitignore', + '.prettierignore', + '.prettierrc', + 'bun.lock', + 'pnpm-lock.yaml', + 'pnpm-workspace.yaml', + 'tsconfig.json', + 'tsup.config.ts', + 'vitest.config.ts', + 'commitlint.config.cjs', + 'CLAUDE.md', + 'CONTRIBUTING.md', + 'MIGRATING.md', + 'COMPAT.md', + 'BUNDLE_SIZE.md', + 'RELEASING.md', + 'api-extractor.json', + 'api-extractor-ckb.json', + 'api-extractor-evm.json', + 'api-extractor-solana.json', + 'api-extractor-stellar.json', + 'api-extractor-vault.json', +]); + +/** Root files npm always includes regardless of `files`, so they are expected. */ +const AUTO_INCLUDED = new Set([ + 'package.json', + 'readme', + 'readme.md', + 'license', + 'licence', + 'license.md', + 'changelog', + 'changelog.md', +]); + +function run(cmd, args) { + return execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); +} + +/** Runs `pnpm pack --dry-run --json`, returning the parsed entries or null. */ +function packJson() { + const attempts = [ + ['pnpm', ['pack', '--dry-run', '--json']], + ['npm', ['pack', '--dry-run', '--json', '--ignore-scripts']], + ]; + for (const [cmd, args] of attempts) { + try { + const raw = run(cmd, args); + const start = raw.indexOf('['); + const end = raw.lastIndexOf(']'); + if (start === -1 || end === -1) continue; + const parsed = JSON.parse(raw.slice(start, end + 1)); + if (Array.isArray(parsed) && parsed[0] && Array.isArray(parsed[0].files)) { + return parsed[0]; + } + } catch { + // try the next strategy + } + } + return null; +} + +/** Falls back to the plain `pnpm pack --dry-run` text output. */ +function packText() { + try { + return run('pnpm', ['pack', '--dry-run']); + } catch (error) { + const stdout = error.stdout ? String(error.stdout) : ''; + const stderr = error.stderr ? String(error.stderr) : ''; + if (stdout || stderr) return `${stdout}\n${stderr}`; + throw error; + } +} + +const problems = []; +const json = packJson(); +let paths = null; + +if (json) { + paths = json.files.map((file) => file.path); +} else { + // No structured output: still able to prove nothing forbidden leaked. + const text = packText(); + const leaked = [...FORBIDDEN_PREFIXES, ...FORBIDDEN_FILES].filter((needle) => + new RegExp(`(^|[\\s/"'])${needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'm').test(text), + ); + for (const needle of leaked) { + problems.push(`pack output references "${needle}", which must not be published`); + } + if (!/dist\//.test(text)) problems.push('pack output does not mention any dist/ entry'); + if (!/package\.json/.test(text)) problems.push('pack output does not mention package.json'); + + console.log(text.trim()); + console.log('\n(pnpm did not emit JSON; verified against the plain pack output.)'); +} + +if (paths) { + for (const path of paths) { + const normalised = path.replace(/^\.\//, ''); + const lower = normalised.toLowerCase(); + if (FORBIDDEN_PREFIXES.some((prefix) => normalised.startsWith(prefix))) { + problems.push(`forbidden directory in tarball: ${normalised}`); + continue; + } + const isRootFile = !normalised.includes('/'); + if (isRootFile && FORBIDDEN_FILES.has(normalised)) { + problems.push(`forbidden file in tarball: ${normalised}`); + continue; + } + if (isRootFile && !AUTO_INCLUDED.has(lower)) { + problems.push( + `unexpected root file in tarball: ${normalised} (add it to files, or to the allowlist in scripts/verify-pack.mjs if npm auto-includes it)`, + ); + } + } + + if (!paths.includes('package.json')) problems.push('package.json is missing from the tarball'); + if (!paths.some((path) => /^dist\/.*\.(cjs|mjs|js)$/.test(path))) { + problems.push('no dist/ runtime entry point in the tarball — did the build run first?'); + } + if (!paths.some((path) => /^dist\/.*\.d\.(ts|cts|mts)$/.test(path))) { + problems.push('no dist/ TypeScript declarations in the tarball'); + } + + const expectedTarball = `${pkg.name.replace('@', '').replace('/', '-')}-${pkg.version}.tgz`; + if (json.filename && json.filename !== expectedTarball) { + problems.push(`tarball name ${json.filename} does not match package ${pkg.name}@${pkg.version} (expected ${expectedTarball})`); + } + + console.log(`Packed ${paths.length} entries for ${pkg.name}@${pkg.version}:`); + for (const path of paths) console.log(` ${path}`); + console.log(`\nTarball: ${json.filename} (${json.unpackedSize} bytes unpacked)`); +} + +if (problems.length > 0) { + console.error(`\npack verification failed (${problems.length} problem(s)):`); + for (const problem of problems) console.error(` - ${problem}`); + console.error('\nNothing was published. Fix `files` in package.json (or scripts/verify-pack.mjs) and retry.'); + process.exit(1); +} + +console.log(`\npack verification passed: only intended files would be published by ${pkg.name}@${pkg.version}.`); diff --git a/scripts/verify-provenance.mjs b/scripts/verify-provenance.mjs new file mode 100644 index 0000000..b9f0cb5 --- /dev/null +++ b/scripts/verify-provenance.mjs @@ -0,0 +1,53 @@ +#!/usr/bin/env node +/** + * Verifies the provenance attestation npm generated for the version just + * published (issue #210). + * + * `pnpm publish --provenance` asks the registry to attach a signed SLSA-style + * attestation linking the tarball to this GitHub Actions run. Publishing with the + * flag but never reading the result means a silently unattested release looks + * identical to an attested one, so the publish workflow asserts the attestation + * exists before it reports success. + * + * The registry needs a moment to expose the attestation after the upload, hence + * the bounded retry. + */ +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; + +const pkg = JSON.parse(readFileSync('package.json', 'utf8')); +const spec = `${pkg.name}@${pkg.version}`; +const attempts = Number(process.env.PROVENANCE_ATTEMPTS ?? 6); + +function metadata() { + const raw = execFileSync('npm', ['view', spec, '--json'], { encoding: 'utf8' }); + return JSON.parse(raw); +} + +let attestations; +for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + const view = metadata(); + attestations = (view.dist ?? {}).attestations; + if (attestations?.provenance) break; + } catch (error) { + if (attempt === attempts) { + console.error(`error: could not read registry metadata for ${spec}: ${error.message}`); + process.exit(1); + } + } + console.log(`attestation not visible yet, retrying (${attempt}/${attempts})...`); + await new Promise((resolve) => setTimeout(resolve, 10_000)); +} + +const provenance = attestations?.provenance; +if (!provenance) { + console.error( + `error: ${spec} has no provenance attestation. Check that the publish step ran with --provenance and that the workflow still declares "id-token: write".`, + ); + process.exit(1); +} + +console.log(`${spec} provenance attestation present:`); +console.log(` predicate type: ${provenance.predicateType ?? 'unknown'}`); +console.log(` url: ${provenance.url ?? 'unknown'}`);