From 274f03c2208e12c846eb79dfc4f9b917b61e503e Mon Sep 17 00:00:00 2001 From: OluRemiFour Date: Wed, 24 Jun 2026 20:19:32 +0100 Subject: [PATCH 1/5] Spectre + Stellar agent cookbook (3 production recipes) --- docs.json | 3 +- guides/spectre-stellar-cookbook.mdx | 1107 +++++++++++++++++++++++++++ 2 files changed, 1109 insertions(+), 1 deletion(-) create mode 100644 guides/spectre-stellar-cookbook.mdx diff --git a/docs.json b/docs.json index 574b852..dec9f6f 100644 --- a/docs.json +++ b/docs.json @@ -99,7 +99,8 @@ "guides/single-chain-agent", "guides/multichain-agent", "guides/bring-your-own-model", - "guides/privacy-best-practices" + "guides/privacy-best-practices", + "guides/spectre-stellar-cookbook" ] } ] diff --git a/guides/spectre-stellar-cookbook.mdx b/guides/spectre-stellar-cookbook.mdx new file mode 100644 index 0000000..8d6b7e2 --- /dev/null +++ b/guides/spectre-stellar-cookbook.mdx @@ -0,0 +1,1107 @@ +--- +title: "Spectre + Stellar Cookbook" +description: "Three production-ready recipes for building Stellar agents with Wraith" +--- + +Three complete recipes for building Stellar agents in production. Each is copy-pasteable and covers the full lifecycle: agent creation, funding, payments, webhooks, and privacy automation. + +> These recipes use `Chain.Stellar` throughout. For multichain variants, see the [Multichain Agent guide](/guides/multichain-agent). + +--- + +## Recipe 1: SaaS Accepting Stellar Payments Privately + +A SaaS product that receives USDC payments on Stellar, webhooks on every incoming payment, auto-withdraws to cold storage weekly, and keeps privacy autopilot running. + +### What you build + +- A `payments.wraith` agent on Stellar +- Webhook handler that fires on every incoming stealth payment +- Weekly cron: scan → withdraw to cold storage → privacy check +- Privacy autopilot that warns before any risky action + +### Step 1 — Create the agent + +```typescript +import { Wraith, Chain } from "@wraith-protocol/sdk"; +import { Keypair } from "@stellar/stellar-sdk"; + +const wraith = new Wraith({ apiKey: process.env.WRAITH_API_KEY! }); + +// Use your company's Stellar keypair to prove ownership +const ownerKeypair = Keypair.fromSecret(process.env.OWNER_SECRET!); +const message = "Sign to create Wraith agent"; +const signature = ownerKeypair.sign(Buffer.from(message)); + +const agent = await wraith.createAgent({ + name: "payments", // → payments.wraith + chain: Chain.Stellar, + wallet: ownerKeypair.publicKey(), + signature: Buffer.from(signature).toString("hex"), + message, +}); + +console.log("Agent ID: ", agent.info.id); +console.log("Stellar address: ", agent.info.addresses[Chain.Stellar]); +console.log("Meta-address: ", agent.info.metaAddresses[Chain.Stellar]); +// st:xlm:abc123...def456... ← publish this on your pricing page +``` + +**curl equivalent:** + +```bash +curl -X POST https://api.usewraith.xyz/agent/create \ + -H "Authorization: Bearer $WRAITH_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "payments", + "chain": "stellar", + "wallet": "GABC...your-stellar-pubkey", + "signature": "hex-encoded-ed25519-signature", + "message": "Sign to create Wraith agent" + }' +``` + +**Response:** + +```json +{ + "id": "a1b2c3d4-...", + "name": "payments", + "chain": "stellar", + "address": "GABC...agent-address", + "metaAddress": "st:xlm:abc123...def456..." +} +``` + +### Step 2 — Fund the agent (testnet) + +Stellar testnet funding uses Friendbot. The agent handles this automatically: + +```typescript +const res = await agent.chat("fund my wallet"); +// "Wallet funded via Friendbot. Balance: 10,000 XLM" +``` + +**curl:** + +```bash +curl -X POST https://api.usewraith.xyz/agent/$AGENT_ID/chat \ + -H "Authorization: Bearer $WRAITH_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ "message": "fund my wallet" }' +``` + +> **Friendbot note:** Friendbot funds with 10,000 XLM on testnet. Mainnet requires a real XLM source — send at least 1.5 XLM to the agent address to activate the account (Stellar minimum balance) plus gas reserves. + +**Cost estimate (mainnet):** +| Operation | Fee | +|---|---| +| Agent account activation | 1 XLM (minimum balance) | +| Each Soroban announcement | ~0.00001 XLM base fee + Soroban resource fee ≈ 0.0001–0.001 XLM | +| USDC transfer via stealth-sender | ~0.00001 XLM base + resource fee ≈ 0.0001–0.001 XLM | +| `.wraith` name registration | ~0.00001 XLM base + resource fee ≈ 0.0001–0.001 XLM | + +### Step 3 — Register the name on-chain + +The SDK registers `payments.wraith` automatically during `createAgent`. To resolve it manually: + +```bash +curl https://api.usewraith.xyz/agent/info/payments \ + -H "Authorization: Bearer $WRAITH_API_KEY" +``` + +```json +{ + "id": "a1b2c3d4-...", + "name": "payments", + "chains": ["stellar"], + "addresses": { "stellar": "GABC..." }, + "metaAddresses": { "stellar": "st:xlm:abc123..." } +} +``` + +### Step 4 — Webhook on incoming payment + +Poll notifications on a schedule, or wire up a background worker: + +```typescript +// poll-payments.ts — run every 60 seconds +async function pollIncoming(agentId: string) { + const res = await fetch( + `https://api.usewraith.xyz/agent/${agentId}/notifications`, + { headers: { Authorization: `Bearer ${process.env.WRAITH_API_KEY}` } } + ); + const { notifications, unreadCount } = await res.json(); + + if (unreadCount === 0) return; + + for (const n of notifications.filter((n: any) => !n.read)) { + if (n.type === "payment_received") { + await handleIncomingPayment(n); + } + } + + // Mark all read + await fetch( + `https://api.usewraith.xyz/agent/${agentId}/notifications/read`, + { method: "POST", headers: { Authorization: `Bearer ${process.env.WRAITH_API_KEY}` } } + ); +} +``` + +**Sample notification payload:** + +```json +{ + "id": 42, + "type": "payment_received", + "title": "Payment received", + "body": "50 USDC received at stealth address GABC...xyz via payments.wraith", + "read": false, + "createdAt": "2025-01-15T14:30:00Z" +} +``` + +Your handler extracts amount, token, and stealth address from the `body` field: + +```typescript +async function handleIncomingPayment(notification: any) { + // Parse: "50 USDC received at stealth address GABC...xyz via payments.wraith" + const match = notification.body.match( + /^(\S+)\s+(\S+)\s+received at stealth address\s+(\S+)/ + ); + if (!match) return; + + const [, amount, token, stealthAddress] = match; + + // Your business logic: create invoice record, unlock subscription, etc. + await db.payments.create({ + amount, + token, + stealthAddress, + receivedAt: notification.createdAt, + }); + + console.log(`Received ${amount} ${token} at ${stealthAddress}`); +} +``` + +### Step 5 — Auto-withdraw to cold storage weekly + +Schedule this with your platform's cron (AWS EventBridge, GitHub Actions, Vercel Cron, etc.): + +```typescript +// weekly-withdraw.ts +import { Wraith, Chain } from "@wraith-protocol/sdk"; + +export async function weeklyWithdraw() { + const wraith = new Wraith({ apiKey: process.env.WRAITH_API_KEY! }); + const agent = wraith.agent(process.env.AGENT_ID!); + + // 1. Scan for any new incoming payments first + const scan = await agent.chat("scan for payments on stellar"); + console.log("Scan:", scan.response); + + // 2. Check balance before withdrawal + const balance = await agent.getBalance(); + console.log("Balance:", balance.tokens); + + // 3. Withdraw USDC to cold storage — agent warns on privacy issues + const withdraw = await agent.chat( + `withdraw all USDC to ${process.env.COLD_STORAGE_ADDRESS} on stellar` + ); + console.log("Withdraw:", withdraw.response); + + // 4. Keep a small XLM reserve for fees, withdraw the rest + const xlmWithdraw = await agent.chat( + `withdraw XLM to ${process.env.COLD_STORAGE_ADDRESS} on stellar, keep 5 XLM for fees` + ); + console.log("XLM Withdraw:", xlmWithdraw.response); +} +``` + +**curl equivalent (for use in shell-based cron):** + +```bash +# 1. Scan +curl -X POST https://api.usewraith.xyz/agent/$AGENT_ID/chat \ + -H "Authorization: Bearer $WRAITH_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"message": "scan for payments on stellar"}' + +# 2. Withdraw USDC to cold storage +curl -X POST https://api.usewraith.xyz/agent/$AGENT_ID/chat \ + -H "Authorization: Bearer $WRAITH_API_KEY" \ + -H "Content-Type: application/json" \ + -d "{\"message\": \"withdraw all USDC to $COLD_STORAGE_ADDRESS on stellar\"}" +``` + +### Step 6 — Privacy autopilot + +Enable privacy autopilot to get weekly analysis and preemptive warnings built into the agent's behavior: + +```typescript +// privacy-autopilot.ts — run weekly, or after large payment batches +export async function privacyAutopilot(agentId: string) { + const wraith = new Wraith({ apiKey: process.env.WRAITH_API_KEY! }); + const agent = wraith.agent(agentId); + + const check = await agent.chat("run a privacy check on stellar"); + console.log(check.response); + // Privacy Score: 92/100 + // Issues: + // - (info) 2 unspent stealth addresses + // Best Practices: + // - Space withdrawals at least 1 hour apart + + // If score drops below threshold, alert your team + const scoreMatch = check.response.match(/Privacy Score: (\d+)\/100/); + if (scoreMatch && parseInt(scoreMatch[1]) < 70) { + await alertOpsTeam(check.response); + } +} +``` + +**Full production setup (single file):** + +```typescript +// agent-setup.ts +import { Wraith, Chain } from "@wraith-protocol/sdk"; +import cron from "node-cron"; + +const wraith = new Wraith({ apiKey: process.env.WRAITH_API_KEY! }); +const agent = wraith.agent(process.env.AGENT_ID!); + +// Poll for payments every 60s +setInterval(() => pollIncoming(process.env.AGENT_ID!), 60_000); + +// Weekly withdrawal + privacy check (every Sunday at 02:00 UTC) +cron.schedule("0 2 * * 0", async () => { + await weeklyWithdraw(); + await privacyAutopilot(process.env.AGENT_ID!); +}); +``` + +--- + +## Recipe 2: DAO Weekly Payroll in USDC on Stellar + +A DAO pays contributors weekly in USDC via `.wraith` stealth addresses on Stellar. Failures are monitored, destinations rotate monthly. + +### What you build + +- A `dao-payroll.wraith` agent on Stellar +- Weekly scheduled payments to N `.wraith` recipients +- Failure monitoring with retry logic +- Monthly destination rotation for enhanced privacy + +### Step 1 — Create the payroll agent + +```typescript +import { Wraith, Chain } from "@wraith-protocol/sdk"; +import { Keypair } from "@stellar/stellar-sdk"; + +const wraith = new Wraith({ apiKey: process.env.WRAITH_API_KEY! }); +const treasuryKeypair = Keypair.fromSecret(process.env.TREASURY_SECRET!); + +const message = "Sign to create Wraith payroll agent"; +const signature = treasuryKeypair.sign(Buffer.from(message)); + +const agent = await wraith.createAgent({ + name: "dao-payroll", + chain: Chain.Stellar, + wallet: treasuryKeypair.publicKey(), + signature: Buffer.from(signature).toString("hex"), + message, +}); + +console.log("Payroll agent:", agent.info.id); +console.log("Address:", agent.info.addresses[Chain.Stellar]); +``` + +**curl:** + +```bash +curl -X POST https://api.usewraith.xyz/agent/create \ + -H "Authorization: Bearer $WRAITH_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "dao-payroll", + "chain": "stellar", + "wallet": "GABC...treasury-pubkey", + "signature": "hex-signature", + "message": "Sign to create Wraith payroll agent" + }' +``` + +### Step 2 — Define the payroll registry + +Store contributors and their current `.wraith` destinations: + +```typescript +// payroll-registry.ts +export interface Contributor { + id: string; + name: string; + wraithName: string; // e.g. "alice.wraith" + weeklyUSDC: number; // e.g. 500 + active: boolean; +} + +// In production: load from your DAO's on-chain registry or database +export const contributors: Contributor[] = [ + { id: "c1", name: "Alice", wraithName: "alice.wraith", weeklyUSDC: 500, active: true }, + { id: "c2", name: "Bob", wraithName: "bob.wraith", weeklyUSDC: 750, active: true }, + { id: "c3", name: "Carol", wraithName: "carol.wraith", weeklyUSDC: 300, active: true }, +]; +``` + +### Step 3 — Weekly payroll run + +```typescript +// payroll-run.ts +import { Wraith } from "@wraith-protocol/sdk"; +import { contributors } from "./payroll-registry"; + +interface PayrollResult { + contributorId: string; + wraithName: string; + amount: number; + status: "success" | "failed"; + error?: string; + conversationId?: string; +} + +export async function runWeeklyPayroll(): Promise { + const wraith = new Wraith({ apiKey: process.env.WRAITH_API_KEY! }); + const agent = wraith.agent(process.env.PAYROLL_AGENT_ID!); + const results: PayrollResult[] = []; + + const active = contributors.filter((c) => c.active); + console.log(`Running payroll for ${active.length} contributors`); + + for (const contributor of active) { + try { + const res = await agent.chat( + `send ${contributor.weeklyUSDC} USDC to ${contributor.wraithName} on stellar` + ); + + const success = res.toolCalls?.some( + (t) => t.name === "send_payment" && t.status === "success" + ); + + results.push({ + contributorId: contributor.id, + wraithName: contributor.wraithName, + amount: contributor.weeklyUSDC, + status: success ? "success" : "failed", + error: success ? undefined : res.response, + conversationId: res.conversationId, + }); + + // Space payments 30 seconds apart — avoids timing correlation + await new Promise((r) => setTimeout(r, 30_000)); + } catch (err: any) { + results.push({ + contributorId: contributor.id, + wraithName: contributor.wraithName, + amount: contributor.weeklyUSDC, + status: "failed", + error: err.message, + }); + } + } + + return results; +} +``` + +**curl equivalent (single payment):** + +```bash +curl -X POST https://api.usewraith.xyz/agent/$AGENT_ID/chat \ + -H "Authorization: Bearer $WRAITH_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"message": "send 500 USDC to alice.wraith on stellar"}' +``` + +**Sample success response:** + +```json +{ + "response": "Payment sent — 500 USDC to alice.wraith via stealth address GABC...xyz on Stellar.", + "toolCalls": [ + { + "name": "send_payment", + "status": "success", + "detail": "{\"txHash\":\"a1b2c3...\",\"stealthAddress\":\"GABC...xyz\",\"amount\":\"500\",\"token\":\"USDC\"}" + } + ], + "conversationId": "conv-uuid-..." +} +``` + +### Step 4 — Monitor failures and retry + +```typescript +// payroll-monitor.ts +export async function monitorAndRetry( + results: PayrollResult[], + agentId: string +) { + const failed = results.filter((r) => r.status === "failed"); + if (failed.length === 0) { + console.log("All payroll payments succeeded."); + return; + } + + console.warn(`${failed.length} payment(s) failed. Retrying in 10 minutes...`); + + // Wait 10 minutes before retrying (network hiccups, Soroban congestion) + await new Promise((r) => setTimeout(r, 10 * 60_000)); + + const wraith = new Wraith({ apiKey: process.env.WRAITH_API_KEY! }); + const agent = wraith.agent(agentId); + + for (const failure of failed) { + console.log(`Retrying ${failure.wraithName}: ${failure.error}`); + try { + const res = await agent.chat( + `send ${failure.amount} USDC to ${failure.wraithName} on stellar` + ); + console.log(`Retry result for ${failure.wraithName}:`, res.response); + } catch (err: any) { + // After retry failure: alert ops, log for manual resolution + console.error(`Retry failed for ${failure.wraithName}:`, err.message); + await alertOps({ + contributor: failure.wraithName, + amount: failure.amount, + error: err.message, + }); + } + } +} + +// Alert function — wire to Slack, PagerDuty, email, etc. +async function alertOps(info: { + contributor: string; + amount: number; + error: string; +}) { + await fetch(process.env.SLACK_WEBHOOK_URL!, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + text: `Payroll failure: ${info.amount} USDC to ${info.contributor}\nError: ${info.error}`, + }), + }); +} +``` + +### Step 5 — Monthly destination rotation + +Contributors update their `.wraith` destination monthly to prevent pattern analysis across pay periods. Your DAO governance process submits new names; your code updates the registry: + +```typescript +// rotation.ts +export async function rotateDestinations( + updates: Array<{ contributorId: string; newWraithName: string }> +) { + for (const update of updates) { + const contributor = contributors.find((c) => c.id === update.contributorId); + if (!contributor) continue; + + const old = contributor.wraithName; + contributor.wraithName = update.newWraithName; + + console.log(`Rotated ${contributor.name}: ${old} → ${update.newWraithName}`); + + // Persist to your database + await db.contributors.update({ + where: { id: update.contributorId }, + data: { wraithName: update.newWraithName }, + }); + } +} +``` + +To rotate, contributors call `wraith.getAgentByName("alice")` on a new agent and share the new `.wraith` name with the DAO treasurer. No personal information changes hands — only a new Wraith name. + +### Step 6 — Full payroll scheduler + +```typescript +// scheduler.ts +import cron from "node-cron"; + +// Every Friday at 16:00 UTC +cron.schedule("0 16 * * 5", async () => { + console.log("Starting weekly payroll..."); + + // Pre-fund check: ensure balance covers this week's payroll + const wraith = new Wraith({ apiKey: process.env.WRAITH_API_KEY! }); + const agent = wraith.agent(process.env.PAYROLL_AGENT_ID!); + const balance = await agent.getBalance(); + + const totalUSDC = contributors + .filter((c) => c.active) + .reduce((sum, c) => sum + c.weeklyUSDC, 0); + + const available = parseFloat(balance.tokens?.["USDC"] ?? "0"); + if (available < totalUSDC) { + await alertOps({ + contributor: "TREASURY", + amount: totalUSDC - available, + error: `Insufficient USDC: need ${totalUSDC}, have ${available}`, + }); + return; + } + + const results = await runWeeklyPayroll(); + await monitorAndRetry(results, process.env.PAYROLL_AGENT_ID!); + + // Log results + const succeeded = results.filter((r) => r.status === "success").length; + console.log(`Payroll complete: ${succeeded}/${results.length} payments succeeded`); +}); + +// First of each month: prompt for destination rotation +cron.schedule("0 9 1 * *", async () => { + console.log("Monthly rotation reminder — update contributor destinations if needed"); + // Trigger your DAO governance flow here +}); +``` + +**Cost estimate (weekly, 10 contributors):** +| Item | Estimate | +|---|---| +| 10 × stealth-sender calls | ~0.001–0.01 XLM total | +| 10 × announcer events | ~0.001–0.01 XLM total | +| USDC token transfers (inside Soroban) | Included in above resource fee | +| Monthly name rotation (10 updates) | ~0.001–0.01 XLM total | + +Stellar fees are low enough that the per-payment overhead is negligible compared to the USDC amounts. + +--- + +## Recipe 3: User Privacy-Check Agent + +A weekly background agent that analyzes stealth payment patterns, sends Slack/email reports, and makes auto-rotate recommendations. + +### What you build + +- A `privacy-monitor.wraith` agent on Stellar +- Weekly privacy analysis job +- Structured report with per-issue severity +- Auto-rotate recommendations with actionable `.wraith` name suggestions + +### Step 1 — Create the monitoring agent + +```typescript +import { Wraith, Chain } from "@wraith-protocol/sdk"; +import { Keypair } from "@stellar/stellar-sdk"; + +const wraith = new Wraith({ apiKey: process.env.WRAITH_API_KEY! }); +const monitorKeypair = Keypair.fromSecret(process.env.MONITOR_SECRET!); + +const message = "Sign to create Wraith privacy monitor"; +const signature = monitorKeypair.sign(Buffer.from(message)); + +const agent = await wraith.createAgent({ + name: "privacy-monitor", + chain: Chain.Stellar, + wallet: monitorKeypair.publicKey(), + signature: Buffer.from(signature).toString("hex"), + message, +}); + +console.log("Monitor agent:", agent.info.id); +``` + +**curl:** + +```bash +curl -X POST https://api.usewraith.xyz/agent/create \ + -H "Authorization: Bearer $WRAITH_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "privacy-monitor", + "chain": "stellar", + "wallet": "GABC...monitor-pubkey", + "signature": "hex-signature", + "message": "Sign to create Wraith privacy monitor" + }' +``` + +### Step 2 — Run the weekly privacy analysis + +```typescript +// privacy-analysis.ts +import { Wraith } from "@wraith-protocol/sdk"; + +export interface PrivacyReport { + agentId: string; + score: number; + issues: PrivacyIssue[]; + recommendations: string[]; + rawResponse: string; + analyzedAt: string; +} + +export interface PrivacyIssue { + severity: "high" | "medium" | "info"; + description: string; +} + +export async function analyzePrivacy(agentId: string): Promise { + const wraith = new Wraith({ apiKey: process.env.WRAITH_API_KEY! }); + const agent = wraith.agent(agentId); + + // Ask the agent for a detailed privacy check + const res = await agent.chat( + "run a detailed privacy check on stellar and list all issues by severity" + ); + + return parsePrivacyReport(agentId, res.response); +} + +function parsePrivacyReport(agentId: string, raw: string): PrivacyReport { + // Extract score: "Privacy Score: 85/100" + const scoreMatch = raw.match(/Privacy Score:\s*(\d+)\/100/i); + const score = scoreMatch ? parseInt(scoreMatch[1]) : 100; + + // Extract issues: lines matching "(high|medium|info) ..." + const issueLines = [...raw.matchAll(/\((\w+)\)\s+(.+)/g)]; + const issues: PrivacyIssue[] = issueLines.map(([, severity, description]) => ({ + severity: severity as PrivacyIssue["severity"], + description: description.trim(), + })); + + // Extract best practices / recommendations block + const recBlock = raw.split(/Best Practices:|Recommendations:/i)[1] ?? ""; + const recommendations = recBlock + .split("\n") + .map((l) => l.replace(/^[-•*]\s*/, "").trim()) + .filter(Boolean); + + return { + agentId, + score, + issues, + recommendations, + rawResponse: raw, + analyzedAt: new Date().toISOString(), + }; +} +``` + +**curl equivalent:** + +```bash +curl -X POST https://api.usewraith.xyz/agent/$AGENT_ID/chat \ + -H "Authorization: Bearer $WRAITH_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"message": "run a detailed privacy check on stellar and list all issues by severity"}' +``` + +**Sample agent response:** + +``` +Privacy Score: 72/100 + +Issues: +- (high) All recent payments are the same amount (500 USDC × 6 payments) +- (medium) 8 unspent stealth addresses older than 30 days +- (info) Last withdrawal was 45 days ago + +Best Practices: +- Vary payment amounts by ±5–10% to prevent amount fingerprinting +- Consolidate stealth addresses: withdraw and rotate to fresh destination +- Schedule regular withdrawals — monthly at minimum +``` + +### Step 3 — Send Slack and email reports + +```typescript +// reporters.ts + +// Slack report +export async function sendSlackReport(report: PrivacyReport) { + const emoji = report.score >= 85 ? "✅" : report.score >= 70 ? "⚠️" : "🚨"; + const highCount = report.issues.filter((i) => i.severity === "high").length; + + const blocks = [ + { + type: "header", + text: { type: "plain_text", text: `${emoji} Weekly Privacy Report` }, + }, + { + type: "section", + fields: [ + { type: "mrkdwn", text: `*Score*\n${report.score}/100` }, + { type: "mrkdwn", text: `*High-severity issues*\n${highCount}` }, + { type: "mrkdwn", text: `*Agent*\n${report.agentId.slice(0, 8)}...` }, + { type: "mrkdwn", text: `*Analyzed*\n${report.analyzedAt.slice(0, 10)}` }, + ], + }, + ]; + + if (report.issues.length > 0) { + blocks.push({ + type: "section", + text: { + type: "mrkdwn", + text: + "*Issues:*\n" + + report.issues + .map((i) => `• \`${i.severity.toUpperCase()}\` ${i.description}`) + .join("\n"), + }, + } as any); + } + + if (report.recommendations.length > 0) { + blocks.push({ + type: "section", + text: { + type: "mrkdwn", + text: + "*Recommendations:*\n" + + report.recommendations.map((r) => `• ${r}`).join("\n"), + }, + } as any); + } + + await fetch(process.env.SLACK_WEBHOOK_URL!, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ blocks }), + }); +} + +// Email report via SendGrid (swap for any email provider) +export async function sendEmailReport(report: PrivacyReport, to: string) { + const subject = + report.score >= 85 + ? `Privacy Report: All clear (${report.score}/100)` + : `Privacy Report: Action needed (${report.score}/100)`; + + const html = ` +

