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
61 changes: 61 additions & 0 deletions src/chains/stellar/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# `@wraith-protocol/sdk/chains/stellar`

Stellar stealth address crypto primitives, announcement scanning, and transaction
helpers for the Wraith stealth address protocol.

## Log redaction

Scanner debug logs can leak meta-addresses, tx hashes, and other Stellar
identifiers. When a user shares a bug report or a test failure, that log text
leaks scan context along with it. `redact()` strips those identifiers out
before you share anything.

```typescript
import { redact } from '@wraith-protocol/sdk/chains/stellar';

redact(`scanning st:xlm:${spendingHex}${viewingHex} against tx ${txHash}`);
// => "scanning [redact:meta:3f1a9c02de] against tx [redact:hash:8b6e21a4f0]"
```

`redact()` recognizes:

- Stealth meta-addresses (`st:xlm:...`)
- Stellar strkeys: accounts (`G...`), secret seeds (`S...`), Soroban contract
IDs (`C...`), pre-auth tx hashes (`T...`), hashx signers (`X...`), and
liquidity pool IDs (`L...`)
- Muxed accounts (`M...`)
- 32-byte hex blobs — transaction hashes, ephemeral public keys, wasm hashes

Each identifier is replaced with a pseudonym derived by hashing the identifier
itself, so the same value always redacts to the same alias — you can still
tell that two log lines reference the same address without either line
revealing what that address is. There's no lookup table and no shared state,
so this holds across calls and across processes.

`redact()` is a plain function with no side effects: it is never called
automatically by SDK code, so production logs are unaffected unless you call
it yourself.

### Redacting Vitest output

`RedactingReporter` wires `redact()` into a project's own test run so failure
output — error messages, stack traces, diffs, and captured `console.log`
output — is redacted by default:

```typescript
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import { RedactingReporter } from '@wraith-protocol/sdk/chains/stellar';

export default defineConfig({
test: {
reporters: [new RedactingReporter(), 'default'],
},
});
```

List `RedactingReporter` **before** your output reporter (`'default'`,
`'verbose'`, etc.) — reporters run in array order, and redaction has to run
before the identifiers are printed. `RedactingReporter` does not import
`vitest` itself; it only relies on the shape of the objects Vitest passes to
reporter hooks, so it has no runtime dependency on the `vitest` package.
2 changes: 2 additions & 0 deletions src/chains/stellar/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,5 @@ 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 { redact, RedactingReporter } from './log/redact';
181 changes: 181 additions & 0 deletions src/chains/stellar/log/redact.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { sha256 } from '@noble/hashes/sha256';
import { META_ADDRESS_PREFIX } from '../constants';
import { bytesToHex } from '../utils';

// ---------------------------------------------------------------------------
// Patterns
// ---------------------------------------------------------------------------

/** Base32 alphabet used by Stellar strkey-encoded identifiers. */
const STRKEY_ALPHABET = 'A-Z2-7';

/** Maps a Stellar strkey's leading character to a human-readable identifier kind. */
const STRKEY_KIND_BY_PREFIX: Record<string, string> = {
G: 'account',
S: 'secret',
C: 'contract',
T: 'preauth',
X: 'hashx',
L: 'pool',
};

/** Matches Wraith Stellar stealth meta-addresses, e.g. `st:xlm:<128 hex chars>`. */
const META_ADDRESS_RE = new RegExp(`${escapeRegExp(META_ADDRESS_PREFIX)}[0-9a-fA-F]{128}`, 'g');

/** Matches 69-char muxed Stellar accounts (`M...`). */
const MUXED_ACCOUNT_RE = new RegExp(`\\bM[${STRKEY_ALPHABET}]{68}\\b`, 'g');

/**
* Matches 56-char Stellar strkeys: accounts (`G`), secret seeds (`S`),
* Soroban contract IDs (`C`), pre-auth tx hashes (`T`), hashx signers (`X`),
* and liquidity pool IDs (`L`).
*/
const STRKEY_RE = new RegExp(
`\\b[${Object.keys(STRKEY_KIND_BY_PREFIX).join('')}][${STRKEY_ALPHABET}]{55}\\b`,
'g',
);

/** Matches 32-byte hex blobs: transaction hashes, ephemeral public keys, wasm hashes. */
const HEX_BLOB_RE = /\b(?:0x)?[0-9a-fA-F]{64}\b/g;

// ---------------------------------------------------------------------------
// Pseudonymization
// ---------------------------------------------------------------------------

const ALIAS_HEX_LENGTH = 10;
const textEncoder = new TextEncoder();

