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
98 changes: 98 additions & 0 deletions architecture/stellar-cryptography.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
---
title: "Stellar Cryptography"
description: "Design rationale, view tag derivation, and RFC-compatible cryptography for Stellar stealth payments."
---

Wraith Protocol implements a non-interactive stealth payment scheme on Stellar. This page documents the cryptography decisions behind the implementation and exactly where each concept is realized in the SDK.

## Why ed25519?

Unlike EVM environments, which rely on `secp256k1`, the Stellar network uses the **ed25519** curve for all account addressing and signatures.
To ensure that stealth accounts are valid Stellar accounts that can sign transactions, the protocol's stealth derivations must perform point addition on the ed25519 curve.

- **Curve definition**: `scalar.ts:1` (via [@noble/curves/ed25519](https://github.com/paulmillr/noble-curves))

## X25519 ECDH and Edwards-to-Montgomery Conversion

Standard ed25519 points (in Edwards form) are optimized for signing, not for Diffie-Hellman key exchange. To securely establish a shared secret between sender and receiver without interaction, we must use **X25519** ECDH.
This requires converting the public and private ed25519 keys from Edwards coordinates to Montgomery coordinates, as specified in [RFC 7748](https://datatracker.ietf.org/doc/html/rfc7748).

- **Edwards-to-Montgomery conversion**: `stealth.ts:91-92`
- **X25519 shared secret**: `stealth.ts:20` and `stealth.ts:93`

## Domain Separation Prefixes

We use domain-separation prefixes in SHA-256 hashes to prevent cryptographic collisions between different key derivation phases.

- `wraith:spending:`: Separates the derivation of the spending seed (`keys.ts:25`).
- `wraith:viewing:`: Separates the derivation of the viewing seed (`keys.ts:26`).
- `wraith:scalar:`: Prevents the hash scalar from colliding with the base shared secret before it's reduced modulo L (`scalar.ts:202`, `scalar.ts:220`).
- `wraith:stellar:view-tag:v2:`: Domains the derivation for the 1-byte view tag (`stealth.ts:8`).
- `wraith:tag:`: The legacy v1 view tag prefix (`stealth.ts:9`).

## View Tag Derivation

To avoid performing an expensive X25519 ECDH operation for every incoming transaction, the sender derives a 1-byte **view tag** and publishes it alongside their ephemeral public key.

**Derivation:**
```
view_tag = SHA-256("wraith:stellar:view-tag:v2:" || R_ephemeral || V_recipient)[0]
```

- **Implementation**: `stealth.ts:99`
- **Performance impact**: This creates a cheap public prefilter before the X25519 shared secret computation (`scan.ts:12`).
- **False-positive rate**: A 1-byte tag produces a false-positive rate of `1/256` (~0.39%). For non-matching announcements, the protocol skips the expensive elliptic curve operations 99.61% of the time.

```mermaid
sequenceDiagram
participant Network
participant Scanner
Network->>Scanner: Fetch Announcements (R, view_tag)
Note over Scanner: Compare cheap view_tag first
alt Match view_tag
Scanner->>Scanner: X25519(v, R) -> shared_secret
Scanner->>Scanner: Derive expected stealth address
alt Match Address
Scanner->>Network: Recovered match!
end
else Mismatch view_tag
Note over Scanner: Skip (99.61% of non-matches)
end
```

## Private Scalar vs. Seeds and RFC 8032

Standard ed25519 signing libraries expect a 32-byte seed as the private key, which they hash (via SHA-512) to produce both the private scalar and a deterministic nonce.

In our non-interactive stealth scheme, the stealth private key is a *derived scalar*, not a raw seed:
```
stealth_scalar = (spending_scalar + hash_scalar) mod L
```

Because we only hold the resulting scalar, we cannot use off-the-shelf seed-based signing APIs. Instead, the SDK exposes a custom `signWithScalar` function to deterministically sign transactions using a raw scalar directly, while maintaining strict [RFC 8032](https://datatracker.ietf.org/doc/html/rfc8032) compatibility for ed25519 signatures.

- **`signWithScalar` implementation**: `scalar.ts:251`

## Meta-Address Encoding

To accept stealth payments, users publish a single "meta-address" that encapsulates both their spending and viewing public keys.

- **Prefix**: `st:xlm:` (`constants.ts:43`).
- **Encoding**: Consists of the prefix concatenated with the hex-encoded 32-byte spending public key and the 32-byte viewing public key (`meta-address.ts:10`).
- **Stellar StrKey compatibility**: To turn the final derived public stealth key into a standard Stellar address format (`G...`), we utilize Stellar's `StrKey` encoding logic (`scalar.ts:171`).

## Key Derivation Overview

```mermaid
flowchart TD
S(Sender) -->|Generates| r(Ephemeral Private Key 'r')
r --> R(Ephemeral Public Key 'R')
S --> |Recipient's| V(Viewing Public Key 'V')
S --> |Recipient's| K(Spending Public Key 'K')
r & V --> X25519(X25519 ECDH)
X25519 --> SS(Shared Secret)
R & V --> VT(View Tag)
SS --> |Hash mod L| HS(Hash Scalar)
HS & K --> |Point Addition| SP(Stealth Public Key)
SP --> |StrKey Encoding| SA(Stellar Address 'G...')
```
3 changes: 2 additions & 1 deletion docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@
"pages": [
"architecture/overview",
"architecture/chain-connectors",
"architecture/tee"
"architecture/tee",
"architecture/stellar-cryptography"
]
},
{
Expand Down
10 changes: 5 additions & 5 deletions guides/stellar-federation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -411,7 +411,7 @@ try {

The domain has a `stellar.toml` but hasn't configured federation.

```typescript
```typescript no-check
} catch (err) {
if (err.code === "NO_FEDERATION_SERVER") {
// Show: "example.com doesn't support federation addresses"
Expand All @@ -423,7 +423,7 @@ The domain has a `stellar.toml` but hasn't configured federation.

The federation server responded but doesn't know this username.

```typescript
```typescript no-check
} catch (err) {
if (err.code === "NOT_FOUND") {
// Show: "alice*example.com was not found"
Expand All @@ -437,7 +437,7 @@ This is the most common failure in payment UIs. Display it inline, next to the i

The federation server is reachable but returns a response missing `account_id`, or returns invalid JSON.

```typescript
```typescript no-check
} catch (err) {
if (err.code === "MALFORMED_RESPONSE") {
// Show: "example.com's federation server returned an unexpected response"
Expand All @@ -451,7 +451,7 @@ The federation server is reachable but returns a response missing `account_id`,

The federation server is too slow. Default timeout is 5 seconds; adjust with `options.timeoutMs`.

```typescript
```typescript no-check
} catch (err) {
if (err.code === "TIMEOUT") {
// Show: "The federation server took too long to respond. Try again."
Expand Down Expand Up @@ -506,7 +506,7 @@ try {

When your payment form accepts a Stellar destination, hint that federation addresses work:

```tsx
```tsx no-check
// React example
<input
type="text"
Expand Down
8 changes: 7 additions & 1 deletion scripts/check-snippets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/prom
import { tmpdir } from "node:os";
import path from "node:path";
import { spawn } from "node:child_process";
import process from "node:process";

type Snippet = {
attrs: string;
Expand All @@ -17,6 +18,7 @@ const checkableLanguages = new Set(["ts", "tsx", "typescript", "js", "javascript
const ignoredDirs = new Set([".git", ".github", "node_modules", ".next", "dist", "build"]);

const ambientPrelude = `
declare module "redis";
declare global {
var account: any;
var agent: any;
Expand All @@ -28,6 +30,7 @@ declare global {
var chainRegistry: any;
var config: any;
var connector: any;
var db: any;
var detected: any;
var ephemeralPubKey: Uint8Array;
var hash: string;
Expand All @@ -39,6 +42,9 @@ declare global {
var privateKey: any;
var publicClient: any;
var publicKey: Uint8Array;
var process: any;
var address: any;
var setError: any;
var recipient: any;
var recipientSpendingPubKey: any;
var recipientViewingPubKey: any;
Expand Down Expand Up @@ -98,7 +104,7 @@ async function main() {
"utf8",
);

const result = await run("npx", ["tsc", "--noEmit", "--project", compilerConfig]);
const result = await run("pnpm", ["exec", "tsc", "--noEmit", "--project", compilerConfig]);
if (result.exitCode !== 0) {
failures.push(appendSourceMap(result.output.trim(), checkable));
}
Expand Down
Loading