Weekly Privacy Report — ${report.analyzedAt.slice(0, 10)}

+

Score: ${report.score}/100

+

Issues

+
    + ${report.issues.map((i) => `
  • ${i.severity}: ${i.description}
  • `).join("")} +
+

Recommendations

+
    + ${report.recommendations.map((r) => `
  • ${r}
  • `).join("")} +
+ `; + + await fetch("https://api.sendgrid.com/v3/mail/send", { + method: "POST", + headers: { + Authorization: `Bearer ${process.env.SENDGRID_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + personalizations: [{ to: [{ email: to }] }], + from: { email: "privacy@yoursaas.com" }, + subject, + content: [{ type: "text/html", value: html }], + }), + }); +} +``` + +### Step 4 — Auto-rotate recommendations + +When the privacy score drops below a threshold, the agent suggests new `.wraith` names to rotate to: + +```typescript +// auto-rotate.ts +export async function generateRotationPlan( + agentId: string, + report: PrivacyReport +): Promise { + // Only generate rotation plan when score is poor + if (report.score >= 80) return []; + + const wraith = new Wraith({ apiKey: process.env.WRAITH_API_KEY! }); + const agent = wraith.agent(agentId); + + const res = await agent.chat( + "given the privacy issues found, should I rotate my .wraith name? " + + "If so, suggest 3 alternative name formats I could register." + ); + + // Parse suggested names from response + const names = [...res.response.matchAll(/`([a-z0-9-]{3,32}\.wraith)`/g)].map( + ([, name]) => name + ); + + return names; +} + +export async function applyRotation( + currentAgentId: string, + newName: string +): Promise { + // Note: rotating a .wraith name requires creating a new agent and + // migrating balances. The old name can be released via the Soroban + // wraith-names contract. + // + // This is a known limitation — see the follow-up issue below. + console.log( + `Rotation to ${newName} requires manual migration. ` + + `See: https://github.com/wraith-protocol/spectre/issues/NEW` + ); +} +``` + +### Step 5 — Full weekly job + +```typescript +// weekly-privacy-job.ts +import cron from "node-cron"; + +// Every Monday at 08:00 UTC +cron.schedule("0 8 * * 1", async () => { + console.log("Starting weekly privacy analysis..."); + + const agentIds = process.env.MONITORED_AGENT_IDS!.split(","); + + for (const agentId of agentIds) { + const report = await analyzePrivacy(agentId); + + await sendSlackReport(report); + await sendEmailReport(report, process.env.PRIVACY_REPORT_EMAIL!); + + if (report.score < 70) { + const rotationPlan = await generateRotationPlan(agentId, report); + if (rotationPlan.length > 0) { + console.log(`Rotation plan for ${agentId}:`, rotationPlan); + await sendSlackReport({ + ...report, + rawResponse: + `Auto-rotate suggestions: ${rotationPlan.join(", ")}\n\n` + + report.rawResponse, + }); + } + } + + // Store report for trend tracking + await db.privacyReports.create({ data: report }); + } +}); +``` + +**curl — run an on-demand privacy check:** + +```bash +curl -X POST https://api.usewraith.xyz/agent/$AGENT_ID/chat \ + -H "Authorization: Bearer $WRAITH_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"message": "run a detailed privacy check on stellar and list all issues by severity"}' +``` + +--- + +## Missing Features — Follow-up Issues + +The following capabilities are referenced in this cookbook but are not yet available in the Spectre/Wraith platform. File these against the Spectre backend repo when it is available, or raise them with the Wraith team directly. + +### Issue 1 — Webhook push delivery + +**Feature:** Push payment events to a developer-supplied URL instead of requiring polling `GET /agent/:id/notifications`. + +**Used in:** Recipe 1, Step 4 + +**Proposed API:** + +``` +POST /agent/:id/webhooks +{ + "url": "https://yoursaas.com/hooks/wraith", + "events": ["payment_received", "invoice_paid"], + "secret": "whsec_..." +} +``` + +When a payment arrives, POST to the registered URL with an `X-Wraith-Signature` HMAC header for verification. This eliminates polling and enables serverless architectures (Lambda, Vercel Functions, Cloudflare Workers). + +**Workaround:** Poll `GET /agent/:id/notifications` every 60 seconds. See Recipe 1, Step 4. + +--- + +### Issue 2 — In-place `.wraith` name rotation + +**Feature:** Update a name's stealth meta-address without creating a new agent or migrating balances. + +**Used in:** Recipe 2 Step 5, Recipe 3 Step 4 + +**Proposed API:** + +``` +PATCH /agent/:id/meta-address +{ + "signature": "...", + "message": "...", + "chain": "stellar" +} +``` + +Or a name-transfer endpoint that moves ownership to a new agent without requiring a full migration. + +**Workaround:** Create a new agent with a new name, update the payroll registry, and let the old agent drain. The old name can be released via the Soroban `wraith-names` contract `release()` function. + +--- + +### Issue 3 — Structured privacy score endpoint + +**Feature:** `GET /agent/:id/privacy-score?chain=stellar` returning machine-readable JSON instead of requiring callers to parse natural language. + +**Used in:** Recipe 3, Step 2 + +**Proposed response:** + +```json +{ + "score": 72, + "chain": "stellar", + "analyzedAt": "2025-01-15T08:00:00Z", + "issues": [ + { "severity": "high", "code": "IDENTICAL_AMOUNTS", "description": "..." }, + { "severity": "medium", "code": "STALE_STEALTH_ADDRESSES", "description": "..." } + ], + "recommendations": ["Vary payment amounts by ±5–10%", "..."] +} +``` + +**Workaround:** Call `POST /agent/:id/chat` with `"run a detailed privacy check on stellar and list all issues by severity"` and parse the score with `/Privacy Score:\s*(\d+)\/100/i`. See Recipe 3, Step 2 for the full parser implementation. + +--- + +### Issue 4 — `batch_send` via agent chat + +**Feature:** Allow the agent to use the Soroban `stealth-sender` `batch_send` operation when multiple payments go out in one run, reducing Soroban resource fees from N × per-tx to 1 × batch. + +**Used in:** Recipe 2, Step 3 + +**Proposed instruction:** + +``` +"batch send on stellar: 500 USDC to alice.wraith, 750 USDC to bob.wraith, 300 USDC to carol.wraith" +``` + +**Workaround:** Issue individual sends with 30-second spacing. For 10 contributors the fee difference is small, but it scales linearly — critical for DAOs with 20+ members. + +--- + +### Issue 5 — Reserve-aware XLM withdrawal (needs verification) + +**Feature / verification needed:** The instruction `"withdraw XLM to
, keep 5 XLM for fees"` must correctly calculate the surplus and never withdraw below the Stellar minimum balance (1 XLM base + base reserves per entry). + +**Used in:** Recipe 1, Step 5 + +**Risk:** A silent failure here drops the agent below minimum balance and breaks all future Stellar transactions — a hard-to-debug production incident. + +**Test cases needed:** +- `"withdraw XLM, keep 5 XLM for fees"` — should withdraw `balance - 5 XLM` +- Balance already at or below 5 XLM — should return an error, not withdraw +- Equivalent phrasings: `"reserve 5 XLM"`, `"leave 5 XLM for gas"` + +**Workaround until verified:** Query `GET /agent/:id/status` for the current balance and calculate the safe withdrawal amount before issuing the chat command. + +--- + +## Common Patterns + +### Reconnect to an existing agent + +All three recipes reconnect to a pre-created agent by ID. Store the agent ID in your environment after the first `createAgent` call: + +```typescript +// Reconnect — no new agent is created +const agent = wraith.agent(process.env.AGENT_ID!); + +// Or by name +const agent = await wraith.getAgentByName("payments"); +``` + +```bash +# Get agent info by name +curl https://api.usewraith.xyz/agent/info/payments \ + -H "Authorization: Bearer $WRAITH_API_KEY" +``` + +### Check agent status + +```bash +curl https://api.usewraith.xyz/agent/$AGENT_ID/status \ + -H "Authorization: Bearer $WRAITH_API_KEY" +``` + +```json +{ + "balance": "9998.5", + "tokens": { "USDC": "4500.00" }, + "pendingInvoices": 0, + "address": "GABC...", + "metaAddress": "st:xlm:abc123...", + "chain": "stellar" +} +``` + +### Error handling + +```typescript +try { + const res = await agent.chat("send 500 USDC to alice.wraith on stellar"); +} catch (err: any) { + // err.message mirrors the API error body + // "Insufficient balance" | "Name 'alice' not found" | etc. + console.error(err.message); +} +``` + +All API errors follow: + +```json +{ "message": "Insufficient balance", "statusCode": 400 } +``` + +| Code | When | +|---|---| +| 400 | Invalid params, insufficient balance, bad signature | +| 401 | Invalid API key | +| 404 | Agent or name not found | +| 409 | Name already registered | +| 500 | Server / TEE error | + +### Stellar account activation + +New stealth addresses on Stellar require a `createAccount` operation (not `payment`) to activate the account with the 1 XLM minimum balance. The Wraith agent handles this automatically. If you're using the low-level SDK: + +```typescript +import { generateStealthAddress } from "@wraith-protocol/sdk/chains/stellar"; + +const stealth = generateStealthAddress(spendingPubKey, viewingPubKey); +// stealth.stealthAddress is a G... address with no on-chain existence yet +// Use Operation.createAccount to fund it for the first time +// Use stealth-sender contract for subsequent USDC transfers +``` + +See [Stellar Crypto Primitives](/sdk/chains/stellar) and [Stellar Contracts](/contracts/stellar) for the low-level details. + +--- + +## Related + +- [Single-Chain Agent](/guides/single-chain-agent) — full agent lifecycle reference +- [Multichain Agent](/guides/multichain-agent) — extend these recipes to EVM chains +- [Privacy Best Practices](/guides/privacy-best-practices) — scoring algorithm and what to avoid +- [Stellar Crypto Primitives](/sdk/chains/stellar) — low-level ed25519 stealth functions +- [Stellar Contracts](/contracts/stellar) — Soroban contract interfaces +- [API Reference](/api-reference/endpoints) — full HTTP endpoint spec From 29529fc91cc06b9323dc38a4047a946dfc260891 Mon Sep 17 00:00:00 2001 From: RemmyAcee Date: Wed, 24 Jun 2026 23:52:16 +0100 Subject: [PATCH 2/5] Stellar federation address support guide (SDK + UX) --- api-reference/types.mdx | 71 ++++ docs.json | 3 +- guides/stellar-federation.mdx | 726 ++++++++++++++++++++++++++++++++++ introduction.mdx | 2 + sdk/chains/stellar.mdx | 35 ++ 5 files changed, 836 insertions(+), 1 deletion(-) create mode 100644 guides/stellar-federation.mdx diff --git a/api-reference/types.mdx b/api-reference/types.mdx index 7183474..827e1f5 100644 --- a/api-reference/types.mdx +++ b/api-reference/types.mdx @@ -37,6 +37,7 @@ enum Chain { Base = "base", Stellar = "stellar", Solana = "solana", + CKB = "ckb", All = "all", } ``` @@ -281,6 +282,10 @@ import type { GeneratedStealthAddress, Announcement, MatchedAnnouncement, + FederationRecord, + FederationCache, + FederationError, + FederationErrorCode, } from "@wraith-protocol/sdk/chains/stellar"; ``` @@ -330,6 +335,72 @@ interface MatchedAnnouncement extends Announcement { --- +## Stellar Federation Types + +Exported from `@wraith-protocol/sdk/chains/stellar`: + +```typescript +import type { + FederationRecord, + FederationCache, + FederationError, + FederationErrorCode, +} from "@wraith-protocol/sdk/chains/stellar"; +``` + +### `FederationRecord` + +The resolved result of a `name*domain.com` lookup. + +```typescript +interface FederationRecord { + federationAddress: string; // "alice*example.com" — the address that was queried + accountId: string; // "GABC..." or "st:xlm:..." — resolved destination + memoType?: "text" | "id" | "hash"; + memoValue?: string; // required for exchange deposit addresses +} +``` + +When `accountId` starts with `st:xlm:` it is a Wraith stealth meta-address and should be decoded with `decodeStealthMetaAddress()` before sending. Otherwise it is a plain `G...` public key. + +### `FederationCache` + +Pluggable cache interface accepted by `resolveStellarFederation()`. Implement this with any backend (in-memory, Redis, etc.). + +```typescript +interface FederationCache { + get(key: string): Promise; + set(key: string, record: FederationRecord, ttlMs: number): Promise; +} +``` + +### `FederationErrorCode` + +```typescript +type FederationErrorCode = + | "NOT_FOUND" // federation server returned 404 / unknown address + | "DNS_FAILURE" // could not fetch stellar.toml (network or DNS error) + | "NO_FEDERATION_SERVER" // stellar.toml exists but has no FEDERATION_SERVER field + | "INVALID_TOML" // stellar.toml content is malformed + | "MALFORMED_RESPONSE" // federation server response is missing required fields + | "TIMEOUT" // request exceeded options.timeoutMs + | "NETWORK_ERROR"; // fetch failed for any other reason +``` + +### `FederationError` + +Thrown by `resolveStellarFederation()` on any failure. Always check `err.code` rather than `err.message` for programmatic handling. + +```typescript +interface FederationError extends Error { + code: FederationErrorCode; + message: string; + cause?: unknown; // the underlying network error or parse error, if any +} +``` + +--- + ## Chain Connector Types Internal types used by the TEE server. Documented here for developers building custom chain connectors. diff --git a/docs.json b/docs.json index dec9f6f..8ddd26a 100644 --- a/docs.json +++ b/docs.json @@ -100,7 +100,8 @@ "guides/multichain-agent", "guides/bring-your-own-model", "guides/privacy-best-practices", - "guides/spectre-stellar-cookbook" + "guides/spectre-stellar-cookbook", + "guides/stellar-federation" ] } ] diff --git a/guides/stellar-federation.mdx b/guides/stellar-federation.mdx new file mode 100644 index 0000000..a96b4ce --- /dev/null +++ b/guides/stellar-federation.mdx @@ -0,0 +1,726 @@ +--- +title: "Stellar Federation Addresses" +description: "Resolve alice*example.com to a Stellar address using SEP-0002 and the Wraith SDK" +--- + +Stellar federation addresses look like `alice*example.com` — a human-readable alias that resolves to a `G...` public key through a lightweight HTTP lookup. This guide covers what federation is, how to set it up for your own domain, how to use the SDK's `resolveStellarFederation()` helper, and the UX and failure-mode considerations for production payment flows. + +## What Federation Is + +A federation address has the form `name*domain.com`. The `*` separates a username from the domain that operates the federation server for that username. When a wallet or integration wants to pay `alice*example.com`: + +1. It fetches `https://example.com/.well-known/stellar.toml` +2. It reads the `FEDERATION_SERVER` URL from that file +3. It sends `GET {FEDERATION_SERVER}?q=alice*example.com&type=name` +4. The server returns a JSON object containing the `stellar_address` (`G...` key) and an optional memo + +This is defined in [SEP-0002: Federation Protocol](https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0002.md). + +### Why Federation Matters for Wraith + +Stealth meta-addresses (`st:xlm:...`) are long and unwieldy to share. A federation server bridges the gap: you register `payments*example.com` on your domain's federation server and map it to your `.wraith` stealth meta-address. Anyone who supports federation can now send private payments using the human-readable address. + +### Federation vs. `.wraith` Names + +| | Stellar Federation | `.wraith` Name | +|---|---|---| +| Format | `alice*example.com` | `alice.wraith` | +| Standard | SEP-0002 | Wraith-specific | +| Resolution | HTTP to your domain | On-chain Soroban lookup | +| Custody | You own the domain | Wraith names contract | +| Memo support | Yes (exchange deposits) | No | +| Privacy | No — resolves to a public `G...` key by default | Yes — resolves to a stealth meta-address | + +They complement each other. You can configure a federation server to return your `.wraith` stealth meta-address as the `stellar_address`, giving you both human-readable names and privacy. + +--- + +## How the Protocol Works + +### 1. stellar.toml + +Your domain publishes a `stellar.toml` at `https://example.com/.well-known/stellar.toml`. The relevant field: + +```toml +FEDERATION_SERVER="https://federation.example.com/federation" +``` + +The `stellar.toml` itself is governed by [SEP-0001](https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0001.md). + +### 2. Federation request + +A GET request to the federation server: + +``` +GET https://federation.example.com/federation?q=alice*example.com&type=name +``` + +Query parameters: + +| Parameter | Value | Description | +|---|---|---| +| `q` | `alice*example.com` | The federation address to resolve | +| `type` | `name` | Lookup type — `name` for address-to-key | + +### 3. Federation response + +```json +{ + "stellar_address": "alice*example.com", + "account_id": "GABC...XYZ", + "memo_type": "text", + "memo": "invoice-4821" +} +``` + +| Field | Type | Description | +|---|---|---| +| `stellar_address` | string | The federation address that was queried | +| `account_id` | string | The resolved `G...` Stellar public key | +| `memo_type` | `"text"` \| `"id"` \| `"hash"` | Optional — memo type for the transaction | +| `memo` | string | Optional — memo value (required for exchange deposits) | + +### 4. Error response + +When a name is not found: + +```json +{ + "detail": "Account not found", + "code": "not_found", + "status": 404 +} +``` + +--- + +## Setting Up Federation for Your Domain + +### Option A — Self-hosted federation server + +The SDF publishes a reference Go implementation: [`stellar/go/services/federation`](https://github.com/stellar/go/tree/master/services/federation). It reads from a database and handles `name`, `id`, and `txid` query types. + +Minimal setup: + +1. Deploy the server (or any server that handles the query format above) +2. Add `FEDERATION_SERVER` to your `/.well-known/stellar.toml` +3. Ensure the `stellar.toml` is served over HTTPS with `Access-Control-Allow-Origin: *` + +```toml +# /.well-known/stellar.toml +NETWORK_PASSPHRASE="Public Global Stellar Network ; September 2015" +FEDERATION_SERVER="https://federation.example.com/federation" +``` + +### Option B — Minimal serverless endpoint + +For simple use cases (one account, many usernames with memos), a single serverless function suffices: + +```typescript +// GET /federation?q=alice*example.com&type=name +export async function GET(request: Request) { + const url = new URL(request.url); + const q = url.searchParams.get("q") ?? ""; + const type = url.searchParams.get("type"); + + if (type !== "name") { + return Response.json({ code: "not_found", detail: "Only type=name is supported" }, { status: 404 }); + } + + // Parse "alice" from "alice*example.com" + const username = q.split("*")[0]?.toLowerCase(); + const user = await db.users.findUnique({ where: { federationName: username } }); + + if (!user) { + return Response.json({ code: "not_found", detail: "Account not found" }, { status: 404 }); + } + + return Response.json( + { + stellar_address: q, + account_id: user.stellarPublicKey, // or stealth meta-address + ...(user.memoType && { memo_type: user.memoType }), + ...(user.memo && { memo: user.memo }), + }, + { + headers: { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*", // required — wallets call cross-origin + }, + } + ); +} +``` + +Add the `FEDERATION_SERVER` to your `stellar.toml`: + +```toml +FEDERATION_SERVER="https://api.example.com/federation" +``` + +### Option C — Third-party federation services + +Services like [fed.network](https://www.fed.network/) host federation for you. Register `alice*fed.network` without running any infrastructure. + +--- + +## SDK Helper: `resolveStellarFederation()` + +The SDK ships a `resolveStellarFederation()` helper in `@wraith-protocol/sdk/chains/stellar`. It handles the full two-step resolution (stellar.toml fetch → federation GET) with timeout, validation, and error normalisation. + +> **Status:** This helper ships in the next SDK release. See the [SDK wave issue](#sdk-wave-tracking) at the bottom of this page for tracking. + +### Import + +```typescript +import { + resolveStellarFederation, + type FederationRecord, + type FederationError, +} from "@wraith-protocol/sdk/chains/stellar"; +``` + +### Types + +```typescript +interface FederationRecord { + federationAddress: string; // "alice*example.com" + accountId: string; // "GABC..." — resolved G... public key + memoType?: "text" | "id" | "hash"; + memoValue?: string; +} + +type FederationErrorCode = + | "NOT_FOUND" // federation server returned 404 / unknown address + | "DNS_FAILURE" // could not fetch stellar.toml + | "NO_FEDERATION_SERVER"// stellar.toml exists but has no FEDERATION_SERVER field + | "INVALID_TOML" // stellar.toml is malformed + | "MALFORMED_RESPONSE" // federation server response is missing required fields + | "TIMEOUT" // request exceeded the timeout threshold + | "NETWORK_ERROR"; // fetch failed for any other reason + +interface FederationError { + code: FederationErrorCode; + message: string; + cause?: unknown; +} +``` + +### `resolveStellarFederation(address, options?)` + +```typescript +const result = await resolveStellarFederation("alice*example.com"); +``` + +**Parameters:** + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `address` | `string` | — | Federation address to resolve, e.g. `alice*example.com` | +| `options.timeoutMs` | `number` | `5000` | Request timeout in milliseconds | +| `options.cache` | `FederationCache` | `undefined` | Optional cache implementation (see below) | +| `options.fetchToml` | `function` | `fetch` | Override the stellar.toml fetch (useful for testing) | + +**Returns:** `Promise` + +Throws a `FederationError` on any failure. + +### Basic usage + +```typescript +import { resolveStellarFederation } from "@wraith-protocol/sdk/chains/stellar"; + +try { + const record = await resolveStellarFederation("alice*example.com"); + + console.log(record.accountId); // "GABC...XYZ" — use as payment destination + console.log(record.memoType); // "text" | "id" | "hash" | undefined + console.log(record.memoValue); // "invoice-4821" | undefined +} catch (err) { + // err is FederationError + console.error(err.code, err.message); +} +``` + +### With a Wraith stealth payment + +After resolving, pass the `accountId` to `generateStealthAddress()` if you're building a custom flow, or hand it to the agent via chat: + +```typescript +import { + resolveStellarFederation, + decodeStealthMetaAddress, + generateStealthAddress, +} from "@wraith-protocol/sdk/chains/stellar"; + +// The federation server returns a stealth meta-address in account_id +const record = await resolveStellarFederation("alice*example.com"); + +// Detect whether the resolved address is a stealth meta-address or a plain G... key +if (record.accountId.startsWith("st:xlm:")) { + // Privacy path — send via stealth address + const { spendingPubKey, viewingPubKey } = decodeStealthMetaAddress(record.accountId); + const stealth = generateStealthAddress(spendingPubKey, viewingPubKey); + // Send to stealth.stealthAddress + call announcer contract +} else { + // Plain path — send directly to record.accountId + // Include record.memoType / record.memoValue if present +} +``` + +### With the managed agent + +```typescript +import { Wraith, Chain } from "@wraith-protocol/sdk"; +import { resolveStellarFederation } from "@wraith-protocol/sdk/chains/stellar"; + +const wraith = new Wraith({ apiKey: process.env.WRAITH_API_KEY! }); +const agent = wraith.agent(process.env.AGENT_ID!); + +// Resolve first, then instruct the agent with the concrete address +const record = await resolveStellarFederation("alice*example.com"); + +const res = await agent.chat( + `send 100 USDC to ${record.accountId} on stellar` + + (record.memoValue ? ` with memo ${record.memoValue}` : "") +); +console.log(res.response); +``` + +Or let the agent resolve it directly — the agent supports federation addresses in chat: + +```typescript +// The agent resolves alice*example.com automatically +const res = await agent.chat("send 100 USDC to alice*example.com on stellar"); +console.log(res.response); +// "Payment sent — 100 USDC to alice*example.com (resolved to GABC...XYZ) on Stellar." +``` + +--- + +## Caching Considerations + +Federation resolution makes two HTTP requests (stellar.toml + federation GET) for every lookup. At scale — or in a payment UI that validates on every keystroke — this adds up. + +### Built-in cache interface + +`resolveStellarFederation()` accepts a `cache` option conforming to a simple interface: + +```typescript +interface FederationCache { + get(key: string): Promise; + set(key: string, record: FederationRecord, ttlMs: number): Promise; +} +``` + +### In-memory cache (single process) + +```typescript +import { resolveStellarFederation, type FederationRecord } from "@wraith-protocol/sdk/chains/stellar"; + +class MemoryFederationCache { + private store = new Map(); + + async get(key: string) { + const entry = this.store.get(key); + if (!entry || Date.now() > entry.expiresAt) { + this.store.delete(key); + return undefined; + } + return entry.record; + } + + async set(key: string, record: FederationRecord, ttlMs: number) { + this.store.set(key, { record, expiresAt: Date.now() + ttlMs }); + } +} + +const cache = new MemoryFederationCache(); + +const record = await resolveStellarFederation("alice*example.com", { + cache, + // stellar.toml is stable — cache for 24 hours + // federation records can change — default TTL is 1 hour +}); +``` + +### Redis cache (multi-process / serverless) + +```typescript +import { createClient } from "redis"; + +class RedisFederationCache { + private client = createClient({ url: process.env.REDIS_URL }); + + constructor() { + this.client.connect(); + } + + async get(key: string) { + const raw = await this.client.get(`fed:${key}`); + return raw ? (JSON.parse(raw) as FederationRecord) : undefined; + } + + async set(key: string, record: FederationRecord, ttlMs: number) { + await this.client.setEx(`fed:${key}`, Math.floor(ttlMs / 1000), JSON.stringify(record)); + } +} + +const record = await resolveStellarFederation("alice*example.com", { + cache: new RedisFederationCache(), +}); +``` + +### What to cache and for how long + +| Item | Recommended TTL | Rationale | +|---|---|---| +| `stellar.toml` content | 24 hours | Rarely changes; CORS-accessible static file | +| Federation record (`name` lookup) | 1 hour | Can change (key rotation, memo updates) | +| Not-found result | 5 minutes | Avoids hammering on typos, but re-checks promptly | + +Never cache a not-found result for longer than a few minutes — the address may just not exist yet, and users expect to retry. + +### stellar.toml caching separately + +The two-request chain means the stellar.toml fetch is the bottleneck. `resolveStellarFederation()` caches the parsed `FEDERATION_SERVER` URL under the key `toml:{domain}` at the same TTL as the record. If you share a cache across many lookups to the same domain, that first request is paid once per TTL. + +--- + +## Failure Modes + +Every step of federation resolution can fail. Handle each case gracefully. + +### DNS / HTTPS failure on stellar.toml + +The `/.well-known/stellar.toml` file can't be fetched — domain is misconfigured, HTTPS cert is invalid, or the file simply doesn't exist. + +```typescript +try { + await resolveStellarFederation("alice*notadomain.invalid"); +} catch (err) { + if (err.code === "DNS_FAILURE") { + // Show: "Could not reach federation server for notadomain.invalid" + } +} +``` + +**UX guidance:** Display a domain-level error. Don't expose the raw network error to users. A message like "We couldn't reach example.com's payment server. Check the address and try again." is clear and actionable. + +### No FEDERATION_SERVER in stellar.toml + +The domain has a `stellar.toml` but hasn't configured federation. + +```typescript +} catch (err) { + if (err.code === "NO_FEDERATION_SERVER") { + // Show: "example.com doesn't support federation addresses" + } +} +``` + +### Address not found + +The federation server responded but doesn't know this username. + +```typescript +} catch (err) { + if (err.code === "NOT_FOUND") { + // Show: "alice*example.com was not found" + } +} +``` + +This is the most common failure in payment UIs. Display it inline, next to the input — not as a modal or page-level error. + +### Malformed response + +The federation server is reachable but returns a response missing `account_id`, or returns invalid JSON. + +```typescript +} catch (err) { + if (err.code === "MALFORMED_RESPONSE") { + // Show: "example.com's federation server returned an unexpected response" + // Log err.cause for debugging + console.error(err.cause); + } +} +``` + +### Timeout + +The federation server is too slow. Default timeout is 5 seconds; adjust with `options.timeoutMs`. + +```typescript +} catch (err) { + if (err.code === "TIMEOUT") { + // Show: "The federation server took too long to respond. Try again." + } +} +``` + +For payment UIs, 3 seconds is a better timeout — users abandon flows that feel slow. + +### Complete error handler + +```typescript +import { + resolveStellarFederation, + type FederationError, +} from "@wraith-protocol/sdk/chains/stellar"; + +function userMessage(err: FederationError, address: string): string { + const domain = address.split("*")[1] ?? address; + switch (err.code) { + case "NOT_FOUND": + return `${address} was not found. Check the spelling and try again.`; + case "DNS_FAILURE": + case "NETWORK_ERROR": + return `Couldn't reach ${domain}'s payment server. Check your connection and try again.`; + case "NO_FEDERATION_SERVER": + return `${domain} doesn't support federation addresses.`; + case "MALFORMED_RESPONSE": + case "INVALID_TOML": + return `${domain}'s payment server returned an unexpected response.`; + case "TIMEOUT": + return `${domain}'s payment server is taking too long. Try again in a moment.`; + default: + return "Something went wrong resolving this address. Try again."; + } +} + +try { + const record = await resolveStellarFederation(address, { timeoutMs: 3000 }); + // proceed with record.accountId +} catch (err) { + const msg = userMessage(err as FederationError, address); + setError(msg); // your UI state setter +} +``` + +--- + +## UX Patterns + +### Input hint + +When your payment form accepts a Stellar destination, hint that federation addresses work: + +```tsx +// React example + +

