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.
288 changes: 288 additions & 0 deletions packages/test-vectors/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,288 @@
# @wraith-protocol/test-vectors

Deterministic cryptographic test vectors for the [Wraith](https://github.com/wraith-protocol) stealth address protocol. Use these to verify that your reimplementation (Go, Rust, Swift, Python, …) produces byte-for-byte identical outputs to the reference TypeScript SDK.

## Structure

```
vectors/
evm.json — EVM (secp256k1 + keccak256)
stellar.json — Stellar (ed25519 + X25519 ECDH)
solana.json — Solana (ed25519 + X25519 ECDH)
ckb.json — CKB (secp256k1 + SHA-256 + blake2b)
checksum.json — SHA-256 of every vector file
```

Each file contains **100 vectors** for each of the 5 operation types:

| Field | Description |
| ---------------- | ------------------------------------------------------------------- |
| `key_derivation` | Derive spending/viewing keypairs from a wallet signature |
| `stealth_gen` | Generate a one-time stealth address from a recipient's meta-address |
| `scan_match` | Verify an announcement matches a recipient's viewing key |
| `signing` | Derive the stealth private key / scalar and optionally sign |
| `encoding` | Encode / decode stealth meta-addresses |

All vectors are generated deterministically from seed `0x57524149` ("WRAI").

## Verifying Checksums

```bash
# Node
node -e "
const {createHash} = require('crypto');
const {readFileSync} = require('fs');
const {files} = JSON.parse(readFileSync('checksum.json'));
for (const [f, expected] of Object.entries(files)) {
const actual = createHash('sha256').update(readFileSync(f)).digest('hex');
console.log(actual === expected ? 'OK' : 'FAIL', f);
}
"
```

---

## Consumption Examples

### Rust

```toml
# Cargo.toml
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
hex = "0.4"
```

```rust
use serde::Deserialize;
use std::fs;

#[derive(Deserialize)]
struct KeyDerivInput { signature: String }
#[derive(Deserialize)]
struct KeyDerivOutput {
spending_key: String,
viewing_key: String,
spending_pub_key: String,
viewing_pub_key: String,
}
#[derive(Deserialize)]
struct KeyDerivVector { input: KeyDerivInput, output: KeyDerivOutput }
#[derive(Deserialize)]
struct EvmVectors { key_derivation: Vec<KeyDerivVector> }

fn main() {
let raw = fs::read_to_string("vectors/evm.json").unwrap();
let vecs: EvmVectors = serde_json::from_str(&raw).unwrap();

for v in &vecs.key_derivation {
let sig = hex::decode(v.input.signature.trim_start_matches("0x")).unwrap();
// call your derive_stealth_keys(sig) and compare with v.output
let expected_spend = v.output.spending_key.trim_start_matches("0x");
// assert_eq!(hex::encode(your_result.spending_key), expected_spend);
println!("vector ok — spending_pub={}", &v.output.spending_pub_key[..16]);
}
}
```

---

### Go

```go
package main

import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
)

type KeyDerivInput struct {
Signature string `json:"signature"`
}
type KeyDerivOutput struct {
SpendingKey string `json:"spendingKey"`
ViewingKey string `json:"viewingKey"`
SpendingPubKey string `json:"spendingPubKey"`
ViewingPubKey string `json:"viewingPubKey"`
}
type KeyDerivVector struct {
Input KeyDerivInput `json:"input"`
Output KeyDerivOutput `json:"output"`
}
type EVMVectors struct {
KeyDerivation []KeyDerivVector `json:"key_derivation"`
}

func main() {
data, _ := os.ReadFile("vectors/evm.json")
var vecs EVMVectors
json.Unmarshal(data, &vecs)

for i, v := range vecs.KeyDerivation {
sig, _ := hex.DecodeString(v.Input.Signature[2:]) // strip 0x
// r = sig[0:32], s = sig[32:64]
r := sig[:32]
spendHash := sha256.Sum256(r) // placeholder — real impl uses keccak256
_ = spendHash
fmt.Printf("vector %d: spendingPubKey=%s...\n", i, v.Output.SpendingPubKey[:12])
}
}
```

---

### Python

```python
import json, hashlib

with open("vectors/evm.json") as f:
vecs = json.load(f)

# Verify checksums first
with open("checksum.json") as f:
checksums = json.load(f)["files"]

for path, expected in checksums.items():
with open(path, "rb") as f:
actual = hashlib.sha256(f.read()).hexdigest()
assert actual == expected, f"Checksum mismatch: {path}"

print("Checksums OK")

# Consume key_derivation vectors
for v in vecs["key_derivation"]:
sig = bytes.fromhex(v["input"]["signature"].lstrip("0x"))
expected_spend_pub = v["output"]["spendingPubKey"].lstrip("0x")
# result = your_derive_stealth_keys(sig)
# assert result.spending_pub_key.hex() == expected_spend_pub
print(f" spendingPubKey={expected_spend_pub[:16]}...")

# Consume stealth_gen vectors
for v in vecs["stealth_gen"]:
inp = v["input"]
out = v["output"]
# result = your_generate_stealth_address(
# bytes.fromhex(inp["spendingPubKey"].lstrip("0x")),
# bytes.fromhex(inp["viewingPubKey"].lstrip("0x")),
# bytes.fromhex(inp["ephemeralPrivateKey"].lstrip("0x")),
# )
# assert result.stealth_address == out["stealthAddress"]
# assert result.view_tag == out["viewTag"]
print(f" stealthAddress={out['stealthAddress'][:12]}...")
```

---

## JSON Schema

### EVM / CKB (secp256k1 chains)

`key_derivation` vector:

```json
{
"input": { "signature": "0x<130 hex chars>" },
"output": {
"spendingKey": "0x<64 hex chars>",
"viewingKey": "0x<64 hex chars>",
"spendingPubKey": "0x<66 hex chars>",
"viewingPubKey": "0x<66 hex chars>"
}
}
```

`stealth_gen` vector (EVM):

```json
{
"input": { "spendingPubKey": "0x...", "viewingPubKey": "0x...", "ephemeralPrivateKey": "0x..." },
"output": { "stealthAddress": "0x<40 hex>", "ephemeralPubKey": "0x<66 hex>", "viewTag": 42 }
}
```

`stealth_gen` vector (CKB):

```json
{
"input": { "spendingPubKey": "0x...", "viewingPubKey": "0x...", "ephemeralPrivateKey": "0x..." },
"output": {
"stealthPubKey": "0x<66 hex>",
"stealthPubKeyHash": "0x<40 hex>",
"ephemeralPubKey": "0x<66 hex>",
"lockArgs": "0x<106 hex>"
}
}
```

### Stellar / Solana (ed25519 chains)

`key_derivation` vector:

```json
{
"input": { "signature": "<128 hex chars>" },
"output": {
"spendingKey": "<64 hex>",
"viewingKey": "<64 hex>",
"spendingScalar": "<decimal bigint>",
"spendingPubKey": "<64 hex>",
"viewingPubKey": "<64 hex>"
}
}
```

`stealth_gen` vector:

```json
{
"input": {
"spendingPubKey": "<64 hex>",
"viewingPubKey": "<64 hex>",
"ephemeralSeed": "<64 hex>"
},
"output": {
"stealthAddress": "G... (Stellar) or base58 (Solana)",
"ephemeralPubKey": "<64 hex>",
"viewTag": 42,
"stealthPubKey": "<64 hex>"
}
}
```

`signing` vector (Stellar / Solana):

```json
{
"input": {
"transactionHash": "<64 hex>",
"stealthScalar": "<decimal bigint>",
"stealthPubKey": "<64 hex>"
},
"output": { "signature": "<128 hex>" }
}
```

---

## Cryptographic Notes

| Chain | Curve | ECDH Hash | View-tag source |
| ------- | ---------------- | --------- | --------------------------------------------------------- |
| EVM | secp256k1 | keccak256 | `hashedSecret[0]` |
| Stellar | ed25519 / X25519 | SHA-256 | `SHA-256("wraith:stellar:view-tag:v2:" \|\| R \|\| V)[0]` |
| Solana | ed25519 / X25519 | SHA-256 | `SHA-256("wraith:tag:" \|\| sharedSecret)[0]` |
| CKB | secp256k1 | SHA-256 | none (full scan) |

Scalar derivation for all chains: `p_stealth = (spending_scalar + hash_scalar) mod curve_order`

For Stellar/Solana: `hash_scalar = SHA-256("wraith:scalar:" || sharedSecret) mod L` (little-endian)

## License

MIT
9 changes: 9 additions & 0 deletions packages/test-vectors/checksum.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"algorithm": "sha256",
"files": {
"vectors/ckb.json": "2665277b147d19d96490bc41f522ebc4d71d065f336d53e95628872f453e3554",
"vectors/evm.json": "1434a472a3ad8eb4165f21db20046bdaf158f44fdb798d7d3a19b19ff2177792",
"vectors/solana.json": "12b52e0bc8410251aa26d90599eca62ff73b817267d539416ae907db2b2933e1",
"vectors/stellar.json": "4b0636ef2b57e949f2aae154eed3736ca6b0c557fa04410d1aabe3f2b808755f"
}
}
Loading