Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ All notable changes to the Wraith Protocol SDK will be documented in this file.
### Changed

- **Vue scanner calls take options objects** (issue #244): `fetchAnnouncements()` now accepts `{ chain, network, url, signal }` instead of a chain or URL string, and `scanAnnouncements()`, `checkAddress()` and `scanWithKeys()` accept one discriminated input object (`{ chain, announcements, keys }`, `{ chain, ...keys, viewTag }`, `{ chain, keys, ...fetchOptions }`) instead of positional arguments. This is a breaking change for consumers on `0.1.x`; the old positional signatures could not reject mismatched key material.
- **Stellar Announcements Emit Scheme 2** (issue #234): `buildStealthAnnouncement()`, `buildPathStealthPayment()`, `buildStellarSwapAndStealth()`, `buildAnnouncementData()` and `encodeAnnouncementData()` now emit scheme `2`, the only value the deployed v2 announcer accepts; they sent `1`. The new `ACTIVE_SCHEME_ID` export is the single source of truth. The v1 announcer accepts any scheme id, so v1 deployments keep working.
- `scanAnnouncements()` and `scanAnnouncementsStream()` accept v1 and v2 announcements. `scanAnnouncementsLegacySharedSecretTag()`, kept for benchmarks, stays v1-only: it checks the legacy shared-secret view tag, which v2 announcements do not carry.
- `SCHEME_ID` (still `1`) is deprecated in favour of `SCHEME_ID_V1` and `ACTIVE_SCHEME_ID`.
- A builder-to-contract conformance test checks every builder against the announcer ABI from wraith-protocol/contracts. See [`MIGRATING.md`](./MIGRATING.md#stellar-announcements-use-scheme-2).
- **Stellar Helper Errors Use the Taxonomy** (issue #235): for a non-2xx Horizon status, `buildMultisigStealthWithdraw()` throws `RPCRequestError` instead of a plain `Error`, and an account record without its sequence, thresholds or signers throws `HorizonResponseError`. `submitOfflineStellarTransaction()` reports the full `/transactions` URL on `RPCRequestError`, throws `HorizonResponseError` for a success body that is not a JSON object, and gives up after 60 s by default; pass `{ timeouts: { requestMs: 0 } }` for the old behaviour.
- **Stellar Horizon and RPC Clients Time Out by Default** (issue #202): an attempt now fails after 10 s without response headers or 30 s in total, then retries or fails over, instead of waiting indefinitely. Pass `timeouts: { connectMs: 0, requestMs: 0 }` for the old behaviour, or longer values for slow calls such as Horizon transaction submission.
- **Stellar Chain Module Cryptographic Audit Fixes**: Applied all findings from independent cryptographic audit (issue #55). Breaking changes:
Expand Down
25 changes: 25 additions & 0 deletions MIGRATING.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,31 @@ See [Stellar Horizon and RPC request timeouts](./docs/chains/stellar-request-tim

---

### Stellar: Announcements Use Scheme 2

The deployed v2 announcer (`stealth-announcer` in wraith-protocol/contracts) accepts only `scheme_id = 2`, but the builders sent `1`. They now all emit `ACTIVE_SCHEME_ID`, which is `SCHEME_ID_V2` (`2`):

- `buildStealthAnnouncement()`, `buildPathStealthPayment()` and `buildStellarSwapAndStealth()` pass it to `announce()`.
- `buildAnnouncementData()` returns it as `schemeId`, and `encodeAnnouncementData()` writes it into byte 0.

What it means if you still use the v1 announcer or v1 events:

- **Sending to a v1 announcer still works.** The v1 contract accepts any scheme id, so the same calls succeed. Its events keep the v1 layout `("announce", scheme_id, stealth_address)`, now with `scheme_id = 2`.
- **The scanners accept both.** `scanAnnouncements()` and `scanAnnouncementsStream()` keep v1 (`1`) and v2 (`2`) announcements. If your own indexer or filter keeps only `schemeId === 1`, accept `2` as well, or it will miss new payments. The exception is `scanAnnouncementsLegacySharedSecretTag()`, kept for benchmarks: it reads v1 only, because it checks the legacy shared-secret view tag and v2 announcements use the public one (`computeAnnouncementViewTag()`).
- **Old events are not reinterpreted.** The SDK picks the v1 or v2 parser from the topic layout (3 or 4 topics), not from the scheme id, so historical v1 events stay v1 events.
- **`SCHEME_ID` is deprecated.** It is still `1`. Use `SCHEME_ID_V1` when you mean the legacy layout and `ACTIVE_SCHEME_ID` when you mean the scheme the SDK emits.

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

const isStellarStealthAnnouncement = (schemeId: number) =>
schemeId === SCHEME_ID_V1 || schemeId === SCHEME_ID_V2;
```

`test/chains/stellar/announcer-conformance.test.ts` checks every builder against the announcer ABI vendored in `test/chains/stellar/fixtures/stealth-announcer-v2.json`.

---

### Scanner Pool API

The SDK now includes a `ScannerPool` utility for efficient multichain scanning. While this is an **additive feature** (no breaking changes), be aware of the new export:
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ import {
decodeStealthMetaAddress,
fetchAnnouncements,
RetentionExceededError,
SCHEME_ID,
ACTIVE_SCHEME_ID,
bytesToHex,
} from '@wraith-protocol/sdk/chains/stellar';

Expand Down
5 changes: 4 additions & 1 deletion etc/sdk-stellar.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ import { Transaction } from '@stellar/stellar-sdk';
import { TransactionBuilder } from '@stellar/stellar-sdk';
import { xdr } from '@stellar/stellar-sdk';

// @public
export const ACTIVE_SCHEME_ID = 2;

// @public
export function addStealthMultisigSigner(tx: Transaction, signerKey: Keypair | string): Transaction;

Expand Down Expand Up @@ -665,7 +668,7 @@ export function scanAnnouncementsStream(source: AsyncIterable<Announcement>, vie
tracer?: Tracer;
}): AsyncGenerator<MatchedAnnouncement>;

// @public
// @public @deprecated
export const SCHEME_ID = 1;

// @public
Expand Down
4 changes: 4 additions & 0 deletions src/chains/stellar/EVENT_FETCHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ announcer events and the privacy trade-offs of RPC topic filtering.
During the v1 → v2 migration window, `fetchAnnouncements()` reads from **both**
contracts when `announcerV2` is configured in deployments.

The builders emit `ACTIVE_SCHEME_ID` (`2`) to whichever announcer you pass. The v1
contract accepts any scheme id, so v1-layout events carry scheme `1` (older SDKs)
or `2`. The parser picks the layout from the topic count, not from the scheme id.

## Bucketed `getEvents` queries (v2)

For v2 announcements, the SDK builds Soroban RPC filters:
Expand Down
6 changes: 3 additions & 3 deletions src/chains/stellar/batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
StrKey,
} from '@stellar/stellar-sdk';
import { hexToBytes } from './utils';
import { SCHEME_ID } from './constants';
import { ACTIVE_SCHEME_ID } from './constants';

const MAX_OPS_PER_TX = 100;
const OPS_PER_PAYMENT = 2;
Expand Down Expand Up @@ -59,13 +59,13 @@ export interface BuildResult {
* storing in a Stellar manageData operation value (max 64 bytes).
*
* Format (34 bytes):
* [0] — schemeId (1 byte)
* [0] — scheme id, {@link ACTIVE_SCHEME_ID} (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[0] = ACTIVE_SCHEME_ID;
data.set(ephemeralPubKey, 1);
data[33] = viewTag;
return data;
Expand Down
4 changes: 2 additions & 2 deletions src/chains/stellar/builders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
nativeToScVal,
xdr,
} from '@stellar/stellar-sdk';
import { SCHEME_ID } from './constants';
import { ACTIVE_SCHEME_ID } from './constants';
import { GeneratedStealthAddress } from './types';

/**
Expand Down Expand Up @@ -312,7 +312,7 @@ export function buildStealthAnnouncement(options: BuildAnnouncementOptions) {
.addOperation(
contract.call(
'announce',
nativeToScVal(SCHEME_ID, { type: 'u32' }),
nativeToScVal(ACTIVE_SCHEME_ID, { type: 'u32' }),
new Address(stealthResult.stealthAddress).toScVal(),
xdr.ScVal.scvBytes(Buffer.from(stealthResult.ephemeralPubKey)),
xdr.ScVal.scvBytes(Buffer.from([stealthResult.viewTag])),
Expand Down
32 changes: 26 additions & 6 deletions src/chains/stellar/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,41 @@ export const STEALTH_SIGNING_MESSAGE =
'Sign this message to generate your Wraith stealth keys.\n\nChain: Stellar\nNote: This signature is used for key derivation only and does not authorize any transaction.';

/**
* Scheme identifier used by Stellar announcements for ed25519 stealth addresses (v1 layout).
* Scheme identifier of the v1 Stellar announcement layout.
*
* Announcements with another scheme id are ignored by the Stellar scanner unless they
* use {@link SCHEME_ID_V2}.
* @deprecated The name reads like the scheme the SDK emits, but it is the v1 value. Use
* {@link SCHEME_ID_V1} for the legacy layout, or {@link ACTIVE_SCHEME_ID} for the scheme the
* announcement builders emit.
*
* @see {@link scanAnnouncements}
*/
export const SCHEME_ID = 1;

/** Alias for {@link SCHEME_ID} — v1 announcer event schema. */
export const SCHEME_ID_V1 = SCHEME_ID;
/**
* Scheme id of the v1 Stellar announcement layout. The v1 announcer emits
* `("announce", scheme_id, stealth_address)` topics and accepts any scheme id.
*/
export const SCHEME_ID_V1 = 1;

/** Scheme ID for the v2 announcer with indexed view-tag bucket topics. */
/**
* Scheme id of the v2 announcer, which emits indexed view-tag bucket topics and accepts only
* this value.
*/
export const SCHEME_ID_V2 = 2;

/**
* Scheme id the Stellar announcement builders emit, and the single source of truth for it:
* `buildStealthAnnouncement()`, `buildPathStealthPayment()`, `buildStellarSwapAndStealth()`,
* `buildAnnouncementData()` and `encodeAnnouncementData()` all read it.
*
* It is {@link SCHEME_ID_V2}, the only value the deployed v2 announcer accepts
* (`STELLAR_V2_SCHEME_ID` in wraith-protocol/contracts). The legacy v1 announcer accepts any
* scheme id, so the same calls also work against a v1 deployment. `scanAnnouncements()` and
* `scanAnnouncementsStream()` accept both {@link SCHEME_ID_V1} and {@link SCHEME_ID_V2};
* `scanAnnouncementsLegacySharedSecretTag()` reads v1 only.
*/
export const ACTIVE_SCHEME_ID = SCHEME_ID_V2;

/** Soroban event topic-0 symbol for stealth announcements. */
export const ANNOUNCE_EVENT_SYMBOL = 'announce';

Expand Down
1 change: 1 addition & 0 deletions src/chains/stellar/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export {
SCHEME_ID,
SCHEME_ID_V1,
SCHEME_ID_V2,
ACTIVE_SCHEME_ID,
ANNOUNCE_EVENT_SYMBOL,
VIEW_TAG_BUCKET_COUNT,
META_ADDRESS_PREFIX,
Expand Down
4 changes: 2 additions & 2 deletions src/chains/stellar/path-payment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
nativeToScVal,
xdr,
} from '@stellar/stellar-sdk';
import { SCHEME_ID } from './constants';
import { ACTIVE_SCHEME_ID } from './constants';
import { generateStealthAddress } from './stealth';
import { decodeStealthMetaAddress } from './meta-address';
import { getDeployment } from './deployments';
Expand Down Expand Up @@ -208,7 +208,7 @@ export function buildPathStealthPayment(
builder.addOperation(
contract.call(
'announce',
nativeToScVal(SCHEME_ID, { type: 'u32' }),
nativeToScVal(ACTIVE_SCHEME_ID, { type: 'u32' }),
new Address(stealthResult.stealthAddress).toScVal(),
xdr.ScVal.scvBytes(Buffer.from(stealthResult.ephemeralPubKey)),
xdr.ScVal.scvBytes(Buffer.from([stealthResult.viewTag])),
Expand Down
21 changes: 13 additions & 8 deletions src/chains/stellar/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { ed25519 } from '@noble/curves/ed25519';
import type { ExtPointType } from '@noble/curves/abstract/edwards';
import { computeAnnouncementViewTag, computeSharedSecret, computeViewTag } from './stealth';
import { hashToScalar, deriveStealthPubKey, pubKeyToStellarAddress, L } from './scalar';
import { SCHEME_ID, SCHEME_ID_V2 } from './constants';
import { SCHEME_ID_V1, SCHEME_ID_V2 } from './constants';
import type { Announcement, MatchedAnnouncement } from './types';
import { encodeStealthMetaAddress, decodeStealthMetaAddress } from './meta-address';
import { hexToBytes } from './utils';
Expand Down Expand Up @@ -115,7 +115,7 @@ export async function* scanAnnouncementsStream(
try {
for await (const ann of piped) {
scanned++;
if (ann.schemeId !== SCHEME_ID && ann.schemeId !== SCHEME_ID_V2) continue;
if (ann.schemeId !== SCHEME_ID_V1 && ann.schemeId !== SCHEME_ID_V2) continue;

const metadataBytes = hexToBytes(ann.metadata);
if (metadataBytes.length === 0) continue;
Expand Down Expand Up @@ -235,7 +235,7 @@ export async function* scanAnnouncementsStreamSequential(
if (batch.length === 0) break;

for (const ann of batch) {
if (ann.schemeId !== SCHEME_ID && ann.schemeId !== SCHEME_ID_V2) continue;
if (ann.schemeId !== SCHEME_ID_V1 && ann.schemeId !== SCHEME_ID_V2) continue;

const viewTag = readViewTag(ann.metadata);
if (viewTag === null) continue;
Expand Down Expand Up @@ -426,7 +426,7 @@ export function scanAnnouncements(

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;
if (ann.schemeId !== SCHEME_ID_V1 && ann.schemeId !== SCHEME_ID_V2) continue;

const viewTag = readViewTag(ann.metadata);
if (viewTag === null) continue;
Expand Down Expand Up @@ -466,9 +466,14 @@ export function scanAnnouncements(
/**
* 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.
* This matches the old Stellar path: every v1 announcement pays for X25519
* first, computes the legacy shared-secret tag second, and only then compares
* the announced stealth address.
*
* It reads {@link SCHEME_ID_V1} announcements only. Scheme 2 announcements carry
* the public view tag from {@link computeAnnouncementViewTag}, which this path
* never computes, so it skips them; scan those with {@link scanAnnouncements}
* or {@link scanAnnouncementsStream}.
*/
export function scanAnnouncementsLegacySharedSecretTag(
announcements: Announcement[],
Expand All @@ -479,7 +484,7 @@ export function scanAnnouncementsLegacySharedSecretTag(
const matched: MatchedAnnouncement[] = [];

for (const ann of announcements) {
if (ann.schemeId !== SCHEME_ID) continue;
if (ann.schemeId !== SCHEME_ID_V1) continue;

const metadataBytes = hexToBytes(ann.metadata);
if (metadataBytes.length === 0) continue;
Expand Down
4 changes: 2 additions & 2 deletions src/chains/stellar/swap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
nativeToScVal,
xdr,
} from '@stellar/stellar-sdk';
import { SCHEME_ID } from './constants';
import { ACTIVE_SCHEME_ID } from './constants';
import { generateStealthAddress } from './stealth';
import { decodeStealthMetaAddress } from './meta-address';
import type { GeneratedStealthAddress } from './types';
Expand Down Expand Up @@ -204,7 +204,7 @@ export function buildStellarSwapAndStealth(
builder.addOperation(
contract.call(
'announce',
nativeToScVal(SCHEME_ID, { type: 'u32' }),
nativeToScVal(ACTIVE_SCHEME_ID, { type: 'u32' }),
new Address(stealthResult.stealthAddress).toScVal(),
xdr.ScVal.scvBytes(Buffer.from(stealthResult.ephemeralPubKey)),
xdr.ScVal.scvBytes(Buffer.from([stealthResult.viewTag])),
Expand Down
4 changes: 2 additions & 2 deletions src/chains/stellar/tx-builder.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { decodeStealthMetaAddress } from './meta-address';
import { generateStealthAddress } from './stealth';
import { bytesToHex } from './utils';
import { SCHEME_ID } from './constants';
import { ACTIVE_SCHEME_ID } from './constants';
import type {
GeneratedStealthAddress,
StealthPayment,
Expand Down Expand Up @@ -228,7 +228,7 @@ export function buildAnnouncementData(
metadata: string;
}> {
return stealthAddresses.map((stealth) => ({
schemeId: SCHEME_ID,
schemeId: ACTIVE_SCHEME_ID,
stealthAddress: stealth.stealthAddress,
caller,
ephemeralPubKey: bytesToHex(stealth.ephemeralPubKey),
Expand Down
Loading
Loading