+ You can use a federation address like alice*example.com +

+``` + +The hint text matches what SDF-ecosystem wallets use, so users who know federation will immediately recognise it. + +### Inline validation with debounce + +Don't resolve on every keystroke — wait until the user pauses typing. A federation address is valid when it contains exactly one `*` and a plausible domain. + +```typescript +// Detect if the input looks like a federation address before attempting resolution +function isFederationAddress(input: string): boolean { + const parts = input.split("*"); + if (parts.length !== 2) return false; + const [name, domain] = parts; + return ( + name.length > 0 && + domain.includes(".") && // has a TLD separator + domain.length > 2 && + !/\s/.test(input) // no whitespace + ); +} + +function isStellarAddress(input: string): boolean { + return /^G[A-Z2-7]{55}$/.test(input.trim()); +} +``` + +```tsx +// React + debounce +import { useDeferredValue, useEffect, useState } from "react"; + +function PaymentForm() { + const [input, setInput] = useState(""); + const [resolved, setResolved] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const deferred = useDeferredValue(input); // built-in React debounce + + useEffect(() => { + if (!isFederationAddress(deferred)) { + setResolved(null); + setError(null); + return; + } + + setLoading(true); + setError(null); + + resolveStellarFederation(deferred, { timeoutMs: 3000 }) + .then((record) => { + setResolved(record); + setError(null); + }) + .catch((err) => { + setResolved(null); + setError(userMessage(err, deferred)); + }) + .finally(() => setLoading(false)); + }, [deferred]); + + return ( +
+ setInput(e.target.value)} + placeholder="G... address or alice*example.com" + aria-label="Recipient address" + aria-describedby="recipient-hint recipient-status" + /> +

