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 4ed6605aa4c0b341f9da188da76e329f4ae3bc58 Mon Sep 17 00:00:00 2001 From: lockoabosede8-byte Date: Fri, 24 Jul 2026 10:53:35 +0000 Subject: [PATCH 2/2] feat(stellar): memo-encoded structured metadata schema (#140) --- CONTRIBUTING.md | 24 +- examples/react-native-stellar/polyfills.ts | 2 +- src/chains/stellar/index.ts | 10 + src/chains/stellar/memo/schema.ts | 172 ++++++++++++ test/chains/stellar/memo/schema.test.ts | 296 +++++++++++++++++++++ 5 files changed, 491 insertions(+), 13 deletions(-) create mode 100644 src/chains/stellar/memo/schema.ts create mode 100644 test/chains/stellar/memo/schema.test.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2bfb4b7..8c6cabf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,19 +10,19 @@ Use semantic versioning for every release: - **Minor** versions are for backward-compatible features, new chain support, and additive exports. - **Patch** versions are for backward-compatible fixes, documentation corrections, and internal-only changes. -| Change | Version bump | Examples | +| Change | Version bump | Examples | | ----------------------------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | -| New chain module | Minor | Adding `@wraith-protocol/sdk/chains/hedera` is minor because existing imports keep working. Adding `chains/stellar` helpers while preserving current Stellar exports is also minor. | -| New function exported from a chain module | Minor | Exporting `validateMetaAddress()` from `chains/stellar` is minor. Exporting `buildAnnouncementMemo()` from `chains/evm` is minor. | -| Function signature changed | Major | Changing `scanAnnouncements(announcements, viewingKey, spendingPubKey, spendingKey)` to accept one options object is major. Changing `generateStealthAddress()` to return renamed fields is major. | -| Function removed | Major | Removing `deriveStealthPrivateKey()` from EVM is major. Removing `decodeStealthMetaAddress()` from any chain module is major. | -| Crypto behavior changed | Major | Changing a domain-separation prefix is major. Changing the view-tag derivation scheme is major. | -| Type tightened | Major | Changing `string` to `` `0x${string}` `` for an accepted user input is major. Changing `Uint8Array` input to a fixed-length branded type is major unless the previous type still works. | -| Type loosened | Minor | Accepting `ReadonlyArray` where `Announcement[]` worked before is minor. Accepting `HexString | Uint8Array` is minor if existing callers still type-check. | -| Bundler config or `exports` changed | Major or patch | Removing a package subpath from `exports` is major. Adding a missing CommonJS condition for an existing subpath is patch if no import path changes. | -| Dependency major bumped | Major or minor | Bumping `@noble/curves` from 1 to 2 is major if it changes public types or runtime support. It can be minor if the SDK API and supported runtimes are unchanged. | -| Default network or RPC URL changed | Major or minor | Changing a default from mainnet to testnet is major. Rotating to an equivalent healthy RPC endpoint is minor if behavior is unchanged. | -| Bug fix that changes buggy behavior | Patch or major | Fixing an invalid checksum calculation is patch if it makes documented behavior work. Changing accepted malformed meta-addresses to throw is major if users may rely on parsing them. | +| New chain module | Minor | Adding `@wraith-protocol/sdk/chains/hedera` is minor because existing imports keep working. Adding `chains/stellar` helpers while preserving current Stellar exports is also minor. | +| New function exported from a chain module | Minor | Exporting `validateMetaAddress()` from `chains/stellar` is minor. Exporting `buildAnnouncementMemo()` from `chains/evm` is minor. | +| Function signature changed | Major | Changing `scanAnnouncements(announcements, viewingKey, spendingPubKey, spendingKey)` to accept one options object is major. Changing `generateStealthAddress()` to return renamed fields is major. | +| Function removed | Major | Removing `deriveStealthPrivateKey()` from EVM is major. Removing `decodeStealthMetaAddress()` from any chain module is major. | +| Crypto behavior changed | Major | Changing a domain-separation prefix is major. Changing the view-tag derivation scheme is major. | +| Type tightened | Major | Changing `string` to `` `0x${string}` `` for an accepted user input is major. Changing `Uint8Array` input to a fixed-length branded type is major unless the previous type still works. | +| Type loosened | Minor | Accepting `ReadonlyArray` where `Announcement[]` worked before is minor. Accepting `HexString | Uint8Array` is minor if existing callers still type-check. | +| Bundler config or `exports` changed | Major or patch | Removing a package subpath from `exports` is major. Adding a missing CommonJS condition for an existing subpath is patch if no import path changes. | +| Dependency major bumped | Major or minor | Bumping `@noble/curves` from 1 to 2 is major if it changes public types or runtime support. It can be minor if the SDK API and supported runtimes are unchanged. | +| Default network or RPC URL changed | Major or minor | Changing a default from mainnet to testnet is major. Rotating to an equivalent healthy RPC endpoint is minor if behavior is unchanged. | +| Bug fix that changes buggy behavior | Patch or major | Fixing an invalid checksum calculation is patch if it makes documented behavior work. Changing accepted malformed meta-addresses to throw is major if users may rely on parsing them. | When a change is ambiguous, choose the larger bump and document why in the changelog. diff --git a/examples/react-native-stellar/polyfills.ts b/examples/react-native-stellar/polyfills.ts index b64410c..e983080 100644 --- a/examples/react-native-stellar/polyfills.ts +++ b/examples/react-native-stellar/polyfills.ts @@ -14,7 +14,7 @@ if (typeof globalThis.atob === 'undefined') { let buffer; const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='; - for (let idx = 0; (buffer = base64.charAt(idx++)); ) { + for (let idx = 0; (buffer = base64.charAt(idx++));) { const code = chars.indexOf(buffer); if (code === -1) continue; bs = (bs << 6) | code; diff --git a/src/chains/stellar/index.ts b/src/chains/stellar/index.ts index d294b4b..5250db7 100644 --- a/src/chains/stellar/index.ts +++ b/src/chains/stellar/index.ts @@ -90,3 +90,13 @@ export type { export { encodeMemo, decodeMemo, extractMemoFromTransaction } from './memo'; export type { MemoType, MemoValue, TypedMemo } from './memo'; export { MemoValidationError, TEXT_MEMO_MAX_BYTES, HASH_MEMO_BYTES, ID_MEMO_MAX } from './memo'; +export { + encodeMemoSchema, + decodeMemoSchema, + MemoKind, + MEMO_SCHEMA_VERSION, + SCHEMA_MEMO_BYTES, + SCHEMA_HEADER_BYTES, + SCHEMA_MAX_DATA_BYTES, +} from './memo/schema'; +export type { MemoSchemaV1, DecodedMemoSchema } from './memo/schema'; diff --git a/src/chains/stellar/memo/schema.ts b/src/chains/stellar/memo/schema.ts new file mode 100644 index 0000000..e56ab31 --- /dev/null +++ b/src/chains/stellar/memo/schema.ts @@ -0,0 +1,172 @@ +/** + * Structured memo schema for Wraith Stellar memos. + * + * The schema encodes a { version, kind, data } triplet into exactly 32 bytes, + * fitting Stellar MEMO_HASH and MEMO_RETURN constraints. + * + * # Binary Layout (32 bytes) + * ``` + * [0] version uint8 – must be 0x01 for v1 + * [1] kind uint8 – MemoKind enum value + * [2] data_length uint8 – number of meaningful bytes in data (0–29) + * [3..31] data bytes – payload, zero-padded + * ``` + * + * # Backwards Compatibility + * If the first byte is NOT `0x01`, the buffer is treated as raw bytes and + * returned as a `raw` result. This ensures existing hash/return memos that + * happen not to carry the version marker decode gracefully instead of failing. + * + * # Extension Mechanism + * New kinds are added by extending the {@link MemoKind} enum with a new + * numeric value and updating the encoder/decoder if special handling is needed. + * The `decodeMemoSchema` function already forwards any kind value in the + * parsed result, so consumers can interpret custom kinds without changes + * to this module. + * + * Kind value ranges: + * - `0x01–0xEF` – Reserved for standardised kinds (add via PR). + * - `0xF0–0xFF` – Reserved for private / experimental use (no coordination needed). + * + * @module + */ + +/** Current schema version identifier. */ +export const MEMO_SCHEMA_VERSION = 0x01; + +/** Total byte length of a schema-encoded memo (matches MEMO_HASH / MEMO_RETURN). */ +export const SCHEMA_MEMO_BYTES = 32; + +/** Number of header bytes (version + kind + data_length). */ +export const SCHEMA_HEADER_BYTES = 3; + +/** Maximum number of payload bytes that fit in one schema memo. */ +export const SCHEMA_MAX_DATA_BYTES = SCHEMA_MEMO_BYTES - SCHEMA_HEADER_BYTES; // 29 + +/** + * Well-known memo kinds. + * + * Each variant carries a short description of the intended semantics and the + * expected data encoding so consumers know how to interpret the payload. + * + * | Value | Name | Data encoding | Description | + * |-------|-------------|-----------------------|-------------------------------------| + * | 0x01 | `Reason` | UTF-8 string | Human-readable reason / note | + * | 0x02 | `InvoiceId` | UTF-8 string | Invoice or reference identifier | + * | 0x03 | `Reference` | Arbitrary bytes | Opaque reference payload | + */ +export enum MemoKind { + Reason = 0x01, + InvoiceId = 0x02, + Reference = 0x03, +} + +/** + * Parsed v1 memo schema. + */ +export interface MemoSchemaV1 { + version: typeof MEMO_SCHEMA_VERSION; + kind: MemoKind; + data: Uint8Array; +} + +/** + * Decode result for a 32-byte memo buffer. + * + * If the buffer is schema-shaped (first byte equals {@link MEMO_SCHEMA_VERSION}), + * the `schema` field is populated. Otherwise the buffer is treated as raw bytes + * for backwards compatibility. + */ +export interface DecodedMemoSchema { + /** The original 32-byte buffer. */ + bytes: Uint8Array; + /** Parsed schema when the buffer is schema-shaped; `undefined` for raw memos. */ + schema?: MemoSchemaV1; +} + +/** + * Returns `true` when the 32-byte buffer carries a recognised schema version marker. + */ +function isSchemaShaped(bytes: Uint8Array): boolean { + return bytes.length === SCHEMA_MEMO_BYTES && bytes[0] === MEMO_SCHEMA_VERSION; +} + +/** + * Encodes a structured memo schema into a 32-byte buffer suitable for + * MEMO_HASH or MEMO_RETURN. + * + * @param kind - The {@link MemoKind} to encode. + * @param data - Payload data. Strings are encoded as UTF-8. + * @returns A 32-byte Uint8Array. + * + * @throws {Error} If the encoded data exceeds {@link SCHEMA_MAX_DATA_BYTES} (29 bytes). + * + * @example + * ```ts + * // Encode a reason string + * const bytes = encodeMemoSchema(MemoKind.Reason, 'Payment for order #123'); + * + * // Encode arbitrary reference bytes + * const ref = encodeMemoSchema(MemoKind.Reference, new Uint8Array([0x01, 0x02, 0x03])); + * ``` + */ +export function encodeMemoSchema(kind: MemoKind, data: Uint8Array | string): Uint8Array { + const dataBytes = typeof data === 'string' ? new TextEncoder().encode(data) : data; + + if (dataBytes.length > SCHEMA_MAX_DATA_BYTES) { + throw new Error( + `Memo schema data exceeds maximum of ${SCHEMA_MAX_DATA_BYTES} bytes (got ${dataBytes.length})`, + ); + } + + const buffer = new Uint8Array(SCHEMA_MEMO_BYTES); + buffer[0] = MEMO_SCHEMA_VERSION; + buffer[1] = kind; + buffer[2] = dataBytes.length; + buffer.set(dataBytes, SCHEMA_HEADER_BYTES); + // Remaining bytes are already zero-filled by the Uint8Array constructor + + return buffer; +} + +/** + * Decodes a 32-byte memo buffer into a {@link DecodedMemoSchema}. + * + * **Backwards compatibility**: buffers whose first byte is not the schema + * version marker are returned as `{ bytes, schema: undefined }` so callers + * can fall back to treating the content as opaque raw bytes. + * + * @param bytes - A 32-byte buffer (typically from MEMO_HASH or MEMO_RETURN). + * @returns The decoded result with optional schema. + * + * @example + * ```ts + * const memo = decodeMemoSchema(memoBytes); + * + * if (memo.schema) { + * // Structured — inspect memo.schema.kind / memo.schema.data + * console.log('Kind:', memo.schema.kind); + * console.log('Data hex:', bytesToHex(memo.schema.data)); + * } else { + * // Raw bytes — handle as before + * console.log('Raw hex:', bytesToHex(memo.bytes)); + * } + * ``` + */ +export function decodeMemoSchema(bytes: Uint8Array): DecodedMemoSchema { + const result: DecodedMemoSchema = { bytes }; + + if (isSchemaShaped(bytes)) { + const kind = bytes[1] as MemoKind; + const dataLength = Math.min(bytes[2], SCHEMA_MAX_DATA_BYTES); + const data = bytes.slice(SCHEMA_HEADER_BYTES, SCHEMA_HEADER_BYTES + dataLength); + + result.schema = { + version: MEMO_SCHEMA_VERSION, + kind, + data, + }; + } + + return result; +} diff --git a/test/chains/stellar/memo/schema.test.ts b/test/chains/stellar/memo/schema.test.ts new file mode 100644 index 0000000..d675bf0 --- /dev/null +++ b/test/chains/stellar/memo/schema.test.ts @@ -0,0 +1,296 @@ +import { describe, it, expect } from 'vitest'; +import { + encodeMemoSchema, + decodeMemoSchema, + MemoKind, + MEMO_SCHEMA_VERSION, + SCHEMA_MEMO_BYTES, + SCHEMA_HEADER_BYTES, + SCHEMA_MAX_DATA_BYTES, +} from '../../../../src/chains/stellar/memo/schema'; +import { bytesToHex, hexToBytes } from '../../../../src/chains/stellar/utils'; + +describe('MemoSchema', () => { + describe('encodeMemoSchema', () => { + it('encodes a reason kind with string data', () => { + const bytes = encodeMemoSchema(MemoKind.Reason, 'Invoice #123'); + expect(bytes).toBeInstanceOf(Uint8Array); + expect(bytes.length).toBe(SCHEMA_MEMO_BYTES); + expect(bytes[0]).toBe(MEMO_SCHEMA_VERSION); + expect(bytes[1]).toBe(MemoKind.Reason); + }); + + it('encodes an invoice_id kind', () => { + const bytes = encodeMemoSchema(MemoKind.InvoiceId, 'INV-2024-001'); + expect(bytes[1]).toBe(MemoKind.InvoiceId); + }); + + it('encodes a reference kind with binary data', () => { + const refData = new Uint8Array([0xab, 0xcd, 0xef]); + const bytes = encodeMemoSchema(MemoKind.Reference, refData); + expect(bytes[1]).toBe(MemoKind.Reference); + expect(bytes[2]).toBe(3); + expect(bytes.slice(3, 6)).toEqual(refData); + }); + + it('encodes with correct data_length header', () => { + const data = 'hi'; + const bytes = encodeMemoSchema(MemoKind.Reason, data); + expect(bytes[2]).toBe(2); + expect(bytes[3]).toBe(0x68); // 'h' + expect(bytes[4]).toBe(0x69); // 'i' + }); + + it('zero-pads remaining bytes', () => { + const bytes = encodeMemoSchema(MemoKind.Reason, 'a'); + for (let i = SCHEMA_HEADER_BYTES + 1; i < SCHEMA_MEMO_BYTES; i++) { + expect(bytes[i]).toBe(0); + } + }); + + it('throws when data exceeds maximum length', () => { + const longData = 'x'.repeat(SCHEMA_MAX_DATA_BYTES + 1); + expect(() => encodeMemoSchema(MemoKind.Reason, longData)).toThrow('exceeds maximum'); + }); + + it('accepts data at exactly maximum length', () => { + const maxData = 'x'.repeat(SCHEMA_MAX_DATA_BYTES); + expect(() => encodeMemoSchema(MemoKind.Reason, maxData)).not.toThrow(); + const bytes = encodeMemoSchema(MemoKind.Reason, maxData); + expect(bytes[2]).toBe(SCHEMA_MAX_DATA_BYTES); + }); + + it('accepts zero-length data', () => { + const bytes = encodeMemoSchema(MemoKind.Reason, ''); + expect(bytes[2]).toBe(0); + const decoded = decodeMemoSchema(bytes); + expect(decoded.schema?.data.length).toBe(0); + }); + + it('encodes UTF-8 multi-byte characters', () => { + const emoji = '😀'; + const bytes = encodeMemoSchema(MemoKind.Reason, emoji); + // 😀 is 4 bytes in UTF-8: F0 9F 98 80 + expect(bytes[2]).toBe(4); + expect(bytes.slice(3, 7)).toEqual(new Uint8Array([0xf0, 0x9f, 0x98, 0x80])); + }); + }); + + describe('decodeMemoSchema', () => { + it('decodes a schema-shaped buffer with reason kind', () => { + const original = 'Payment for order'; + const encoded = encodeMemoSchema(MemoKind.Reason, original); + const decoded = decodeMemoSchema(encoded); + + expect(decoded.schema).toBeDefined(); + expect(decoded.schema!.version).toBe(MEMO_SCHEMA_VERSION); + expect(decoded.schema!.kind).toBe(MemoKind.Reason); + expect(new TextDecoder().decode(decoded.schema!.data)).toBe(original); + }); + + it('decodes a schema-shaped buffer with invoice_id kind', () => { + const original = 'INV-2025-999'; + const encoded = encodeMemoSchema(MemoKind.InvoiceId, original); + const decoded = decodeMemoSchema(encoded); + + expect(decoded.schema).toBeDefined(); + expect(decoded.schema!.kind).toBe(MemoKind.InvoiceId); + expect(new TextDecoder().decode(decoded.schema!.data)).toBe(original); + }); + + it('decodes a schema-shaped buffer with reference kind', () => { + const refData = new Uint8Array([0x01, 0x02, 0x03, 0xff]); + const encoded = encodeMemoSchema(MemoKind.Reference, refData); + const decoded = decodeMemoSchema(encoded); + + expect(decoded.schema).toBeDefined(); + expect(decoded.schema!.kind).toBe(MemoKind.Reference); + expect(decoded.schema!.data).toEqual(refData); + }); + + it('returns raw result for buffers that are not schema-shaped', () => { + const rawBytes = new Uint8Array(SCHEMA_MEMO_BYTES).fill(0xab); + const decoded = decodeMemoSchema(rawBytes); + + expect(decoded.schema).toBeUndefined(); + expect(decoded.bytes).toEqual(rawBytes); + }); + + it('returns raw result for buffer starting with 0x02 (non-version)', () => { + const bytes = new Uint8Array(SCHEMA_MEMO_BYTES); + bytes[0] = 0x02; + bytes[1] = 0x01; + const decoded = decodeMemoSchema(bytes); + expect(decoded.schema).toBeUndefined(); + }); + + it('preserves the original bytes in the result', () => { + const encoded = encodeMemoSchema(MemoKind.Reason, 'test'); + const decoded = decodeMemoSchema(encoded); + expect(decoded.bytes).toEqual(encoded); + expect(decoded.bytes.length).toBe(SCHEMA_MEMO_BYTES); + }); + }); + + describe('round-trip', () => { + it('round-trips a reason string', () => { + const data = 'Payment for order #12345'; + const encoded = encodeMemoSchema(MemoKind.Reason, data); + const decoded = decodeMemoSchema(encoded); + expect(new TextDecoder().decode(decoded.schema!.data)).toBe(data); + }); + + it('round-trips an invoice id string', () => { + const data = 'INV-12345-ABC'; + const encoded = encodeMemoSchema(MemoKind.InvoiceId, data); + const decoded = decodeMemoSchema(encoded); + expect(new TextDecoder().decode(decoded.schema!.data)).toBe(data); + }); + + it('round-trips binary reference data', () => { + const data = new Uint8Array([0xde, 0xad, 0xbe, 0xef]); + const encoded = encodeMemoSchema(MemoKind.Reference, data); + const decoded = decodeMemoSchema(encoded); + expect(decoded.schema!.data).toEqual(data); + }); + + it('round-trips all kinds deterministically', () => { + const testCases = [ + { kind: MemoKind.Reason, data: 'test' }, + { kind: MemoKind.InvoiceId, data: 'INV-001' }, + { kind: MemoKind.Reference, data: new Uint8Array([0x01, 0x02]) }, + ]; + + for (const tc of testCases) { + const encoded1 = encodeMemoSchema(tc.kind, tc.data); + const encoded2 = encodeMemoSchema(tc.kind, tc.data); + expect(encoded1).toEqual(encoded2); + + const decoded = decodeMemoSchema(encoded1); + expect(decoded.schema!.kind).toBe(tc.kind); + } + }); + + it('round-trips empty string data', () => { + const encoded = encodeMemoSchema(MemoKind.Reason, ''); + const decoded = decodeMemoSchema(encoded); + expect(decoded.schema!.data.length).toBe(0); + }); + + it('round-trips max-length string data', () => { + const data = 'x'.repeat(SCHEMA_MAX_DATA_BYTES); + const encoded = encodeMemoSchema(MemoKind.Reason, data); + const decoded = decodeMemoSchema(encoded); + expect(new TextDecoder().decode(decoded.schema!.data)).toBe(data); + }); + }); + + describe('backwards compatibility', () => { + it('treats arbitrary 32-byte hash as raw bytes', () => { + const hashBytes = hexToBytes('deadbeef' + '00'.repeat(28)); + const decoded = decodeMemoSchema(hashBytes); + expect(decoded.schema).toBeUndefined(); + }); + + it('treats all-zero buffer as raw bytes', () => { + const zeros = new Uint8Array(SCHEMA_MEMO_BYTES); + const decoded = decodeMemoSchema(zeros); + expect(decoded.schema).toBeUndefined(); + }); + + it('treats buffer starting with version byte but wrong length as raw', () => { + // A buffer that starts with 0x01 but is not 32 bytes long + const short = new Uint8Array([MEMO_SCHEMA_VERSION, 0x01, 0x00]); + const decoded = decodeMemoSchema(short); + expect(decoded.schema).toBeUndefined(); + }); + }); + + describe('extension mechanism', () => { + it('decodes unknown kind values without throwing', () => { + const bytes = new Uint8Array(SCHEMA_MEMO_BYTES); + bytes[0] = MEMO_SCHEMA_VERSION; + bytes[1] = 0x42; // unknown kind + bytes[2] = 2; + bytes[3] = 0xaa; + bytes[4] = 0xbb; + + const decoded = decodeMemoSchema(bytes); + expect(decoded.schema).toBeDefined(); + expect(decoded.schema!.kind).toBe(0x42); + expect(decoded.schema!.data).toEqual(new Uint8Array([0xaa, 0xbb])); + }); + + it('decodes private/experimental range kinds (0xF0-0xFF)', () => { + for (const kind of [0xf0, 0xff]) { + const bytes = new Uint8Array(SCHEMA_MEMO_BYTES); + bytes[0] = MEMO_SCHEMA_VERSION; + bytes[1] = kind; + bytes[2] = 1; + bytes[3] = 0x99; + + const decoded = decodeMemoSchema(bytes); + expect(decoded.schema).toBeDefined(); + expect(decoded.schema!.kind).toBe(kind); + expect(decoded.schema!.data).toEqual(new Uint8Array([0x99])); + } + }); + + it('clamps data_length that exceeds max data bytes', () => { + const bytes = new Uint8Array(SCHEMA_MEMO_BYTES); + bytes[0] = MEMO_SCHEMA_VERSION; + bytes[1] = 0x01; + bytes[2] = 255; // larger than max + bytes[3] = 0xaa; + + const decoded = decodeMemoSchema(bytes); + expect(decoded.schema!.data.length).toBe(SCHEMA_MAX_DATA_BYTES); + }); + }); + + describe('edge cases', () => { + it('handles single-byte data', () => { + const data = new Uint8Array([0x42]); + const encoded = encodeMemoSchema(MemoKind.Reference, data); + const decoded = decodeMemoSchema(encoded); + expect(decoded.schema!.data).toEqual(data); + }); + + it('handles all-zeros data', () => { + const data = new Uint8Array(10); + const encoded = encodeMemoSchema(MemoKind.Reference, data); + const decoded = decodeMemoSchema(encoded); + expect(decoded.schema!.data).toEqual(data); + }); + + it('produces exactly 32 bytes', () => { + const bytes = encodeMemoSchema(MemoKind.Reason, 'hello'); + expect(bytes.length).toBe(32); + }); + + it('header bytes are in correct positions', () => { + const bytes = encodeMemoSchema(MemoKind.InvoiceId, 'test'); + // [0] = version + expect(bytes[0]).toBe(0x01); + // [1] = kind + expect(bytes[1]).toBe(0x02); + // [2] = data_length + expect(bytes[2]).toBe(4); + }); + }); + + describe('exports', () => { + it('exports constants with expected values', () => { + expect(MEMO_SCHEMA_VERSION).toBe(0x01); + expect(SCHEMA_MEMO_BYTES).toBe(32); + expect(SCHEMA_HEADER_BYTES).toBe(3); + expect(SCHEMA_MAX_DATA_BYTES).toBe(29); + }); + + it('exports MemoKind enum values', () => { + expect(MemoKind.Reason).toBe(0x01); + expect(MemoKind.InvoiceId).toBe(0x02); + expect(MemoKind.Reference).toBe(0x03); + }); + }); +});