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
67 changes: 67 additions & 0 deletions docs/chains/stellar-view-tag-batching.md
Original file line number Diff line number Diff line change
@@ -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.
328 changes: 328 additions & 0 deletions src/chains/stellar/batch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,328 @@
import {
TransactionBuilder,
Account,
Operation,
BASE_FEE,
Asset,
StrKey,
} from '@stellar/stellar-sdk';
import { hexToBytes } from './utils';
import { SCHEME_ID } from './constants';

const MAX_OPS_PER_TX = 100;
const OPS_PER_PAYMENT = 2;
const MAX_PAYMENTS_PER_TX = Math.floor(MAX_OPS_PER_TX / OPS_PER_PAYMENT);

export interface StealthPaymentConfig {
/** Stellar public key (G...) of the stealth address. */
destination: string;
/** Amount to send (e.g. "10.5"). */
amount: string;
/** Asset code ("native", "XLM", or a custom asset code). Defaults to "native". */
asset?: string;
/** Asset issuer G... address (required for non-native assets). */
assetIssuer?: string;
/** Hex-encoded 32-byte ephemeral public key. */
ephemeralPubKey: string;
/** View tag byte (0–255). */
viewTag: number;
/** Optional caller — defaults to the source account. */
caller?: string;
}

export interface BatchConfig {
/** Source account public key (G...). */
source: string;
/** Current sequence number of the source account. */
sequence: string;
/** Stellar network passphrase (e.g. Networks.TESTNET). */
networkPassphrase: string;
/** Fee per operation in stroops. Defaults to BASE_FEE (100). */
feePerOp?: number;
/** Timeout in seconds. Defaults to 300. */
timeout?: number;
}

export interface BuildResult {
/** Transaction envelope XDR (base64) strings, one per transaction. */
transactions: string[];
/** Number of transactions built. */
txCount: number;
/** Total number of stealth payments across all transactions. */
paymentCount: number;
/** Total fee across all transactions in stroops. */
totalFee: number;
}

/**
* Encodes announcement data into a compact binary format suitable for
* storing in a Stellar manageData operation value (max 64 bytes).
*
* Format (34 bytes):
* [0] — schemeId (1 byte)
* [1..32] — ephemeral public key (32 bytes)
* [33] — view tag (1 byte)
*/
export function encodeAnnouncementData(ephemeralPubKey: Uint8Array, viewTag: number): Uint8Array {
const data = new Uint8Array(1 + 32 + 1);
data[0] = SCHEME_ID;
data.set(ephemeralPubKey, 1);
data[33] = viewTag;
return data;
}

/**
* Decodes announcement data back into its components.
*/
export function decodeAnnouncementData(data: Uint8Array): {
schemeId: number;
ephemeralPubKey: Uint8Array;
viewTag: number;
} {
if (data.length < 34) {
throw new Error(`Invalid announcement data length: expected 34 bytes, got ${data.length}`);
}
const ephPubKey = new Uint8Array(data.slice(1, 33));
const viewTag = data[33];
return {
schemeId: data[0],
ephemeralPubKey: ephPubKey,
viewTag,
};
}