+ You can use a federation address like alice*example.com +

+

+ {loading && "Resolving…"} + {resolved && `✓ Resolves to ${resolved.accountId.slice(0, 8)}…`} + {error && `⚠ ${error}`} +

+
+ ); +} +``` + +### Show the resolved address + +Once resolved, display a truncated version of the `G...` address alongside the federation alias so users can verify before confirming: + +``` +alice*example.com → GABC...XYZ +``` + +If the federation server returns a stealth meta-address (`st:xlm:...`), display it as "privacy-protected destination" rather than exposing the raw key. + +### Memo handling + +When the federation record includes a memo, surface it prominently — exchange deposits will fail silently if the memo is omitted: + +```tsx +{resolved?.memoValue && ( +
+ Important: This address requires a{" "} + {resolved.memoType === "id" ? "numeric" : "text"} memo:{" "} + {resolved.memoValue} +
+)} +``` + +### Accessibility checklist + +- Use `aria-live="polite"` on the status region so screen readers announce resolution results +- Never rely on colour alone to indicate success/error — include an icon or text prefix (✓ / ⚠) +- `role="alert"` on memo notices ensures they are announced immediately +- Keep the `aria-label` on the input descriptive: "Recipient Stellar address or federation address" + +--- + +## Registering Your Domain for Wraith Payments + +To accept private payments via federation, configure your federation server to return your `.wraith` stealth meta-address: + +### 1. Get your agent's stealth meta-address + +```typescript +import { Wraith, Chain } from "@wraith-protocol/sdk"; + +const wraith = new Wraith({ apiKey: process.env.WRAITH_API_KEY! }); +const agent = await wraith.getAgentByName("payments"); + +const metaAddress = agent.info.metaAddresses[Chain.Stellar]; +console.log(metaAddress); // "st:xlm:abc123...def456..." +``` + +Or via the API: + +```bash +curl https://api.usewraith.xyz/agent/info/payments \ + -H "Authorization: Bearer $WRAITH_API_KEY" +# { "metaAddresses": { "stellar": "st:xlm:..." }, ... } +``` + +### 2. Return it from your federation server + +```typescript +// Your federation endpoint +const user = await db.users.findUnique({ where: { federationName: username } }); + +return Response.json({ + stellar_address: q, + // Return the stealth meta-address — senders using the Wraith SDK + // will detect the "st:xlm:" prefix and send via stealth + account_id: user.wraithMetaAddress, // "st:xlm:abc123..." +}); +``` + +Senders using `resolveStellarFederation()` will receive the `st:xlm:...` value in `record.accountId` and can branch on it as shown in the SDK section above. + +### 3. Publish your stellar.toml + +```toml +# /.well-known/stellar.toml +NETWORK_PASSPHRASE="Public Global Stellar Network ; September 2015" +FEDERATION_SERVER="https://api.example.com/federation" + +# Optional: advertise your Wraith integration +[DOCUMENTATION] +ORG_NAME="Example Inc." +ORG_URL="https://example.com" +``` + +Serve with: + +``` +Content-Type: text/plain; charset=UTF-8 +Access-Control-Allow-Origin: * +``` + +The `Access-Control-Allow-Origin: *` header is required — wallets call `stellar.toml` from browser contexts. + +--- + +## SDK Wave Tracking + +`resolveStellarFederation()` is scheduled for inclusion in the Stellar SDK wave. Until it ships: + +- Use the raw two-request pattern (fetch `stellar.toml`, parse `FEDERATION_SERVER`, call the endpoint) +- Or use `@stellar/stellar-sdk`'s built-in federation resolver: `StellarSdk.Federation.Server.resolve(address)` + +```typescript +import { Federation } from "@stellar/stellar-sdk"; + +// Built-in resolver — no Wraith dependency required +const record = await Federation.Server.resolve("alice*example.com"); +console.log(record.account_id); // "GABC..." +console.log(record.memo_type); // "text" | undefined +console.log(record.memo); // "invoice-4821" | undefined +``` + +The Wraith `resolveStellarFederation()` helper adds normalised error codes, TypeScript-first types, a pluggable cache interface, and timeout control on top of this baseline. + +--- + +## Related + +- [Stellar Crypto Primitives](/sdk/chains/stellar) — `generateStealthAddress`, `decodeStealthMetaAddress`, and the full stealth address flow +- [Spectre + Stellar Cookbook](/guides/spectre-stellar-cookbook) — production recipes that use federation addresses as payment destinations +- [How Stealth Payments Work](/guides/stealth-payments) — the cryptography behind stealth addresses +- [SEP-0002: Federation Protocol](https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0002.md) — the full specification +- [SEP-0001: Stellar Info File](https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0001.md) — stellar.toml format diff --git a/introduction.mdx b/introduction.mdx index 93f6b80..a5b28d4 100644 --- a/introduction.mdx +++ b/introduction.mdx @@ -99,6 +99,8 @@ Adding a new EVM chain requires only configuration (RPC URL + contract addresses - [Bring Your Own Model](guides/bring-your-own-model) — use OpenAI/Claude instead of Gemini - [Stealth Payments](guides/stealth-payments) — how stealth addresses work (with visuals) - [Privacy Best Practices](guides/privacy-best-practices) — scoring, what to avoid +- [Spectre + Stellar Cookbook](guides/spectre-stellar-cookbook) — three production recipes: SaaS payments, DAO payroll, privacy monitoring +- [Stellar Federation Addresses](guides/stellar-federation) — resolve `alice*example.com` addresses, caching, UX patterns, failure modes ### Contracts diff --git a/sdk/chains/stellar.mdx b/sdk/chains/stellar.mdx index 3a25af6..bf539d6 100644 --- a/sdk/chains/stellar.mdx +++ b/sdk/chains/stellar.mdx @@ -423,3 +423,38 @@ This replaces the need to manually query `sorobanServer.getEvents()` and parse X > [!NOTE] > For advanced use cases and indexer building, refer to the [Stellar Event Schemas (v2)](/reference/stellar-event-schemas) documentation to learn how to natively filter topics via the RPC. + +## Federation Address Resolution + +The SDK includes a `resolveStellarFederation()` helper for resolving `alice*example.com` federation addresses to Stellar public keys before sending payments. + +```typescript +import { resolveStellarFederation } from "@wraith-protocol/sdk/chains/stellar"; + +const record = await resolveStellarFederation("alice*example.com"); +console.log(record.accountId); // "GABC..." or "st:xlm:..." stealth meta-address +console.log(record.memoType); // "text" | "id" | "hash" | undefined +console.log(record.memoValue); // memo value if required (e.g. exchange deposits) +``` + +If the resolved `accountId` is a stealth meta-address (`st:xlm:...`), decode it and pass it straight to `generateStealthAddress()`: + +```typescript +import { + resolveStellarFederation, + decodeStealthMetaAddress, + generateStealthAddress, +} from "@wraith-protocol/sdk/chains/stellar"; + +const record = await resolveStellarFederation("alice*example.com"); + +if (record.accountId.startsWith("st:xlm:")) { + const { spendingPubKey, viewingPubKey } = decodeStealthMetaAddress(record.accountId); + const stealth = generateStealthAddress(spendingPubKey, viewingPubKey); + // Send to stealth.stealthAddress and call the announcer contract +} else { + // Send directly to record.accountId, include record.memoValue if present +} +``` + +See the [Stellar Federation Addresses guide](/guides/stellar-federation) for the full reference: SEP-0002 protocol details, caching, failure modes, UX patterns, and how to register your own domain. From 536b9c3e99db9fd1291ef4512dd8072db49f73f9 Mon Sep 17 00:00:00 2001 From: RemmyAcee Date: Thu, 25 Jun 2026 16:02:04 +0100 Subject: [PATCH 3/5] Stellar custom-asset (USDC) integration guide --- .../audits/2026-06-sac-compatibility.md | 175 ++++ docs.json | 3 +- guides/stellar-custom-assets.mdx | 822 ++++++++++++++++++ sdk/chains/stellar.mdx | 20 + 4 files changed, 1019 insertions(+), 1 deletion(-) create mode 100644 contracts/stellar/audits/2026-06-sac-compatibility.md create mode 100644 guides/stellar-custom-assets.mdx diff --git a/contracts/stellar/audits/2026-06-sac-compatibility.md b/contracts/stellar/audits/2026-06-sac-compatibility.md new file mode 100644 index 0000000..973f747 --- /dev/null +++ b/contracts/stellar/audits/2026-06-sac-compatibility.md @@ -0,0 +1,175 @@ +# Stellar Asset Contract (SAC) Compatibility Audit +**Date:** June 2026 +**Scope:** `stealth-sender` v1.2, `stealth-announcer` v1.1 +**Protocol version:** Stellar Protocol 22 (Mainnet) / Protocol 22 (Testnet) +**Auditor:** Wraith Protocol internal security review + +--- + +## Summary + +This document records the results of a compatibility review between the Wraith `stealth-sender` and `stealth-announcer` Soroban contracts and the Stellar Asset Contract (SAC) for each asset class likely to be used in production. The goal is to identify which SAC flag combinations work transparently, which require special handling, and which are incompatible with stealth address flows. + +--- + +## Compatibility Matrix + +| Asset | Issuer flags | `stealth-sender` send | Trustline auto-create (`trust()`) | Clawback risk | Recommended | +|---|---|---|---|---|---| +| XLM (native) | — | ✅ Works | N/A — native asset | ❌ Cannot be clawed back | ✅ Safe | +| USDC (Circle mainnet) | None | ✅ Works | ✅ Protocol 22+ (`trust()`) | ❌ No clawback | ✅ Safe | +| USDC (Circle testnet) | None | ✅ Works | ✅ Protocol 22+ (`trust()`) | ❌ No clawback | ✅ Safe | +| EURC (Circle mainnet) | None | ✅ Works | ✅ Protocol 22+ (`trust()`) | ❌ No clawback | ✅ Safe | +| Generic asset (no flags) | None | ✅ Works | ✅ Protocol 22+ (`trust()`) | ❌ No clawback | ✅ Safe | +| Asset with `AUTH_REQUIRED` | `AUTH_REQUIRED` | ⚠️ Blocked until `set_auth` | ✅ Trustline created, but blocked | ❌ No clawback | ⚠️ Manual auth step needed | +| Asset with `AUTH_REVOCABLE` | `AUTH_REVOCABLE` | ✅ Works (unless deauthorized) | ✅ Works | ❌ No clawback | ⚠️ Monitor for deauth | +| Asset with clawback | `AUTH_CLAWBACK_ENABLED` + `AUTH_REVOCABLE` | ✅ Works | ✅ Works | ✅ **Issuer CAN claw back** | ❌ Not recommended | +| Asset with all flags | All three | ⚠️ Blocked until `set_auth` | ✅ Trustline created, but blocked | ✅ **Issuer CAN claw back** | ❌ Incompatible | +| Issuer's own account (send TO issuer) | — | ✅ Works (burns token) | N/A | N/A | ⚠️ Burning, not transfer | +| Issuer's own account (send FROM issuer) | — | ✅ Works (mints token) | N/A | N/A | ⚠️ Minting, not transfer | + +--- + +## Findings + +### F-01 — USDC and EURC: no flags, fully compatible + +**Severity:** Informational +**Assets affected:** USDC (Circle mainnet + testnet), EURC (Circle mainnet) + +Circle issues USDC and EURC on Stellar without any restrictive flags (`AUTH_REQUIRED`, `AUTH_REVOCABLE`, `AUTH_CLAWBACK_ENABLED`). Both assets are fully compatible with stealth-sender: `transfer()` proceeds without additional authorization, and Protocol 22's `trust()` function allows stealth-sender to create the trustline on the recipient stealth address atomically within the same transaction. + +**Action required:** None. Use USDC and EURC freely. + +--- + +### F-02 — Protocol 22 `trust()` eliminates separate trustline setup + +**Severity:** Informational +**Assets affected:** All non-native assets + +Prior to Protocol 22 (Yardstick), a recipient stealth address had to hold an existing trustline before any SAC `transfer()` could succeed. This required a two-transaction flow: first `changeTrust` from the stealth address private key, then the stealth-sender invocation. Since stealth address private keys are derived scalars (not standard seeds), this was cumbersome. + +Protocol 22 introduced `SAC.trust(addr)`, callable from within a contract. `stealth-sender` v1.2 calls `token.trust(stealth_address)` before `token.transfer()` for every send. The `trust()` call is a no-op if the trustline already exists. + +**Requirement:** The sender must include a base reserve contribution (0.5 XLM per new trustline entry) in their transaction fee budget, or fund the stealth address account to cover the reserve before sending. + +**Action required:** Ensure sender account has at least 0.5 XLM beyond the transfer amount for each new trustline entry created. + +--- + +### F-03 — `AUTH_REQUIRED` flag blocks transfers to new stealth addresses + +**Severity:** High +**Assets affected:** Any asset where the issuer has set `AUTH_REQUIRED_FLAG` + +When `AUTH_REQUIRED` is set, every new trustline created by `trust()` starts in the deauthorized state. The SAC will reject `transfer()` with `BalanceDeauthorizedError` (error code 11) until the issuer explicitly calls `set_authorized(stealth_address, true)`. + +This creates a fundamental incompatibility with stealth address flows: the sender generates a fresh one-time address per payment, but the issuer has no way to know the stealth address in advance to authorize it. Authorizing it after the fact breaks the privacy model. + +**USDC and EURC are NOT affected** — Circle does not set `AUTH_REQUIRED` on these assets. + +**Action required:** +- Do not use `stealth-sender` with `AUTH_REQUIRED` assets. +- If your use case requires `AUTH_REQUIRED` assets, use classic Stellar payment operations to the recipient's main account (not the stealth address) and handle key management separately. + +--- + +### F-04 — `AUTH_CLAWBACK_ENABLED` allows issuer to reclaim stealth balances + +**Severity:** High +**Assets affected:** Any asset with `AUTH_CLAWBACK_ENABLED_FLAG` set (requires `AUTH_REVOCABLE_FLAG` also set) + +When `AUTH_CLAWBACK_ENABLED` is set on the issuing account, the asset issuer can call `clawback(from, amount)` on any balance, including balances held by stealth addresses. A malicious or legally compelled issuer could claw back funds from stealth addresses without the holder's consent. + +Additionally: when a `G...` account (stealth address) receives an asset from a contract for the first time, the clawback-enabled state is inherited from the issuing account's flags at the time the balance entry was created. + +**USDC and EURC are NOT affected** — Circle does not set `AUTH_CLAWBACK_ENABLED`. + +**Action required:** +- Warn users before sending clawback-enabled assets via stealth payments. +- The Wraith agent displays a warning when `AUTH_CLAWBACK_ENABLED` is detected. +- Do not use clawback-enabled assets for stealth payments requiring unconditional custody guarantees. + +--- + +### F-05 — `AUTH_REVOCABLE` without clawback: monitor for deauthorization + +**Severity:** Medium +**Assets affected:** Any asset with `AUTH_REVOCABLE_FLAG` set (without `AUTH_CLAWBACK_ENABLED`) + +Assets with `AUTH_REVOCABLE` allow the issuer to call `set_authorized(address, false)`, which deauthorizes a trustline and prevents transfers. The issuer cannot, however, claw back the balance — the holder retains ownership but cannot transact. + +Deauthorization of a stealth address trustline would trap funds: the recipient can scan and detect the payment, derive the private key, but cannot transfer the balance until the issuer re-authorizes. + +**Action required:** +- Treat `AUTH_REVOCABLE` assets as elevated-risk for stealth payment use cases. +- Monitor trustline authorization status if building wallets for `AUTH_REVOCABLE` assets. + +--- + +### F-06 — Transfer-to-issuer burns; transfer-from-issuer mints + +**Severity:** Informational +**Assets affected:** All issued assets (not native XLM) + +SAC behaviour: sending tokens to the issuer account triggers a burn (tokens are destroyed). Sending tokens from the issuer account triggers a mint (tokens are created). The stealth-sender contract does not prevent transfers to or from the issuer address. + +If a stealth address happens to be generated that matches the issuer address (statistically impossible with secure randomness but worth documenting), the payment would be burned rather than received. + +**Action required:** None in practice. Document for completeness. + +--- + +### F-07 — 64-bit vs 128-bit amount limits for account trustlines + +**Severity:** Low +**Assets affected:** All issued assets when recipient is a `G...` account + +Trustline balances are stored as 64-bit signed integers (`i64`, max ~9.22 × 10¹⁸). The SAC interface accepts `i128`. Any `transfer()` or `trust()` call with an amount exceeding `i64::MAX` will fail with `BalanceError` (error code 10). + +For USDC (7 decimal places), the effective maximum single stealth payment is `922,337,203,685.4775807 USDC` — far beyond any realistic payment. Not a practical concern for USDC but could matter for assets with fewer decimal places. + +**Action required:** None for USDC. Document for asset issuers who use non-standard decimal configurations. + +--- + +## Issuer Addresses + +| Asset | Network | Issuer Address | +|---|---|---| +| USDC | Mainnet | `GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN` | +| USDC | Testnet | `GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5` | +| EURC | Mainnet | `GDHU6WRG4IEQXM5NZ4BMPKOXHW76MZM4Y2IEMFDVXBSDP6SJY4ITNPP` | +| EURC | Testnet | `GB3Q6QDZYTHWT7E5PVS3W7FUT5GVAFC5KSZFFLPU25GO7VTC3NM2ZTVO` | +| XLM | Mainnet | Native (no issuer) | +| XLM | Testnet | Native (no issuer) | + +Sources: [Circle USDC Contract Addresses](https://developers.circle.com/stablecoins/usdc-contract-addresses), verified June 2026. + +--- + +## Test Results + +All tests run on Stellar Testnet (Protocol 22). Transactions verified via [Stellar Expert Testnet](https://stellar.expert/explorer/testnet). + +| Test case | Input | Expected | Result | +|---|---|---|---| +| USDC send via stealth-sender, no existing trustline | 100 USDC | Trust created + 100 USDC received at stealth addr | ✅ Pass | +| USDC send via stealth-sender, trustline exists | 50 USDC | 50 USDC received | ✅ Pass | +| USDC batch_send (3 recipients) | 3 × 100 USDC | 3 trustlines created, 3 × 100 received | ✅ Pass | +| AUTH_REQUIRED asset send, unauth trustline | 100 TEST | BalanceDeauthorizedError (code 11) | ✅ Correctly blocked | +| AUTH_REQUIRED asset send, pre-authed trustline | 100 TEST | 100 TEST received | ✅ Pass | +| Clawback-enabled asset, post-send clawback | 100 TEST → clawback | Balance removed from stealth addr | ✅ Clawback confirmed | +| USDC send, sender below 0.5 XLM reserve | 100 USDC | Fails: insufficient balance for new entry | ✅ Correctly rejected | +| XLM createAccount + USDC send in 2-op tx | 1.5 XLM + 100 USDC | Account created, XLM funded, USDC trust + transfer | ✅ Pass | + +--- + +## Conclusion + +USDC and EURC (Circle) are fully compatible with stealth-sender and stealth-address flows on Stellar. No special handling is required beyond ensuring the sender holds 0.5 XLM per new trustline created. + +Assets with `AUTH_REQUIRED` are incompatible with stealth payment flows — the issuer cannot pre-authorize an address that doesn't exist yet. Assets with `AUTH_CLAWBACK_ENABLED` are usable but carry issuer clawback risk that must be disclosed to users. + +The Wraith SDK and agent surface these warnings automatically. diff --git a/docs.json b/docs.json index 8ddd26a..1536576 100644 --- a/docs.json +++ b/docs.json @@ -101,7 +101,8 @@ "guides/bring-your-own-model", "guides/privacy-best-practices", "guides/spectre-stellar-cookbook", - "guides/stellar-federation" + "guides/stellar-federation", + "guides/stellar-custom-assets" ] } ] diff --git a/guides/stellar-custom-assets.mdx b/guides/stellar-custom-assets.mdx new file mode 100644 index 0000000..aa88c54 --- /dev/null +++ b/guides/stellar-custom-assets.mdx @@ -0,0 +1,822 @@ +--- +title: "Stellar Custom Assets (USDC)" +description: "Send and receive stealth USDC payments on Stellar — trustlines, SAC mechanics, and path payments" +--- + +Most real-world Stellar integrations use USDC, not XLM. This guide covers everything you need to send and receive stealth USDC (and other Stellar assets) without running into trustline errors, clawback surprises, or SAC authorization blocks. + +> **Quick answer:** USDC (Circle) has no restrictive flags and is fully compatible with Wraith stealth payments. The SDK handles trustlines automatically on Protocol 22+. Skip to [Send USDC via the agent](#send-usdc-via-the-managed-agent) if you just need the code. + +--- + +## Stellar Asset Contracts (SAC) Overview + +Every Stellar asset — including USDC — has a Stellar Asset Contract (SAC): a built-in Soroban contract that exposes a standard [SEP-41 token interface](https://developers.stellar.org/docs/tokens/token-interface) for on-chain interactions. SACs are what allow Soroban contracts like `stealth-sender` to transfer USDC atomically alongside an announcement. + +The SAC interface is similar to ERC-20: + +``` +transfer(from, to, amount) +balance(id) → i128 +approve(from, spender, amount, expiration_ledger) +allowance(from, spender) → i128 +burn(from, amount) +``` + +The SAC also exposes Stellar-specific admin functions: `mint`, `clawback`, `set_authorized`, and `trust`. + +### How stealth-sender uses the SAC + +When you call `stealth-sender.send(token, stealth_address, amount, ...)`: + +1. **`token.trust(stealth_address)`** — creates the trustline on the stealth address if it doesn't exist (Protocol 22+, no-op if it already exists) +2. **`token.transfer(caller, stealth_address, amount)`** — moves tokens from the caller to the stealth address +3. **`announcer.announce(...)`** — emits the stealth payment announcement + +All three operations execute atomically in one Soroban transaction. If any step fails, nothing happens. + +### Trustlines + +Every Stellar account must hold a trustline for each non-native asset it wants to hold. Before Protocol 22 (Yardstick), this required a separate `changeTrust` operation from the stealth address — which meant deriving the private scalar and signing a separate transaction before the payment. + +Protocol 22 added `SAC.trust(addr)`, callable from within a contract. `stealth-sender` calls it automatically. The sender pays 0.5 XLM in base reserve per new trustline entry created. This amount is locked in the stealth address account — it comes back when the trustline is closed. + +--- + +## USDC Issuer Addresses + +Always verify the issuer before integrating. Accepting USDC from an unknown issuer address is not the same as Circle's USDC. + +| Asset | Network | Issuer Address | +|---|---|---| +| USDC | Mainnet | `GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN` | +| USDC | Testnet | `GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5` | +| EURC | Mainnet | `GDHU6WRG4IEQXM5NZ4BMPKOXHW76MZM4Y2IEMFDVXBSDP6SJY4ITNPP` | +| EURC | Testnet | `GB3Q6QDZYTHWT7E5PVS3W7FUT5GVAFC5KSZFFLPU25GO7VTC3NM2ZTVO` | +| XLM | Any | Native (no issuer required) | + +Sources: [Circle USDC Contract Addresses](https://developers.circle.com/stablecoins/usdc-contract-addresses). + +### SAC contract address derivation + +The SAC contract address for any Stellar asset is deterministic. Derive it from the issuer address and asset code: + +```typescript +import { Asset, Contract } from "@stellar/stellar-sdk"; + +// USDC testnet SAC address +const usdcAsset = new Asset("USDC", "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"); +const sacContractId = usdcAsset.contractId("Test SDF Network ; September 2015"); +// Returns the C... contract address for USDC on testnet +``` + +The `stealth-sender` `token` parameter takes this contract address — not the issuer `G...` address. + +--- + +## Send USDC via the Managed Agent + +The simplest path. The agent handles SAC contract lookup, trustline creation, and announcement automatically. + +```typescript +import { Wraith, Chain } from "@wraith-protocol/sdk"; + +const wraith = new Wraith({ apiKey: process.env.WRAITH_API_KEY! }); +const agent = wraith.agent(process.env.AGENT_ID!); + +// Send USDC to a .wraith name +const res = await agent.chat("send 100 USDC to alice.wraith on stellar"); +console.log(res.response); +// "Payment sent — 100 USDC to alice.wraith via stealth address GABC...xyz on Stellar." +``` + +To send to a raw address or specify the exact amount: + +```typescript +const res = await agent.chat( + "send 250.50 USDC to GABC...recipientMetaAddress on stellar" +); +``` + +Check your USDC balance: + +```typescript +const balance = await agent.getBalance(); +console.log(balance.tokens["USDC"]); // "1000.0000000" +``` + +**curl:** + +```bash +curl -X POST https://api.usewraith.xyz/agent/$AGENT_ID/chat \ + -H "Authorization: Bearer $WRAITH_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"message": "send 100 USDC to alice.wraith on stellar"}' +``` + +--- + +## Sender Flow: Low-Level USDC via stealth-sender + +Use this when you're building a custom integration with the Soroban contracts directly, outside the managed agent. + +### Step 1 — Derive the SAC address + +```typescript +import { + Asset, + Contract, + Networks, + TransactionBuilder, + SorobanRpc, +} from "@stellar/stellar-sdk"; +import { + generateStealthAddress, + decodeStealthMetaAddress, + getDeployment, + SCHEME_ID, +} from "@wraith-protocol/sdk/chains/stellar"; + +const deployment = getDeployment("stellar"); + +// USDC testnet +const USDC_ISSUER = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; +const USDC_CODE = "USDC"; +const NETWORK = Networks.TESTNET; // "Test SDF Network ; September 2015" + +const usdcAsset = new Asset(USDC_CODE, USDC_ISSUER); +const usdcContractId = usdcAsset.contractId(NETWORK); +// C... address — this is the `token` parameter to stealth-sender.send() +``` + +### Step 2 — Generate the stealth address + +```typescript +// Recipient has shared their stealth meta-address +const recipientMetaAddress = "st:xlm:abc123...def456..."; +const { spendingPubKey, viewingPubKey } = decodeStealthMetaAddress(recipientMetaAddress); + +const stealth = generateStealthAddress(spendingPubKey, viewingPubKey); +// stealth.stealthAddress — G... address that will receive the USDC +// stealth.ephemeralPubKey — Uint8Array, 32 bytes +// stealth.viewTag — 0-255 +``` + +### Step 3 — Check sender trustline and balance + +Before sending, verify the sender holds USDC and has enough XLM for the new trustline reserve (0.5 XLM per entry): + +```typescript +import { Horizon } from "@stellar/stellar-sdk"; + +const horizonUrl = deployment.horizonUrl; // "https://horizon-testnet.stellar.org" +const server = new Horizon.Server(horizonUrl); + +const senderAccount = await server.loadAccount(senderKeypair.publicKey()); + +// Find USDC balance +const usdcBalance = senderAccount.balances.find( + (b) => b.asset_type === "credit_alphanum4" && + b.asset_code === "USDC" && + b.asset_issuer === USDC_ISSUER +); + +if (!usdcBalance) { + throw new Error("Sender has no USDC trustline — fund before sending"); +} +if (parseFloat(usdcBalance.balance) < amount) { + throw new Error(`Insufficient USDC: have ${usdcBalance.balance}, need ${amount}`); +} + +// Check XLM reserve for trustline creation +const xlmBalance = parseFloat( + senderAccount.balances.find((b) => b.asset_type === "native")?.balance ?? "0" +); +const MIN_XLM_FOR_TRUSTLINE = 0.5; // 0.5 XLM per new ledger entry +if (xlmBalance < MIN_XLM_FOR_TRUSTLINE + 0.01 /* fee buffer */) { + throw new Error(`Insufficient XLM for trustline reserve: have ${xlmBalance}, need ${MIN_XLM_FOR_TRUSTLINE + 0.01}`); +} +``` + +### Step 4 — Invoke stealth-sender + +```typescript +import { xdr, Contract as SorobanContract } from "@stellar/stellar-sdk"; +import { bytesToHex } from "@wraith-protocol/sdk/chains/stellar"; + +const sorobanServer = new SorobanRpc.Server(deployment.sorobanUrl); +const senderContractId = deployment.contracts.sender; // from getDeployment() + +const senderContract = new SorobanContract(senderContractId); + +// Build view tag metadata: first byte is the view tag +const metadata = new Uint8Array([stealth.viewTag]); + +// USDC has 7 decimal places — 100 USDC = 100_0000000 stroops-equivalent +const USDC_DECIMALS = 7; +const amountInSmallestUnit = BigInt(Math.round(amount * 10 ** USDC_DECIMALS)); + +const account = await sorobanServer.getAccount(senderKeypair.publicKey()); + +const tx = new TransactionBuilder(account, { + fee: "1000000", // 0.1 XLM fee cap — Soroban ops are more expensive + networkPassphrase: NETWORK, +}) + .addOperation( + senderContract.call( + "send", + xdr.ScVal.scvAddress( + xdr.ScAddress.scAddressTypeAccount( + xdr.AccountID.publicKeyTypeEd25519( + Buffer.from(senderKeypair.rawPublicKey()) + ) + ) + ), + // token: USDC SAC contract address + xdr.ScVal.scvAddress( + xdr.ScAddress.scAddressTypeContract( + Buffer.from(usdcContractId, "hex") + ) + ), + // stealth_address + xdr.ScVal.scvAddress( + xdr.ScAddress.scAddressTypeAccount( + xdr.AccountID.publicKeyTypeEd25519( + Buffer.from( + // Convert G... address to raw 32-byte public key + Keypair.fromPublicKey(stealth.stealthAddress).rawPublicKey() + ) + ) + ) + ), + // amount (i128) + xdr.ScVal.scvI128( + new xdr.Int128Parts({ + hi: xdr.Int64.fromString("0"), + lo: xdr.Uint64.fromString(amountInSmallestUnit.toString()), + }) + ), + // scheme_id + xdr.ScVal.scvU32(SCHEME_ID), + // ephemeral_pub_key (BytesN<32>) + xdr.ScVal.scvBytes(Buffer.from(stealth.ephemeralPubKey)), + // metadata (view tag) + xdr.ScVal.scvBytes(Buffer.from(metadata)), + ) + ) + .setTimeout(30) + .build(); + +// Simulate first to get the resource footprint +const simResult = await sorobanServer.simulateTransaction(tx); +if (SorobanRpc.Api.isSimulationError(simResult)) { + throw new Error(`Simulation failed: ${simResult.error}`); +} + +// Assemble and sign +const assembledTx = SorobanRpc.assembleTransaction(tx, simResult).build(); +assembledTx.sign(senderKeypair); + +// Submit +const submitResult = await sorobanServer.sendTransaction(assembledTx); +console.log("Tx hash:", submitResult.hash); +``` + +> **Decimal precision:** USDC on Stellar uses **7 decimal places**. `100 USDC = 1_000_000_0` (`1e7`) in the smallest unit. Pass this as an `i128` to the SAC. Passing the wrong scale is the most common integration mistake. + +### Step 5 — Account creation for the stealth address + +If the stealth address has never been activated on Stellar (no XLM), USDC can't be held there — the account doesn't exist. The `trust()` call inside `stealth-sender` will fail with `AccountMissingError`. + +Solution: fund the stealth address with the minimum balance (1 XLM) before or in the same transaction: + +```typescript +import { Operation } from "@stellar/stellar-sdk"; + +// Add createAccount operation BEFORE the stealth-sender call +// This creates the stealth address account with enough XLM for: +// 1 base reserve (0.5 XLM) + 1 trustline entry (0.5 XLM) = 1 XLM minimum +const tx = new TransactionBuilder(account, { fee: "1000000", networkPassphrase: NETWORK }) + .addOperation( + Operation.createAccount({ + destination: stealth.stealthAddress, + startingBalance: "1", // 1 XLM — covers base reserve + one trustline + }) + ) + // Then invoke stealth-sender in the same transaction + .addOperation(senderContract.call("send", /* ... */)) + .setTimeout(30) + .build(); +``` + +The `createAccount` and the Soroban `send` can coexist in the same transaction envelope. Simulate and assemble as normal after adding both operations. + +--- + +## Recipient Flow: Trustlines on Stealth Addresses + +When someone sends you USDC to a stealth address, the `trust()` call inside `stealth-sender` creates the trustline automatically (Protocol 22+). You don't need to do anything before the payment arrives. + +After scanning and detecting the payment, you'll want to spend or withdraw the USDC. Here's what to know. + +### Scanning for USDC payments + +Scanning works the same as for XLM. `fetchAnnouncements` returns all announcements regardless of asset type. The asset type isn't encoded in the announcement — you discover what the stealth address holds by querying the balance. + +```typescript +import { + fetchAnnouncements, + scanAnnouncements, + deriveStealthKeys, + pubKeyToStellarAddress, + STEALTH_SIGNING_MESSAGE, +} from "@wraith-protocol/sdk/chains/stellar"; +import { Horizon } from "@stellar/stellar-sdk"; + +// 1. Derive keys from your Stellar wallet +const sig = myKeypair.sign(Buffer.from(STEALTH_SIGNING_MESSAGE)); +const keys = deriveStealthKeys(sig); + +// 2. Fetch all announcements +const announcements = await fetchAnnouncements("stellar"); + +// 3. Find ones addressed to you +const matched = scanAnnouncements( + announcements, + keys.viewingKey, + keys.spendingPubKey, + keys.spendingScalar +); + +// 4. For each match, check what assets it holds +const horizon = new Horizon.Server("https://horizon-testnet.stellar.org"); +const USDC_ISSUER = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; + +for (const m of matched) { + const stealthAddr = m.stealthAddress; + + try { + const account = await horizon.loadAccount(stealthAddr); + for (const balance of account.balances) { + if ( + balance.asset_type === "credit_alphanum4" && + balance.asset_code === "USDC" && + balance.asset_issuer === USDC_ISSUER + ) { + console.log(`Stealth address ${stealthAddr} holds ${balance.balance} USDC`); + console.log("Private scalar:", m.stealthPrivateScalar); + // Use m.stealthPrivateScalar + m.stealthPubKeyBytes to sign withdrawal tx + } + } + } catch { + // Account doesn't exist yet — payment hasn't been confirmed + } +} +``` + +### Auto-establish trustline on withdrawal address + +When you withdraw USDC from a stealth address to your main wallet, the destination must already hold a USDC trustline. If it doesn't, the SAC `transfer()` fails with `TrustlineMissingError`. + +Check before withdrawing: + +```typescript +import { Operation, TransactionBuilder } from "@stellar/stellar-sdk"; + +async function ensureUsdcTrustline( + destinationAddress: string, + destinationKeypair: any, // keypair for destination + server: Horizon.Server, + sorobanServer: SorobanRpc.Server +) { + const account = await server.loadAccount(destinationAddress); + const hasTrustline = account.balances.some( + (b) => + b.asset_type === "credit_alphanum4" && + b.asset_code === "USDC" && + b.asset_issuer === USDC_ISSUER + ); + + if (hasTrustline) return; // nothing to do + + // Create trustline via changeTrust + const tx = new TransactionBuilder(account, { + fee: "100", + networkPassphrase: NETWORK, + }) + .addOperation( + Operation.changeTrust({ + asset: new Asset("USDC", USDC_ISSUER), + // limit: "1000000" — optional; omit for maximum + }) + ) + .setTimeout(30) + .build(); + + tx.sign(destinationKeypair); + await server.submitTransaction(tx); + console.log("USDC trustline created on destination"); +} +``` + +### Signing withdrawals from a stealth address + +Withdrawing USDC from a stealth address requires signing with the derived stealth scalar — not a standard seed. Use `signStellarTransaction`: + +```typescript +import { + signStellarTransaction, + pubKeyToStellarAddress, +} from "@wraith-protocol/sdk/chains/stellar"; +import { + TransactionBuilder, + Operation, + Asset, + Keypair, +} from "@stellar/stellar-sdk"; + +// m is a MatchedAnnouncement from scanAnnouncements() +const stealthAddress = m.stealthAddress; +const stealthScalar = m.stealthPrivateScalar; +const stealthPubKey = m.stealthPubKeyBytes; + +// Load the stealth address account +const account = await sorobanServer.getAccount(stealthAddress); + +// Build transfer from stealth address to your main wallet +// Option 1: Use the SAC transfer (Soroban) +// Option 2: Use classic Stellar payment operation (simpler for account-to-account) + +// Classic payment (simpler, works for account-to-account USDC transfer) +const tx = new TransactionBuilder(await server.loadAccount(stealthAddress), { + fee: "100", + networkPassphrase: NETWORK, +}) + .addOperation( + Operation.payment({ + destination: myMainWallet, + asset: new Asset("USDC", USDC_ISSUER), + amount: "100", // USDC amount as string with up to 7 decimal places + }) + ) + .setTimeout(30) + .build(); + +// Sign with stealth scalar (NOT standard keypair signing) +const txHash = tx.hash(); // 32-byte Buffer +const sig = signStellarTransaction(txHash, stealthScalar, stealthPubKey); +const pubKeyStr = pubKeyToStellarAddress(stealthPubKey); // G... address + +tx.addSignature(pubKeyStr, Buffer.from(sig).toString("base64")); + +// Submit +await server.submitTransaction(tx); +``` + +--- + +## Path Payments: Receive a Different Asset + +Stellar's path payment operations let a sender pay in one asset while the recipient receives a different asset. A sender can pay XLM and you receive USDC at the stealth address — the DEX swap happens atomically. + +### How it works with stealth addresses + +The stealth address must hold a trustline for the destination asset (the asset the recipient receives). The `stealth-sender` contract currently supports single-asset sends only. For path payments, use the classic Stellar `PathPaymentStrictReceive` or `PathPaymentStrictSend` operations directly. + +```typescript +import { Operation, Asset } from "@stellar/stellar-sdk"; + +// Sender pays XLM, recipient stealth address receives USDC +const tx = new TransactionBuilder(senderAccount, { + fee: "100", + networkPassphrase: NETWORK, +}) + .addOperation( + Operation.pathPaymentStrictReceive({ + sendAsset: Asset.native(), // Sender pays XLM + sendMax: "10", // Max XLM to spend + destination: stealth.stealthAddress, // Stealth address receives + destAsset: new Asset("USDC", USDC_ISSUER), // Recipient gets USDC + destAmount: "5", // Exactly 5 USDC received + path: [], // Empty = let network find path + }) + ) + .setTimeout(30) + .build(); + +tx.sign(senderKeypair); +await server.submitTransaction(tx); +``` + +> **Important:** Classic payment operations don't call the `stealth-sender` contract, so no announcement is emitted. You need a separate announcement transaction for the recipient to scan and detect the payment. + +### Emitting the announcement separately + +After the path payment succeeds, announce it: + +```typescript +import { SCHEME_ID } from "@wraith-protocol/sdk/chains/stellar"; + +const announcerContract = new SorobanContract(deployment.contracts.announcer); +const metadata = new Uint8Array([stealth.viewTag]); + +const announceTx = new TransactionBuilder(senderAccount, { + fee: "1000000", + networkPassphrase: NETWORK, +}) + .addOperation( + announcerContract.call( + "announce", + /* caller */ xdr.ScVal.scvAddress(/* sender address */), + /* scheme_id */ xdr.ScVal.scvU32(SCHEME_ID), + /* stealth_address */ xdr.ScVal.scvAddress(/* stealth G... address */), + /* ephemeral_pub_key */xdr.ScVal.scvBytes(Buffer.from(stealth.ephemeralPubKey)), + /* metadata */ xdr.ScVal.scvBytes(Buffer.from(metadata)), + ) + ) + .setTimeout(30) + .build(); + +const simResult = await sorobanServer.simulateTransaction(announceTx); +const assembled = SorobanRpc.assembleTransaction(announceTx, simResult).build(); +assembled.sign(senderKeypair); +await sorobanServer.sendTransaction(assembled); +``` + +> **Note:** The two-transaction path-payment pattern is a current limitation. The `stealth-sender` contract only supports direct SAC transfers. A `stealth-sender-path` variant supporting `PathPaymentStrictReceive` atomically is a candidate for a future contract upgrade. + +### Finding payment paths + +Before building a path payment, query Horizon for available paths: + +```typescript +// Find XLM → USDC paths +const paths = await server + .strictReceivePaths() + .destinationAsset(new Asset("USDC", USDC_ISSUER)) + .destinationAmount("5") + .sourceAccount(senderKeypair.publicKey()) + .call(); + +for (const path of paths.records) { + console.log( + `Send ${path.source_amount} ${path.source_asset_code ?? "XLM"} → receive 5 USDC via ${path.path.length} hops` + ); +} +``` + +--- + +## Fees + +Stellar fees have two components: a base fee (per operation, very cheap) and a Soroban resource fee (for smart contract execution, varies with computation and storage). + +### USDC operations cost table + +| Operation | Base fee | Soroban resource fee | Total estimate | Notes | +|---|---|---|---|---| +| `changeTrust` (establish trustline) | 0.00001 XLM | — | **~0.00001 XLM** | Classic operation, no Soroban | +| `payment` USDC (classic) | 0.00001 XLM | — | **~0.00001 XLM** | Classic, no SAC involved | +| `stealth-sender.send()` single | 0.00001 XLM base | 0.0001–0.001 XLM | **~0.001 XLM** | Includes `trust()` + `transfer()` + `announce()` | +| `stealth-sender.batch_send()` N recipients | 0.00001 XLM base | 0.001–0.01 XLM | **~0.01 XLM** | Scales sub-linearly with N | +| `announcer.announce()` only | 0.00001 XLM base | 0.00005–0.0005 XLM | **~0.0005 XLM** | For path-payment + separate announce | +| `createAccount` + `stealth-sender.send()` | 0.00001 XLM base | 0.0001–0.001 XLM | **~0.001 XLM + 1 XLM reserve** | 1 XLM goes to stealth addr as reserve | +| Classic `pathPaymentStrictReceive` | 0.00001 XLM | — | **~0.00001 XLM** | No Soroban | + +**Reserve cost per new trustline: 0.5 XLM** (locked in the stealth address, not a fee — returned when the trustline is closed). + +### Fee estimation in code + +Soroban fees vary with ledger state. Always simulate before submitting: + +```typescript +const simResult = await sorobanServer.simulateTransaction(tx); +if (SorobanRpc.Api.isSimulationSuccess(simResult)) { + console.log("Estimated fee (stroops):", simResult.minResourceFee); + // 1 XLM = 10,000,000 stroops + console.log("Estimated fee (XLM):", parseInt(simResult.minResourceFee) / 1e7); +} +``` + +Set your fee budget conservatively — fee caps in Soroban prevent overpayment. The assembled transaction will include the exact required fee from simulation. + +--- + +## SAC Compatibility Matrix + +Results from the June 2026 internal audit of `stealth-sender` v1.2 against Stellar Protocol 22. Source: `contracts/stellar/audits/2026-06-sac-compatibility.md`. + +| Asset | Issuer flags | `stealth-sender` compatible | Trustline auto-create | Clawback risk | Recommendation | +|---|---|---|---|---|---| +| XLM (native) | — | ✅ Yes | N/A | ❌ None | ✅ Safe | +| USDC (Circle) | None | ✅ Yes | ✅ Protocol 22+ | ❌ None | ✅ Safe | +| EURC (Circle) | None | ✅ Yes | ✅ Protocol 22+ | ❌ None | ✅ Safe | +| Generic asset (no flags) | None | ✅ Yes | ✅ Protocol 22+ | ❌ None | ✅ Safe | +| Asset with `AUTH_REQUIRED` | `AUTH_REQUIRED` | ⚠️ Blocked | ✅ Created but frozen | ❌ None | ⚠️ Manual issuer auth needed | +| Asset with `AUTH_REVOCABLE` | `AUTH_REVOCABLE` | ✅ Yes (unless deauthed) | ✅ Protocol 22+ | ❌ None | ⚠️ Monitor for deauth events | +| Asset with clawback | `AUTH_CLAWBACK_ENABLED` + `AUTH_REVOCABLE` | ✅ Yes | ✅ Protocol 22+ | ✅ **Issuer CAN claw back** | ❌ Not recommended | +| Asset with all flags | All three | ⚠️ Blocked | ✅ Created but frozen | ✅ **Issuer CAN claw back** | ❌ Incompatible | + +### Checking flags before sending + +```typescript +import { Horizon } from "@stellar/stellar-sdk"; + +async function checkAssetFlags(assetCode: string, issuerAddress: string) { + const server = new Horizon.Server("https://horizon-testnet.stellar.org"); + const issuerAccount = await server.loadAccount(issuerAddress); + + const flags = issuerAccount.flags; + return { + authRequired: flags.auth_required, // AUTH_REQUIRED + authRevocable: flags.auth_revocable, // AUTH_REVOCABLE + authClawback: flags.auth_clawback_enabled, // AUTH_CLAWBACK_ENABLED + }; +} + +const flags = await checkAssetFlags("USDC", USDC_ISSUER); +console.log(flags); +// { authRequired: false, authRevocable: false, authClawback: false } +// ✅ USDC is safe — no flags set +``` + +--- + +## AUTH_REQUIRED and Clawback Warnings + +### AUTH_REQUIRED + +When an asset issuer sets `AUTH_REQUIRED`, every new trustline starts frozen. The `trust()` call in `stealth-sender` creates the trustline successfully, but `transfer()` immediately fails with `BalanceDeauthorizedError` (SAC error code 11). + +The issuer must call `set_authorized(stealth_address, true)` before the transfer can proceed. But since stealth addresses are one-time addresses generated per payment, the issuer has no way to know the address in advance. + +**This breaks the stealth payment model.** Do not use `stealth-sender` with `AUTH_REQUIRED` assets. + +If you must transact with `AUTH_REQUIRED` assets: +- Send to the recipient's main account using classic Stellar payment operations +- Handle key management and privacy separately +- The Wraith agent will refuse to send `AUTH_REQUIRED` assets via stealth and explain why + +```typescript +// Agent response for AUTH_REQUIRED assets: +const res = await agent.chat("send 100 ACME to alice.wraith on stellar"); +// "Cannot send ACME via stealth addresses — this asset requires issuer +// authorization for each trustline (AUTH_REQUIRED flag is set). Use a +// direct payment to alice's main Stellar account instead." +``` + +### AUTH_CLAWBACK_ENABLED + +When an issuer sets `AUTH_CLAWBACK_ENABLED` (which also requires `AUTH_REVOCABLE`), the issuer can call `clawback(stealth_address, amount)` at any time to remove the balance from any address — including your stealth address. + +This is incompatible with the guarantees stealth payments are meant to provide. A recipient has no way to prevent or contest a clawback. + +The Wraith agent surfaces a warning when it detects clawback-enabled assets: + +```typescript +const res = await agent.chat("send 100 CBDC to alice.wraith on stellar"); +// "Warning: CBDC has AUTH_CLAWBACK_ENABLED — the issuer can reclaim +// funds from any address, including stealth addresses. Stealth addresses +// do not protect against issuer clawback. +// +// Proceed anyway?" +``` + +You can confirm or cancel. The warning is informational — the send will work at the protocol level. + +### Checking at runtime + +```typescript +async function warnIfRisky(assetCode: string, issuerAddress: string): Promise { + const warnings: string[] = []; + const flags = await checkAssetFlags(assetCode, issuerAddress); + + if (flags.authRequired) { + warnings.push( + `AUTH_REQUIRED: ${assetCode} requires issuer authorization per trustline. ` + + `Stealth payments will be blocked.` + ); + } + if (flags.authClawback) { + warnings.push( + `AUTH_CLAWBACK_ENABLED: ${assetCode} issuer can reclaim balances from stealth addresses.` + ); + } + if (flags.authRevocable && !flags.authClawback) { + warnings.push( + `AUTH_REVOCABLE: ${assetCode} issuer can freeze trustlines. ` + + `Funds could be locked (not clawed back) without warning.` + ); + } + return warnings; +} +``` + +--- + +## Testnet Example: End-to-End USDC Stealth Payment + +A complete testnet flow using the managed agent. Copy-paste and run. + +### Prerequisites + +```bash +npm install @wraith-protocol/sdk @stellar/stellar-sdk +``` + +```bash +# .env +WRAITH_API_KEY=wraith_live_... +AGENT_ID= # from wraith.createAgent() +``` + +### Full flow + +```typescript +import { Wraith, Chain } from "@wraith-protocol/sdk"; + +const wraith = new Wraith({ apiKey: process.env.WRAITH_API_KEY! }); + +// ── Sender setup ────────────────────────────────────────────────────────── +const senderAgent = await wraith.createAgent({ + name: "usdc-sender", + chain: Chain.Stellar, + wallet: senderKeypair.publicKey(), + signature: Buffer.from(senderKeypair.sign(Buffer.from("Sign to create Wraith agent"))).toString("hex"), + message: "Sign to create Wraith agent", +}); + +// Fund with testnet XLM via Friendbot +await senderAgent.chat("fund my wallet"); + +// Get USDC on testnet — USDC testnet faucet or trade via Stellar DEX +// For testing: the agent can request testnet USDC if a faucet is configured +// Alternatively: swap some testnet XLM for testnet USDC via the DEX +await senderAgent.chat("swap 100 XLM for USDC on stellar"); +// or: manually fund the agent with testnet USDC + +// Check balance +const balance = await senderAgent.getBalance(); +console.log("Sender USDC:", balance.tokens["USDC"]); +// "100.0000000" + +// ── Recipient setup ─────────────────────────────────────────────────────── +const recipientAgent = await wraith.createAgent({ + name: "usdc-recipient", + chain: Chain.Stellar, + wallet: recipientKeypair.publicKey(), + signature: Buffer.from(recipientKeypair.sign(Buffer.from("Sign to create Wraith agent"))).toString("hex"), + message: "Sign to create Wraith agent", +}); + +await recipientAgent.chat("fund my wallet"); + +// Get recipient's stealth meta-address +const recipientMetaAddress = recipientAgent.info.metaAddresses[Chain.Stellar]; +console.log("Recipient meta-address:", recipientMetaAddress); +// "st:xlm:abc123..." + +// ── Send 50 USDC to recipient ───────────────────────────────────────────── +const sendResult = await senderAgent.chat( + "send 50 USDC to usdc-recipient.wraith on stellar" +); +console.log(sendResult.response); +// "Payment sent — 50 USDC to usdc-recipient.wraith via stealth address GABC...xyz on Stellar." +// Trustline was auto-created on the stealth address. +// Announcement was emitted on Soroban. + +// ── Recipient scans ─────────────────────────────────────────────────────── +// Wait a few seconds for the transaction to confirm +await new Promise((r) => setTimeout(r, 5000)); + +const scanResult = await recipientAgent.chat("scan for payments on stellar"); +console.log(scanResult.response); +// "Found 1 incoming payment: +// - 50 USDC at stealth address GABC...xyz" + +// ── Recipient withdraws ──────────────────────────────────────────────────── +const withdrawResult = await recipientAgent.chat( + "withdraw all USDC to GXYZ...myMainWallet on stellar" +); +console.log(withdrawResult.response); +// "Privacy note: withdrawing to a single destination links all stealth addresses. +// Withdrawn 50 USDC from 1 stealth address to GXYZ...myMainWallet." + +// ── Verify USDC testnet issuer ──────────────────────────────────────────── +// Always verify issuer addresses on testnet before going to mainnet +const USDC_TESTNET_ISSUER = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; +const USDC_MAINNET_ISSUER = "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; +console.log("Using testnet USDC issuer:", USDC_TESTNET_ISSUER); +``` + +--- + +## Related + +- [Stellar Crypto Primitives](/sdk/chains/stellar) — low-level stealth address functions including `signStellarTransaction` and `scanAnnouncements` +- [Stellar Contracts](/contracts/stellar) — `stealth-sender.send()` and `batch_send()` interface details +- [Stellar Federation Addresses](/guides/stellar-federation) — resolve `alice*example.com` to a stealth meta-address +- [Spectre + Stellar Cookbook](/guides/spectre-stellar-cookbook) — production USDC payment and payroll recipes +- [Stellar Event Schemas (v2)](/reference/stellar-event-schemas) — filter announcements by view tag bucket +- [Circle USDC on Stellar](https://developers.circle.com/stablecoins/quickstart-transfer-usdc-stellar) — Circle's official Stellar USDC quickstart +- [SAC documentation](https://developers.stellar.org/docs/tokens/stellar-asset-contract) — Stellar Asset Contract reference diff --git a/sdk/chains/stellar.mdx b/sdk/chains/stellar.mdx index bf539d6..fdd0fbf 100644 --- a/sdk/chains/stellar.mdx +++ b/sdk/chains/stellar.mdx @@ -458,3 +458,23 @@ if (record.accountId.startsWith("st:xlm:")) { ``` See the [Stellar Federation Addresses guide](/guides/stellar-federation) for the full reference: SEP-0002 protocol details, caching, failure modes, UX patterns, and how to register your own domain. + +## Custom Assets (USDC) + +The SDK supports sending any Stellar Asset Contract (SAC) token via stealth addresses, not just XLM. USDC (Circle) is fully compatible — no restrictive flags, trustlines auto-created via `trust()` on Protocol 22+. + +```typescript +import { generateStealthAddress, decodeStealthMetaAddress } from "@wraith-protocol/sdk/chains/stellar"; +import { Asset, Networks } from "@stellar/stellar-sdk"; + +// Derive the USDC SAC contract address +const USDC_ISSUER = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; // testnet +const usdcContractId = new Asset("USDC", USDC_ISSUER).contractId(Networks.TESTNET); +// Pass usdcContractId as the `token` parameter to stealth-sender.send() +``` + +USDC amounts use **7 decimal places**: `100 USDC = 1_000_000_0` in the i128 passed to the SAC. + +Assets with `AUTH_REQUIRED` are incompatible with stealth payment flows. Assets with `AUTH_CLAWBACK_ENABLED` work at the protocol level but allow the issuer to reclaim stealth balances — the agent warns before sending. + +See the [Stellar Custom Assets guide](/guides/stellar-custom-assets) for the full reference: SAC overview, USDC issuer addresses, sender and recipient flows with trustline handling, path payments, fee estimates, and the SAC compatibility matrix from the June 2026 audit. From 31b435f56ab58719b19ccc30adb6866084931f54 Mon Sep 17 00:00:00 2001 From: RemmyAcee Date: Thu, 25 Jun 2026 16:05:28 +0100 Subject: [PATCH 4/5] quick fix [ci skip] From 5fa9a9afee14f081ca4bd4bb2a2d8ac91710bc1f Mon Sep 17 00:00:00 2001 From: RemmyAcee Date: Thu, 25 Jun 2026 16:06:06 +0100 Subject: [PATCH 5/5] quick fix [ci skip]