Skip to content

Commit 15316f2

Browse files
Add Wraith name subscriptions tutorial
1 parent 1a43cb4 commit 15316f2

2 files changed

Lines changed: 310 additions & 0 deletions

File tree

docs.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@
177177
"guides/stellar/passkey-signing",
178178
"guides/stellar/stellar-quickstart",
179179
"guides/stellar/wraith-names-lifecycle",
180+
"guides/stellar/subscriptions-with-wraith-names",
180181
"guides/wraith-names-stellar",
181182
"guides/ops/self-hosted-deployment",
182183
"guides/ops/monitoring-and-on-call"
Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
---
2+
title: "Subscriptions with Wraith Names"
3+
description: "Build recurring Stellar subscriptions that resolve a .wraith name at every billing run, so recipients can rotate stealth keys without breaking payer schedules."
4+
keywords: "Stellar, Soroban, Wraith names, subscriptions, recurring payments, USDC, stealth meta-address, futurenet fixtures"
5+
---
6+
7+
This tutorial shows how to build a recurring Stellar subscription around a `.wraith` name instead of a raw stealth meta-address. The payer stores `merchant.wraith` as the billing destination, resolves the name before each scheduled payment, and sends USDC to the current meta-address behind that name.
8+
9+
For the lower-level scheduler mechanics, read [Recipe 2 in the Spectre + Stellar Cookbook](/guides/spectre-stellar-cookbook#recipe-2-dao-weekly-payroll-in-usdc-on-stellar). For one-time invoices and unsigned or signed URLs, read [Stellar Payment Links](/guides/stellar-payment-links). For name state, expiry, renewal, and update rules, read [Wraith Names Lifecycle on Stellar](/guides/stellar/wraith-names-lifecycle).
10+
11+
<Warning>
12+
The fixture example below uses Futurenet RPC/passphrase values and canned name records so the subscription flow can run without live Wraith contracts. Current Wraith contract deployments are listed in [Stellar Networks](/reference/stellar-networks); use Stellar testnet for live `wraith-names` contract calls until Futurenet deployments are published.
13+
</Warning>
14+
15+
---
16+
17+
## Flow
18+
19+
The subscription contract between payer and recipient is off-chain. The on-chain privacy boundary is still Wraith's Stellar stealth payment flow:
20+
21+
1. The recipient registers or updates `merchant.wraith` to point at their current Stellar stealth meta-address.
22+
2. The payer stores the subscription as `{ destinationName: "merchant.wraith", amount: "25", asset: "USDC" }`.
23+
3. Each billing run resolves `merchant.wraith` immediately before payment.
24+
4. The payer derives and sends to a fresh stealth address for the resolved meta-address.
25+
5. If the recipient rotates keys, only the name record changes. The payer's subscription row stays the same.
26+
27+
That last point is the difference from a raw meta-address subscription. A raw `st:xlm:...` destination is stable only while the recipient keeps the same stealth keys. A `.wraith` destination lets the recipient rotate the mapping behind the name.
28+
29+
---
30+
31+
## Data Model
32+
33+
Store the destination as a name, not as the resolved meta-address.
34+
35+
```typescript
36+
type SubscriptionStatus = "active" | "paused" | "cancelled";
37+
38+
interface Subscription {
39+
id: string;
40+
payerAccount: string;
41+
destinationName: `${string}.wraith`;
42+
amount: string;
43+
asset: "USDC" | "XLM";
44+
cadence: "monthly";
45+
nextRunAt: string;
46+
status: SubscriptionStatus;
47+
lastResolvedMetaAddress?: string;
48+
}
49+
50+
const subscription: Subscription = {
51+
id: "sub_merchant_001",
52+
payerAccount: "GBILLINGPAYER...",
53+
destinationName: "merchant.wraith",
54+
amount: "25",
55+
asset: "USDC",
56+
cadence: "monthly",
57+
nextRunAt: "2026-09-01T09:00:00Z",
58+
status: "active",
59+
};
60+
```
61+
62+
Keep `lastResolvedMetaAddress` only as an audit field. Do not use it as the next billing destination unless a retry policy explicitly says to retry the exact same resolved target.
63+
64+
---
65+
66+
## Resolve on Every Billing Run
67+
68+
The billing worker should resolve the name just before sending. This gives the recipient a clean rotation path: update the name record before the next billing date, and the payer automatically routes future payments to the new meta-address.
69+
70+
```typescript
71+
import { Chain, Wraith } from "@wraith-protocol/sdk";
72+
73+
interface BillingResult {
74+
subscriptionId: string;
75+
destinationName: string;
76+
resolvedMetaAddress: string;
77+
status: "sent" | "failed";
78+
txHash?: string;
79+
error?: string;
80+
}
81+
82+
export async function runMonthlySubscription(
83+
subscription: Subscription
84+
): Promise<BillingResult> {
85+
const wraith = new Wraith({ apiKey: process.env.WRAITH_API_KEY! });
86+
const payerAgent = wraith.agent(process.env.PAYER_AGENT_ID!);
87+
88+
try {
89+
const resolvedMetaAddress = await wraith.resolveName(
90+
subscription.destinationName,
91+
Chain.Stellar
92+
);
93+
94+
const response = await payerAgent.chat(
95+
`send ${subscription.amount} ${subscription.asset} to ${subscription.destinationName} on stellar`
96+
);
97+
98+
return {
99+
subscriptionId: subscription.id,
100+
destinationName: subscription.destinationName,
101+
resolvedMetaAddress,
102+
status: "sent",
103+
txHash: extractTxHash(response),
104+
};
105+
} catch (error: any) {
106+
return {
107+
subscriptionId: subscription.id,
108+
destinationName: subscription.destinationName,
109+
resolvedMetaAddress: subscription.lastResolvedMetaAddress ?? "",
110+
status: "failed",
111+
error: error.message,
112+
};
113+
}
114+
}
115+
116+
function extractTxHash(response: any): string | undefined {
117+
const detail = response.toolCalls?.find((call: any) => call.name === "send_payment")?.detail;
118+
if (!detail) return undefined;
119+
return JSON.parse(detail).txHash;
120+
}
121+
```
122+
123+
The chat instruction still uses `merchant.wraith`. That keeps the payment intent human-readable in logs and lets the agent perform name resolution with the same routing rules users see elsewhere in the Wraith app.
124+
125+
---
126+
127+
## Futurenet Fixture Example
128+
129+
Use this fixture-backed resolver in tests and tutorials that must run with Futurenet settings while live Wraith Futurenet contracts are unavailable. The shape mirrors the fields the scheduler cares about: name, meta-address, state, and expiry ledger.
130+
131+
```typescript
132+
type NameState = "active" | "grace_period" | "expired";
133+
134+
interface NameFixture {
135+
name: `${string}.wraith`;
136+
metaAddress: string;
137+
owner: string;
138+
state: NameState;
139+
expiryLedger: number;
140+
}
141+
142+
const FUTURENET_SUBSCRIPTION_FIXTURES: NameFixture[] = [
143+
{
144+
name: "merchant.wraith",
145+
metaAddress:
146+
"st:xlm:eb8452e938d04e9a56ef69c47dacd8224464b030b5ca569d5b4e4399f8d0fb5529a8dd877a3803289ab3a62ac39cce4a99021a3cd0fac6ad982e051c8fa769dc",
147+
owner: "GB4X7TDIRWAXKYRAYRXSTY27DZUTQKEMJKV7GBKZ3RVJJS5XCHAELUZI",
148+
state: "active",
149+
expiryLedger: 58_600_000,
150+
},
151+
];
152+
153+
export function resolveFixtureName(name: string): NameFixture {
154+
const record = FUTURENET_SUBSCRIPTION_FIXTURES.find((item) => item.name === name);
155+
if (!record) throw new Error(`Name not found: ${name}`);
156+
if (record.state === "expired") throw new Error(`Name expired: ${name}`);
157+
return record;
158+
}
159+
```
160+
161+
Now run a monthly payment against the fixture. The example records the resolved meta-address and emits the payment intent your real worker would hand to the Wraith sender.
162+
163+
```typescript
164+
const FUTURENET_RPC_URL = "https://rpc-futurenet.stellar.org";
165+
const FUTURENET_PASSPHRASE = "Test SDF Future Network ; October 2022";
166+
167+
interface FixturePaymentIntent {
168+
network: "futurenet";
169+
rpcUrl: string;
170+
networkPassphrase: string;
171+
toName: string;
172+
toMetaAddress: string;
173+
amount: string;
174+
asset: string;
175+
memo: string;
176+
}
177+
178+
export function buildFixturePaymentIntent(
179+
subscription: Subscription
180+
): FixturePaymentIntent {
181+
const record = resolveFixtureName(subscription.destinationName);
182+
183+
return {
184+
network: "futurenet",
185+
rpcUrl: FUTURENET_RPC_URL,
186+
networkPassphrase: FUTURENET_PASSPHRASE,
187+
toName: record.name,
188+
toMetaAddress: record.metaAddress,
189+
amount: subscription.amount,
190+
asset: subscription.asset,
191+
memo: `subscription:${subscription.id}`,
192+
};
193+
}
194+
195+
const intent = buildFixturePaymentIntent(subscription);
196+
console.log(intent.toName, intent.toMetaAddress);
197+
```
198+
199+
For a live network run, replace `resolveFixtureName` with `wraith.resolveName("merchant.wraith", Chain.Stellar)` and keep the rest of the billing workflow the same.
200+
201+
---
202+
203+
## Rotation Mid-Subscription
204+
205+
Assume the first billing run resolves `merchant.wraith` to meta-address A. Before the second run, the merchant rotates their stealth keys and updates the name record to meta-address B.
206+
207+
```typescript
208+
const beforeRotation = resolveFixtureName("merchant.wraith");
209+
210+
const afterRotation: NameFixture = {
211+
...beforeRotation,
212+
metaAddress:
213+
"st:xlm:61a798cab73a628668eff0cc4a5cf51d5687c947bfc674100080538049e1363a61a798cab73a628668eff0cc4a5cf51d5687c947bfc674100080538049e1363a",
214+
expiryLedger: beforeRotation.expiryLedger + 6_307_200,
215+
};
216+
217+
console.log("month 1:", beforeRotation.metaAddress);
218+
console.log("month 2:", afterRotation.metaAddress);
219+
```
220+
221+
The payer does not edit the subscription. They keep sending to `merchant.wraith`; the resolver supplies the current meta-address at each run.
222+
223+
In production, the merchant performs the rotation with the `wraith-names` `update` entrypoint or the agent flow documented in [Update Meta-Address](/guides/stellar/wraith-names-lifecycle#update-meta-address).
224+
225+
---
226+
227+
## Cancellation
228+
229+
Cancellation belongs to the payer's billing system. The payer should mark the subscription cancelled and stop scheduling future sends:
230+
231+
```typescript
232+
export function cancelSubscription(
233+
current: Subscription,
234+
cancelledAt: string
235+
): Subscription {
236+
return {
237+
...current,
238+
status: "cancelled",
239+
nextRunAt: cancelledAt,
240+
};
241+
}
242+
```
243+
244+
Do not model cancellation as a name update. A `.wraith` name may receive payments from many payers, so changing the merchant's name record would affect unrelated subscriptions and payment links.
245+
246+
---
247+
248+
## Missed Payments
249+
250+
When a billing run fails, separate name-resolution failures from payment-execution failures.
251+
252+
| Failure | Recommended behavior |
253+
|---|---|
254+
| `NameNotFound` | Pause the subscription and ask the merchant to confirm the destination. |
255+
| `NameExpired` | Pause new sends until the merchant renews the name. Do not fall back to a stale meta-address. |
256+
| Grace period | Continue sending, but warn the merchant that renewal is needed. |
257+
| Insufficient payer balance | Retry after funding; resolve the name again before the retry unless you are retrying the same submitted transaction. |
258+
| Network congestion | Retry with backoff; resolve the name again for a new payment attempt. |
259+
260+
This is stricter than the raw meta-address cookbook flow. With a raw meta-address, retries can safely reuse the stored destination because the destination is the actual routing key. With a `.wraith` name, a retry may cross a rotation boundary, so the worker should resolve again unless it is replaying a transaction already built for a specific meta-address.
261+
262+
---
263+
264+
## Name Expiry Edge Case
265+
266+
A `.wraith` name can be active, in grace period, or expired. The subscription worker should treat those states differently:
267+
268+
```typescript
269+
interface NameInfo {
270+
state: "active" | "grace_period" | "expired";
271+
expiryLedger: number;
272+
}
273+
274+
export function shouldBillName(info: NameInfo): boolean {
275+
if (info.state === "expired") return false;
276+
return true;
277+
}
278+
279+
export function renewalWarning(info: NameInfo): string | undefined {
280+
if (info.state !== "grace_period") return undefined;
281+
return `Name is in grace period and expires after ledger ${info.expiryLedger}.`;
282+
}
283+
```
284+
285+
Payments initiated before expiry still target the meta-address resolved at send time. Future billing runs should stop once resolution reports the name as expired. Falling back to `lastResolvedMetaAddress` after expiry defeats the purpose of name-based routing and may send funds to a stale destination.
286+
287+
---
288+
289+
## Compare with Raw Meta-Address Scheduling
290+
291+
| Concern | Raw meta-address subscription | `.wraith` name subscription |
292+
|---|---|---|
293+
| Stored destination | `st:xlm:...` | `merchant.wraith` |
294+
| Recipient rotation | Payer must update every subscription row | Recipient updates one name record |
295+
| Cancellation | Payer stops scheduler | Payer stops scheduler |
296+
| Retry destination | Reuse the stored meta-address | Resolve again for each new attempt |
297+
| Expiry behavior | No name expiry state | Pause on expired name; warn during grace period |
298+
| Audit trail | Shows opaque meta-address | Shows human-readable name plus resolved meta-address |
299+
300+
Use raw meta-addresses when the recipient wants no public name mapping. Use `.wraith` names when operational stability and human-readable routing are more important than hiding the routing identifier itself.
301+
302+
---
303+
304+
## Related
305+
306+
- [Wraith Names Lifecycle on Stellar](/guides/stellar/wraith-names-lifecycle)
307+
- [Stellar Payment Links](/guides/stellar-payment-links)
308+
- [Spectre + Stellar Cookbook, Recipe 2](/guides/spectre-stellar-cookbook#recipe-2-dao-weekly-payroll-in-usdc-on-stellar)
309+
- [Stellar Networks](/reference/stellar-networks)

0 commit comments

Comments
 (0)