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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/chains/stellar/scalar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,12 @@ export function scalarToBytes(scalar: bigint): Uint8Array {
*
* @see {@link pubKeyToStellarAddress}
*/
export function deriveStealthPubKey(spendingPubKey: Uint8Array, hashScalar: bigint): Uint8Array {
const K_spend = ed25519.ExtendedPoint.fromHex(spendingPubKey);
export function deriveStealthPubKey(
spendingPubKey: Uint8Array,
hashScalar: bigint,
spendingPoint?: ed25519.ExtendedPoint,
): Uint8Array {
const K_spend = spendingPoint ?? ed25519.ExtendedPoint.fromHex(spendingPubKey);
const hashPoint = ed25519.ExtendedPoint.BASE.multiply(hashScalar);
const stealthPoint = K_spend.add(hashPoint);
return stealthPoint.toRawBytes();
Expand Down
105 changes: 89 additions & 16 deletions src/chains/stellar/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,46 @@
import type { Announcement, MatchedAnnouncement } from './types';
import { hexToBytes } from './utils';

const HEX_TO_BYTE = (() => {
const table = new Uint8Array(256).fill(255);
const lower = '0123456789abcdef';
const upper = '0123456789ABCDEF';
for (let i = 0; i < 16; i++) {
table[lower.charCodeAt(i)] = i;
table[upper.charCodeAt(i)] = i;
}
return table;
})();

function readViewTag(metadata: string): number | null {
const clean = metadata.startsWith('0x') ? metadata.slice(2) : metadata;
if (clean.length < 2) return null;

const hi = HEX_TO_BYTE[clean.charCodeAt(0)];
const lo = HEX_TO_BYTE[clean.charCodeAt(1)];
if (hi === 255 || lo === 255) return null;

return (hi << 4) | lo;
}

function decodeHexToBuffer(hex: string, output: Uint8Array): Uint8Array | null {
const clean = hex.startsWith('0x') ? hex.slice(2) : hex;
if ((clean.length & 1) !== 0) return null;

const byteCount = clean.length / 2;
if (byteCount > output.length) return null;

let outOffset = 0;
for (let i = 0; i < clean.length; i += 2) {
const hi = HEX_TO_BYTE[clean.charCodeAt(i)];
const lo = HEX_TO_BYTE[clean.charCodeAt(i + 1)];
if (hi === 255 || lo === 255) return null;
output[outOffset++] = (hi << 4) | lo;
}

return output.subarray(0, outOffset);
}

/**
* Streaming announcement scanner. Pulls announcements from `source` in windows of
* `opts.window` (default 64), scans each window, and yields matches immediately.
Expand Down Expand Up @@ -33,11 +73,20 @@
): AsyncGenerator<MatchedAnnouncement> {
const windowSize = Math.max(1, opts.window ?? 64);
const iter = source[Symbol.asyncIterator]();
const viewingPubKey = ed25519.getPublicKey(viewingKey);
const ephemeralBuffer = new Uint8Array(32);
const batch: Announcement[] = [];
let spendingPoint: ed25519.ExtendedPoint | undefined;

Check failure on line 79 in src/chains/stellar/scan.ts

View workflow job for this annotation

GitHub Actions / test (22)

Cannot find namespace 'ed25519'.

Check failure on line 79 in src/chains/stellar/scan.ts

View workflow job for this annotation

GitHub Actions / examples (stellar-spectre-agent)

Cannot find namespace 'ed25519'.

Check failure on line 79 in src/chains/stellar/scan.ts

View workflow job for this annotation

GitHub Actions / examples (stellar-cli-send)

Cannot find namespace 'ed25519'.

Check failure on line 79 in src/chains/stellar/scan.ts

View workflow job for this annotation

GitHub Actions / examples (stellar-react-receive)

Cannot find namespace 'ed25519'.

Check failure on line 79 in src/chains/stellar/scan.ts

View workflow job for this annotation

GitHub Actions / examples (stellar-cli-scan)

Cannot find namespace 'ed25519'.

Check failure on line 79 in src/chains/stellar/scan.ts

View workflow job for this annotation

GitHub Actions / examples (multichain-scan)

Cannot find namespace 'ed25519'.

try {
try {
spendingPoint = ed25519.ExtendedPoint.fromHex(spendingPubKey);
} catch {
spendingPoint = undefined;
}

while (true) {
// Prefetch up to windowSize announcements — bounds peak memory to O(window)
const batch: Announcement[] = [];
batch.length = 0;
for (let i = 0; i < windowSize; i++) {
const next = await iter.next();
if (next.done) break;
Expand All @@ -49,14 +98,20 @@
for (const ann of batch) {
if (ann.schemeId !== SCHEME_ID && ann.schemeId !== SCHEME_ID_V2) continue;

const metadataBytes = hexToBytes(ann.metadata);
if (metadataBytes.length === 0) continue;
const viewTag = metadataBytes[0];
const viewTag = readViewTag(ann.metadata);
if (viewTag === null) continue;

const ephPubKey = hexToBytes(ann.ephemeralPubKey);
if (ephPubKey.length !== 32) continue;
const ephPubKey = decodeHexToBuffer(ann.ephemeralPubKey, ephemeralBuffer);
if (!ephPubKey || ephPubKey.length !== 32 || !spendingPoint) continue;

const result = checkStealthAddress(ephPubKey, viewingKey, spendingPubKey, viewTag);
const result = checkStealthAddressWithViewingPubKey(
ephPubKey,
viewingKey,
viewingPubKey,
spendingPubKey,
viewTag,
spendingPoint,
);

if (
result.isMatch &&
Expand Down Expand Up @@ -121,12 +176,14 @@
stealthPubKeyBytes: Uint8Array | null;
} {
const viewingPubKey = ed25519.getPublicKey(viewingKey);
const spendingPoint = ed25519.ExtendedPoint.fromHex(spendingPubKey);
return checkStealthAddressWithViewingPubKey(
ephemeralPubKey,
viewingKey,
viewingPubKey,
spendingPubKey,
viewTag,
spendingPoint,
);
}

Expand All @@ -136,6 +193,7 @@
viewingPubKey: Uint8Array,
spendingPubKey: Uint8Array,
viewTag: number,
spendingPoint?: ed25519.ExtendedPoint,

Check failure on line 196 in src/chains/stellar/scan.ts

View workflow job for this annotation

GitHub Actions / test (22)

Cannot find namespace 'ed25519'.

Check failure on line 196 in src/chains/stellar/scan.ts

View workflow job for this annotation

GitHub Actions / examples (stellar-spectre-agent)

Cannot find namespace 'ed25519'.

Check failure on line 196 in src/chains/stellar/scan.ts

View workflow job for this annotation

GitHub Actions / examples (stellar-cli-send)

Cannot find namespace 'ed25519'.

Check failure on line 196 in src/chains/stellar/scan.ts

View workflow job for this annotation

GitHub Actions / examples (stellar-react-receive)

Cannot find namespace 'ed25519'.

Check failure on line 196 in src/chains/stellar/scan.ts

View workflow job for this annotation

GitHub Actions / examples (stellar-cli-scan)

Cannot find namespace 'ed25519'.

Check failure on line 196 in src/chains/stellar/scan.ts

View workflow job for this annotation

GitHub Actions / examples (multichain-scan)

Cannot find namespace 'ed25519'.
): {
isMatch: boolean;
stealthAddress: string | null;
Expand All @@ -148,7 +206,12 @@
}

try {
return deriveStealthAddressFromAnnouncement(ephemeralPubKey, viewingKey, spendingPubKey);
return deriveStealthAddressFromAnnouncement(
ephemeralPubKey,
viewingKey,
spendingPubKey,
spendingPoint,
);
} catch {
return { isMatch: false, stealthAddress: null, hashScalar: null, stealthPubKeyBytes: null };
}
Expand All @@ -158,6 +221,7 @@
ephemeralPubKey: Uint8Array,
viewingKey: Uint8Array,
spendingPubKey: Uint8Array,
spendingPoint?: ed25519.ExtendedPoint,

Check failure on line 224 in src/chains/stellar/scan.ts

View workflow job for this annotation

GitHub Actions / test (22)

Cannot find namespace 'ed25519'.

Check failure on line 224 in src/chains/stellar/scan.ts

View workflow job for this annotation

GitHub Actions / examples (stellar-spectre-agent)

Cannot find namespace 'ed25519'.

Check failure on line 224 in src/chains/stellar/scan.ts

View workflow job for this annotation

GitHub Actions / examples (stellar-cli-send)

Cannot find namespace 'ed25519'.

Check failure on line 224 in src/chains/stellar/scan.ts

View workflow job for this annotation

GitHub Actions / examples (stellar-react-receive)

Cannot find namespace 'ed25519'.

Check failure on line 224 in src/chains/stellar/scan.ts

View workflow job for this annotation

GitHub Actions / examples (stellar-cli-scan)

Cannot find namespace 'ed25519'.

Check failure on line 224 in src/chains/stellar/scan.ts

View workflow job for this annotation

GitHub Actions / examples (multichain-scan)

Cannot find namespace 'ed25519'.
): {
isMatch: boolean;
stealthAddress: string | null;
Expand All @@ -167,7 +231,7 @@
const sharedSecret = computeSharedSecret(viewingKey, ephemeralPubKey);
const hScalar = hashToScalar(sharedSecret);

const stealthPubKeyBytes = deriveStealthPubKey(spendingPubKey, hScalar);
const stealthPubKeyBytes = deriveStealthPubKey(spendingPubKey, hScalar, spendingPoint);
const stealthAddress = pubKeyToStellarAddress(stealthPubKeyBytes);

return { isMatch: true, stealthAddress, hashScalar: hScalar, stealthPubKeyBytes };
Expand Down Expand Up @@ -214,23 +278,32 @@
): MatchedAnnouncement[] {
const matched: MatchedAnnouncement[] = [];
const viewingPubKey = ed25519.getPublicKey(viewingKey);
const ephemeralBuffer = new Uint8Array(32);
let spendingPoint: ed25519.ExtendedPoint | undefined;

Check failure on line 282 in src/chains/stellar/scan.ts

View workflow job for this annotation

GitHub Actions / test (22)

Cannot find namespace 'ed25519'.

Check failure on line 282 in src/chains/stellar/scan.ts

View workflow job for this annotation

GitHub Actions / examples (stellar-spectre-agent)

Cannot find namespace 'ed25519'.

Check failure on line 282 in src/chains/stellar/scan.ts

View workflow job for this annotation

GitHub Actions / examples (stellar-cli-send)

Cannot find namespace 'ed25519'.

Check failure on line 282 in src/chains/stellar/scan.ts

View workflow job for this annotation

GitHub Actions / examples (stellar-react-receive)

Cannot find namespace 'ed25519'.

Check failure on line 282 in src/chains/stellar/scan.ts

View workflow job for this annotation

GitHub Actions / examples (stellar-cli-scan)

Cannot find namespace 'ed25519'.

Check failure on line 282 in src/chains/stellar/scan.ts

View workflow job for this annotation

GitHub Actions / examples (multichain-scan)

Cannot find namespace 'ed25519'.

for (const ann of announcements) {
try {
spendingPoint = ed25519.ExtendedPoint.fromHex(spendingPubKey);
} catch {
spendingPoint = undefined;
}

for (let i = 0, len = announcements.length; i < len; i++) {
const ann = announcements[i];
if (ann.schemeId !== SCHEME_ID && ann.schemeId !== SCHEME_ID_V2) continue;

const metadataBytes = hexToBytes(ann.metadata);
if (metadataBytes.length === 0) continue;
const viewTag = metadataBytes[0];
const viewTag = readViewTag(ann.metadata);
if (viewTag === null) continue;

const ephPubKey = hexToBytes(ann.ephemeralPubKey);
if (ephPubKey.length !== 32) continue;
const ephPubKey = decodeHexToBuffer(ann.ephemeralPubKey, ephemeralBuffer);
if (!ephPubKey || ephPubKey.length !== 32 || !spendingPoint) continue;

const result = checkStealthAddressWithViewingPubKey(
ephPubKey,
viewingKey,
viewingPubKey,
spendingPubKey,
viewTag,
spendingPoint,
);

if (
Expand Down
107 changes: 20 additions & 87 deletions test/chains/stellar/bench/scan.bench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import type { Announcement, StealthKeys } from '../../../../src/chains/stellar/t

const MATCH_INDEX = 997;
const POOL_SIZE = 512;
const DEFAULT_DATASET_SIZES = [10_000, 100_000, 1_000_000] as const;
const DEFAULT_DATASET_SIZES = [10_000, 100_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);
Expand All @@ -43,12 +43,6 @@ function makeAnnouncementFor(
tagScheme: 'legacy-shared-secret' | 'public-announcement',
): Announcement {
const stealth = generateStealthAddress(
async function makeAnnouncementFor(
recipient: StealthKeys,
ephemeralSeed: Uint8Array,
tagScheme: 'legacy-shared-secret' | 'public-announcement',
): Promise<Announcement> {
const stealth = await generateStealthAddress(
recipient.spendingPubKey,
recipient.viewingPubKey,
ephemeralSeed,
Expand All @@ -68,43 +62,23 @@ async function makeAnnouncementFor(
};
}

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];
let pools: { legacy: Announcement[]; optimized: Announcement[] } | undefined;
let matchingAnnouncements: { legacy: Announcement; optimized: Announcement } | undefined;

async function initFixtures() {
if (pools && matchingAnnouncements) return;

pools = {
legacy: await Promise.all(
Array.from({ length: POOL_SIZE }, (_, i) =>
makeAnnouncementFor(foreignKeys, seedFor(i), 'legacy-shared-secret'),
),
legacy: Array.from({ length: POOL_SIZE }, (_, i) =>
makeAnnouncementFor(foreignKeys, seedFor(i), 'legacy-shared-secret'),
),
optimized: await Promise.all(
Array.from({ length: POOL_SIZE }, (_, i) =>
makeAnnouncementFor(foreignKeys, seedFor(i), 'public-announcement'),
),
optimized: Array.from({ length: POOL_SIZE }, (_, i) =>
makeAnnouncementFor(foreignKeys, seedFor(i), 'public-announcement'),
),
};
matchingAnnouncements = {
legacy: await makeAnnouncementFor(keys, seedFor(POOL_SIZE + 1), 'legacy-shared-secret'),
optimized: await makeAnnouncementFor(keys, seedFor(POOL_SIZE + 1), 'public-announcement'),
legacy: makeAnnouncementFor(keys, seedFor(POOL_SIZE + 1), 'legacy-shared-secret'),
optimized: makeAnnouncementFor(keys, seedFor(POOL_SIZE + 1), 'public-announcement'),
};
}

Expand All @@ -117,71 +91,32 @@ function makeDataset(size: number, tagScheme: 'legacy' | 'optimized') {
);
}

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(
async function getDatasets(): Promise<
Map<number, { legacy: Announcement[]; optimized: Announcement[] }>
> {
await initFixtures();
return 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', async () => {
const datasets = await getDatasets();
const dataset = datasets.get(10_000)?.optimized;
expect(dataset).toBeDefined();
await initFixtures();
const dataset = makeDataset(10_000, 'optimized');

const matched = await scanAnnouncements(
dataset!,
const matched = scanAnnouncements(
dataset,
keys.viewingKey,
keys.spendingPubKey,
keys.spendingScalar,
);

expect(matched).toHaveLength(1);
expect(matched[0].stealthAddress).toBe(matchingAnnouncements.optimized.stealthAddress);
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(
bench(
`before: shared-secret view tag (${size.toLocaleString()} announcements)`,
async () => {
const datasets = await getDatasets();
const dataset = datasets.get(size)!;
await scanAnnouncementsLegacySharedSecretTag(
dataset.legacy,
await initFixtures();
const dataset = makeDataset(size, 'legacy');
scanAnnouncementsLegacySharedSecretTag(
dataset,
keys.viewingKey,
keys.spendingPubKey,
keys.spendingScalar,
Expand All @@ -192,13 +127,11 @@ describe('Stellar scan announcement view-tag batching', () => {

bench(
`after: public view-tag prefilter (${size.toLocaleString()} announcements)`,
() => {
scanAnnouncements(
async () => {
const datasets = await getDatasets();
const dataset = datasets.get(size)!;
await scanAnnouncements(
dataset.optimized,
await initFixtures();
const dataset = makeDataset(size, 'optimized');
scanAnnouncements(
dataset,
keys.viewingKey,
keys.spendingPubKey,
keys.spendingScalar,
Expand Down
Loading