/**
* Derives a stable, non-reversible alias for an identifier.
*
* Hashing (rather than a lookup table) means the same identifier always
* redacts to the same alias — across calls, and across processes — without
* needing any shared state.
*/
function pseudonymize(kind: string, value: string): string {
const digest = sha256(textEncoder.encode(`wraith:redact:v1:${kind}:${value}`));
return `[redact:${kind}:${bytesToHex(digest).slice(0, ALIAS_HEX_LENGTH)}]`;
}

function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------

/**
* Redacts common Stellar identifiers from a block of text, replacing each
* with a stable pseudonym.
*
* Handles stealth meta-addresses (`st:xlm:...`), Stellar strkeys (accounts,
* secrets, Soroban contract IDs, pre-auth tx hashes, hashx signers, and
* liquidity pool IDs), muxed accounts, and 32-byte hex blobs such as
* transaction hashes and ephemeral public keys.
*
* The same identifier always redacts to the same alias, so redacted logs
* still let you correlate repeated occurrences of the same address or hash
* without revealing what it is.
*
* @param text - Arbitrary log text that may contain Stellar identifiers.
* @returns The text with every recognized identifier replaced by an alias.
*
* @example
* ```ts
* import { redact } from "@wraith-protocol/sdk/chains/stellar";
*
* redact("scanning st:xlm:" + "ab".repeat(64));
* // => "scanning [redact:meta:<hash>]"
* ```
*/
export function redact(text: string): string {
let redacted = text;
redacted = redacted.replace(META_ADDRESS_RE, (match) => pseudonymize('meta', match));
redacted = redacted.replace(MUXED_ACCOUNT_RE, (match) => pseudonymize('muxed', match));
redacted = redacted.replace(STRKEY_RE, (match) =>
pseudonymize(STRKEY_KIND_BY_PREFIX[match[0]] ?? 'id', match),
);
redacted = redacted.replace(HEX_BLOB_RE, (match) => pseudonymize('hash', match));
return redacted;
}

// ---------------------------------------------------------------------------
// Vitest reporter
// ---------------------------------------------------------------------------

/** Minimal shape of a Vitest `UserConsoleLog` — only the field we redact. */
interface RedactableConsoleLog {
content: string;
}

/** Minimal shape of a Vitest `TestError` / `SerializedError` — only the fields we redact. */
interface RedactableError {
message?: string;
stack?: string;
diff?: string;
actual?: string;
expected?: string;
cause?: RedactableError;
[key: string]: unknown;
}

/** Minimal shape of a Vitest `TestCase` / `TestSuite` — only what we need to reach its errors. */
interface RedactableTaskResult {
result?: () => { errors?: readonly RedactableError[] } | undefined;
}

function redactErrorInPlace(error: RedactableError | undefined): void {
if (!error) return;
if (typeof error.message === 'string') error.message = redact(error.message);
if (typeof error.stack === 'string') error.stack = redact(error.stack);
if (typeof error.diff === 'string') error.diff = redact(error.diff);
if (typeof error.actual === 'string') error.actual = redact(error.actual);
if (typeof error.expected === 'string') error.expected = redact(error.expected);
redactErrorInPlace(error.cause);
}

/**
* Vitest reporter that redacts Stellar identifiers from failure output.
*
* Register it alongside your normal reporter, listed *before* it, so the
* redaction runs before anything is printed:
*
* ```ts
* // vitest.config.ts
* import { RedactingReporter } from "@wraith-protocol/sdk/chains/stellar";
*
* export default defineConfig({
* test: { reporters: [new RedactingReporter(), "default"] },
* });
* ```
*
* This has no dependency on the `vitest` package — it only relies on the
* shape of the objects Vitest passes to reporter hooks — so it is safe to
* import outside of a Vitest process.
*/
export class RedactingReporter {
onTestCaseResult(testCase: RedactableTaskResult): void {
for (const error of testCase.result?.()?.errors ?? []) {
redactErrorInPlace(error);
}
}

onTestSuiteResult(testSuite: RedactableTaskResult): void {
for (const error of testSuite.result?.()?.errors ?? []) {
redactErrorInPlace(error);
}
}

onTestRunEnd(_testModules: unknown, unhandledErrors?: readonly RedactableError[]): void {
for (const error of unhandledErrors ?? []) {
redactErrorInPlace(error);
}
}

onUserConsoleLog(log: RedactableConsoleLog): void {
if (typeof log?.content === 'string') {
log.content = redact(log.content);
}
}
}
140 changes: 140 additions & 0 deletions test/chains/stellar/log/redact.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { describe, test, expect } from 'vitest';
import { redact, RedactingReporter } from '../../../../src/chains/stellar/log/redact';