/**
* Builds Stellar transactions for multiple stealth payments in a batch.
*
* Stellar supports up to 100 operations per transaction with atomic semantics,
* so a batch of N stealth payments produces ceil(N / 50) transactions, each
* containing up to 50 payment+announcement pairs (100 ops).
*
* Usage:
* ```ts
* const builder = new StellarBatchBuilder({
* source: 'G...',
* sequence: '1234',
* networkPassphrase: Networks.TESTNET,
* });
*
* builder
* .addPayment({
* destination: 'G...',
* amount: '10',
* asset: 'native',
* ephemeralPubKey: 'aabb...',
* viewTag: 42,
* })
* .addPayment({ ... });
*
* const result = builder.build();
* // result.transactions — XDR base64 strings ready to sign
* ```
*/
export class StellarBatchBuilder {
private config: BatchConfig;
private payments: StealthPaymentConfig[] = [];
private _needsBuild = true;
private _cachedResult: BuildResult | null = null;

constructor(config: BatchConfig) {
this.config = {
feePerOp: Number(BASE_FEE),
timeout: 300,
...config,
};
this.validateAddress(config.source, 'source');
}

/**
* Validates a Stellar G... address.
*/
private validateAddress(addr: string, label: string): void {
if (!StrKey.isValidEd25519PublicKey(addr)) {
throw new Error(`Invalid ${label} address: ${addr}`);
}
}

/**
* Validates a single payment config.
*/
private validatePayment(payment: StealthPaymentConfig, index: number): void {
this.validateAddress(payment.destination, `payment[${index}].destination`);

const ephBytes = hexToBytes(payment.ephemeralPubKey);
if (ephBytes.length !== 32) {
throw new Error(
`payment[${index}].ephemeralPubKey: expected 32 bytes (64 hex chars), got ${ephBytes.length} bytes`,
);
}

if (payment.viewTag < 0 || payment.viewTag > 255) {
throw new Error(`payment[${index}].viewTag: expected 0–255, got ${payment.viewTag}`);
}

const amount = Number(payment.amount);
if (!isFinite(amount) || amount <= 0) {
throw new Error(
`payment[${index}].amount: expected positive number, got "${payment.amount}"`,
);
}

if (payment.asset && payment.asset !== 'native' && payment.asset !== 'XLM') {
if (!payment.assetIssuer) {
throw new Error(`payment[${index}].assetIssuer is required for asset "${payment.asset}"`);
}
this.validateAddress(payment.assetIssuer, `payment[${index}].assetIssuer`);
}
}

/**
* Adds a stealth payment to the batch.
*
* Each payment produces two operations:
* - A payment operation sending assets to the stealth address.
* - A manageData operation storing the announcement data.
*/
addPayment(payment: StealthPaymentConfig): this {
this.validatePayment(payment, this.payments.length);
this.payments.push(payment);
this._needsBuild = true;
return this;
}

/**
* Returns the number of payments currently in the batch.
*/
get paymentCount(): number {
return this.payments.length;
}

/**
* Returns the number of operations the batch would produce
* (payments * 2) before splitting.
*/
get operationCount(): number {
return this.payments.length * OPS_PER_PAYMENT;
}

/**
* Returns the number of transactions the batch will split into.
*/
get expectedTransactionCount(): number {
if (this.payments.length === 0) return 0;
return Math.ceil(this.payments.length / MAX_PAYMENTS_PER_TX);
}

/**
* Validates that the source account balance can cover the total fee.
*
* @param balanceXlm Account XLM balance as a string (e.g. "100.5").
* @throws If the total fee (converted to XLM) exceeds the balance.
*/
validateBalance(balanceXlm: string): void {
const feePerOp = this.config.feePerOp!;
const totalOps = this.operationCount;
const totalFeeStroops = totalOps * feePerOp;
const totalFeeXlm = totalFeeStroops / 10_000_000;

const balance = Number(balanceXlm);
if (!isFinite(balance) || balance < 0) {
throw new Error(`Invalid balance: "${balanceXlm}"`);
}

if (totalFeeXlm > balance) {
throw new Error(
`Insufficient balance: ${totalFeeXlm} XLM fee required but account has ${balanceXlm} XLM`,
);
}
}

/**
* Builds the transaction(s) for all added payments.
*
* Groups payments into chunks of at most `MAX_PAYMENTS_PER_TX` (50),
* producing one Stellar transaction per chunk. Each transaction includes
* the payment operations followed by announcement manageData operations
* for every recipient in the chunk.
*
* @returns BuildResult containing the XDR base64-encoded transactions.
*/
build(): BuildResult {
if (!this._needsBuild && this._cachedResult) {
return this._cachedResult;
}

if (this.payments.length === 0) {
throw new Error('No payments added to the batch');
}

const feePerOp = this.config.feePerOp!;
const timeout = this.config.timeout!;
const sourceStr = this.config.source;
const chunks: StealthPaymentConfig[][] = [];

for (let i = 0; i < this.payments.length; i += MAX_PAYMENTS_PER_TX) {
chunks.push(this.payments.slice(i, i + MAX_PAYMENTS_PER_TX));
}

const transactions: string[] = [];
let seqNum = BigInt(this.config.sequence);

for (const chunk of chunks) {
const sourceAccount = new Account(sourceStr, seqNum.toString());
const opsCount = chunk.length * OPS_PER_PAYMENT;
const fee = String(opsCount * feePerOp);

const builder = new TransactionBuilder(sourceAccount, {
fee,
networkPassphrase: this.config.networkPassphrase,
});

for (let i = 0; i < chunk.length; i++) {
const payment = chunk[i];

const asset =
payment.asset && payment.asset !== 'native' && payment.asset !== 'XLM'
? new Asset(payment.asset, payment.assetIssuer!)
: Asset.native();

builder.addOperation(
Operation.payment({
destination: payment.destination,
asset,
amount: payment.amount,
}),
);

const ephBytes = hexToBytes(payment.ephemeralPubKey);
const annData = encodeAnnouncementData(ephBytes, payment.viewTag);

builder.addOperation(
Operation.manageData({
name: `wraith:ann:${i}`,
value: Buffer.from(annData),
}),
);
}

builder.setTimeout(timeout);
const tx = builder.build();
transactions.push(tx.toEnvelope().toXDR('base64'));

seqNum++;
}

const totalOps = this.payments.length * OPS_PER_PAYMENT;
const totalFee = totalOps * feePerOp;

this._cachedResult = {
transactions,
txCount: transactions.length,
paymentCount: this.payments.length,
totalFee,
};
this._needsBuild = false;

return this._cachedResult;
}
}
Loading