Skip to content

Commit 997100f

Browse files
authored
feat(#93): add interactive scanner playground and Stellar quickstart (#96)
- Add scripts/playground/index.html — sandboxed iframe playground with dark-themed UI: meta-address + key inputs, Load demo keys shortcut, fixture announcement table, match result cards - Add scripts/playground/scanner.js — fully self-contained client-side scanner engine using only SubtleCrypto (SHA-256/SHA-512) and inline Curve25519/ed25519 point arithmetic. Zero third-party dependencies. CSP: default-src 'none'; script-src 'unsafe-inline' - Add scripts/playground/generate-fixtures.mjs — offline script that deterministically derives the demo keypair and computes the 5 fixture announcements (2 matches, 3 noise) written into scanner.js - Add guides/stellar/stellar-quickstart.mdx — 5-step quickstart guide covering key derivation, stealth address generation, scanning, spending, and EVM comparison; embeds the playground via sandboxed iframe with allow-scripts only - Update docs.json — add 'Stellar' nav group under Guides tab with the quickstart as its first page Acceptance criteria met: [x] Playground renders on the quickstart page [x] Runs entirely client-side against fixture data [x] No third-party JS (all crypto inline via Web Crypto API)
1 parent e1a07b6 commit 997100f

5 files changed

Lines changed: 1387 additions & 0 deletions

File tree

docs.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,12 @@
123123
"guides/integrations/react-native"
124124
]
125125
},
126+
{
127+
"group": "Stellar",
128+
"pages": [
129+
"guides/stellar/stellar-quickstart"
130+
]
131+
},
126132
{
127133
"group": "Operations",
128134
"pages": [
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
---
2+
title: "Stellar Quickstart"
3+
description: "Derive stealth keys, scan announcements, and spend from a stealth address on Stellar — with an interactive demo"
4+
keywords: "Stellar, stealth address, ed25519, scanner, quickstart, soroban"
5+
---
6+
7+
This guide walks you through the complete Stellar stealth-address flow in five steps:
8+
derive keys → generate a stealth address → send → announce → scan.
9+
The interactive playground at the bottom lets you run the scanner against a
10+
real batch of fixture announcements, entirely in your browser.
11+
12+
## Prerequisites
13+
14+
```bash
15+
npm install @wraith-protocol/sdk @stellar/stellar-sdk
16+
```
17+
18+
## 1. Derive Stealth Keys
19+
20+
Sign the canonical Wraith message with a Stellar keypair, then pass the signature
21+
to `deriveStealthKeys`. The two derived keys serve different purposes:
22+
the **viewing key** detects incoming payments; the **spending key** authorizes withdrawals.
23+
24+
```typescript
25+
import {
26+
deriveStealthKeys,
27+
encodeStealthMetaAddress,
28+
STEALTH_SIGNING_MESSAGE,
29+
} from "@wraith-protocol/sdk/chains/stellar";
30+
import { Keypair } from "@stellar/stellar-sdk";
31+
32+
const keypair = Keypair.fromSecret("S...");
33+
const signature = keypair.sign(Buffer.from(STEALTH_SIGNING_MESSAGE));
34+
const keys = deriveStealthKeys(signature);
35+
36+
// Publish this so senders can find you
37+
const metaAddress = encodeStealthMetaAddress(keys.spendingPubKey, keys.viewingPubKey);
38+
console.log(metaAddress); // "st:xlm:<64hex><64hex>"
39+
```
40+
41+
## 2. Generate a Stealth Address (Sender Side)
42+
43+
The sender decodes the recipient's meta-address and generates a fresh one-time
44+
stealth address. The ephemeral public key and view tag are announced on-chain.
45+
46+
```typescript
47+
import {
48+
decodeStealthMetaAddress,
49+
generateStealthAddress,
50+
} from "@wraith-protocol/sdk/chains/stellar";
51+
52+
const { spendingPubKey, viewingPubKey } = decodeStealthMetaAddress(
53+
"st:xlm:<recipient-meta-address>"
54+
);
55+
56+
const { stealthAddress, ephemeralPubKey, viewTag } = generateStealthAddress(
57+
spendingPubKey,
58+
viewingPubKey
59+
);
60+
61+
// Send XLM to stealthAddress via Operation.createAccount
62+
// Then call the Soroban announcer contract with:
63+
// ephemeralPubKey (hex), viewTag (as first byte of metadata)
64+
```
65+
66+
## 3. Scan for Incoming Payments
67+
68+
`scanAnnouncements` takes the raw announcement list from `fetchAnnouncements`
69+
(or your own indexer) and returns only the entries that belong to your keys,
70+
together with the derived private scalar needed to spend.
71+
72+
```typescript
73+
import {
74+
scanAnnouncements,
75+
fetchAnnouncements,
76+
} from "@wraith-protocol/sdk/chains/stellar";
77+
78+
// Fetch all announcements from the Soroban RPC
79+
const announcements = await fetchAnnouncements("stellar");
80+
81+
const matched = await scanAnnouncements(
82+
announcements,
83+
keys.viewingKey, // 32-byte Uint8Array seed
84+
keys.spendingPubKey, // 32-byte Uint8Array
85+
keys.spendingScalar, // bigint
86+
);
87+
88+
for (const m of matched) {
89+
console.log("Found payment at:", m.stealthAddress);
90+
console.log("Private scalar: ", m.stealthPrivateScalar);
91+
}
92+
```
93+
94+
<Tip>
95+
View tags filter out ~255 of every 256 non-matching announcements with a
96+
single byte comparison, making scans fast even over thousands of events.
97+
</Tip>
98+
99+
## 4. Spend From a Stealth Address
100+
101+
Because stealth private keys are derived scalars (not raw seeds), standard
102+
Stellar signing does not work. Use `signStellarTransaction` instead.
103+
104+
```typescript
105+
import {
106+
signStellarTransaction,
107+
pubKeyToStellarAddress,
108+
} from "@wraith-protocol/sdk/chains/stellar";
109+
import { Transaction, Keypair } from "@stellar/stellar-sdk";
110+
111+
// m = a matched announcement from scanAnnouncements()
112+
const txHash = transaction.hash(); // 32-byte Buffer
113+
const sig = signStellarTransaction(txHash, m.stealthPrivateScalar, m.stealthPubKeyBytes);
114+
115+
// Attach signature and submit
116+
const hint = Buffer.from(m.stealthPubKeyBytes.slice(0, 4));
117+
transaction.addSignature(
118+
pubKeyToStellarAddress(m.stealthPubKeyBytes),
119+
hint.toString("base64"),
120+
sig.toString("base64"),
121+
);
122+
```
123+
124+
## 5. Key Differences from EVM
125+
126+
| Aspect | EVM | Stellar |
127+
|---|---|---|
128+
| Curve | secp256k1 | ed25519 |
129+
| ECDH | `secp256k1.getSharedSecret` | X25519 (Montgomery form) |
130+
| Private key output | hex string | bigint scalar |
131+
| Address format | `0x…` (20 bytes) | `G…` (56 chars) |
132+
| Signing | secp256k1 ECDSA | `signWithScalar` / `signStellarTransaction` |
133+
134+
---
135+
136+
## Interactive Scanner Demo
137+
138+
Paste your own stealth meta-address and keys, or click **Load demo keys** to
139+
scan the five pre-built fixture announcements. Everything runs client-side —
140+
no wallet, no network calls, no third-party scripts.
141+
142+
<iframe
143+
src="/scripts/playground/index.html"
144+
sandbox="allow-scripts"
145+
width="100%"
146+
height="700"
147+
style={{ border: "1px solid #2a2f3d", borderRadius: "8px" }}
148+
title="Wraith Stellar Scanner Playground"
149+
/>
150+
151+
<Note>
152+
The demo keys are derived deterministically from a test seed and are not
153+
connected to any real wallet. Two of the five fixture announcements match
154+
the pre-filled keys; the other three are noise.
155+
</Note>
156+
157+
---
158+
159+
## Next Steps
160+
161+
- [Stellar Primitives Reference](/sdk/chains/stellar) — full API docs for all exported functions
162+
- [Stealth Payments Explained](/guides/stealth-payments) — visual walkthrough of the cryptography
163+
- [Stellar Custom Assets](/guides/stellar-custom-assets) — send USDC and other SAC tokens via stealth addresses
164+
- [Stellar Multisig Withdrawals](/guides/stellar-multisig-withdrawal) — N-of-M coordinator flows
165+
- [Stellar Troubleshooting](/guides/stellar-troubleshooting) — common errors and fixes
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
// generate-fixtures.mjs
2+
// Run: node scripts/playground/generate-fixtures.mjs
3+
// Outputs hardcoded fixture values for scanner.js
4+
//
5+
// Uses the same algorithms as the Wraith SDK (inline, no SDK import)
6+
// so the docs repo doesn't need the SDK installed at build time.
7+
8+
import { createHash } from 'node:crypto';
9+
10+
// ── Curve25519 field arithmetic ──────────────────────────────
11+
const P = (2n ** 255n) - 19n;
12+
const A24 = 121665n;
13+
const L = (2n ** 252n) + 27742317777372353535851937790883648493n;
14+
15+
const fmod = x => { let r = x % P; return r < 0n ? r + P : r; };
16+
const fadd = (a,b) => fmod(a+b);
17+
const fsub = (a,b) => fmod(a-b);
18+
const fmul = (a,b) => fmod(a*b);
19+
function fpow(base, exp) {
20+
base = fmod(base); let r = 1n;
21+
while (exp > 0n) { if (exp&1n) r=fmul(r,base); base=fmul(base,base); exp>>=1n; }
22+
return r;
23+
}
24+
const finv = a => fpow(a, P-2n);
25+
26+
// ── helpers ──────────────────────────────────────────────────
27+
function bytesToBigintLE(b) {
28+
let n=0n; for (let i=b.length-1;i>=0;i--) n=(n<<8n)|BigInt(b[i]); return n;
29+
}
30+
function bigintToBytesLE(n, len) {
31+
const o=Buffer.alloc(len); for(let i=0;i<len;i++){o[i]=Number(n&0xffn);n>>=8n;} return o;
32+
}
33+
function hexToBytes(h) { return Buffer.from(h,'hex'); }
34+
function bytesToHex(b) { return Buffer.from(b).toString('hex'); }
35+
function concatBufs(...a) { return Buffer.concat(a.map(x=>Buffer.from(x))); }
36+
function sha256(...parts) { return createHash('sha256').update(concatBufs(...parts)).digest(); }
37+
function sha512(d) { return createHash('sha512').update(d).digest(); }
38+
39+
// ── ed25519 math ─────────────────────────────────────────────
40+
const ED_D = fmod(-121665n * finv(121666n));
41+
const ED_GY = fmod(4n * finv(5n));
42+
const ED_GX = (() => {
43+
const y2=fmul(ED_GY,ED_GY), num=fsub(y2,1n), den=fadd(fmul(ED_D,y2),1n);
44+
const x2=fmul(num,finv(den));
45+
let x=fpow(x2,(P+3n)/8n);
46+
if (fmul(x,x)!==x2) x=fmul(x,fpow(2n,(P-1n)/4n));
47+
if (x%2n!==0n) x=P-x; return x;
48+
})();
49+
const ED_G = [ED_GX, ED_GY, 1n, fmul(ED_GX,ED_GY)];
50+
51+
function edAdd(P1,P2){
52+
const[X1,Y1,Z1,T1]=P1,[X2,Y2,Z2,T2]=P2;
53+
const A=fmul(fsub(Y1,X1),fsub(Y2,X2)),B=fmul(fadd(Y1,X1),fadd(Y2,X2));
54+
const C=fmul(fmul(2n,ED_D),fmul(T1,T2)),D=fmul(2n,fmul(Z1,Z2));
55+
const E=fsub(B,A),F=fsub(D,C),G=fadd(D,C),H=fadd(B,A);
56+
return[fmul(E,F),fmul(G,H),fmul(F,G),fmul(E,H)];
57+
}
58+
function edMul(k,pt){
59+
let Q=[0n,1n,1n,0n],R=pt;
60+
while(k>0n){if(k&1n)Q=edAdd(Q,R);R=edAdd(R,R);k>>=1n;} return Q;
61+
}
62+
function edCompress(pt){
63+
const[X,Y,Z]=pt,zi=finv(Z),x=fmul(X,zi),y=fmul(Y,zi);
64+
const o=bigintToBytesLE(y,32); if(x&1n)o[31]|=0x80; return o;
65+
}
66+
function edDecompress(b){
67+
const c=Buffer.from(b); const sign=(c[31]>>7)&1; c[31]&=0x7f;
68+
const y=bytesToBigintLE(c),y2=fmul(y,y),num=fsub(y2,1n),den=fadd(fmul(ED_D,y2),1n);
69+
const x2=fmul(num,finv(den)); let x=fpow(x2,(P+3n)/8n);
70+
if(fmul(x,x)!==fmod(x2))x=fmul(x,fpow(2n,(P-1n)/4n));
71+
if(Number(x&1n)!==sign)x=P-x; return[x,y,1n,fmul(x,y)];
72+
}
73+
74+
// seedToScalar: expand 32-byte seed via SHA-512 lower half, clamp
75+
function seedToScalar(seed) {
76+
const h = sha512(seed); const a=h.slice(0,32);
77+
a[0]&=248; a[31]&=127; a[31]|=64; return bytesToBigintLE(a);
78+
}
79+
function seedToPubKey(seed) { return edCompress(edMul(seedToScalar(seed),ED_G)); }
80+
81+
// ── X25519 ───────────────────────────────────────────────────
82+
function clamp(s){ let c=s&((1n<<255n)-1n); c&=~7n; c|=(1n<<254n); return c; }
83+
function x25519(scalar,u){
84+
const s=clamp(scalar);
85+
let x1=u,x2=1n,z2=0n,x3=u,z3=1n,sw=0n;
86+
for(let t=254n;t>=0n;t--){
87+
const bit=(s>>t)&1n,xs=sw^bit; sw=bit;
88+
if(xs===1n){[x2,x3]=[x3,x2];[z2,z3]=[z3,z2];}
89+
const A=fadd(x2,z2),AA=fmul(A,A),B=fsub(x2,z2),BB=fmul(B,B),E=fsub(AA,BB);
90+
const C=fadd(x3,z3),D=fsub(x3,z3),DA=fmul(D,A),CB=fmul(C,B);
91+
x3=fpow(fadd(DA,CB),2n); z3=fmul(x1,fpow(fsub(DA,CB),2n));
92+
x2=fmul(AA,BB); z2=fmul(E,fadd(AA,fmul(A24,E)));
93+
}
94+
if(sw===1n){[x2,x3]=[x3,x2];}
95+
return fmul(x2,finv(z2));
96+
}
97+
function edPubToMont(b){
98+
const c=Buffer.from(b); c[31]&=0x7f;
99+
const y=bytesToBigintLE(c),u=fmul(fadd(1n,y),finv(fsub(1n,y)));
100+
return bigintToBytesLE(u,32);
101+
}
102+
function edSeedToMontScalar(seed){
103+
const h=sha512(seed),a=h.slice(0,32); a[0]&=248;a[31]&=127;a[31]|=64; return a;
104+
}
105+
function sharedSecret(viewingSeed, ephPub){
106+
const priv=bytesToBigintLE(edSeedToMontScalar(viewingSeed));
107+
const pubU=bytesToBigintLE(edPubToMont(ephPub));
108+
return bigintToBytesLE(x25519(priv,pubU),32);
109+
}
110+
111+
// ── Stellar StrKey ───────────────────────────────────────────
112+
function crc16(data){
113+
let c=0xffff; for(const b of data){c^=(b<<8);for(let i=0;i<8;i++)c=(c&0x8000)?((c<<1)^0x1021):(c<<1);c&=0xffff;} return c;
114+
}
115+
const B32='ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
116+
function b32enc(bytes){
117+
let bits=0,val=0,o='';
118+
for(const b of bytes){val=(val<<8)|b;bits+=8;while(bits>=5){bits-=5;o+=B32[(val>>bits)&31];}}
119+
if(bits>0)o+=B32[(val<<(5-bits))&31]; return o;
120+
}
121+
function pubToAddr(pub){
122+
const p=Buffer.alloc(33); p[0]=0x30; pub.copy(p,1);
123+
const c=crc16(p),f=Buffer.alloc(35); p.copy(f); f[33]=c&0xff;f[34]=(c>>8)&0xff;
124+
return b32enc(f);
125+
}
126+
127+
// ── Fixture generation ───────────────────────────────────────
128+
// Deterministic "demo" recipient keys
129+
const viewingSeed = sha256(Buffer.from('wraith:viewing:demo'));
130+
const spendingSeed = sha256(Buffer.from('wraith:spending:demo'));
131+
const viewingPub = seedToPubKey(viewingSeed);
132+
const spendingPub = seedToPubKey(spendingSeed);
133+
const spendingScalar = seedToScalar(spendingSeed);
134+
135+
console.log('=== DEMO KEYS ===');
136+
console.log('viewingKey (hex seed): ', bytesToHex(viewingSeed));
137+
console.log('spendingScalar (bigint):', spendingScalar.toString());
138+
console.log('spendingPubKey (hex): ', bytesToHex(spendingPub));
139+
console.log('viewingPubKey (hex): ', bytesToHex(viewingPub));
140+
console.log('metaAddress: ', `st:xlm:${bytesToHex(spendingPub)}${bytesToHex(viewingPub)}`);
141+
console.log('');
142+
143+
// Generate 5 fixture announcements; indices 0 and 2 are "yours"
144+
const YOURS_INDICES = new Set([0,2]);
145+
146+
function makeAnnouncement(ephSeed, isYours) {
147+
// ephemeral keypair
148+
const ephPrivScalar = seedToScalar(ephSeed);
149+
const ephPub = edCompress(edMul(ephPrivScalar, ED_G));
150+
151+
if (!isYours) {
152+
// noise: random stealth address, wrong view tag
153+
const noiseKey = sha256(ephSeed, Buffer.from('noise'));
154+
const noisePub = edCompress(edMul(seedToScalar(noiseKey), ED_G));
155+
const noiseAddr = pubToAddr(Buffer.from(noisePub));
156+
const noiseTag = sha256(Buffer.from('wraith:tag:'), noiseKey)[0];
157+
const meta = Buffer.alloc(32); meta[0] = noiseTag;
158+
return {
159+
schemeId: 1,
160+
stealthAddress: noiseAddr,
161+
caller: pubToAddr(Buffer.from(ephPub)),
162+
ephemeralPubKey: bytesToHex(ephPub),
163+
metadata: bytesToHex(meta),
164+
_yours: false,
165+
};
166+
}
167+
168+
// Compute shared secret from ephemeral privkey and recipient viewing pub
169+
const ephPriv = bytesToBigintLE(edSeedToMontScalar(ephSeed));
170+
const viewingMontPub = bytesToBigintLE(edPubToMont(viewingPub));
171+
const sharedU = x25519(ephPriv, viewingMontPub);
172+
const shared = bigintToBytesLE(sharedU, 32);
173+
174+
// view tag
175+
const viewTag = sha256(Buffer.from('wraith:tag:'), shared)[0];
176+
// hash scalar
177+
const hScalarBytes = sha256(Buffer.from('wraith:scalar:'), shared);
178+
const hScalar = bytesToBigintLE(hScalarBytes) % L;
179+
// stealth pub = spendingPub + hScalar * G
180+
const spendPt = edDecompress(spendingPub);
181+
const hG = edMul(hScalar, ED_G);
182+
const stealthPt = edAdd(spendPt, hG);
183+
const stealthPub = edCompress(stealthPt);
184+
const stealthAddr = pubToAddr(Buffer.from(stealthPub));
185+
// metadata: first byte = view tag
186+
const meta = Buffer.alloc(32); meta[0] = viewTag;
187+
188+
console.log(`[Match] ephSeed=${bytesToHex(ephSeed).slice(0,16)}…`);
189+
console.log(` ephPub: ${bytesToHex(ephPub)}`);
190+
console.log(` viewTag: 0x${viewTag.toString(16).padStart(2,'0')}`);
191+
console.log(` stealthAddr: ${stealthAddr}`);
192+
console.log(` metadata: ${bytesToHex(meta)}`);
193+
194+
return {
195+
schemeId: 1,
196+
stealthAddress: stealthAddr,
197+
caller: pubToAddr(Buffer.from(ephPub)),
198+
ephemeralPubKey: bytesToHex(ephPub),
199+
metadata: bytesToHex(meta),
200+
_yours: true,
201+
};
202+
}
203+
204+
const seeds = [
205+
sha256(Buffer.from('eph:0')),
206+
sha256(Buffer.from('eph:1')),
207+
sha256(Buffer.from('eph:2')),
208+
sha256(Buffer.from('eph:3')),
209+
sha256(Buffer.from('eph:4')),
210+
];
211+
212+
const fixtures = seeds.map((s,i) => makeAnnouncement(s, YOURS_INDICES.has(i)));
213+
214+
console.log('\n=== FIXTURE_ANNOUNCEMENTS (paste into scanner.js) ===');
215+
console.log(JSON.stringify(fixtures, (k,v) => typeof v==='bigint'?v.toString():v, 2));

0 commit comments

Comments
 (0)