const account = 'G' + 'A'.repeat(55);
const otherAccount = 'G' + 'B'.repeat(55);
const secret = 'S' + 'A'.repeat(55);
const contract = 'C' + 'A'.repeat(55);
const preauth = 'T' + 'A'.repeat(55);
const hashx = 'X' + 'A'.repeat(55);
const pool = 'L' + 'A'.repeat(55);
const muxed = 'M' + 'A'.repeat(68);
const txHash = 'ab'.repeat(32);
const metaAddress = 'st:xlm:' + 'cd'.repeat(64);

describe('redact', () => {
test('redacts an account strkey', () => {
const out = redact(`scanning ${account} for payments`);
expect(out).not.toContain(account);
expect(out).toMatch(/\[redact:account:[0-9a-f]{10}\]/);
});

test('redacts every identifier kind in a canned log', () => {
const log = [
`account=${account}`,
`secret=${secret}`,
`contract=${contract}`,
`preauth=${preauth}`,
`hashx=${hashx}`,
`pool=${pool}`,
`muxed=${muxed}`,
`tx=${txHash}`,
`meta=${metaAddress}`,
].join('\n');

const out = redact(log);

for (const identifier of [
account,
secret,
contract,
preauth,
hashx,
pool,
muxed,
txHash,
metaAddress,
]) {
expect(out).not.toContain(identifier);
}

expect(out).toMatch(/\[redact:account:[0-9a-f]{10}\]/);
expect(out).toMatch(/\[redact:secret:[0-9a-f]{10}\]/);
expect(out).toMatch(/\[redact:contract:[0-9a-f]{10}\]/);
expect(out).toMatch(/\[redact:preauth:[0-9a-f]{10}\]/);
expect(out).toMatch(/\[redact:hashx:[0-9a-f]{10}\]/);
expect(out).toMatch(/\[redact:pool:[0-9a-f]{10}\]/);
expect(out).toMatch(/\[redact:muxed:[0-9a-f]{10}\]/);
expect(out).toMatch(/\[redact:hash:[0-9a-f]{10}\]/);
expect(out).toMatch(/\[redact:meta:[0-9a-f]{10}\]/);
});

test('pseudonyms are stable across calls', () => {
const first = redact(account);
const second = redact(account);
expect(first).toBe(second);
});

test('different identifiers get different pseudonyms', () => {
expect(redact(account)).not.toBe(redact(otherAccount));
});

test('is idempotent — redacting already-redacted text is a no-op', () => {
const once = redact(`caller ${account}`);
const twice = redact(once);
expect(twice).toBe(once);
});

test('leaves non-identifier text untouched', () => {
const text = 'no secrets here, just a regular log line';
expect(redact(text)).toBe(text);
});

test('does not redact a bare 32-char hex string (too short to be a hash)', () => {
const shortHex = 'ab'.repeat(16);
expect(redact(shortHex)).toBe(shortHex);
});
});

describe('RedactingReporter', () => {
test('redacts error message, stack, and cause on test case failure', () => {
const reporter = new RedactingReporter();
const error = {
message: `expected ${account}`,
stack: `Error: mismatch\n at scan (${txHash})`,
cause: { message: `nested ${contract}` },
};
const testCase = { result: () => ({ errors: [error] }) };

reporter.onTestCaseResult(testCase);

expect(error.message).not.toContain(account);
expect(error.stack).not.toContain(txHash);
expect(error.cause.message).not.toContain(contract);
});

test('redacts suite-level errors', () => {
const reporter = new RedactingReporter();
const error = { message: `beforeAll failed for ${account}` };
const testSuite = { result: () => ({ errors: [error] }) };

reporter.onTestSuiteResult(testSuite);

expect(error.message).not.toContain(account);
});

test('redacts unhandled errors passed to onTestRunEnd', () => {
const reporter = new RedactingReporter();
const error = { message: `unhandled rejection: ${account}` };

reporter.onTestRunEnd([], [error]);

expect(error.message).not.toContain(account);
});

test('redacts console log content in place', () => {
const reporter = new RedactingReporter();
const log = { content: `debug: matched ${account}` };

reporter.onUserConsoleLog(log);

expect(log.content).not.toContain(account);
expect(log.content).toMatch(/\[redact:account:[0-9a-f]{10}\]/);
});

test('tolerates a passing test case with no errors', () => {
const reporter = new RedactingReporter();
expect(() => reporter.onTestCaseResult({ result: () => ({ errors: [] }) })).not.toThrow();
expect(() => reporter.onTestCaseResult({ result: () => undefined })).not.toThrow();
});
});
Loading