diff --git a/.gitignore b/.gitignore
index b109443..e052923 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,6 +16,7 @@
# misc
.DS_Store
+**/__pycache__/
*.pem
# debug
diff --git a/README.md b/README.md
index 9109b03..6bb4cad 100644
--- a/README.md
+++ b/README.md
@@ -18,11 +18,11 @@ Non-custodial · Client-side signing · Soroban & DeFi s
## What is LumenWipe?
-LumenWipe is an open-source, non-custodial web app that walks you through closing a Stellar account from start to finish - automatically. It detects everything that holds your account open (trustlines, DEX offers, DeFi positions, data entries, extra signers), unwinds it step by step, converts leftover tokens to XLM, and merges the account into your destination wallet or exchange address.
+LumenWipe is an open-source, non-custodial web app that walks you through closing a Stellar account from start to finish - automatically. It detects everything holding your account open (trustlines, DEX offers, DeFi positions, data entries, extra signers), unwinds it step by step, converts leftover tokens to XLM, and merges the account into your destination wallet or exchange address.
-Every transaction is built and signed in your browser. Your private keys never leave your device. The backend is read-only and stateless: it aggregates data, it never touches your funds.
+**Every transaction is built and signed in your browser. Your private keys never leave your device.** The backend is read-only and stateless: it aggregates data and can never move funds.
-> **Status:** the classic account wind-down runs today on testnet and mainnet. Soroban & DeFi protocol exits, the allowance inspector, and sponsored fees are in active development — see the [roadmap](#delivery-roadmap).
+> **Status:** the classic account wind-down runs today on testnet and mainnet. Soroban & DeFi protocol exits, the allowance inspector, and sponsored fees are in active development - see the [roadmap](#delivery-roadmap).
---
@@ -30,138 +30,101 @@ Every transaction is built and signed in your browser. Your private keys never l
Stellar has over **10 million accounts on mainnet**, and a large share are stale, abandoned, or locked. Two structural issues cause this:
-1. **Every account locks XLM in reserve.** The minimum balance is 1 XLM (two base reserves of 0.5 XLM), and each trustline, open offer, data entry, or extra signer adds one more base reserve: 0.5 XLM. An account with four trustlines, two offers, one data entry, and one extra signer locks **5 XLM** that the user cannot spend until each entry is removed.
+**1. Every account locks XLM in reserve.** The minimum balance is 1 XLM (two base reserves of 0.5 XLM), and each trustline, open offer, data entry, or extra signer adds another 0.5 XLM. An account with four trustlines, two offers, one data entry, and one extra signer locks **5 XLM** that cannot be spent until each entry is individually removed.
-2. **Closing an account manually is hard.** A single leftover subentry causes the final `ACCOUNT_MERGE` to fail. Users must cancel every offer, exit every DeFi position, sell every asset, remove every trustline, and clear every data entry - in the correct order - before the merge will succeed. Miss one and everything reverts.
+**2. Closing an account manually is hard.** A single leftover subentry causes the final `ACCOUNT_MERGE` to fail. Users must cancel every offer, exit every DeFi position, sell every asset, remove every trustline, and clear every data entry - in the correct order - before the merge succeeds. Miss one and everything reverts.
-**Exchanges compound the problem.** No major exchange supports `ACCOUNT_MERGE`. A user sending remaining XLM to a CEX cannot merge directly into the deposit address, so the final 1 XLM minimum balance stays permanently locked. LumenWipe solves this with a shared mediator account and an atomic forwarding payment.
+**Exchanges compound the problem.** No major exchange supports `ACCOUNT_MERGE`. Sending remaining XLM to a CEX deposit address still leaves the 1 XLM minimum balance permanently locked. LumenWipe solves this with a shared mediator account and an atomic forwarding payment.
-**DeFi users have no tool at all today.** No existing tool supports closing Soroban positions, so any account with a Blend loan, an Aquarius LP position, or a Soroswap pair share cannot be closed with what exists today.
+**DeFi users have no tool at all.** The existing demolisher has no Soroban support. Any account with a Blend loan, an Aquarius LP position, or a Soroswap pair share cannot be closed with existing tools.
---
-## What LumenWipe Does
+## Architecture
-LumenWipe handles the complete account wind-down in a single guided flow:
+The system has three layers. The trust boundary is the browser - signing never leaves the client.
-| Step | What happens |
-| ------------------------------ | --------------------------------------------------------------------------------------------------------------------- |
-| **1. Analyze** | Reads all subentries (trustlines, offers, data entries, signers, DeFi positions) and builds an ordered execution plan |
-| **2. Normalize signers** | Removes extra signers and normalizes thresholds so a single key can authorize every remaining step |
-| **3. Remove data entries** | Clears `ManageData` entries in batches |
-| **4. Cancel DEX offers** | Cancels all open order-book offers, freeing their reserves |
-| **5. Exit AMM & LP positions** | Withdraws from classic liquidity pools and all supported Soroban DeFi protocols |
-| **6. Convert assets** | Swaps every remaining token to XLM via the best available route (Soroswap API or SDEX path payments) |
-| **7. Remove trustlines** | Removes all trustlines once their balances are zero |
-| **8. Merge account** | Executes `ACCOUNT_MERGE`, directly or via a mediator account for exchange destinations |
+
-Additional standalone feature: a **read-only allowance inspector** that shows every token approval your account has granted to DeFi contracts, and lets you revoke them without closing the account.
+**Browser (trust boundary)** - The guided UI, wallet adapter, and a pure-TypeScript transaction builder all live in the browser. The signer receives unsigned envelopes, presents them for review, and sends signed XDR directly to Stellar RPC. The session is persisted to IndexedDB; keys are never stored.
----
+**Read-only backend** - Stateless Next.js API routes aggregate account data, detect DeFi positions, build routing quotes, and construct the mediator's unsigned forwarding transaction. The backend holds no keys and is not in the signing path. A backend compromise cannot move funds.
-## Supported DeFi Protocols
+**Stellar network and data services** - Stellar RPC for live reads, simulation, submission, and events; `stellar.expert` API for subentry enumeration; Soroswap Aggregator API for conversion routing; OctoPos for DeFi position detection.
-LumenWipe detects and unwinds positions across the major Soroban DeFi protocols using [OctoPos](https://communityfund.stellar.org/project/octopos-defi-position-api-g6i) as the DeFi Position API.
-
-| Protocol | Position type | Exit mechanism |
-| --------------- | ------------------------------------------- | ------------------------------------------------------------------- |
-| **Classic DEX** | Order-book offers | `ManageSellOffer` / `ManageBuyOffer` (amount = 0) |
-| **Classic AMM** | Pool-share trustline (CAP-38) | `LiquidityPoolWithdraw` |
-| **Blend** | Supply (bToken), borrow (dToken), backstop | `Pool.submit` - repay then withdraw, via `@blend-capital/blend-sdk` |
-| **Aquarius** | AMM LP, AQUA rewards | `withdraw`, `claim` via Aquarius contracts |
-| **Soroswap** | AMM LP | `remove_liquidity` via Soroswap Router API |
-| **Phoenix** | AMM LP, optional stake | `withdraw_liquidity`, `unstake` first if staked |
-| **FxDAO** | CDP vault (XLM collateral, stablecoin debt) | `pay_debt` then collateral withdrawal |
+**Key design decisions:**
-If the DeFi position provider is unavailable, the tool enters a **degraded mode**: classic entries process normally and the user is warned to verify DeFi positions manually - the flow never silently fails.
+- **No bespoke indexer.** Stellar RPC cannot enumerate unknown subentries. LumenWipe reads enumeration from `stellar.expert`, re-reads exact on-chain state over RPC immediately before building each transaction, and never signs based on stale data.
+- **Pluggable data sources.** Every read source (RPC provider, indexer, routing API, DeFi position API) is behind an adapter, so any compatible provider can be swapped in without touching the transaction logic.
+- **Soroban exits are simulated before signing.** Every `InvokeHostFunction` is run through `simulateTransaction` to fill in footprint, authorization, and resource fees. The user sees the simulation result before being asked to sign.
---
## How It Works
-### Execution flow
+### Execution plan
-```
-Analyze account
- └── Enumerate subentries (stellar.expert indexer)
- └── Detect DeFi positions (OctoPos)
- │
- ▼
-Build deterministic execution plan
- │
- ▼
-For each step:
- Re-read live state (Stellar RPC getLedgerEntries)
- Simulate Soroban steps (Stellar RPC simulateTransaction)
- Show step to user → explicit confirmation
- Sign in browser → submit signed XDR to Stellar RPC
- Poll for confirmation → advance
- │
- ▼
-AccountMerge (direct or via mediator for exchanges)
-```
+LumenWipe builds a **deterministic, ordered execution plan** from the account's live state. The same account state always produces the same plan - which makes it auditable and unit-testable. Steps are reconciled against on-chain state on resume, so an interrupted session never double-executes a completed step.
-The plan is **deterministic**: same account state always produces the same ordered plan, which makes it auditable and testable. Steps are reconciled against on-chain state on resume, so an interrupted session never double-executes a completed step.
+
-### CEX mediator flow
+| Step | Operation | Details |
+| ---------------------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------- |
+| **1. Normalize signers** | `SetOptions` | Removes extra signers, normalizes thresholds to 0/1/1 so a single key can authorize all remaining steps |
+| **2. Remove data entries** | `ManageData` | Clears all `ManageData` entries in batches of 100 |
+| **3. Claim claimable balances** | `ClaimClaimableBalance` | Optional; claims any claimable balances before conversion |
+| **4. Cancel DEX offers** | `ManageSellOffer` / `ManageBuyOffer` | Sets amount to 0 on all open order-book offers, freeing reserves |
+| **5. Withdraw AMM & LP positions** | `LiquidityPoolWithdraw` + Soroban calls | Classic CAP-38 pools and all supported Soroban protocol exits |
+| **6. Exit DeFi protocols** | Soroban `InvokeHostFunction` | Blend, Phoenix, FxDAO: repay debt then withdraw collateral |
+| **7. Convert assets** | `PathPaymentStrictSend` / Soroban swaps | Every non-XLM balance converted to XLM via the best available route |
+| **8. Remove trustlines** | `ChangeTrust` (limit 0) | Removes all trustlines once their balances are zero, batched by 100 |
+| **9. Merge account** | `AccountMerge` | Direct merge, or via mediator account for exchange destinations |
-Exchanges don't support `ACCOUNT_MERGE`. LumenWipe routes the merge through a shared mediator account, in one atomic transaction:
+### Session state machine
-```
-Source account ──(AccountMerge)──► Shared mediator account
- │
- (Payment + memo)
- │
- ▼
- Exchange deposit address
-```
+The entire wind-down is held as an explicit state machine persisted to IndexedDB. Users can close the tab at any point and resume - completed steps are skipped, never re-executed.
-The mediator is a single persistent account whose minimum balance is funded once by the operator and reused for every close, so you recover essentially all of your XLM; only standard network fees apply. The merge half of the transaction is signed in your browser; the backend co-signs only the mediator's forward payment, after validating the exact transaction shape, and cannot change the destination or the amount. Known exchange destinations are validated against a registry that enforces the correct memo type.
+
-### State machine
+### Signing flow
-The tool holds the entire wind-down as an explicit state machine persisted in IndexedDB (never keys). Users can close the tab and resume - the session is reconciled against on-chain state and completed steps are skipped.
+Every step follows the same pattern: build unsigned envelope → present for review → sign in browser → submit → poll to confirmation → advance.
-```
-Idle → Analyzing → PreflightComplete → StepExecuting ⇄ StepFailed
- │
- StepConfirmed
- │
- (repeat)
- │
- Complete
-```
+
+
+### CEX mediator flow
+
+Exchanges don't support `ACCOUNT_MERGE`. LumenWipe routes the merge through a shared mediator account in a single atomic transaction sequence:
+
+
+
+The mediator is a persistent account funded once by the operator and reused for every close. You recover essentially all of your XLM; only standard network fees apply. The merge half is signed in your browser. The backend co-signs only the mediator's forward payment, after validating the exact transaction shape, and cannot alter the destination or amount. Known exchange destinations are validated against a registry that enforces the correct memo type - a missing memo blocks submission.
---
-## Architecture
+## Supported DeFi Protocols
-The system has three layers. The trust boundary is the browser - signing never leaves the client.
+LumenWipe detects and unwinds positions across the major Soroban DeFi protocols using [OctoPos](https://communityfund.stellar.org/project/octopos-defi-position-api-g6i) as the DeFi Position API.
-```
-┌──────────────────────────────────────────────────────┐
-│ Browser (trust boundary - keys never leave) │
-│ Guided UI · Wallet adapter (stellar-wallets-kit) │
-│ Transaction builder (pure TS) · Session (IndexedDB) │
-└────────────────────┬─────────────────────────────────┘
- │ signed XDR ──────────────────────────┐
-┌────────────────────▼─────────────────────────────────┐ │
-│ Read-only backend (stateless, no keys, no custody) │ │
-│ Account analysis · DeFi adapter (OctoPos) │ │
-│ Routing service · Mediator factory · Redis cache │ │
-└────────────────────┬─────────────────────────────────┘ │
- │ read-only │
-┌────────────────────▼─────────────────────────────────────▼───┐
-│ Stellar network & data services │
-│ Stellar RPC · stellar.expert API · Soroswap API │
-└──────────────────────────────────────────────────────────────┘
-```
+
-**Key design decisions:**
+| Protocol | Position type | Exit mechanism |
+| --------------- | ------------------------------------------- | ------------------------------------------------------------------- |
+| **Classic DEX** | Order-book offers | `ManageSellOffer` / `ManageBuyOffer` (amount = 0) |
+| **Classic AMM** | Pool-share trustline (CAP-38) | `LiquidityPoolWithdraw` |
+| **Blend** | Supply (bToken), borrow (dToken), backstop | `Pool.submit` - repay then withdraw, via `@blend-capital/blend-sdk` |
+| **Aquarius** | AMM LP, AQUA rewards | `withdraw`, `claim` via Aquarius contracts |
+| **Soroswap** | AMM LP | `remove_liquidity` via Soroswap Router API |
+| **Phoenix** | AMM LP, optional stake | `withdraw_liquidity`, `unstake` first if staked |
+| **FxDAO** | CDP vault (XLM collateral, stablecoin debt) | `pay_debt` then collateral withdrawal |
-- **No bespoke indexer.** Stellar RPC cannot enumerate unknown subentries. LumenWipe reads enumeration from `stellar.expert`, re-reads exact on-chain state over RPC immediately before building each transaction, and never signs based on stale data.
-- **Pluggable data sources.** Every read source (RPC provider, indexer, routing API, DeFi position API) is behind an adapter, so any compatible provider can be swapped in without touching the transaction logic.
-- **Soroban exits are simulated before signing.** Every `InvokeHostFunction` is run through `simulateTransaction` to fill in footprint, authorization, and resource fees. The user sees the simulation result before being asked to sign.
+If the DeFi position provider is unavailable, the tool enters **degraded mode**: classic entries process normally and the user is warned to verify DeFi positions manually. The flow never silently fails or skips a position.
+
+### Asset conversion routing
+
+
+
+All non-XLM balances are converted using the best available route: the Soroswap Aggregator API (which spans both Soroban AMMs and the classic SDEX), with SDEX path payments as the fallback. Every conversion is quoted, slippage-bounded, and simulated before the user is asked to sign.
---
@@ -179,7 +142,7 @@ LumenWipe builds transactions that drain accounts irreversibly. The security des
| **XSS** | Strict Content Security Policy - no inline scripts, no `unsafe-eval` |
| **Supply chain** | Lockfile-pinned dependencies, audited in CI |
-The codebase undergoes internal security reviews as part of our development process. External security audits will be conducted when possible.
+The codebase undergoes internal security reviews as part of the development process. External security audits will be conducted when possible.
---
@@ -220,12 +183,12 @@ Open [http://localhost:3000](http://localhost:3000). The tool defaults to Stella
Copy `.env.example` to `.env.local`. The minimum to run on testnet is the `NEXT_PUBLIC_STELLAR_RPC_*` endpoints; everything else has sensible defaults or is only needed for specific features.
-| Variable | Description |
-| --------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
-| `NEXT_PUBLIC_STELLAR_RPC_TESTNET` / `NEXT_PUBLIC_STELLAR_RPC_MAINNET` | Stellar RPC endpoints (testnet required for local dev) |
-| `NEXT_PUBLIC_PATH_ROUTING_API_TESTNET` / `_MAINNET` | Horizon-compatible endpoints for offers, full account state, and path finding |
-| `NEXT_PUBLIC_MEDIATOR_PUBLIC_TESTNET` / `_MAINNET` | Public key of the shared exchange mediator account |
-| `KV_REST_API_URL` / `KV_REST_API_TOKEN` | Vercel KV — only needed for the merge-stats counter |
+| Variable | Description |
+| --------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
+| `NEXT_PUBLIC_STELLAR_RPC_TESTNET` / `NEXT_PUBLIC_STELLAR_RPC_MAINNET` | Stellar RPC endpoints (testnet required for local dev) |
+| `NEXT_PUBLIC_PATH_ROUTING_API_TESTNET` / `_MAINNET` | Horizon-compatible endpoints for offers, full account state, and path finding |
+| `NEXT_PUBLIC_MEDIATOR_PUBLIC_TESTNET` / `_MAINNET` | Public key of the shared exchange mediator account |
+| `KV_REST_API_URL` / `KV_REST_API_TOKEN` | Vercel KV - only needed for the merge-stats counter |
See `.env.example` for the full list, including operator-only secrets.
@@ -246,27 +209,26 @@ bun run type-check
## Documentation
-Full technical documentation is hosted at [**docs.lumenwipe.com**](https://docs.lumenwipe.com).
-
-| Document | Description |
-| ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| [Executive Summary](docs/executive-summary.md) | One-page overview: problem, solution, technical pillars, and delivery plan. Start here. |
-| [Technical Architecture](docs/architecture.md) | Complete system design: data sources, execution plan, Soroban & DeFi integration, mediator flow, security, testing, and roadmap. Includes Mermaid diagrams. |
-| [Community & Communications](docs/community-and-communications.md) | Building in the open, update cadence, decentralized social channels, and post-launch maintenance. |
+Full technical documentation is at [**docs.lumenwipe.com**](https://docs.lumenwipe.com).
-Diagram sources (Mermaid) live in [`docs/diagrams/`](docs/diagrams/) for export to Whimsical, Excalidraw, or image formats.
+| Document | Description |
+| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
+| [Executive Summary](docs/executive-summary.md) | One-page overview: problem, solution, technical pillars, and delivery plan |
+| [Technical Architecture](docs/architecture.md) | Complete system design: data sources, execution plan, Soroban & DeFi integration, mediator flow, security, testing, and roadmap |
+| [Community & Communications](docs/community-and-communications.md) | Building in the open, update cadence, decentralized social channels, and post-launch maintenance |
+| [Diagram sources](docs/diagrams/) | All 9 Mermaid diagram sources with rendered PNG/SVG exports |
---
## Delivery Roadmap
-The project is delivered in three cumulative tranches, each a working and independently verifiable artifact:
+The project is delivered in three cumulative tranches, each independently verifiable:
| Tranche | Focus | Status |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------- |
| **1 - Classic MVP** | Full classic wind-down on testnet: signer normalization, data entries, offer cancellation, classic liquidity pool withdrawal, asset conversion, trustline removal, merge, mediator flow, multisig, session recovery | **In progress** |
| **2 - Soroban & DeFi** | DeFi position detection via OctoPos; Blend, Aquarius, Soroswap, Phoenix, and FxDAO exits; Soroban token conversion; allowance inspector; per-step simulation; sponsored fees for reserve-locked accounts | Planned |
-| **3 - Production hardening** | Security review and remediation, performance validation, final UX from user testing, complete public documentation, public REST API and TypeScript SDK for integrators | Planned |
+| **3 - Production hardening** | Security review and remediation, performance validation, final UX from user testing, complete public documentation, public REST API and TypeScript SDK for integrators | Planned |
> The classic wind-down already runs. The current codebase builds and signs classic transactions client-side and executes the full path - signer normalization, offer cancellation, asset conversion, trustline removal, and `AccountMerge` including the mediator flow - on both testnet and mainnet.
@@ -276,14 +238,12 @@ The project is delivered in three cumulative tranches, each a working and indepe
LumenWipe is open source from day one. The full frontend, read-only backend, transaction construction layer, contract registry, and test suite are public.
-**Channels:**
-
| Channel | Use |
| --------------------------------------------------------------------------- | ---------------------------------------------------- |
| [GitHub Issues](https://github.com/LumenWipe/lumenwipe/issues) | Bug reports, feature requests, roadmap |
| [LumenWipe Discord](https://discord.gg/b37CPB7g) | Community chat, support, and project discussion |
| [Matrix - #lumenwipe:matrix.org](https://matrix.to/#/#lumenwipe:matrix.org) | Project discussion (open, decentralized) |
-| Telegram | Real-time community chat, support, and announcements |
+| [Telegram - t.me/lumenwipe](https://t.me/lumenwipe) | Real-time community chat, support, and announcements |
**Contributing:** open an issue or pull request. The contract and exchange registries (versioned JSON) are especially easy to contribute to - new exchange addresses and protocol contract versions are reviewed pull requests, not code changes.
diff --git a/app/(marketing)/content/page.tsx b/app/(marketing)/content/page.tsx
new file mode 100644
index 0000000..27b3f3f
--- /dev/null
+++ b/app/(marketing)/content/page.tsx
@@ -0,0 +1,152 @@
+import type { Metadata } from "next";
+import { getAllPostMetas } from "@/lib/blog";
+import PostCard from "@/components/blog/PostCard";
+import { Play } from "lucide-react";
+
+const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "";
+
+export const metadata: Metadata = {
+ title: "Content",
+ description:
+ "Videos, guides, and technical deep dives on LumenWipe - closing Stellar accounts, recovering XLM reserves, and DeFi position unwinding.",
+ openGraph: {
+ title: "Content | LumenWipe",
+ description: "Videos and articles on Stellar account management and reserve recovery.",
+ url: `${APP_URL}/content`,
+ type: "website",
+ },
+ alternates: {
+ canonical: `${APP_URL}/content`,
+ },
+};
+
+type Video = {
+ id: string;
+ title: string;
+ description: string;
+ lang: "EN" | "ES";
+ duration: string;
+};
+
+const VIDEOS: Video[] = [
+ {
+ id: "vD3xhPpqah8",
+ title: "What is LumenWipe?",
+ description:
+ "A 3-minute overview of what LumenWipe does, why Stellar reserves get locked, and how the non-custodial close flow works.",
+ lang: "EN",
+ duration: "3 min",
+ },
+ {
+ id: "nVS2zI9mRzw",
+ title: "Full walkthrough: testnet, playground & mainnet",
+ description:
+ "An 11-minute demo covering the full demolition flow - from testnet dry run to a live mainnet account close.",
+ lang: "EN",
+ duration: "11 min",
+ },
+ {
+ id: "eRkcNd9996c",
+ title: "Demo completo: testnet, playground y mainnet",
+ description:
+ "Demo de 14 minutos que cubre el flujo completo: testnet, playground y cierre real en mainnet.",
+ lang: "ES",
+ duration: "14 min",
+ },
+];
+
+function Eyebrow({ children }: { children: React.ReactNode }) {
+ return (
+
+
+ {children}
+
+ );
+}
+
+function LangBadge({ lang }: { lang: "EN" | "ES" }) {
+ return (
+
+ {lang}
+
+ );
+}
+
+function DurationBadge({ duration }: { duration: string }) {
+ return (
+
+
+ {duration}
+
+ );
+}
+
+export default function ContentPage() {
+ const posts = getAllPostMetas();
+
+ return (
+
+ {/* Header */}
+
+
Content
+
+ Videos & articles
+
+
+ Walkthroughs, deep dives, and guides on closing Stellar accounts and recovering locked
+ reserves.
+
+
+
+ {/* Videos */}
+
+ Videos
+
+ {VIDEOS.map((v) => (
+
+ {/* 16:9 embed */}
+
+
+
+ {/* Metadata */}
+
+
+
+
+
+
+ {v.title}
+
+
{v.description}
+
+
+ ))}
+
+
+
+ {/* Divider */}
+
+
+ {/* Blog */}
+
+ From the blog
+ {posts.length > 0 ? (
+
+ {posts.map((post) => (
+
+ ))}
+
+ ) : (
+ No articles published yet.
+ )}
+
+
+ );
+}
diff --git a/app/(marketing)/faq/page.tsx b/app/(marketing)/faq/page.tsx
index 7b68763..95e83cf 100644
--- a/app/(marketing)/faq/page.tsx
+++ b/app/(marketing)/faq/page.tsx
@@ -4,7 +4,7 @@ import { ArrowRight } from "lucide-react";
import Faq from "@/components/marketing/Faq";
export const metadata: Metadata = {
- title: "FAQ — LumenWipe",
+ title: "FAQ - LumenWipe",
description:
"Answers to the common questions about closing a Stellar account with LumenWipe: non-custodial signing, irreversibility, exchange merges, Soroban DeFi, supported wallets and resumable sessions.",
};
diff --git a/app/(marketing)/page.tsx b/app/(marketing)/page.tsx
index 03561ac..ed29a84 100644
--- a/app/(marketing)/page.tsx
+++ b/app/(marketing)/page.tsx
@@ -4,6 +4,7 @@ import { ArrowRight, Check, RefreshCw, Building2, Layers, Eye, ScanLine } from "
import Faq from "@/components/marketing/Faq";
import Reveal from "@/components/marketing/Reveal";
import HeroConsole from "@/components/marketing/HeroConsole";
+import HeroAccountInput from "@/components/marketing/HeroAccountInput";
export const metadata: Metadata = {
title: "LumenWipe: Recover the XLM locked in your Stellar account",
@@ -102,9 +103,13 @@ export default function LandingPage() {
<>
{/* ============================ HERO ============================ */}
-
-
- Stellar Account Demolisher
+ {/* Label - typographic, not a status pill */}
+
+
+
+ Stellar Account Demolisher
+
+
@@ -116,7 +121,12 @@ export default function LandingPage() {
leftovers, and merge out. Signed entirely in your browser.
-
+ {/* Inline account analyzer */}
+
+
+
+
+
Open the app
diff --git a/app/(marketing)/security/page.tsx b/app/(marketing)/security/page.tsx
index 7c3dd47..a1c18ca 100644
--- a/app/(marketing)/security/page.tsx
+++ b/app/(marketing)/security/page.tsx
@@ -11,7 +11,7 @@ import {
} from "lucide-react";
export const metadata: Metadata = {
- title: "Security — LumenWipe",
+ title: "Security - LumenWipe",
description:
"LumenWipe builds transactions that drain accounts irreversibly, so the design starts from that fact. Keys are created and used only in your browser; the read-only backend can never move your funds.",
};
diff --git a/app/api/v1/[network]/close/transactions/route.ts b/app/api/v1/[network]/close/transactions/route.ts
index f2ed85d..d5b5d6c 100644
--- a/app/api/v1/[network]/close/transactions/route.ts
+++ b/app/api/v1/[network]/close/transactions/route.ts
@@ -74,7 +74,12 @@ export async function POST(
);
}
- const transactions = await buildCloseTransactions(accountState, destination, dispositions, network);
+ const transactions = await buildCloseTransactions(
+ accountState,
+ destination,
+ dispositions,
+ network
+ );
const planHash = computePlanHash({
source,
@@ -93,7 +98,11 @@ export async function POST(
} catch (e) {
if (e instanceof AccountNotFoundError) return err("account_not_found", e.message, 404);
if (e instanceof AssetRouteLostError) {
- return err("quote_drifted", "A conversion route is no longer available; re-plan and retry.", 409);
+ return err(
+ "quote_drifted",
+ "A conversion route is no longer available; re-plan and retry.",
+ 409
+ );
}
if (e instanceof CloseBuildError) return err(e.code, e.message, e.status);
console.error("close/transactions error:", e);
diff --git a/app/globals.css b/app/globals.css
index 9c2a455..d98cf03 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -122,7 +122,7 @@
backdrop-filter: blur(8px);
}
-/* Redesign: crisp, flat card — depth from edge-light + tight shadow, no glow halo. */
+/* Redesign: crisp, flat card - depth from edge-light + tight shadow, no glow halo. */
.mkt-card {
background: hsl(var(--mkt-panel));
border: 1px solid hsl(var(--mkt-line) / 0.1);
diff --git a/components/complete/CompletionReceipt.tsx b/components/complete/CompletionReceipt.tsx
index 8b580f5..3cd663c 100644
--- a/components/complete/CompletionReceipt.tsx
+++ b/components/complete/CompletionReceipt.tsx
@@ -90,7 +90,7 @@ export default function CompletionReceipt({ network }: CompletionReceiptProps) {
// The "what was done" groups below describe state changes; the transaction ledger
// describes the real on-chain transactions. A fused close is one transaction, a
- // mediator merge is two, and DeFi exits will each add their own — the ledger reflects
+ // mediator merge is two, and DeFi exits will each add their own - the ledger reflects
// that count instead of implying one transaction per group.
const ledger = buildTxLedger(confirmedSteps);
diff --git a/components/marketing/HeroAccountInput.tsx b/components/marketing/HeroAccountInput.tsx
new file mode 100644
index 0000000..73fadc0
--- /dev/null
+++ b/components/marketing/HeroAccountInput.tsx
@@ -0,0 +1,75 @@
+"use client";
+
+import { useState, type FormEvent } from "react";
+import { useRouter } from "next/navigation";
+import { Search, AlertCircle } from "lucide-react";
+
+function isValidStellarAddress(addr: string): boolean {
+ return /^G[A-Z2-7]{55}$/.test(addr);
+}
+
+export default function HeroAccountInput() {
+ const router = useRouter();
+ const [address, setAddress] = useState("");
+ const [error, setError] = useState
(null);
+
+ function handleSubmit(e: FormEvent) {
+ e.preventDefault();
+ const trimmed = address.trim();
+ if (!trimmed) {
+ setError("Enter a Stellar account address.");
+ return;
+ }
+ if (!isValidStellarAddress(trimmed)) {
+ setError("Not a valid Stellar address - must start with G and be 56 characters.");
+ return;
+ }
+ setError(null);
+ router.push(`/mainnet/analyze?source=${encodeURIComponent(trimmed)}`);
+ }
+
+ return (
+
+
+
+ {error ? (
+
+
+ {error}
+
+ ) : (
+
+ Analyzes on mainnet · read-only until you sign · no account needed
+
+ )}
+
+ );
+}
diff --git a/components/marketing/HeroConsole.tsx b/components/marketing/HeroConsole.tsx
index 0d84753..e4b2efc 100644
--- a/components/marketing/HeroConsole.tsx
+++ b/components/marketing/HeroConsole.tsx
@@ -20,7 +20,7 @@ const TOTAL = ROWS.reduce((s, r) => s + r.amount, 0);
// Row height in px. MUST match the `h-11` on each row so the sliding highlight lines up.
const ROW_HEIGHT_PX = 44;
-// Scan cadence in ms — tuned for a calm read (~8.5s per loop).
+// Scan cadence in ms - tuned for a calm read (~8.5s per loop).
const TIMING = {
startDelay: 800, // before the first step lights up
perStep: 850, // how long each step stays active
@@ -108,7 +108,7 @@ export default function HeroConsole() {
done ? "text-white/60" : "text-white/30"
}`}
>
- {r.amount > 0 ? `+${r.amount.toFixed(2)}` : "—"}
+ {r.amount > 0 ? `+${r.amount.toFixed(2)}` : "-"}
);
diff --git a/components/marketing/MarketingFooter.tsx b/components/marketing/MarketingFooter.tsx
index 4ff2333..81838d2 100644
--- a/components/marketing/MarketingFooter.tsx
+++ b/components/marketing/MarketingFooter.tsx
@@ -18,7 +18,7 @@ const COLS: { title: string; links: { label: string; href: string; external?: bo
{ label: "Documentation", href: "https://docs.lumenwipe.com", external: true },
{ label: "Architecture", href: "https://docs.lumenwipe.com/architecture", external: true },
{ label: "Security", href: "/security" },
- { label: "Blog", href: "/blog" },
+ { label: "Content", href: "/content" },
],
},
{
diff --git a/components/marketing/MarketingNav.tsx b/components/marketing/MarketingNav.tsx
index 09e3658..18330ec 100644
--- a/components/marketing/MarketingNav.tsx
+++ b/components/marketing/MarketingNav.tsx
@@ -14,7 +14,7 @@ const LINKS: NavLink[] = [
{ href: "/faq", label: "FAQ" },
{ href: "/playground", label: "Playground" },
{ href: "/stats", label: "Stats" },
- { href: "/blog", label: "Blog" },
+ { href: "/content", label: "Content" },
];
const DOCS: NavLink = { href: "https://docs.lumenwipe.com", label: "Docs", external: true };
@@ -81,7 +81,8 @@ export default function MarketingNav() {
function isActive(l: NavLink): boolean {
if (l.external) return false;
if (l.section) return pathname === "/" && activeSection === l.section;
- if (l.href === "/blog") return pathname === "/blog" || pathname.startsWith("/blog/");
+ if (l.href === "/content")
+ return pathname === "/content" || pathname === "/blog" || pathname.startsWith("/blog/");
return pathname === l.href;
}
diff --git a/components/marketing/stats/CalendarHeatmap.tsx b/components/marketing/stats/CalendarHeatmap.tsx
index e527eff..5a531d1 100644
--- a/components/marketing/stats/CalendarHeatmap.tsx
+++ b/components/marketing/stats/CalendarHeatmap.tsx
@@ -14,12 +14,14 @@ function countToLevel(count: number): 0 | 1 | 2 | 3 | 4 {
return 4;
}
-const LEVEL_CLASS: Record<0 | 1 | 2 | 3 | 4, string> = {
- 0: "bg-white/[0.04]",
- 1: "bg-stellar/[0.22]",
- 2: "bg-stellar/40",
- 3: "bg-stellar/65",
- 4: "bg-stellar",
+// "stellar" is a hand-written CSS class in globals.css, not a Tailwind color token,
+// so bg-stellar/x modifier classes are never generated by JIT. Use inline styles instead.
+const LEVEL_BG: Record<0 | 1 | 2 | 3 | 4, string> = {
+ 0: "hsl(0 0% 100% / 0.06)",
+ 1: "hsl(var(--stellar) / 0.50)",
+ 2: "hsl(var(--stellar) / 0.68)",
+ 3: "hsl(var(--stellar) / 0.84)",
+ 4: "hsl(var(--stellar))",
};
interface Cell {
@@ -33,14 +35,12 @@ function buildGrid(daily: DailyActivity[]): Cell[][] {
const byDate = new Map
(daily.map((d) => [d.date, d.count]));
- // Start from Monday of the week 364 days ago
const today = new Date();
today.setUTCHours(0, 0, 0, 0);
const todayMs = today.getTime();
const start = new Date(todayMs - 363 * 86_400_000);
- // Roll back to Monday (dow 0=Sun,1=Mon...6=Sat → we want Mon=0)
- const startDow = (start.getUTCDay() + 6) % 7; // 0=Mon
+ const startDow = (start.getUTCDay() + 6) % 7;
start.setUTCDate(start.getUTCDate() - startDow);
const weeks: Cell[][] = [];
@@ -115,7 +115,12 @@ export default function CalendarHeatmap({ feed }: { feed: FeedData | null }) {
})();
return (
-
+ // onMouseLeave lives here, not on individual cells, so the cursor can travel
+ // from an active cell to the detail panel without clearing the hover state.
+
setHovered(null)}
+ >
Activity - last 12 months
@@ -134,7 +139,11 @@ export default function CalendarHeatmap({ feed }: { feed: FeedData | null }) {
Less
{([0, 1, 2, 3, 4] as const).map((l) => (
-
+
))}
More
@@ -178,7 +187,8 @@ export default function CalendarHeatmap({ feed }: { feed: FeedData | null }) {
{Array.from({ length: 7 }).map((_, di) => (
))}
@@ -188,21 +198,31 @@ export default function CalendarHeatmap({ feed }: { feed: FeedData | null }) {
{week.map((cell, di) => (
- setHovered({
- date: cell.date,
- count: cell.count,
- closes: closesByDate.get(cell.date) ?? [],
- col: wi,
- row: di,
- })
- }
- onMouseLeave={() => setHovered(null)}
+ className="h-[12px] w-[12px] cursor-default rounded-[2px] transition-[outline] duration-100"
+ style={{
+ backgroundColor: LEVEL_BG[cell.level],
+ // outline instead of ring - doesn't affect layout and works
+ // without Tailwind color tokens
+ outline:
+ hovered?.date === cell.date
+ ? "1px solid hsl(var(--stellar) / 0.80)"
+ : undefined,
+ outlineOffset: "1px",
+ }}
+ onMouseEnter={() => {
+ // Only update for days with actual closes so that the cursor
+ // can travel from an active cell through empty cells to the
+ // detail panel without the TX chips disappearing.
+ if (cell.count > 0) {
+ setHovered({
+ date: cell.date,
+ count: cell.count,
+ closes: closesByDate.get(cell.date) ?? [],
+ col: wi,
+ row: di,
+ });
+ }
+ }}
/>
))}
@@ -212,11 +232,11 @@ export default function CalendarHeatmap({ feed }: { feed: FeedData | null }) {
- {/* tooltip / detail panel */}
- {hovered && (
-
+ {/* Detail panel at fixed height - never changes size so the component never jumps. */}
+
+ {hovered ? (
-
+
{new Date(hovered.date + "T00:00:00Z").toLocaleDateString("en-US", {
month: "long",
@@ -252,8 +272,10 @@ export default function CalendarHeatmap({ feed }: { feed: FeedData | null }) {
)}
-
- )}
+ ) : (
+
Hover a day to inspect
+ )}
+
{DAYS.length === 0 && null}
diff --git a/components/playground/PlaygroundControls.tsx b/components/playground/PlaygroundControls.tsx
index d7fac86..cc13092 100644
--- a/components/playground/PlaygroundControls.tsx
+++ b/components/playground/PlaygroundControls.tsx
@@ -280,6 +280,7 @@ export default function PlaygroundControls({
const customConfig = usePlaygroundStore((s) => s.customConfig);
const setSelectedMode = usePlaygroundStore((s) => s.setSelectedMode);
const setCustomConfig = usePlaygroundStore((s) => s.setCustomConfig);
+ const reset = usePlaygroundStore((s) => s.reset);
const [credentials, setCredentials] = useState(null);
const [loadingCredentials, setLoadingCredentials] = useState(false);
@@ -469,13 +470,21 @@ export default function PlaygroundControls({
)}{" "}
- check the activity log for the receipts.
-
-
- Run it again
-
+
+
+
+ Run {modeLabel.toLowerCase()} again
+
+
+ Choose a different mode
+
+
>
)}
diff --git a/docs/architecture.md b/docs/architecture.md
index 79ca60d..3a4b776 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -103,59 +103,7 @@ Note that being a _claimant_ of a claimable balance does not block the merge, bu
The system has three layers: a browser client that builds and signs every transaction, a thin read-only backend that aggregates data (and co-signs one thing, the exchange forwarding payment), and the Stellar network plus the external data services the backend reads from. The trust boundary is the browser. A user's account keys and signing live entirely on the client side. The backend's only key is the shared mediator, which can co-sign the exchange forwarding payment but cannot sign for a user's account, change a destination, or move a user's funds.
-```mermaid
-flowchart TB
- subgraph client["Browser client: trust boundary, keys never leave"]
- direction TB
- ui["Guided UI (plan, confirmations, dry-run preview)"]
- wk["Wallet adapter (stellar-wallets-kit)"]
- sk["Secret-key mode (in-memory only)"]
- builder["Transaction builder (pure TypeScript)"]
- signer["Signer + XDR review"]
- sess["Session store (IndexedDB, no keys)"]
- end
-
- subgraph backend["Read-only backend: stateless, no custody, one mediator co-sign key"]
- direction TB
- analysis["Account analysis aggregator"]
- defi["DeFi position adapter (OctoPos)"]
- route["Routing service (Soroswap API + SDEX paths)"]
- med["Mediator factory (builds unsigned XDR)"]
- reg["Exchange + contract registries"]
- cache["Cache (Redis)"]
- end
-
- subgraph data["Stellar network and data services"]
- direction TB
- rpc["Stellar RPC (live reads, simulate, submit, events)"]
- idx["Existing indexer (stellar.expert API)"]
- soro["Soroswap API"]
- pos["DeFi Position API (OctoPos)"]
- net["Stellar ledger (classic + Soroban)"]
- end
-
- ui --> builder
- wk --> signer
- sk --> signer
- builder --> signer
- signer -->|"signed XDR"| rpc
- signer --> sess
-
- ui -->|"read-only requests"| analysis
- analysis --> idx
- analysis --> rpc
- analysis --> defi
- defi --> pos
- route --> soro
- route --> idx
- med --> rpc
- analysis --> cache
-
- rpc --> net
- idx --> net
- soro --> net
- pos --> net
-```
+
Two things to read off this diagram. The signed-XDR arrow runs from the client directly to Stellar RPC; submission is always client-side. The backend is not in the signing path for a user's account - its only signature is the shared mediator's co-signature on the exchange forwarding payment (section 11). And every external read source is pluggable: RPC, the indexer, the routing API, and the DeFi position API can each be swapped for another provider without touching the transaction logic.
@@ -188,16 +136,7 @@ The split is deliberate. An indexer answers "what does this account hold". RPC a
Account age never limits this design, and that is worth stating precisely because Stellar RPC does have a retention window. The window (at most 7 days) applies only to history-shaped methods: `getTransactions`, `getTransaction`, and `getEvents`. It does not apply to `getLedgerEntries`, which reads the current ledger snapshot: a trustline created in 2015 and a trustline created yesterday are the same read. Closing an account needs no transaction history at all; it needs current state, which RPC serves for any account regardless of age, and enumeration, which the indexer serves from full history. The one age-correlated wrinkle is Soroban state archival: a long-dormant account's contract entries (a DeFi position, a token balance) may have expired to the archive, where a plain read no longer sees them. The tool detects archived entries and inserts a `RestoreFootprint` step before the exit that needs them (Section 22). Classic entries never archive.
-```mermaid
-flowchart LR
- acct["Source account"] --> enum["Enumerate subentries (stellar.expert indexer)"]
- acct --> defiq["Detect DeFi positions (OctoPos)"]
- enum --> verify["Re-read each entry live (Stellar RPC getLedgerEntries)"]
- defiq --> verify
- verify --> plan["Build execution plan"]
- plan --> sim["Simulate Soroban steps (Stellar RPC simulateTransaction)"]
- sim --> submit["Submit signed XDR (Stellar RPC sendTransaction)"]
-```
+
### Data freshness and consistency
@@ -213,24 +152,7 @@ The user-facing flow asks for one thing at a time, in the order the work actuall
### 6.1 State machine
-```mermaid
-stateDiagram-v2
- [*] --> Idle
- Idle --> Analyzing: submit source public key
- Analyzing --> PreflightComplete: analysis succeeds, preview built
- Analyzing --> Aborted: account not found or blocked
- PreflightComplete --> SignerSetup: multisig detected
- PreflightComplete --> StepExecuting: single-signer
- SignerSetup --> StepExecuting: enough signatures gathered
- StepExecuting --> StepConfirmed: ledger confirms step
- StepExecuting --> StepFailed: submission or simulation error
- StepFailed --> StepExecuting: retry same step
- StepConfirmed --> StepExecuting: next step
- StepConfirmed --> Complete: merge confirmed
- Complete --> [*]
- StepExecuting --> Aborted: user cancels
- StepFailed --> Aborted: user cancels
-```
+
Each transition is written to a local session store in IndexedDB. The store holds the source and destination addresses, the network, the ordered plan, which steps have confirmed and their transaction hashes, and the shared mediator public key when an exchange destination is in use. It never holds secret keys or fully-signed envelopes beyond the step currently in flight. On re-entry the tool re-runs the analysis and reconciles against on-chain state, so a step that already confirmed (or was completed externally) is skipped rather than repeated.
@@ -242,19 +164,7 @@ The builder is a pure module: account state in, an ordered list of unsigned tran
Signing has two paths. The primary path is [stellar-wallets-kit](https://github.com/Creit-Tech/Stellar-Wallets-Kit), which gives a unified interface across Freighter, xBull, Albedo, LOBSTR, Rabet, Hana, WalletConnect, and others. The application passes an unsigned XDR and receives a signed XDR through `signTransaction`; the underlying private key never enters the application. For Soroban operations the kit also exposes `signAuthEntry`, though wallet support varies (Freighter, Hana, WalletConnect, and Ledger implement it; several others do not), so the tool builds its Soroban exits with source-account authorization, which the plain `signTransaction` path covers on every wallet, and reserves `signAuthEntry` for the cases that genuinely need a separate auth entry. The secondary path is an advanced secret-key mode for users whose keys are not in any wallet. In that mode the key lives only in memory for the duration of the execution session, never in any persisted storage and never in a network request, and is wiped on completion, on abort, on navigation away from the flow, or when the user explicitly clicks "Forget key". Section 13 details the handling.
-```mermaid
-flowchart TB
- env["Unsigned transaction envelope"] --> review["XDR review (collapsible)"]
- review --> choice{"Signing method"}
- choice -->|"wallet"| kit["stellar-wallets-kit signTransaction / signAuthEntry"]
- choice -->|"advanced"| key["Secret key in memory"]
- kit --> signed["Signed XDR"]
- key --> signed
- signed --> confirm["Explicit irreversibility confirmation"]
- confirm --> send["sendTransaction (Stellar RPC)"]
- send --> poll["Poll getTransaction until confirmed"]
- poll --> next["Mark step confirmed, advance"]
-```
+
For multisig accounts the kit and secret-key paths both support accumulating signatures: the tool collects signatures from several keypairs or wallets in sequence on the same envelope until the account thresholds are met, then submits. Each individual key is cleared from memory immediately after its signature is applied.
@@ -291,15 +201,7 @@ The provider returns a position payload, an enrichment dictionary (asset symbols
The adapter uses the authenticated tier where an API key is configured and the public tier otherwise. It sends only the address it was asked to analyze, and it caches only public position data.
-```mermaid
-flowchart TB
- req["Position request for address"] --> octo["Query OctoPos (5s timeout)"]
- octo -->|"fresh"| ok1["Return OctoPos data"]
- octo -->|"stale"| retry["Refresh request"]
- retry -->|"fresh"| ok1
- octo -->|"error"| degraded["Degraded mode: classic steps only, warn user to verify DeFi manually"]
- retry -->|"stale or error"| degraded
-```
+
### 7.2 Caching
@@ -318,19 +220,7 @@ The design cost of this is near zero precisely because of the existing constrain
From the analysis the tool generates a deterministic, ordered plan. Same account state, same plan. The order satisfies ledger constraints: you cannot withdraw collateral while a loan is open, you cannot remove a trustline while it holds a balance, and you cannot merge while any subentry remains.
-```mermaid
-flowchart TD
- s1["1. Normalize signers (SetOptions: remove extra signers, thresholds to 0/1/1)"]
- s2["2. Remove data entries (ManageData, batched by 100)"]
- s3["3. Claim selected claimable balances (ClaimClaimableBalance, optional)"]
- s4["4. Cancel DEX offers (ManageSellOffer / ManageBuyOffer, amount 0)"]
- s5["5. Withdraw AMM and LP positions (classic LiquidityPoolWithdraw + Soroban pool withdrawals)"]
- s6["6. Exit DeFi protocols (Blend, Phoenix, FxDAO: repay then withdraw)"]
- s7["7. Convert assets to XLM (PathPaymentStrictSend / Soroban swaps)"]
- s8["8. Remove trustlines (ChangeTrust limit 0, batched by 100)"]
- s9["9. Merge account (AccountMerge, direct or via mediator)"]
- s1 --> s2 --> s3 --> s4 --> s5 --> s6 --> s7 --> s8 --> s9
-```
+
A few details that matter for correctness:
@@ -389,20 +279,7 @@ Stellar's native AMM (CAP-38, protocol 18 and later) holds a user's stake as a p
Blend positions are detected by OctoPos: supply held as bTokens, debt as dTokens, with per-position health factors. The tool builds the exit itself with the official [`@blend-capital/blend-sdk`](https://www.npmjs.com/package/@blend-capital/blend-sdk) through the `Pool.submit` entry point, which takes a list of typed requests, each a `{ request_type, address, amount }`. The relevant request types are `Repay` (5), `Withdraw` (1), and `WithdrawCollateral` (3); supplied and collateralized balances are tracked separately, so the exit uses the request type matching how each position is held. For withdrawals, passing an amount larger than the position clamps down to the actual balance, which the tool uses to fully exit without dust. Repay behaves differently: the pool pulls the full stated amount from the account and refunds any excess in the same transaction, so the tool caps the repay amount at what the account actually holds rather than padding it. (OctoPos ships a Transaction Builder that can construct Blend exits server-side, but its own documentation marks it experimental and unmaintained, so the tool does not depend on it.)
-```mermaid
-flowchart TD
- detect["Detect Blend position (OctoPos)"] --> ver["Resolve pool version (wasmHash: V1 or V2)"]
- ver --> hasdebt{"Open dToken debt?"}
- hasdebt -->|"yes"| acquire["Acquire repayment asset if needed (route + PathPayment / swap)"]
- acquire --> repay["Repay (RequestType 5)"]
- hasdebt -->|"no"| withdraw
- repay --> hf{"Health factor stays >= 1.0?"}
- hf -->|"yes"| withdraw["Withdraw / WithdrawCollateral (RequestType 1 / 3)"]
- hf -->|"no"| block["Block step, explain risk"]
- withdraw --> backstop{"Backstop deposit?"}
- backstop -->|"queued (Q4W)"| wait["Show queue (21d V1 / 17d V2); proceed with rest, warn funds locked"]
- backstop -->|"none"| done["Position closed"]
-```
+
The order is enforced: repay all dToken debt first, then withdraw bToken supply, because the protocol rejects collateral withdrawal that would leave a position undercollateralized. When the account lacks the asset to repay, the tool routes and acquires it first (Section 10).
@@ -453,22 +330,7 @@ After positions are unwound, the account may hold several classic and Soroban to
Routing for the convert path has two engines. The primary is the Soroswap API, which finds optimal routes across Soroswap, Phoenix, Aquarius, and the classic SDEX, handles both classic and Soroban tokens, and builds the swap XDR. Like every server-built transaction, that XDR is decoded and verified client-side before signing (Section 9.9). The fallback for pure-classic assets is strict-send path finding from a Horizon-compatible endpoint, executed with `PathPaymentStrictSend` across SDEX order books and classic liquidity pools (up to six hops). Either way the tool computes a minimum-received amount from the quoted output and a slippage tolerance, and passes it as the destination minimum so a sudden price move cannot fill the swap at a bad rate.
-```mermaid
-flowchart TD
- asset["Non-XLM balance"] --> q["Quote route (Soroswap API; SDEX paths fallback)"]
- q --> hasroute{"Route found?"}
- hasroute -->|"yes"| disp{"Per-asset disposition (user confirms)"}
- disp -->|"swap (default)"| minrecv["Compute minimum received (quote x (1 - slippage))"]
- minrecv --> kind{"Token kind"}
- kind -->|"classic"| pp["PathPaymentStrictSend (dest_min = minimum received)"]
- kind -->|"Soroban"| inv["InvokeHostFunction swap (min_out = minimum received)"]
- pp --> conv["Swapped to XLM"]
- inv --> conv
- hasroute -->|"no"| issuer["Return to issuer (explicit irreversible confirm)"]
- disp -->|"return to issuer"| issuer
- conv --> rm["Remove trustline (ChangeTrust limit 0)"]
- issuer --> rm
-```
+
The user keeps control. A trustline is only removed once the protocol's full deletion preconditions hold: zero balance, zero buying liabilities (every open offer buying the asset cancelled, which the step order guarantees), and no pool-share trustline still referencing the asset (pool exits run earlier for the same reason). If a residual balance remains after a swap, the tool offers the return-to-issuer disposition or lets the user lower slippage and retry, rather than silently failing the later merge.
@@ -476,18 +338,7 @@ The user keeps control. A trustline is only removed once the protocol's full del
Exchanges do not support `ACCOUNT_MERGE`, and their crediting systems only recognize `Payment` operations with a memo, so a user cannot merge directly into a deposit address (a direct merge is typically lost). The tool bridges this with a single shared mediator account, the same pattern the reference demolisher uses, in one atomic transaction.
-```mermaid
-sequenceDiagram
- participant S as Source account (user)
- participant M as Shared mediator (operator-funded)
- participant D as Destination (exchange deposit)
-
- Note over S,D: One atomic transaction, two operations
- S->>M: op1 AccountMerge (source into mediator)
- M->>D: op2 Payment (mediator to exchange, with memo)
- Note over D: Exchange credits the user by address + memo
- Note over S,D: User signs op1, backend co-signs op2: both apply or neither
-```
+
The mediator is a single, persistent account that the operator funds once. Its ~1 XLM minimum balance is paid once and reused for every close, so the user recovers essentially all of their XLM, including the source account's freed reserves; only standard network fees apply. This is the key difference from a throwaway per-user intermediary, which would sacrifice ~1 XLM on every close.
diff --git a/docs/diagrams/README.md b/docs/diagrams/README.md
index f63a1a9..7f4e92b 100644
--- a/docs/diagrams/README.md
+++ b/docs/diagrams/README.md
@@ -1,23 +1,59 @@
# Diagram sources
-Mermaid sources for every diagram in the [architecture document](../architecture.md). They are duplicated here as standalone `.mmd` files so they can be exported to Whimsical, Excalidraw, or image formats without copying them out of the prose. The architecture document already renders these inline on GitHub and GitBook, so these files are for polishing and export, not for reading.
-
-| File | Diagram | Section in architecture.md |
-| -------------------------------------------------------------------- | ----------------------------------------------------------------------- | -------------------------- |
-| [01-system-architecture.mmd](./01-system-architecture.mmd) | System architecture: client, read-only backend, network, trust boundary | §4 |
-| [02-data-flow.mmd](./02-data-flow.mmd) | Data flow: enumerate via indexer, re-read live via RPC, build plan | §5 |
-| [03-state-machine.mmd](./03-state-machine.mmd) | Demolish flow state machine | §6.1 |
-| [04-signing-flow.mmd](./04-signing-flow.mmd) | Signing flow: wallet or secret key, review, submit, poll | §6.3 |
-| [05-defi-adapter-fallback.mmd](./05-defi-adapter-fallback.mmd) | DeFi position adapter: OctoPos, freshness gate, degraded mode | §7.1 |
-| [06-execution-plan.mmd](./06-execution-plan.mmd) | Ordered demolish execution plan | §8 |
-| [07-blend-unwind.mmd](./07-blend-unwind.mmd) | Blend unwind: repay, withdraw, backstop Q4W | §9.3 |
-| [08-asset-conversion-routing.mmd](./08-asset-conversion-routing.mmd) | Asset conversion and routing | §10 |
-| [09-mediator-flow.mmd](./09-mediator-flow.mmd) | Mediator account flow for exchange destinations | §11 |
-
-## Rendering and export
-
-- Quick preview or PNG/SVG export: paste a file into the [Mermaid Live Editor](https://mermaid.live).
-- Local image export: `npx -y @mermaid-js/mermaid-cli -i docs/diagrams/01-system-architecture.mmd -o 01-system-architecture.svg`.
-- Whimsical or a custom visual style: feed the natural-language description (or the Mermaid source) to the Cocoon-AI architecture-diagram-generator, or recreate in the Excalidraw MCP.
-
-Keep these in sync with the inline diagrams in `architecture.md`. The inline copies are the source of truth for the hosted document; these standalone files are derived from them.
+Mermaid sources for every diagram in the [architecture document](../architecture.md), with rendered PNG and SVG exports in [`output/`](./output/).
+
+The architecture document embeds the SVG exports directly. The `.mmd` files here are the authoring source; edit them and re-export to update the diagrams. The rendered outputs in `output/` are also referenced by the root [README](../../README.md).
+
+## Diagrams
+
+| # | File | Description | Section |
+| --- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | ------- |
+| 1 | [01-system-architecture.mmd](./01-system-architecture.mmd) | Three-layer architecture: browser trust boundary, read-only backend, Stellar network | §4 |
+| 2 | [02-data-flow.mmd](./02-data-flow.mmd) | Data flow: enumerate via indexer, re-read live via RPC, build plan | §5 |
+| 3 | [03-state-machine.mmd](./03-state-machine.mmd) | Demolish flow state machine (Idle → Analyzing → Executing → Complete) | §6.1 |
+| 4 | [04-signing-flow.mmd](./04-signing-flow.mmd) | Signing flow: wallet or secret key, XDR review, submit, poll | §6.3 |
+| 5 | [05-defi-adapter-fallback.mmd](./05-defi-adapter-fallback.mmd) | DeFi position adapter: OctoPos, freshness gate, degraded mode | §7.1 |
+| 6 | [06-execution-plan.mmd](./06-execution-plan.mmd) | Ordered 9-step demolish execution plan | §8 |
+| 7 | [07-blend-unwind.mmd](./07-blend-unwind.mmd) | Blend unwind: repay debt, withdraw supply, backstop Q4W | §9.3 |
+| 8 | [08-asset-conversion-routing.mmd](./08-asset-conversion-routing.mmd) | Asset conversion routing: Soroswap Aggregator with SDEX fallback | §10 |
+| 9 | [09-mediator-flow.mmd](./09-mediator-flow.mmd) | Mediator account flow for exchange destinations (no ACCOUNT_MERGE support) | §11 |
+
+## Rendered outputs
+
+All diagrams are exported to [`output/`](./output/) in both SVG and PNG formats. SVGs are vector and preferred for documentation embeds. PNGs are 2× rasterized for use in slide decks or contexts that don't support SVG.
+
+| Diagram | SVG | PNG |
+| ------------------------ | --------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
+| System architecture | [01-system-architecture.svg](./output/01-system-architecture.svg) | [01-system-architecture.png](./output/01-system-architecture.png) |
+| Data flow | [02-data-flow.svg](./output/02-data-flow.svg) | [02-data-flow.png](./output/02-data-flow.png) |
+| State machine | [03-state-machine.svg](./output/03-state-machine.svg) | [03-state-machine.png](./output/03-state-machine.png) |
+| Signing flow | [04-signing-flow.svg](./output/04-signing-flow.svg) | [04-signing-flow.png](./output/04-signing-flow.png) |
+| DeFi adapter fallback | [05-defi-adapter-fallback.svg](./output/05-defi-adapter-fallback.svg) | [05-defi-adapter-fallback.png](./output/05-defi-adapter-fallback.png) |
+| Execution plan | [06-execution-plan.svg](./output/06-execution-plan.svg) | [06-execution-plan.png](./output/06-execution-plan.png) |
+| Blend unwind | [07-blend-unwind.svg](./output/07-blend-unwind.svg) | [07-blend-unwind.png](./output/07-blend-unwind.png) |
+| Asset conversion routing | [08-asset-conversion-routing.svg](./output/08-asset-conversion-routing.svg) | [08-asset-conversion-routing.png](./output/08-asset-conversion-routing.png) |
+| Mediator flow | [09-mediator-flow.svg](./output/09-mediator-flow.svg) | [09-mediator-flow.png](./output/09-mediator-flow.png) |
+
+## Keeping diagrams in sync
+
+The `.mmd` files here are the source of truth. `architecture.md` embeds the pre-rendered SVGs from `output/`. When updating a diagram:
+
+1. Edit the corresponding `.mmd` file here.
+2. Re-export the SVG and PNG to `output/` (see below).
+3. The updated SVG is picked up automatically by `architecture.md` on the next build.
+
+## Export
+
+```bash
+# Single diagram to SVG
+npx -y @mermaid-js/mermaid-cli -i docs/diagrams/01-system-architecture.mmd -o docs/diagrams/output/01-system-architecture.svg
+
+# All diagrams (requires @mermaid-js/mermaid-cli installed globally or via npx)
+for f in docs/diagrams/*.mmd; do
+ name=$(basename "$f" .mmd)
+ npx @mermaid-js/mermaid-cli -i "$f" -o "docs/diagrams/output/${name}.svg"
+ npx @mermaid-js/mermaid-cli -i "$f" -o "docs/diagrams/output/${name}.png"
+done
+```
+
+For interactive editing and quick preview, paste any `.mmd` file into the [Mermaid Live Editor](https://mermaid.live).
diff --git a/docs/diagrams/output/01-system-architecture.png b/docs/diagrams/output/01-system-architecture.png
new file mode 100644
index 0000000..d02ba54
Binary files /dev/null and b/docs/diagrams/output/01-system-architecture.png differ
diff --git a/docs/diagrams/output/01-system-architecture.svg b/docs/diagrams/output/01-system-architecture.svg
new file mode 100644
index 0000000..d864089
--- /dev/null
+++ b/docs/diagrams/output/01-system-architecture.svg
@@ -0,0 +1,287 @@
+
+
+
+
+
+
+system-architecture
+LumenWipe - System Architecture
+Non-custodial Stellar account closure · private keys never leave the browser
+
+cluster_browser
+
+Browser - Trust Boundary
+All transaction construction, signing, and submission happen here
+
+
+cluster_backend
+
+Read-Only Backend - Stateless
+No user keys · no custody · one mediator co-sign key only
+
+
+cluster_network
+
+Stellar Network & Data Services
+
+
+
+ui
+
+Guided UI
+Analyze -> Execute -> Complete
+Plan preview · per-step confirmations · irreversibility warnings
+
+
+
+builder
+
+Transaction Builder
+Pure TypeScript · zero network calls
+Account state in -> unsigned XDR out · unit-testable
+
+
+
+ui->builder
+
+
+
+
+
+analysis
+
+Account Analysis
+Subentries · blockers · DeFi positions · reserves
+Full pre-flight merge check per §3 result codes
+
+
+
+ui->analysis
+
+
+read-only requests
+
+
+
+wallet
+
+Wallet Adapter
+stellar-wallets-kit (SEP-43)
+Freighter · Albedo · LOBSTR · Hana · WalletConnect · more
+
+
+
+signer
+
+Signer + XDR Review
+Collects signatures (multi-sig accumulation)
+User inspects every operation before signing
+
+
+
+wallet->signer
+
+
+
+
+
+sk
+
+Secret-Key Mode
+In-memory only · never persisted
+Wiped on completion, abort, or navigation away
+
+
+
+sk->signer
+
+
+
+
+
+builder->signer
+
+
+
+
+
+sess
+
+Session Store
+IndexedDB · no keys · no signed envelopes
+Resumable after browser close · reconciles on-chain on re-entry
+
+
+
+signer->sess
+
+
+saves step state
+
+
+
+rpc
+
+Stellar RPC
+getLedgerEntries · simulateTransaction
+sendTransaction · getTransaction · getEvents
+
+
+
+signer->rpc
+
+
+signed XDR
+(direct - bypasses backend)
+
+
+
+defi
+
+DeFi Position Adapter
+OctoPos proxy · freshness gate · degraded mode
+Blend · Aquarius · Soroswap · Phoenix · FxDAO
+
+
+
+analysis->defi
+
+
+
+
+
+cache
+
+Cache
+Redis · short TTLs (seconds)
+Public read data only · no identity · no user keys
+
+
+
+analysis->cache
+
+
+
+
+
+analysis->rpc
+
+
+
+
+
+idx
+
+stellar.expert Indexer API
+Enumerate trustlines · offers · signers · pool shares
+Primary source for account subentry discovery
+
+
+
+analysis->idx
+
+
+
+
+
+octopos
+
+OctoPos DeFi Position API
+Detects positions across all Soroban DeFi protocols
+Returns freshness metadata · mainnet only
+
+
+
+defi->octopos
+
+
+
+
+
+route
+
+Routing Service
+Soroswap API (primary) -> SDEX paths (fallback)
+Quote routes · compute min-received for slippage protection
+
+
+
+route->idx
+
+
+SDEX paths fallback
+
+
+
+soro_api
+
+Soroswap API
+Optimal swap routes · LP pair data
+Builds Soroban swap XDR (client verifies before signing)
+
+
+
+route->soro_api
+
+
+
+
+
+med_be
+
+Mediator Factory
+Builds unsigned merge+forward XDR
+Co-signs only after validating exact transaction shape
+
+
+
+med_be->rpc
+
+
+
+
+
+reg
+
+Registries
+Exchange deposit addresses -> memo type rules
+Contract wasmHash -> protocol version (Blend V1/V2 · pool IDs)
+
+
+
+ledger
+
+
+Stellar Ledger
+Classic + Soroban (Protocol 26 · Yardstick)
+Source of truth for all account state
+
+
+
+rpc->ledger
+
+
+
+
+
+idx->ledger
+
+
+
+
+
+soro_api->ledger
+
+
+
+
+
+octopos->ledger
+
+
+
+
+
diff --git a/docs/diagrams/output/02-data-flow.png b/docs/diagrams/output/02-data-flow.png
new file mode 100644
index 0000000..8b010be
Binary files /dev/null and b/docs/diagrams/output/02-data-flow.png differ
diff --git a/docs/diagrams/output/02-data-flow.svg b/docs/diagrams/output/02-data-flow.svg
new file mode 100644
index 0000000..624abe6
--- /dev/null
+++ b/docs/diagrams/output/02-data-flow.svg
@@ -0,0 +1,223 @@
+
+
+
+
+
+
+data-flow
+LumenWipe - Data Acquisition & Execution Flow
+Enumerate with indexer · re-read authoritative state over RPC · build plan · simulate · execute
+
+cluster_scan
+
+Phase 1 · Scan
+Discover everything the account holds
+
+
+cluster_verify
+
+Phase 2 · Verify Live State
+Never build a transaction from indexer data alone
+
+
+cluster_plan
+
+Phase 3 · Plan
+Deterministic - same state always produces same ordered plan
+
+
+cluster_exec
+
+Phase 4 · Execute
+One step at a time · sign · submit · confirm · advance
+
+
+
+acct
+
+Source Account
+Stellar public key
+
+
+
+enum
+
+Enumerate Subentries
+stellar.expert Indexer API
+Trustlines · offers · data entries · pool shares · signers · sponsorships
+
+
+
+acct->enum
+
+
+
+
+
+defi
+
+Detect DeFi Positions
+OctoPos API (mainnet)
+Blend · Aquarius · Soroswap · Phoenix · FxDAO
+
+
+
+acct->defi
+
+
+
+
+
+reread
+
+Re-read Every Entry
+Stellar RPC · getLedgerEntries
+Authoritative amounts · archived entry detection · sequence numbers
+
+
+
+enum->reread
+
+
+
+
+
+defi->reread
+
+
+
+
+
+reconcile
+
+Reconcile Completeness
+numSubEntries vs enumerated count
+Mismatch -> surface blocker instead of building an incomplete plan
+
+
+
+reread->reconcile
+
+
+
+
+
+plan
+
+Build Execution Plan
+9-step ordered pipeline
+Signers -> Data -> Balances -> Offers -> Pools -> DeFi -> Convert -> Trustlines -> Merge
+
+
+
+reconcile->plan
+
+
+counts match
+
+
+
+block
+
+Blocker Surfaced
+Sponsoring other accounts · AUTH_IMMUTABLE · missing route
+Explained in plain language - never silently skipped
+
+
+
+reconcile->block
+
+
+mismatch
+
+
+
+sim
+
+Simulate Soroban Steps
+Stellar RPC · simulateTransaction
+Computes footprint · authorization entries · resource fee for every InvokeHostFunction
+
+
+
+plan->sim
+
+
+Soroban steps
+
+
+
+sign
+
+Sign Transaction
+Wallet adapter or in-memory secret key
+User reviews XDR + confirms irreversibility before each step
+
+
+
+plan->sign
+
+
+classic steps
+
+
+
+sim->sign
+
+
+
+
+
+submit
+
+Submit Signed XDR
+Stellar RPC · sendTransaction
+Client submits directly - never routed through the backend
+
+
+
+sign->submit
+
+
+
+
+
+poll
+
+Poll for Confirmation
+Stellar RPC · getTransaction
+Exponential backoff · lost-response recovery · marks step confirmed
+
+
+
+submit->poll
+
+
+
+
+
+poll->sign
+
+
+next step
+
+
+
+done
+
+Account Merged
+AccountMerge confirmed on ledger
+All XLM recovered to destination
+
+
+
+poll->done
+
+
+merge confirmed
+
+
+
diff --git a/docs/diagrams/output/03-state-machine.png b/docs/diagrams/output/03-state-machine.png
new file mode 100644
index 0000000..1476f33
Binary files /dev/null and b/docs/diagrams/output/03-state-machine.png differ
diff --git a/docs/diagrams/output/03-state-machine.svg b/docs/diagrams/output/03-state-machine.svg
new file mode 100644
index 0000000..e19bd33
--- /dev/null
+++ b/docs/diagrams/output/03-state-machine.svg
@@ -0,0 +1,195 @@
+
+
+
+
+
+
+state-machine
+LumenWipe - Demolish Flow State Machine
+DemolishPhase · each transition written to IndexedDB · resumable after interruption
+
+
+__start__
+
+
+
+
+idle
+
+IDLE
+Waiting for source account input
+
+
+
+
+__end__
+
+
+
+
+
+analyzing
+
+ANALYZING
+Reading account state from indexer + RPC
+Building plan · checking merge preconditions
+
+
+
+idle->analyzing
+
+
+submit source public key
+
+
+
+preflight
+
+PREFLIGHT COMPLETE
+Plan built · blockers resolved
+User reviews accordion preview · confirms per-asset dispositions
+
+
+
+analyzing->preflight
+
+
+analysis OK · plan built
+
+
+
+aborted
+
+ABORTED
+User cancelled or unresolvable blocker
+Progress up to last confirmed step is preserved on-chain
+
+
+
+analyzing->aborted
+
+
+account not found
+or has unresolvable blocker
+
+
+
+signer_setup
+
+SIGNER SETUP
+Multi-sig account detected
+Gathering signatures from multiple keys/wallets
+
+
+
+preflight->signer_setup
+
+
+multi-sig detected
+(threshold > 1 or multiple keys)
+
+
+
+executing
+
+STEP EXECUTING
+Active transaction in flight
+Built · signed · submitted to Stellar RPC
+
+
+
+preflight->executing
+
+
+single-signer · enter execute loop
+
+
+
+signer_setup->executing
+
+
+enough signatures gathered
+thresholds met
+
+
+
+confirmed
+
+STEP CONFIRMED
+Ledger confirmed the transaction
+Step hash recorded · plan advances
+
+
+
+executing->confirmed
+
+
+ledger confirms
+(getTransaction -> SUCCESS)
+
+
+
+failed
+
+STEP FAILED
+Submission or simulation error
+Reason shown in plain language · user can retry same step
+
+
+
+executing->failed
+
+
+error: simulation failure
+or submission rejected
+
+
+
+executing->aborted
+
+
+user cancels
+
+
+
+confirmed->executing
+
+
+advance to next step
+
+
+
+complete
+
+COMPLETE
+AccountMerge confirmed
+All XLM recovered · summary shown
+
+
+
+confirmed->complete
+
+
+AccountMerge step confirmed
+
+
+
+failed->executing
+
+
+retry same step
+
+
+
+failed->aborted
+
+
+user cancels
+or max retries
+
+
+
+
diff --git a/docs/diagrams/output/04-signing-flow.png b/docs/diagrams/output/04-signing-flow.png
new file mode 100644
index 0000000..f36dc5d
Binary files /dev/null and b/docs/diagrams/output/04-signing-flow.png differ
diff --git a/docs/diagrams/output/04-signing-flow.svg b/docs/diagrams/output/04-signing-flow.svg
new file mode 100644
index 0000000..4833e95
--- /dev/null
+++ b/docs/diagrams/output/04-signing-flow.svg
@@ -0,0 +1,202 @@
+
+
+
+
+
+
+signing-flow
+LumenWipe - Transaction Signing Flow
+Happens entirely in the browser · private key never transmitted · backend not in signing path
+
+cluster_wallet
+
+Wallet Path (primary)
+Private key stays inside the wallet - never enters this app
+
+
+cluster_sk
+
+Secret-Key Path (advanced)
+For keys not held in any wallet
+
+
+
+env
+
+Unsigned Transaction Envelope
+Built by the pure TypeScript transaction builder
+Each step's envelope is built fresh with a live RPC re-read
+
+
+
+review
+
+XDR Review Panel
+Collapsible · human-readable operation list
+User can inspect every operation, fee, and sequence number before signing
+
+
+
+env->review
+
+
+
+
+
+choice
+
+Signing Method?
+
+
+
+review->choice
+
+
+
+
+
+kit
+
+stellar-wallets-kit
+signTransaction (all wallets)
+signAuthEntry (Freighter · Hana · WalletConnect · Ledger)
+
+
+
+choice->kit
+
+
+wallet mode
+
+
+
+sk_in
+
+Key loaded in memory only
+Password-field input · never stored
+Wiped on completion · abort · navigation away · 'Forget key' click
+
+
+
+choice->sk_in
+
+
+secret-key mode
+
+
+
+multisig
+
+Multi-sig Accumulation
+Both paths support multiple keypairs
+Signatures accumulated on same envelope · each key wiped after signing · submit when thresholds met
+
+
+
+kit->multisig
+
+
+
+
+
+signed
+
+Signed XDR
+All required signatures attached
+Thresholds satisfied
+
+
+
+kit->signed
+
+
+
+
+
+sk_in->multisig
+
+
+
+
+
+sk_in->signed
+
+
+
+
+
+multisig->signed
+
+
+all signatures
+accumulated
+
+
+
+confirm
+
+Irreversibility Confirmation
+Explicit per-step acknowledgment required
+Shows what will change · warns it cannot be undone · user triggers submission
+
+
+
+signed->confirm
+
+
+
+
+
+send
+
+sendTransaction
+Stellar RPC · client-to-network directly
+Backend not in path for user account transactions
+
+
+
+confirm->send
+
+
+
+
+
+poll
+
+Poll getTransaction
+Exponential backoff until ledger response
+If response lost: check first - never re-submit without verifying
+
+
+
+send->poll
+
+
+
+
+
+poll->confirm
+
+
+FAILED - retry
+
+
+
+next
+
+Step Confirmed - Advance Plan
+Transaction hash recorded in IndexedDB session
+Session state transitions to STEP_CONFIRMED -> STEP_EXECUTING (next)
+
+
+
+poll->next
+
+
+SUCCESS
+
+
+
diff --git a/docs/diagrams/output/05-defi-adapter-fallback.png b/docs/diagrams/output/05-defi-adapter-fallback.png
new file mode 100644
index 0000000..def9fd1
Binary files /dev/null and b/docs/diagrams/output/05-defi-adapter-fallback.png differ
diff --git a/docs/diagrams/output/05-defi-adapter-fallback.svg b/docs/diagrams/output/05-defi-adapter-fallback.svg
new file mode 100644
index 0000000..aff83cf
--- /dev/null
+++ b/docs/diagrams/output/05-defi-adapter-fallback.svg
@@ -0,0 +1,221 @@
+
+
+
+
+
+
+defi-adapter
+LumenWipe - DeFi Position Adapter & Fallback
+Detects positions across Blend · Aquarius · Soroswap · Phoenix · FxDAO via OctoPos
+
+cluster_normalize
+
+Normalize Position Data
+Adapter maps provider shapes to one internal model
+
+
+cluster_degraded
+
+Degraded Mode
+OctoPos unavailable or data too stale
+
+
+
+req
+
+Analysis Request
+Account public key
+Triggered when user submits address for analysis
+
+
+
+net_check
+
+Network?
+Mainnet or Testnet?
+
+
+
+req->net_check
+
+
+
+
+
+testnet_path
+
+Testnet: Direct Contract Reads
+OctoPos is mainnet-only
+Reads DeFi state directly via RPC getLedgerEntries + contract registry
+(same code path as degraded mode -> always exercised in CI)
+
+
+
+net_check->testnet_path
+
+
+testnet
+
+
+
+octo
+
+Query OctoPos
+HTTP · 5 second timeout
+Sends only the account address · no user identity stored
+
+
+
+net_check->octo
+
+
+mainnet
+
+
+
+plan_ready
+
+DeFi Positions Ready
+Feed into execution plan builder
+Each position becomes one or more exit steps
+
+
+
+testnet_path->plan_ready
+
+
+positions via RPC
+
+
+
+fresh_gate
+
+Freshness Gate
+Check data_staleness_seconds + partial_result
+
+
+
+octo->fresh_gate
+
+
+response received
+
+
+
+degraded
+
+Classic Steps Only
+Trustlines · offers · data entries proceed normally
+DeFi positions NOT detected automatically
+
+
+
+octo->degraded
+
+
+timeout / error
+
+
+
+refresh
+
+Request Refresh
+Signal OctoPos to re-index this address
+Wait and retry once
+
+
+
+fresh_gate->refresh
+
+
+stale
+
+
+
+normalize
+
+Map Provider Response
+Supply (bTokens) · Borrow (dTokens) · LP shares · Backstop
+Enriched with asset symbols · USD prices · contract names and versions
+
+
+
+fresh_gate->normalize
+
+
+fresh
+
+
+
+refresh_gate
+
+Still Fresh Enough?
+Check staleness after refresh
+
+
+
+refresh->refresh_gate
+
+
+response received
+
+
+
+refresh->degraded
+
+
+timeout / error
+
+
+
+refresh_gate->normalize
+
+
+fresh
+
+
+
+refresh_gate->degraded
+
+
+still stale
+
+
+
+meta
+
+Attach Freshness Metadata
+last_indexed_ledger · staleness · partial_result
+Low attribution_confidence -> show notice to verify on explorer
+
+
+
+normalize->meta
+
+
+
+
+
+meta->plan_ready
+
+
+
+
+
+warn
+
+User Warning Displayed
+DeFi positions could not be detected
+User advised to verify Blend · Aquarius · Soroswap · Phoenix · FxDAO manually
+
+
+
+degraded->warn
+
+
+
+
+
diff --git a/docs/diagrams/output/06-execution-plan.png b/docs/diagrams/output/06-execution-plan.png
new file mode 100644
index 0000000..a823f10
Binary files /dev/null and b/docs/diagrams/output/06-execution-plan.png differ
diff --git a/docs/diagrams/output/06-execution-plan.svg b/docs/diagrams/output/06-execution-plan.svg
new file mode 100644
index 0000000..b5106ef
--- /dev/null
+++ b/docs/diagrams/output/06-execution-plan.svg
@@ -0,0 +1,159 @@
+
+
+
+
+
+
+execution-plan
+LumenWipe - Ordered Demolition Execution Plan
+Same account state always produces the same plan · steps batch to 100 ops/tx · no-ops skipped
+
+
+s1
+
+Step 1 · Normalize Signers
+SetOptions · weight = 0 per extra signer · thresholds -> 0/1/1
+Not a merge precondition, but runs first: collapses multi-sig to one key for all later steps
+Frees 0.5 XLM per removed signer mid-flow, available to cover fees
+
+
+
+s2
+
+Step 2 · Remove Data Entries
+ManageData · value = null (delete) · batched ≤ 100 ops / tx
+Data entries block AccountMerge · common use: TOML, federation, app metadata
+
+
+
+s1->s2
+
+
+
+
+
+s3
+
+Step 3 · Claim Claimable Balances
+ClaimClaimableBalance · optional - user-selected
+Sponsoring a claimable balance blocks the merge · claimed proceeds flow into conversion step
+
+
+
+s2->s3
+
+
+
+
+
+s4
+
+Step 4 · Cancel DEX Offers
+ManageSellOffer / ManageBuyOffer · amount = 0 (delete) · batched ≤ 100 ops / tx
+Removes buying liabilities, which must be zero before trustlines can be deleted
+Passive sell offers cancelled the same way · each freed offer returns 0.5 XLM reserve
+
+
+
+s3->s4
+
+
+
+
+
+s5
+
+Step 5 · Withdraw AMM & LP Positions
+Classic: LiquidityPoolWithdraw · Soroban: remove_liquidity per protocol
+Classic pool-share trustline = 2 base reserves · withdraw before trustline removal
+Soroban: Soroswap router · Phoenix withdraw_liquidity + unbond · Aquarius withdraw
+
+
+
+s4->s5
+
+
+
+
+
+s6
+
+Step 6 · Exit DeFi Protocols
+InvokeHostFunction · one transaction per protocol exit · simulated before signing
+Blend: repay dToken debt (Pool.submit Repay) -> withdraw bToken (Withdraw / WithdrawCollateral)
+FxDAO: pay_debt stablecoin -> withdraw XLM collateral · Phoenix: unbond staked position
+Health factor checked ≥ 1.0 before any collateral withdrawal
+
+
+
+s5->s6
+
+
+
+
+
+s7
+
+Step 7 · Convert Assets to XLM
+PathPaymentStrictSend (classic) or InvokeHostFunction swap (Soroban)
+Soroswap API: primary - routes across Soroban + classic, builds XDR, client verifies before signing
+SDEX strict-send path: fallback for pure-classic assets · min_received = quote × (1 − slippage)
+No route -> user confirms explicit return-to-issuer · never a silent default
+
+
+
+s6->s7
+
+
+
+
+
+s8
+
+Step 8 · Remove Trustlines
+ChangeTrust · limit = 0 · batched ≤ 100 ops / tx
+Requires: balance = 0 · buying liabilities = 0 (guaranteed by step 4) · no pool-share refs
+Each removed trustline frees 0.5 XLM reserve (pool-share trustlines free 1.0 XLM)
+
+
+
+s7->s8
+
+
+
+
+
+s9
+
+Step 9 · Merge Account
+AccountMerge · direct or via mediator (exchange destinations)
+Transfers entire XLM balance to destination · deletes source account from ledger
+Destination verified on-chain first · memo required and validated for known exchanges
+
+
+
+s8->s9
+
+
+
+
+
+fastpath
+
+Fast Path (most accounts)
+Simple accounts collapse to 1–2 signed transactions
+Fused CLOSE_ACCOUNT tx: normalize + data + offers + convert + trustlines + merge in one atomic op
+Exchange destination: fused cleanup tx, then co-signed mediator transfer (2 signatures total)
+
+
+
+s9->fastpath
+
+
+see note
+
+
+
diff --git a/docs/diagrams/output/07-blend-unwind.png b/docs/diagrams/output/07-blend-unwind.png
new file mode 100644
index 0000000..2d904aa
Binary files /dev/null and b/docs/diagrams/output/07-blend-unwind.png differ
diff --git a/docs/diagrams/output/07-blend-unwind.svg b/docs/diagrams/output/07-blend-unwind.svg
new file mode 100644
index 0000000..ed0fd71
--- /dev/null
+++ b/docs/diagrams/output/07-blend-unwind.svg
@@ -0,0 +1,255 @@
+
+
+
+
+
+
+blend-unwind
+LumenWipe - Blend Protocol Unwind
+Blend V1 / V2 · supply (bToken) · borrow (dToken) · backstop · via @blend-capital/blend-sdk
+
+cluster_q4w
+
+Queue-for-Withdrawal (Q4W) Cooldown
+
+
+
+detect
+
+Detect Blend Position
+Via OctoPos DeFi Position API
+Supply (bTokens) · Debt (dTokens) · Backstop · per-pool health factors
+
+
+
+ver
+
+Resolve Pool Version
+Read wasmHash from contract on-chain
+V1 (older pools) or V2 (newer pools) · unknown wasmHash: position blocked with explanation
+
+
+
+detect->ver
+
+
+
+
+
+emissions
+
+Check Unclaimed BLND Emissions
+Blend SDK reads accrued rewards
+User offered to claim before exit (OctoPos does not report emissions)
+
+
+
+ver->emissions
+
+
+
+
+
+has_debt
+
+Open dToken Debt?
+dTokens represent borrowed assets · must be repaid before collateral withdrawal
+
+
+
+emissions->has_debt
+
+
+
+
+
+has_asset
+
+Holds Repayment Asset?
+Account must hold the borrowed token to repay
+
+
+
+has_debt->has_asset
+
+
+yes - debt exists
+
+
+
+withdraw
+
+Withdraw Supply / Collateral
+Pool.submit (RequestType 1 = Withdraw or 3 = WithdrawCollateral)
+Supplied and collateralized balances tracked separately - correct request type per position
+Pass amount greater than balance: clamps to actual balance for a full exit with no dust
+
+
+
+has_debt->withdraw
+
+
+no debt
+
+
+
+acquire
+
+Acquire Repayment Asset
+Route via Soroswap API or SDEX path payment
+Swap XLM (or another held asset) for the borrowed token at current market rate
+
+
+
+has_asset->acquire
+
+
+no - acquire first
+
+
+
+repay
+
+Repay Debt
+Pool.submit (RequestType 5 = Repay)
+Blend pulls exact stated amount · refunds any excess in same tx
+Tool caps repay at balance held - avoids overdraft
+
+
+
+has_asset->repay
+
+
+yes
+
+
+
+acquire->repay
+
+
+
+
+
+hf
+
+Health Factor ≥ 1.0 after repay?
+Protocol rejects withdrawal that would undercollateralize remaining positions
+
+
+
+repay->hf
+
+
+
+
+
+hf_block
+
+BLOCKED - Liquidation Risk
+Withdrawing collateral would undercollateralize position
+Shown in plain language · user must repay more before tool can proceed
+
+
+
+hf->hf_block
+
+
+no - would undercollateralize
+
+
+
+hf->withdraw
+
+
+yes - safe to withdraw
+
+
+
+backstop
+
+Backstop Deposit?
+Backstop is the pool's insurance layer - separate from supply
+
+
+
+withdraw->backstop
+
+
+
+
+
+q4w_check
+
+Already Queued?
+Check Q4W status on-chain
+
+
+
+backstop->q4w_check
+
+
+has backstop deposit
+
+
+
+done
+
+Blend Position Closed
+bTokens redeemed · dToken debt cleared · proceeds in XLM
+Flows into asset conversion step
+
+
+
+backstop->done
+
+
+no backstop deposit
+
+
+
+queue_start
+
+Start Q4W Queue
+Call withdraw on the backstop module
+V1: 21-day cooldown · V2: 17-day cooldown · Backstop token = BLND:USDC 80/20 Comet LP share
+
+
+
+q4w_check->queue_start
+
+
+not queued -> queue now
+
+
+
+q4w_wait
+
+Backstop Funds Locked
+Wind-down continues with remaining steps
+User warned funds unavailable until cooldown ends · shown remaining time
+
+
+
+q4w_check->q4w_wait
+
+
+already queued
+
+
+
+queue_start->q4w_wait
+
+
+
+
+
+q4w_wait->done
+
+
+after cooldown ends
+
+
+
diff --git a/docs/diagrams/output/08-asset-conversion-routing.png b/docs/diagrams/output/08-asset-conversion-routing.png
new file mode 100644
index 0000000..bbfc8af
Binary files /dev/null and b/docs/diagrams/output/08-asset-conversion-routing.png differ
diff --git a/docs/diagrams/output/08-asset-conversion-routing.svg b/docs/diagrams/output/08-asset-conversion-routing.svg
new file mode 100644
index 0000000..4bf2261
--- /dev/null
+++ b/docs/diagrams/output/08-asset-conversion-routing.svg
@@ -0,0 +1,248 @@
+
+
+
+
+
+
+asset-conversion
+LumenWipe - Asset Conversion & Routing
+Every non-XLM balance gets an explicit per-asset disposition confirmed by the user
+
+cluster_quote
+
+Quote Phase
+Find best available route at current market price
+
+
+cluster_issuer
+
+Return-to-Issuer Path
+Irreversible · explicit confirmation required
+
+
+
+asset
+
+Non-XLM Balance
+Classic token (trustline) or Soroban token
+Each balance handled independently - no silent batch-swap
+
+
+
+soroswap_q
+
+Soroswap API (primary)
+Routes across Soroban DEXes + classic SDEX
+Covers classic tokens, Soroban tokens, and SAC-wrapped assets
+
+
+
+asset->soroswap_q
+
+
+
+
+
+sdex_q
+
+SDEX Strict-Send Path (fallback)
+Horizon-compatible /paths/strict-send
+Classic order books + AMM pools · up to 6 hops · used when Soroswap has no route
+
+
+
+soroswap_q->sdex_q
+
+
+no route via Soroswap
+
+
+
+has_route
+
+Route Found?
+A route exists if at least one path from this asset to XLM is available
+
+
+
+soroswap_q->has_route
+
+
+result
+
+
+
+sdex_q->has_route
+
+
+result
+
+
+
+disp_preview
+
+Per-Asset Disposition - User Confirms in Preview
+Shown in accordion before any transaction is built
+User makes an explicit choice for every non-XLM balance
+
+
+
+has_route->disp_preview
+
+
+yes - route available
+
+
+
+issuer_confirm
+
+User Confirms Return to Issuer
+Tool states explicitly: this is irreversible
+Never labeled as a 'conversion' · right choice for spam tokens, dust, assets with no route
+
+
+
+has_route->issuer_confirm
+
+
+no route
+
+
+
+disp_choice
+
+User Disposition?
+swap = default when route exists · return to issuer always requires explicit confirm
+
+
+
+disp_preview->disp_choice
+
+
+
+
+
+minrecv
+
+Compute Minimum Received
+min_received = quoted_out × (1 − slippage_tolerance)
+Re-quoted at build time - if route lost, falls back to return-to-issuer
+
+
+
+disp_choice->minrecv
+
+
+swap (default)
+
+
+
+disp_choice->issuer_confirm
+
+
+return to issuer
+(explicit)
+
+
+
+token_kind
+
+Token Kind?
+Determines which Stellar operation to use
+
+
+
+minrecv->token_kind
+
+
+
+
+
+pp
+
+PathPaymentStrictSend
+Classic Stellar operation · dest_min = min_received
+SDEX order books + classic AMM pools · enforces minimum received on-chain
+
+
+
+token_kind->pp
+
+
+classic token
+
+
+
+invoke
+
+InvokeHostFunction Swap
+Soroban swap via Soroswap router · min_out = min_received
+Soroswap API builds XDR · client decodes and verifies contract + amounts before signing
+
+
+
+token_kind->invoke
+
+
+Soroban token
+
+
+
+swapped
+
+Balance Converted to XLM
+Destination receives XLM ≥ min_received
+Slippage beyond tolerance causes tx to fail - user retries at new price or lowers tolerance
+
+
+
+pp->swapped
+
+
+
+
+
+invoke->swapped
+
+
+
+
+
+rm_tl
+
+Remove Trustline
+ChangeTrust · limit = 0
+Preconditions: balance = 0 · buying_liabilities = 0 · no pool-share references
+Frees 0.5 XLM base reserve (pool-share trustlines free 1.0 XLM)
+
+
+
+swapped->rm_tl
+
+
+
+
+
+issuer_pay
+
+Payment to Issuer Address
+Sends full balance back · issuer burns it
+Clears balance to zero · enables trustline removal
+
+
+
+issuer_confirm->issuer_pay
+
+
+
+
+
+issuer_pay->rm_tl
+
+
+
+
+
diff --git a/docs/diagrams/output/09-mediator-flow.png b/docs/diagrams/output/09-mediator-flow.png
new file mode 100644
index 0000000..8e15cc0
Binary files /dev/null and b/docs/diagrams/output/09-mediator-flow.png differ
diff --git a/docs/diagrams/output/09-mediator-flow.svg b/docs/diagrams/output/09-mediator-flow.svg
new file mode 100644
index 0000000..12b91a2
--- /dev/null
+++ b/docs/diagrams/output/09-mediator-flow.svg
@@ -0,0 +1,242 @@
+
+
+
+
+
+
+mediator-flow
+LumenWipe - Mediator Account Flow (Exchange Destinations)
+Exchanges don't support AccountMerge · one atomic two-op transaction bridges the gap
+
+cluster_why
+
+Why a Mediator is Needed
+
+
+cluster_tx
+
+Client Builds One Atomic Transaction (two operations)
+Both operations apply or neither - atomicity guaranteed by Stellar protocol
+
+
+cluster_validate
+
+Backend Co-Sign - Strict Validation First
+Backend holds mediator key · cannot sign for user's account
+
+
+cluster_outcome
+
+Atomic Outcome
+
+
+
+why1
+
+Exchange Constraint
+No major exchange supports AccountMerge
+Direct merge to exchange deposit address -> funds typically lost
+
+
+
+why2
+
+Exchange Credit Requirement
+Exchanges credit by: deposit address + memo
+Payment operation with correct memo type required for crediting
+
+
+
+
+dest_entry
+
+User Enters Destination Address
+Final step of the setup flow
+Exchange detection happens at destination entry - depends on the destination
+
+
+
+is_exchange
+
+Known Exchange / Anchor?
+Exchange registry sourced from stellar.expert directory
+
+
+
+dest_entry->is_exchange
+
+
+
+
+
+direct_merge
+
+Direct AccountMerge
+Destination is a regular wallet address
+Standard flow - no mediator needed
+
+
+
+is_exchange->direct_merge
+
+
+regular wallet
+
+
+
+has_memo
+
+Memo Provided?
+Correct memo type required per registry (text / id / hash)
+
+
+
+is_exchange->has_memo
+
+
+exchange / anchor detected
+
+
+
+memo_block
+
+BLOCKED - Missing Memo
+Submission blocked until memo is provided
+Known exchange without memo -> funds would be lost · tool enforces this
+
+
+
+has_memo->memo_block
+
+
+memo missing
+
+
+
+op1
+
+op 1 - AccountMerge
+Source account -> Mediator account
+Transfers all source XLM into the mediator · source account deleted
+
+
+
+has_memo->op1
+
+
+memo provided
+
+
+
+op2
+
+op 2 - Payment
+Mediator -> Exchange deposit address
+Carries required memo · amount = all recovered XLM · source: mediator
+
+
+
+op1->op2
+
+
+same atomic tx
+
+
+
+user_sign
+
+User Signs the Transaction
+User's private key signs op1 (AccountMerge)
+Key stays in browser · never transmitted · wallet adapter or secret-key mode
+
+
+
+op2->user_sign
+
+
+
+
+
+validate
+
+Validate Exact Transaction Shape
+Decodes and checks every field before signing
+op1 must be AccountMerge into the mediator · op2 must be Payment from mediator
+Destination = user's chosen address (cannot be changed) · Amount ≥ 1 XLM
+
+
+
+user_sign->validate
+
+
+
+
+
+validate->memo_block
+
+
+invalid shape -> rejected
+
+
+
+cosign
+
+Co-sign op2 with Mediator Key
+Backend signs only the forwarding payment
+Cannot sign for user account · cannot redirect destination · cannot change amount
+
+
+
+validate->cosign
+
+
+shape verified
+
+
+
+submit
+
+Submit Combined Transaction
+Client submits to Stellar RPC · sendTransaction
+Signed by user (op1) + mediator key (op2) - both signatures present
+
+
+
+cosign->submit
+
+
+
+
+
+exchange_credit
+
+Exchange Credits User
+By deposit address + memo (exactly as provided)
+Standard Payment credit - indistinguishable from a normal deposit
+
+
+
+submit->exchange_credit
+
+
+
+
+
+mediator_stays
+
+Mediator Retains ~1 XLM Base Reserve
+Permanent mediator account - reused for every close
+User recovers essentially all XLM · only standard network fees apply
+No per-user 1 XLM sacrifice (unlike throwaway intermediary accounts)
+
+
+
+submit->mediator_stays
+
+
+
+
+
diff --git a/lib/close-api/build-transactions.ts b/lib/close-api/build-transactions.ts
index 2bd7339..5d48512 100644
--- a/lib/close-api/build-transactions.ts
+++ b/lib/close-api/build-transactions.ts
@@ -49,7 +49,9 @@ function buildSummary(input: FusedCloseInput): string {
if (issuerReturns > 0)
parts.push(`return ${issuerReturns} asset${issuerReturns === 1 ? "" : "s"} to the issuer`);
if (input.trustlines.length > 0)
- parts.push(`remove ${input.trustlines.length} trustline${input.trustlines.length === 1 ? "" : "s"}`);
+ parts.push(
+ `remove ${input.trustlines.length} trustline${input.trustlines.length === 1 ? "" : "s"}`
+ );
if (input.includeMerge) parts.push("merge the account into the destination");
const joined = parts.length > 0 ? parts.join(", ") : "close the account";
return `${joined.charAt(0).toUpperCase()}${joined.slice(1)}.`;
diff --git a/lib/close-api/plan-response.ts b/lib/close-api/plan-response.ts
index 615d65b..50e475b 100644
--- a/lib/close-api/plan-response.ts
+++ b/lib/close-api/plan-response.ts
@@ -41,7 +41,10 @@ export function toExecutionBreakdown(steps: PlannedStep[]): {
};
}
-function deriveStatus(buildResult: BuildPlanResult, decisionPoints: DecisionPoint[]): CloseApiStatus {
+function deriveStatus(
+ buildResult: BuildPlanResult,
+ decisionPoints: DecisionPoint[]
+): CloseApiStatus {
if (buildResult.blockers.length > 0) return "blocked";
if (buildResult.steps.length === 0) return "complete";
if (decisionPoints.length > 0) return "needs_decisions";
diff --git a/lib/kv.ts b/lib/kv.ts
index b1b3e84..3df5836 100644
--- a/lib/kv.ts
+++ b/lib/kv.ts
@@ -123,10 +123,15 @@ export async function checkNamespacedRateLimit(
}
}
-// Lua script: atomically check deduplication, increment account counter, and add XLM stroops.
-// KEYS[1] = processed set, KEYS[2] = count key, KEYS[3] = xlm key
-// ARGV[1] = txHash, ARGV[2] = xlmStroops
-// Returns 1 when the txHash is new (counters updated), 0 when duplicate.
+// Lua script: atomically deduplicate, update all counters, and push the recent-list entry.
+// Running everything in one script guarantees that a KV failure cannot leave the
+// global count incremented but the recent list or daily counter missing.
+//
+// KEYS[1] = processed set, KEYS[2] = count key, KEYS[3] = xlm key,
+// KEYS[4] = recent list key, KEYS[5] = daily counter key
+// ARGV[1] = txHash, ARGV[2] = xlmStroops, ARGV[3] = MergeRecord JSON,
+// ARGV[4] = recentMax, ARGV[5] = daily TTL in seconds
+// Returns 1 when the txHash is new (all state updated), 0 when duplicate.
const RECORD_SCRIPT = `
if redis.call('SISMEMBER', KEYS[1], ARGV[1]) == 1 then
return 0
@@ -134,6 +139,10 @@ const RECORD_SCRIPT = `
redis.call('SADD', KEYS[1], ARGV[1])
redis.call('INCR', KEYS[2])
redis.call('INCRBY', KEYS[3], ARGV[2])
+ redis.call('LPUSH', KEYS[4], ARGV[3])
+ redis.call('LTRIM', KEYS[4], 0, tonumber(ARGV[4]) - 1)
+ redis.call('INCR', KEYS[5])
+ redis.call('EXPIRE', KEYS[5], tonumber(ARGV[5]))
return 1
`;
@@ -147,35 +156,22 @@ export async function recordMerge(
txHash: string,
xlmStroops: string
): Promise {
+ const timestamp = new Date().toISOString();
+ const date = timestamp.slice(0, 10);
+ const record: MergeRecord = { txHash, xlmStroops, timestamp, network };
+
const result = await kv.eval(
RECORD_SCRIPT,
- [PROCESSED_KEY(network), COUNT_KEY[network], XLM_KEY[network]],
- [txHash, xlmStroops]
+ [
+ PROCESSED_KEY(network),
+ COUNT_KEY[network],
+ XLM_KEY[network],
+ RECENT_KEY(network),
+ DAILY_KEY(network, date),
+ ],
+ [txHash, xlmStroops, JSON.stringify(record), String(RECENT_MAX), String(400 * 86_400)]
);
- const isNew = result === 1;
- if (isNew) {
- const timestamp = new Date().toISOString();
- pushRecentMerge(network, txHash, xlmStroops, timestamp).catch((err) =>
- console.error(`Failed to push recent merge for tx ${txHash}:`, err)
- );
- }
- return isNew;
-}
-
-async function pushRecentMerge(
- network: Network,
- txHash: string,
- xlmStroops: string,
- timestamp: string
-): Promise {
- const record: MergeRecord = { txHash, xlmStroops, timestamp, network };
- const date = timestamp.slice(0, 10);
- const pipeline = kv.pipeline();
- pipeline.lpush(RECENT_KEY(network), JSON.stringify(record));
- pipeline.ltrim(RECENT_KEY(network), 0, RECENT_MAX - 1);
- pipeline.incr(DAILY_KEY(network, date));
- pipeline.expire(DAILY_KEY(network, date), 400 * 86_400);
- await pipeline.exec();
+ return result === 1;
}
export async function getRecentMerges(network: Network, limit = 20): Promise {
diff --git a/lib/stellar/intent/serialize.ts b/lib/stellar/intent/serialize.ts
index dbbdb5a..31c3edd 100644
--- a/lib/stellar/intent/serialize.ts
+++ b/lib/stellar/intent/serialize.ts
@@ -61,9 +61,7 @@ function sumAmounts(a: string, b: string): string {
export function intentFromXdr(xdr: string, networkPassphrase: string): TxIntent {
const tx = TransactionBuilder.fromXDR(xdr, networkPassphrase) as Transaction;
- const operations = tx.operations
- .map(normalizeOp)
- .filter((o): o is IntentOperation => o !== null);
+ const operations = tx.operations.map(normalizeOp).filter((o): o is IntentOperation => o !== null);
const merge = operations.find(
(o): o is Extract => o.type === "account_merge"
@@ -82,7 +80,9 @@ export function intentFromXdr(xdr: string, networkPassphrase: string): TxIntent
(o): o is Extract =>
o.type === "path_payment_strict_send"
)
- .reduce((acc, o) => (acc === null ? o.destMin : sumAmounts(acc, o.destMin)), null);
+ .reduce<
+ string | null
+ >((acc, o) => (acc === null ? o.destMin : sumAmounts(acc, o.destMin)), null);
const memoValue = tx.memo?.value;
diff --git a/lib/utils/txLedger.ts b/lib/utils/txLedger.ts
index b757177..2e7f583 100644
--- a/lib/utils/txLedger.ts
+++ b/lib/utils/txLedger.ts
@@ -11,8 +11,8 @@ export interface TxEntry {
* Collapse confirmed steps into the distinct transactions that effected them.
*
* A fused fast-path close is a single CLOSE_ACCOUNT step (one entry); a mediator
- * merge adds a second MERGE step (two entries); a stepwise run — and, in future,
- * each Soroban DeFi exit — contributes its own transaction. Steps that share a
+ * merge adds a second MERGE step (two entries); a stepwise run - and, in future,
+ * each Soroban DeFi exit - contributes its own transaction. Steps that share a
* hash collapse into one entry; steps without a hash are skipped.
*/
export function buildTxLedger(confirmedSteps: PlannedStep[]): TxEntry[] {
diff --git a/scripts/diagrams/01-system-architecture.py b/scripts/diagrams/01-system-architecture.py
new file mode 100644
index 0000000..3d25f32
--- /dev/null
+++ b/scripts/diagrams/01-system-architecture.py
@@ -0,0 +1,130 @@
+"""
+LumenWipe - 01 System Architecture
+Three-layer view: browser (trust boundary), read-only backend, Stellar network.
+Private keys sign transactions in the browser and never reach any server.
+"""
+import sys, os
+sys.path.insert(0, os.path.dirname(__file__))
+from _style import *
+import graphviz
+
+g = graphviz.Digraph("system-architecture")
+g.attr(**base_graph_attr(
+ rankdir="TB",
+ splines="spline",
+ size="15,11",
+ label=hl(
+ "LumenWipe - System Architecture",
+ "Non-custodial Stellar account closure · private keys never leave the browser",
+ ),
+))
+g.attr("node", **base_node_attr())
+g.attr("edge", **base_edge_attr())
+
+# ── Browser (trust boundary) ──────────────────────────────────────────────────
+with g.subgraph(name="cluster_browser") as b:
+ b.attr(
+ label=hl("Browser - Trust Boundary", "All transaction construction, signing, and submission happen here"),
+ style="rounded",
+ color=B_CLIENT,
+ fontcolor=B_CLIENT,
+ fontname=FONT,
+ fontsize="12",
+ penwidth="2.5",
+ margin="18",
+ )
+ b.node("ui", hl("Guided UI", "Analyze -> Execute -> Complete", "Plan preview · per-step confirmations · irreversibility warnings"),
+ fillcolor=F_CLIENT, color=B_CLIENT)
+ b.node("wallet", hl("Wallet Adapter", "stellar-wallets-kit (SEP-43)", "Freighter · Albedo · LOBSTR · Hana · WalletConnect · more"),
+ fillcolor=F_CLIENT, color=B_CLIENT)
+ b.node("sk", hl("Secret-Key Mode", "In-memory only · never persisted", "Wiped on completion, abort, or navigation away"),
+ fillcolor=F_CLIENT, color=B_CLIENT)
+ b.node("builder", hl("Transaction Builder", "Pure TypeScript · zero network calls", "Account state in -> unsigned XDR out · unit-testable"),
+ fillcolor=F_CLIENT, color=B_CLIENT)
+ b.node("signer", hl("Signer + XDR Review", "Collects signatures (multi-sig accumulation)", "User inspects every operation before signing"),
+ fillcolor=F_CLIENT, color=B_CLIENT)
+ b.node("sess", hl("Session Store", "IndexedDB · no keys · no signed envelopes", "Resumable after browser close · reconciles on-chain on re-entry"),
+ fillcolor=F_CLIENT, color=B_CLIENT)
+
+# ── Read-only backend ─────────────────────────────────────────────────────────
+with g.subgraph(name="cluster_backend") as b:
+ b.attr(
+ label=hl("Read-Only Backend - Stateless", "No user keys · no custody · one mediator co-sign key only"),
+ style="rounded",
+ color=B_BACKEND,
+ fontcolor=B_BACKEND,
+ fontname=FONT,
+ fontsize="12",
+ penwidth="2.5",
+ margin="18",
+ )
+ b.node("analysis", hl("Account Analysis", "Subentries · blockers · DeFi positions · reserves", "Full pre-flight merge check per §3 result codes"),
+ fillcolor=F_BACKEND, color=B_BACKEND)
+ b.node("defi", hl("DeFi Position Adapter", "OctoPos proxy · freshness gate · degraded mode", "Blend · Aquarius · Soroswap · Phoenix · FxDAO"),
+ fillcolor=F_BACKEND, color=B_BACKEND)
+ b.node("route", hl("Routing Service", "Soroswap API (primary) -> SDEX paths (fallback)", "Quote routes · compute min-received for slippage protection"),
+ fillcolor=F_BACKEND, color=B_BACKEND)
+ b.node("med_be", hl("Mediator Factory", "Builds unsigned merge+forward XDR", "Co-signs only after validating exact transaction shape"),
+ fillcolor=F_BACKEND, color=B_BACKEND)
+ b.node("reg", hl("Registries", "Exchange deposit addresses -> memo type rules", "Contract wasmHash -> protocol version (Blend V1/V2 · pool IDs)"),
+ fillcolor=F_BACKEND, color=B_BACKEND)
+ b.node("cache", hl("Cache", "Redis · short TTLs (seconds)", "Public read data only · no identity · no user keys"),
+ fillcolor=F_BACKEND, color=B_BACKEND)
+
+# ── Stellar network & data services ──────────────────────────────────────────
+with g.subgraph(name="cluster_network") as n:
+ n.attr(
+ label=hl("Stellar Network & Data Services"),
+ style="rounded",
+ color=B_EXTERNAL,
+ fontcolor=B_EXTERNAL,
+ fontname=FONT,
+ fontsize="12",
+ penwidth="2.5",
+ margin="18",
+ )
+ n.node("rpc", hl("Stellar RPC", "getLedgerEntries · simulateTransaction", "sendTransaction · getTransaction · getEvents"),
+ fillcolor=F_EXTERNAL, color=B_EXTERNAL)
+ n.node("idx", hl("stellar.expert Indexer API", "Enumerate trustlines · offers · signers · pool shares", "Primary source for account subentry discovery"),
+ fillcolor=F_EXTERNAL, color=B_EXTERNAL)
+ n.node("soro_api", hl("Soroswap API", "Optimal swap routes · LP pair data", "Builds Soroban swap XDR (client verifies before signing)"),
+ fillcolor=F_EXTERNAL, color=B_EXTERNAL)
+ n.node("octopos", hl("OctoPos DeFi Position API", "Detects positions across all Soroban DeFi protocols", "Returns freshness metadata · mainnet only"),
+ fillcolor=F_EXTERNAL, color=B_EXTERNAL)
+ n.node("ledger", hl("Stellar Ledger", "Classic + Soroban (Protocol 26 · Yardstick)", "Source of truth for all account state"),
+ fillcolor=F_EXTERNAL, color=B_EXTERNAL,
+ shape="cylinder", penwidth="2")
+
+# ── Client internal wiring ────────────────────────────────────────────────────
+g.edge("ui", "builder")
+g.edge("wallet", "signer")
+g.edge("sk", "signer")
+g.edge("builder", "signer")
+g.edge("signer", "sess", label="saves step state", style="dashed")
+g.edge("signer", "rpc",
+ label="signed XDR\n(direct - bypasses backend)",
+ color=B_CLIENT, fontcolor=B_CLIENT, penwidth="2", fontsize="9")
+
+# ── Client -> backend (read-only) ──────────────────────────────────────────────
+g.edge("ui", "analysis",
+ label="read-only requests", style="dashed", color=B_DEFAULT)
+
+# ── Backend internal ──────────────────────────────────────────────────────────
+g.edge("analysis", "defi")
+g.edge("analysis", "cache", style="dashed")
+g.edge("med_be", "rpc", style="dashed")
+
+# ── Backend -> external ────────────────────────────────────────────────────────
+g.edge("analysis", "idx")
+g.edge("analysis", "rpc")
+g.edge("defi", "octopos")
+g.edge("route", "soro_api")
+g.edge("route", "idx", label="SDEX paths fallback", style="dashed")
+
+# ── External -> ledger ─────────────────────────────────────────────────────────
+g.edge("rpc", "ledger", penwidth="2")
+g.edge("idx", "ledger")
+g.edge("soro_api", "ledger")
+g.edge("octopos", "ledger")
+
+render(g, "01-system-architecture")
diff --git a/scripts/diagrams/02-data-flow.py b/scripts/diagrams/02-data-flow.py
new file mode 100644
index 0000000..e04cafd
--- /dev/null
+++ b/scripts/diagrams/02-data-flow.py
@@ -0,0 +1,102 @@
+"""
+LumenWipe - 02 Data Acquisition & Execution Flow
+How the tool discovers what an account holds, verifies live state,
+builds a deterministic plan, and executes it transaction by transaction.
+"""
+import sys, os
+sys.path.insert(0, os.path.dirname(__file__))
+from _style import *
+import graphviz
+
+g = graphviz.Digraph("data-flow")
+g.attr(**base_graph_attr(
+ rankdir="LR",
+ splines="polyline",
+ size="16,7",
+ label=hl(
+ "LumenWipe - Data Acquisition & Execution Flow",
+ "Enumerate with indexer · re-read authoritative state over RPC · build plan · simulate · execute",
+ ),
+))
+g.attr("node", **base_node_attr())
+g.attr("edge", **base_edge_attr())
+
+# ── Source account ────────────────────────────────────────────────────────────
+g.node("acct", hl("Source Account", "Stellar public key"),
+ shape="ellipse", fillcolor=F_ACCENT, color=B_ACCENT, penwidth="2")
+
+# ── Parallel scan phase ───────────────────────────────────────────────────────
+with g.subgraph(name="cluster_scan") as s:
+ s.attr(label=hl("Phase 1 · Scan", "Discover everything the account holds"),
+ style="rounded,dashed", color=B_DEFAULT, fontcolor=T_MED,
+ fontname=FONT, fontsize="10", penwidth="1.2")
+ s.node("enum", hl("Enumerate Subentries", "stellar.expert Indexer API",
+ "Trustlines · offers · data entries · pool shares · signers · sponsorships"),
+ fillcolor=F_EXTERNAL, color=B_EXTERNAL)
+ s.node("defi", hl("Detect DeFi Positions", "OctoPos API (mainnet)",
+ "Blend · Aquarius · Soroswap · Phoenix · FxDAO"),
+ fillcolor=F_EXTERNAL, color=B_EXTERNAL)
+
+# ── Verify phase ──────────────────────────────────────────────────────────────
+with g.subgraph(name="cluster_verify") as v:
+ v.attr(label=hl("Phase 2 · Verify Live State", "Never build a transaction from indexer data alone"),
+ style="rounded,dashed", color=B_DEFAULT, fontcolor=T_MED,
+ fontname=FONT, fontsize="10", penwidth="1.2")
+ v.node("reread", hl("Re-read Every Entry", "Stellar RPC · getLedgerEntries",
+ "Authoritative amounts · archived entry detection · sequence numbers"),
+ fillcolor=F_CLIENT, color=B_CLIENT)
+ v.node("reconcile", hl("Reconcile Completeness", "numSubEntries vs enumerated count",
+ "Mismatch -> surface blocker instead of building an incomplete plan"),
+ fillcolor=F_DECISION, color=B_DECISION)
+
+# ── Plan phase ────────────────────────────────────────────────────────────────
+with g.subgraph(name="cluster_plan") as p:
+ p.attr(label=hl("Phase 3 · Plan", "Deterministic - same state always produces same ordered plan"),
+ style="rounded,dashed", color=B_DEFAULT, fontcolor=T_MED,
+ fontname=FONT, fontsize="10", penwidth="1.2")
+ p.node("plan", hl("Build Execution Plan", "9-step ordered pipeline",
+ "Signers -> Data -> Balances -> Offers -> Pools -> DeFi -> Convert -> Trustlines -> Merge"),
+ fillcolor=F_BACKEND, color=B_BACKEND)
+ p.node("sim", hl("Simulate Soroban Steps", "Stellar RPC · simulateTransaction",
+ "Computes footprint · authorization entries · resource fee for every InvokeHostFunction"),
+ fillcolor=F_BACKEND, color=B_BACKEND)
+
+# ── Execute loop ──────────────────────────────────────────────────────────────
+with g.subgraph(name="cluster_exec") as e:
+ e.attr(label=hl("Phase 4 · Execute", "One step at a time · sign · submit · confirm · advance"),
+ style="rounded,dashed", color=B_DEFAULT, fontcolor=T_MED,
+ fontname=FONT, fontsize="10", penwidth="1.2")
+ e.node("sign", hl("Sign Transaction", "Wallet adapter or in-memory secret key",
+ "User reviews XDR + confirms irreversibility before each step"),
+ fillcolor=F_CLIENT, color=B_CLIENT)
+ e.node("submit", hl("Submit Signed XDR", "Stellar RPC · sendTransaction",
+ "Client submits directly - never routed through the backend"),
+ fillcolor=F_CLIENT, color=B_CLIENT)
+ e.node("poll", hl("Poll for Confirmation", "Stellar RPC · getTransaction",
+ "Exponential backoff · lost-response recovery · marks step confirmed"),
+ fillcolor=F_CLIENT, color=B_CLIENT)
+
+# ── Terminal states ───────────────────────────────────────────────────────────
+g.node("done", hl("Account Merged", "AccountMerge confirmed on ledger", "All XLM recovered to destination"),
+ shape="ellipse", fillcolor=F_SUCCESS, color=B_SUCCESS, penwidth="2")
+g.node("block", hl("Blocker Surfaced", "Sponsoring other accounts · AUTH_IMMUTABLE · missing route",
+ "Explained in plain language - never silently skipped"),
+ shape="ellipse", fillcolor=F_DANGER, color=B_DANGER, penwidth="2")
+
+# ── Edges ─────────────────────────────────────────────────────────────────────
+g.edge("acct", "enum")
+g.edge("acct", "defi")
+g.edge("enum", "reread")
+g.edge("defi", "reread")
+g.edge("reread", "reconcile")
+g.edge("reconcile", "plan", label="counts match")
+g.edge("reconcile", "block", label="mismatch", color=E_DANGER, fontcolor=B_DANGER)
+g.edge("plan", "sim", label="Soroban steps")
+g.edge("plan", "sign", label="classic steps", style="dashed")
+g.edge("sim", "sign")
+g.edge("sign", "submit")
+g.edge("submit", "poll")
+g.edge("poll", "sign", label="next step", style="dashed")
+g.edge("poll", "done", label="merge confirmed", color=E_SUCCESS, fontcolor=B_SUCCESS)
+
+render(g, "02-data-flow")
diff --git a/scripts/diagrams/03-state-machine.py b/scripts/diagrams/03-state-machine.py
new file mode 100644
index 0000000..92d5d45
--- /dev/null
+++ b/scripts/diagrams/03-state-machine.py
@@ -0,0 +1,122 @@
+"""
+LumenWipe - 03 Demolish Flow State Machine
+DemolishPhase transitions from IDLE to COMPLETE (or ABORTED).
+Each transition is persisted to IndexedDB; resume reconciles against on-chain state.
+"""
+import sys, os
+sys.path.insert(0, os.path.dirname(__file__))
+from _style import *
+import graphviz
+
+g = graphviz.Digraph("state-machine")
+g.attr(**base_graph_attr(
+ rankdir="TB",
+ splines="spline",
+ size="12,14",
+ label=hl(
+ "LumenWipe - Demolish Flow State Machine",
+ "DemolishPhase · each transition written to IndexedDB · resumable after interruption",
+ ),
+))
+g.attr("node", **base_node_attr(shape="box", style="filled,rounded"))
+g.attr("edge", **base_edge_attr())
+
+# ── Start / end pseudostates ──────────────────────────────────────────────────
+g.node("__start__", "",
+ shape="circle", style="filled", fillcolor=T_DARK, color=T_DARK,
+ width="0.3", height="0.3", fixedsize="true")
+g.node("__end__", "",
+ shape="doublecircle", style="filled", fillcolor=T_DARK, color=T_DARK,
+ width="0.3", height="0.3", fixedsize="true")
+
+# ── States ────────────────────────────────────────────────────────────────────
+g.node("idle",
+ hl("IDLE", "Waiting for source account input"),
+ fillcolor=F_DEFAULT, color=B_DEFAULT)
+
+g.node("analyzing",
+ hl("ANALYZING", "Reading account state from indexer + RPC",
+ "Building plan · checking merge preconditions"),
+ fillcolor=F_ACCENT, color=B_ACCENT, penwidth="2")
+
+g.node("preflight",
+ hl("PREFLIGHT COMPLETE", "Plan built · blockers resolved",
+ "User reviews accordion preview · confirms per-asset dispositions"),
+ fillcolor=F_CLIENT, color=B_CLIENT, penwidth="2")
+
+g.node("signer_setup",
+ hl("SIGNER SETUP", "Multi-sig account detected",
+ "Gathering signatures from multiple keys/wallets"),
+ fillcolor=F_DECISION, color=B_DECISION, penwidth="2")
+
+g.node("executing",
+ hl("STEP EXECUTING", "Active transaction in flight",
+ "Built · signed · submitted to Stellar RPC"),
+ fillcolor=F_BACKEND, color=B_BACKEND, penwidth="2.5")
+
+g.node("confirmed",
+ hl("STEP CONFIRMED", "Ledger confirmed the transaction",
+ "Step hash recorded · plan advances"),
+ fillcolor=F_SUCCESS, color=B_SUCCESS, penwidth="2")
+
+g.node("failed",
+ hl("STEP FAILED", "Submission or simulation error",
+ "Reason shown in plain language · user can retry same step"),
+ fillcolor=F_DANGER, color=B_DANGER, penwidth="2")
+
+g.node("complete",
+ hl("COMPLETE", "AccountMerge confirmed",
+ "All XLM recovered · summary shown"),
+ shape="box", style="filled,rounded",
+ fillcolor=F_SUCCESS, color=B_SUCCESS, penwidth="3")
+
+g.node("aborted",
+ hl("ABORTED", "User cancelled or unresolvable blocker",
+ "Progress up to last confirmed step is preserved on-chain"),
+ shape="box", style="filled,rounded",
+ fillcolor=F_DANGER, color=B_DANGER, penwidth="2.5")
+
+# ── Transitions ───────────────────────────────────────────────────────────────
+g.edge("__start__", "idle", style="invis")
+
+g.edge("idle", "analyzing", label="submit source public key")
+g.edge("analyzing", "preflight", label="analysis OK · plan built", color=E_SUCCESS)
+g.edge("analyzing", "aborted",
+ label="account not found\nor has unresolvable blocker",
+ color=E_DANGER, fontcolor=B_DANGER)
+
+g.edge("preflight", "signer_setup",
+ label="multi-sig detected\n(threshold > 1 or multiple keys)",
+ color=E_WARNING, fontcolor=B_DECISION)
+g.edge("preflight", "executing", label="single-signer · enter execute loop")
+
+g.edge("signer_setup", "executing",
+ label="enough signatures gathered\nthresholds met",
+ color=E_WARNING, fontcolor=B_DECISION)
+
+g.edge("executing", "confirmed",
+ label="ledger confirms\n(getTransaction -> SUCCESS)",
+ color=E_SUCCESS, fontcolor=B_SUCCESS)
+g.edge("executing", "failed",
+ label="error: simulation failure\nor submission rejected",
+ color=E_DANGER, fontcolor=B_DANGER)
+g.edge("executing", "aborted",
+ label="user cancels", style="dashed",
+ color=E_DANGER, fontcolor=B_DANGER)
+
+g.edge("failed", "executing",
+ label="retry same step",
+ color=E_WARNING, fontcolor=B_DECISION)
+g.edge("failed", "aborted",
+ label="user cancels\nor max retries",
+ style="dashed", color=E_DANGER, fontcolor=B_DANGER)
+
+g.edge("confirmed", "executing",
+ label="advance to next step")
+g.edge("confirmed", "complete",
+ label="AccountMerge step confirmed",
+ color=E_SUCCESS, fontcolor=B_SUCCESS, penwidth="2")
+
+g.edge("complete", "__end__", style="invis")
+
+render(g, "03-state-machine")
diff --git a/scripts/diagrams/04-signing-flow.py b/scripts/diagrams/04-signing-flow.py
new file mode 100644
index 0000000..ffaec8a
--- /dev/null
+++ b/scripts/diagrams/04-signing-flow.py
@@ -0,0 +1,111 @@
+"""
+LumenWipe - 04 Signing Flow
+Unsigned transaction envelope -> XDR review -> wallet or secret key -> sign
+-> irreversibility confirmation -> submit -> poll -> advance.
+All steps happen in the browser. The backend is never in the signing path.
+"""
+import sys, os
+sys.path.insert(0, os.path.dirname(__file__))
+from _style import *
+import graphviz
+
+g = graphviz.Digraph("signing-flow")
+g.attr(**base_graph_attr(
+ rankdir="TB",
+ splines="polyline",
+ size="12,13",
+ label=hl(
+ "LumenWipe - Transaction Signing Flow",
+ "Happens entirely in the browser · private key never transmitted · backend not in signing path",
+ ),
+))
+g.attr("node", **base_node_attr())
+g.attr("edge", **base_edge_attr())
+
+# ── Entry ─────────────────────────────────────────────────────────────────────
+g.node("env",
+ hl("Unsigned Transaction Envelope", "Built by the pure TypeScript transaction builder",
+ "Each step's envelope is built fresh with a live RPC re-read"),
+ fillcolor=F_CLIENT, color=B_CLIENT)
+
+g.node("review",
+ hl("XDR Review Panel", "Collapsible · human-readable operation list",
+ "User can inspect every operation, fee, and sequence number before signing"),
+ fillcolor=F_CLIENT, color=B_CLIENT)
+
+# ── Signing method decision ───────────────────────────────────────────────────
+g.node("choice",
+ hl("Signing Method?"),
+ shape="diamond", fillcolor=F_DECISION, color=B_DECISION, penwidth="2",
+ margin="0.35,0.2")
+
+# ── Wallet path ───────────────────────────────────────────────────────────────
+with g.subgraph(name="cluster_wallet") as w:
+ w.attr(label=hl("Wallet Path (primary)", "Private key stays inside the wallet - never enters this app"),
+ style="rounded,dashed", color=B_CLIENT, fontcolor=B_CLIENT,
+ fontname=FONT, fontsize="10", penwidth="1.2")
+ w.node("kit",
+ hl("stellar-wallets-kit", "signTransaction (all wallets)",
+ "signAuthEntry (Freighter · Hana · WalletConnect · Ledger)"),
+ fillcolor=F_CLIENT, color=B_CLIENT)
+
+# ── Secret-key path ───────────────────────────────────────────────────────────
+with g.subgraph(name="cluster_sk") as s:
+ s.attr(label=hl("Secret-Key Path (advanced)", "For keys not held in any wallet"),
+ style="rounded,dashed", color=B_DECISION, fontcolor=B_DECISION,
+ fontname=FONT, fontsize="10", penwidth="1.2")
+ s.node("sk_in",
+ hl("Key loaded in memory only", "Password-field input · never stored",
+ "Wiped on completion · abort · navigation away · 'Forget key' click"),
+ fillcolor=F_DECISION, color=B_DECISION)
+
+# ── Multi-sig note ────────────────────────────────────────────────────────────
+g.node("multisig",
+ hl("Multi-sig Accumulation", "Both paths support multiple keypairs",
+ "Signatures accumulated on same envelope · each key wiped after signing · submit when thresholds met"),
+ fillcolor=F_DEFAULT, color=B_DEFAULT, style="filled,rounded,dashed")
+
+# ── Post-signing ──────────────────────────────────────────────────────────────
+g.node("signed",
+ hl("Signed XDR", "All required signatures attached", "Thresholds satisfied"),
+ fillcolor=F_SUCCESS, color=B_SUCCESS, penwidth="2")
+
+g.node("confirm",
+ hl("Irreversibility Confirmation", "Explicit per-step acknowledgment required",
+ "Shows what will change · warns it cannot be undone · user triggers submission"),
+ fillcolor=F_DANGER, color=B_DANGER, penwidth="2")
+
+g.node("send",
+ hl("sendTransaction", "Stellar RPC · client-to-network directly",
+ "Backend not in path for user account transactions"),
+ fillcolor=F_EXTERNAL, color=B_EXTERNAL)
+
+g.node("poll",
+ hl("Poll getTransaction", "Exponential backoff until ledger response",
+ "If response lost: check first - never re-submit without verifying"),
+ fillcolor=F_EXTERNAL, color=B_EXTERNAL)
+
+g.node("next",
+ hl("Step Confirmed - Advance Plan", "Transaction hash recorded in IndexedDB session",
+ "Session state transitions to STEP_CONFIRMED -> STEP_EXECUTING (next)"),
+ fillcolor=F_SUCCESS, color=B_SUCCESS, penwidth="2")
+
+# ── Edges ─────────────────────────────────────────────────────────────────────
+g.edge("env", "review")
+g.edge("review", "choice")
+g.edge("choice", "kit", label="wallet mode")
+g.edge("choice", "sk_in", label="secret-key mode")
+g.edge("kit", "multisig", style="dashed")
+g.edge("sk_in", "multisig", style="dashed")
+g.edge("kit", "signed")
+g.edge("sk_in", "signed")
+g.edge("multisig", "signed", style="dashed", label="all signatures\naccumulated")
+g.edge("signed", "confirm")
+g.edge("confirm","send")
+g.edge("send", "poll")
+g.edge("poll", "next", label="SUCCESS", color=E_SUCCESS, fontcolor=B_SUCCESS)
+g.edge("poll", "confirm",
+ label="FAILED - retry", style="dashed",
+ color=E_DANGER, fontcolor=B_DANGER)
+
+render(g, "04-signing-flow")
diff --git a/scripts/diagrams/05-defi-adapter-fallback.py b/scripts/diagrams/05-defi-adapter-fallback.py
new file mode 100644
index 0000000..0eea9dc
--- /dev/null
+++ b/scripts/diagrams/05-defi-adapter-fallback.py
@@ -0,0 +1,120 @@
+"""
+LumenWipe - 05 DeFi Position Adapter & Fallback
+How the backend queries OctoPos, validates freshness,
+and gracefully degrades when the provider is unavailable.
+"""
+import sys, os
+sys.path.insert(0, os.path.dirname(__file__))
+from _style import *
+import graphviz
+
+g = graphviz.Digraph("defi-adapter")
+g.attr(**base_graph_attr(
+ rankdir="TB",
+ splines="polyline",
+ size="13,11",
+ label=hl(
+ "LumenWipe - DeFi Position Adapter & Fallback",
+ "Detects positions across Blend · Aquarius · Soroswap · Phoenix · FxDAO via OctoPos",
+ ),
+))
+g.attr("node", **base_node_attr())
+g.attr("edge", **base_edge_attr())
+
+# ── Entry ─────────────────────────────────────────────────────────────────────
+g.node("req",
+ hl("Analysis Request", "Account public key",
+ "Triggered when user submits address for analysis"),
+ shape="ellipse", fillcolor=F_ACCENT, color=B_ACCENT, penwidth="2")
+
+# ── Network check ─────────────────────────────────────────────────────────────
+g.node("net_check",
+ hl("Network?", "Mainnet or Testnet?"),
+ shape="diamond", fillcolor=F_DECISION, color=B_DECISION, penwidth="2", margin="0.3,0.2")
+
+g.node("testnet_path",
+ hl("Testnet: Direct Contract Reads", "OctoPos is mainnet-only",
+ "Reads DeFi state directly via RPC getLedgerEntries + contract registry\n(same code path as degraded mode -> always exercised in CI)"),
+ fillcolor=F_BACKEND, color=B_BACKEND)
+
+# ── OctoPos query ─────────────────────────────────────────────────────────────
+g.node("octo",
+ hl("Query OctoPos", "HTTP · 5 second timeout",
+ "Sends only the account address · no user identity stored"),
+ fillcolor=F_EXTERNAL, color=B_EXTERNAL)
+
+# ── Freshness gate ────────────────────────────────────────────────────────────
+g.node("fresh_gate",
+ hl("Freshness Gate", "Check data_staleness_seconds + partial_result"),
+ shape="diamond", fillcolor=F_DECISION, color=B_DECISION, penwidth="2", margin="0.3,0.2")
+
+g.node("refresh",
+ hl("Request Refresh", "Signal OctoPos to re-index this address",
+ "Wait and retry once"),
+ fillcolor=F_EXTERNAL, color=B_EXTERNAL)
+
+g.node("refresh_gate",
+ hl("Still Fresh Enough?", "Check staleness after refresh"),
+ shape="diamond", fillcolor=F_DECISION, color=B_DECISION, penwidth="2", margin="0.3,0.2")
+
+# ── Success path ──────────────────────────────────────────────────────────────
+with g.subgraph(name="cluster_normalize") as n:
+ n.attr(label=hl("Normalize Position Data", "Adapter maps provider shapes to one internal model"),
+ style="rounded,dashed", color=B_SUCCESS, fontcolor=B_SUCCESS,
+ fontname=FONT, fontsize="10", penwidth="1.2")
+ n.node("normalize",
+ hl("Map Provider Response", "Supply (bTokens) · Borrow (dTokens) · LP shares · Backstop",
+ "Enriched with asset symbols · USD prices · contract names and versions"),
+ fillcolor=F_SUCCESS, color=B_SUCCESS)
+ n.node("meta",
+ hl("Attach Freshness Metadata", "last_indexed_ledger · staleness · partial_result",
+ "Low attribution_confidence -> show notice to verify on explorer"),
+ fillcolor=F_SUCCESS, color=B_SUCCESS)
+
+g.node("plan_ready",
+ hl("DeFi Positions Ready", "Feed into execution plan builder",
+ "Each position becomes one or more exit steps"),
+ fillcolor=F_SUCCESS, color=B_SUCCESS, penwidth="2", shape="ellipse")
+
+# ── Degraded mode ─────────────────────────────────────────────────────────────
+with g.subgraph(name="cluster_degraded") as d:
+ d.attr(label=hl("Degraded Mode", "OctoPos unavailable or data too stale"),
+ style="rounded,dashed", color=B_DANGER, fontcolor=B_DANGER,
+ fontname=FONT, fontsize="10", penwidth="1.5")
+ d.node("degraded",
+ hl("Classic Steps Only", "Trustlines · offers · data entries proceed normally",
+ "DeFi positions NOT detected automatically"),
+ fillcolor=F_DANGER, color=B_DANGER)
+ d.node("warn",
+ hl("User Warning Displayed", "DeFi positions could not be detected",
+ "User advised to verify Blend · Aquarius · Soroswap · Phoenix · FxDAO manually"),
+ fillcolor=F_DANGER, color=B_DANGER)
+
+# ── Edges ─────────────────────────────────────────────────────────────────────
+g.edge("req", "net_check")
+g.edge("net_check", "testnet_path", label="testnet")
+g.edge("net_check", "octo", label="mainnet")
+g.edge("testnet_path", "plan_ready", label="positions via RPC",
+ color=E_SUCCESS, fontcolor=B_SUCCESS)
+
+g.edge("octo", "fresh_gate", label="response received")
+g.edge("octo", "degraded", label="timeout / error",
+ color=E_DANGER, fontcolor=B_DANGER)
+
+g.edge("fresh_gate", "normalize", label="fresh")
+g.edge("fresh_gate", "refresh", label="stale")
+
+g.edge("refresh", "refresh_gate", label="response received")
+g.edge("refresh", "degraded", label="timeout / error",
+ color=E_DANGER, fontcolor=B_DANGER)
+g.edge("refresh_gate", "normalize", label="fresh")
+g.edge("refresh_gate", "degraded", label="still stale",
+ color=E_DANGER, fontcolor=B_DANGER)
+
+g.edge("normalize", "meta")
+g.edge("meta", "plan_ready",
+ color=E_SUCCESS, fontcolor=B_SUCCESS)
+
+g.edge("degraded", "warn")
+
+render(g, "05-defi-adapter-fallback")
diff --git a/scripts/diagrams/06-execution-plan.py b/scripts/diagrams/06-execution-plan.py
new file mode 100644
index 0000000..dca364a
--- /dev/null
+++ b/scripts/diagrams/06-execution-plan.py
@@ -0,0 +1,101 @@
+"""
+LumenWipe - 06 Ordered Demolition Execution Plan
+9-step deterministic pipeline. Order satisfies ledger constraints:
+cannot remove a trustline while it holds a balance; cannot merge with subentries remaining.
+"""
+import sys, os
+sys.path.insert(0, os.path.dirname(__file__))
+from _style import *
+import graphviz
+
+g = graphviz.Digraph("execution-plan")
+g.attr(**base_graph_attr(
+ rankdir="TB",
+ splines="polyline",
+ size="13,17",
+ nodesep="0.35",
+ ranksep="0.45",
+ label=hl(
+ "LumenWipe - Ordered Demolition Execution Plan",
+ "Same account state always produces the same plan · steps batch to 100 ops/tx · no-ops skipped",
+ ),
+))
+g.attr("node", **base_node_attr(width="7.5", margin="0.3,0.18"))
+g.attr("edge", **base_edge_attr(penwidth="2"))
+
+def step(g, nid, num, title, ops, detail, **kw):
+ label = hl(f"{num} · {title}", ops, detail)
+ g.node(nid, label, **kw)
+
+# ── Steps ─────────────────────────────────────────────────────────────────────
+step(g, "s1", "Step 1", "Normalize Signers",
+ "SetOptions · weight = 0 per extra signer · thresholds -> 0/1/1",
+ "Not a merge precondition, but runs first: collapses multi-sig to one key for all later steps\n"
+ "Frees 0.5 XLM per removed signer mid-flow, available to cover fees",
+ fillcolor=F_CLIENT, color=B_CLIENT)
+
+step(g, "s2", "Step 2", "Remove Data Entries",
+ "ManageData · value = null (delete) · batched ≤ 100 ops / tx",
+ "Data entries block AccountMerge · common use: TOML, federation, app metadata",
+ fillcolor=F_DEFAULT, color=B_DEFAULT)
+
+step(g, "s3", "Step 3", "Claim Claimable Balances",
+ "ClaimClaimableBalance · optional - user-selected",
+ "Sponsoring a claimable balance blocks the merge · claimed proceeds flow into conversion step",
+ fillcolor=F_DEFAULT, color=B_DEFAULT)
+
+step(g, "s4", "Step 4", "Cancel DEX Offers",
+ "ManageSellOffer / ManageBuyOffer · amount = 0 (delete) · batched ≤ 100 ops / tx",
+ "Removes buying liabilities, which must be zero before trustlines can be deleted\n"
+ "Passive sell offers cancelled the same way · each freed offer returns 0.5 XLM reserve",
+ fillcolor=F_DEFAULT, color=B_DEFAULT)
+
+step(g, "s5", "Step 5", "Withdraw AMM & LP Positions",
+ "Classic: LiquidityPoolWithdraw · Soroban: remove_liquidity per protocol",
+ "Classic pool-share trustline = 2 base reserves · withdraw before trustline removal\n"
+ "Soroban: Soroswap router · Phoenix withdraw_liquidity + unbond · Aquarius withdraw",
+ fillcolor=F_EXTERNAL, color=B_EXTERNAL)
+
+step(g, "s6", "Step 6", "Exit DeFi Protocols",
+ "InvokeHostFunction · one transaction per protocol exit · simulated before signing",
+ "Blend: repay dToken debt (Pool.submit Repay) -> withdraw bToken (Withdraw / WithdrawCollateral)\n"
+ "FxDAO: pay_debt stablecoin -> withdraw XLM collateral · Phoenix: unbond staked position\n"
+ "Health factor checked ≥ 1.0 before any collateral withdrawal",
+ fillcolor=F_EXTERNAL, color=B_EXTERNAL)
+
+step(g, "s7", "Step 7", "Convert Assets to XLM",
+ "PathPaymentStrictSend (classic) or InvokeHostFunction swap (Soroban)",
+ "Soroswap API: primary - routes across Soroban + classic, builds XDR, client verifies before signing\n"
+ "SDEX strict-send path: fallback for pure-classic assets · min_received = quote × (1 − slippage)\n"
+ "No route -> user confirms explicit return-to-issuer · never a silent default",
+ fillcolor=F_DECISION, color=B_DECISION)
+
+step(g, "s8", "Step 8", "Remove Trustlines",
+ "ChangeTrust · limit = 0 · batched ≤ 100 ops / tx",
+ "Requires: balance = 0 · buying liabilities = 0 (guaranteed by step 4) · no pool-share refs\n"
+ "Each removed trustline frees 0.5 XLM reserve (pool-share trustlines free 1.0 XLM)",
+ fillcolor=F_DEFAULT, color=B_DEFAULT)
+
+step(g, "s9", "Step 9", "Merge Account",
+ "AccountMerge · direct or via mediator (exchange destinations)",
+ "Transfers entire XLM balance to destination · deletes source account from ledger\n"
+ "Destination verified on-chain first · memo required and validated for known exchanges",
+ fillcolor=F_SUCCESS, color=B_SUCCESS, penwidth="2.5")
+
+# ── Fast-path note ────────────────────────────────────────────────────────────
+g.node("fastpath",
+ hl("Fast Path (most accounts)",
+ "Simple accounts collapse to 1–2 signed transactions",
+ "Fused CLOSE_ACCOUNT tx: normalize + data + offers + convert + trustlines + merge in one atomic op\n"
+ "Exchange destination: fused cleanup tx, then co-signed mediator transfer (2 signatures total)"),
+ fillcolor=F_ACCENT, color=B_ACCENT, style="filled,rounded,dashed",
+ width="7.5", margin="0.3,0.18")
+
+# ── Pipeline edges ────────────────────────────────────────────────────────────
+for a, b in [("s1","s2"),("s2","s3"),("s3","s4"),("s4","s5"),
+ ("s5","s6"),("s6","s7"),("s7","s8"),("s8","s9")]:
+ g.edge(a, b)
+
+g.edge("s9", "fastpath", style="dashed", label="see note", color=B_ACCENT)
+
+render(g, "06-execution-plan")
diff --git a/scripts/diagrams/07-blend-unwind.py b/scripts/diagrams/07-blend-unwind.py
new file mode 100644
index 0000000..bc2d812
--- /dev/null
+++ b/scripts/diagrams/07-blend-unwind.py
@@ -0,0 +1,141 @@
+"""
+LumenWipe - 07 Blend Protocol Unwind
+Blend is the largest lending market on Stellar.
+Positions: supply (bTokens), borrow (dTokens), backstop deposits.
+Exit order is enforced: repay all debt first, then withdraw supply/collateral.
+"""
+import sys, os
+sys.path.insert(0, os.path.dirname(__file__))
+from _style import *
+import graphviz
+
+g = graphviz.Digraph("blend-unwind")
+g.attr(**base_graph_attr(
+ rankdir="TB",
+ splines="polyline",
+ size="14,17",
+ nodesep="0.6",
+ ranksep="0.75",
+ label=hl(
+ "LumenWipe - Blend Protocol Unwind",
+ "Blend V1 / V2 · supply (bToken) · borrow (dToken) · backstop · via @blend-capital/blend-sdk",
+ ),
+))
+g.attr("node", **base_node_attr())
+g.attr("edge", **base_edge_attr())
+
+def diamond(g, nid, label, sub=""):
+ l = hl(label, sub) if sub else label
+ g.node(nid, l, shape="diamond", fillcolor=F_DECISION, color=B_DECISION,
+ penwidth="2", margin="0.35,0.2")
+
+# ── Detection ─────────────────────────────────────────────────────────────────
+g.node("detect",
+ hl("Detect Blend Position", "Via OctoPos DeFi Position API",
+ "Supply (bTokens) · Debt (dTokens) · Backstop · per-pool health factors"),
+ fillcolor=F_EXTERNAL, color=B_EXTERNAL)
+
+g.node("ver",
+ hl("Resolve Pool Version", "Read wasmHash from contract on-chain",
+ "V1 (older pools) or V2 (newer pools) · unknown wasmHash: position blocked with explanation"),
+ fillcolor=F_DEFAULT, color=B_DEFAULT)
+
+g.node("emissions",
+ hl("Check Unclaimed BLND Emissions", "Blend SDK reads accrued rewards",
+ "User offered to claim before exit (OctoPos does not report emissions)"),
+ fillcolor=F_DEFAULT, color=B_DEFAULT)
+
+diamond(g, "has_debt",
+ "Open dToken Debt?",
+ "dTokens represent borrowed assets · must be repaid before collateral withdrawal")
+
+# ── Acquire repayment asset ───────────────────────────────────────────────────
+diamond(g, "has_asset",
+ "Holds Repayment Asset?",
+ "Account must hold the borrowed token to repay")
+
+g.node("acquire",
+ hl("Acquire Repayment Asset", "Route via Soroswap API or SDEX path payment",
+ "Swap XLM (or another held asset) for the borrowed token at current market rate"),
+ fillcolor=F_DECISION, color=B_DECISION)
+
+# ── Repay ─────────────────────────────────────────────────────────────────────
+g.node("repay",
+ hl("Repay Debt", "Pool.submit (RequestType 5 = Repay)",
+ "Blend pulls exact stated amount · refunds any excess in same tx\n"
+ "Tool caps repay at balance held - avoids overdraft"),
+ fillcolor=F_BACKEND, color=B_BACKEND)
+
+# ── Health factor gate ────────────────────────────────────────────────────────
+diamond(g, "hf",
+ "Health Factor ≥ 1.0 after repay?",
+ "Protocol rejects withdrawal that would undercollateralize remaining positions")
+
+g.node("hf_block",
+ hl("BLOCKED - Liquidation Risk", "Withdrawing collateral would undercollateralize position",
+ "Shown in plain language · user must repay more before tool can proceed"),
+ fillcolor=F_DANGER, color=B_DANGER, penwidth="2")
+
+# ── Withdraw ──────────────────────────────────────────────────────────────────
+g.node("withdraw",
+ hl("Withdraw Supply / Collateral", "Pool.submit (RequestType 1 = Withdraw or 3 = WithdrawCollateral)",
+ "Supplied and collateralized balances tracked separately - correct request type per position\n"
+ "Pass amount greater than balance: clamps to actual balance for a full exit with no dust"),
+ fillcolor=F_BACKEND, color=B_BACKEND)
+
+# ── Backstop gate ─────────────────────────────────────────────────────────────
+diamond(g, "backstop",
+ "Backstop Deposit?",
+ "Backstop is the pool's insurance layer - separate from supply")
+
+with g.subgraph(name="cluster_q4w") as q:
+ q.attr(label=hl("Queue-for-Withdrawal (Q4W) Cooldown"),
+ style="rounded,dashed", color=B_DANGER, fontcolor=B_DANGER,
+ fontname=FONT, fontsize="10", penwidth="1.2")
+ q.node("q4w_check",
+ hl("Already Queued?", "Check Q4W status on-chain"),
+ shape="diamond", fillcolor=F_DECISION, color=B_DECISION, penwidth="2", margin="0.3,0.2")
+ q.node("queue_start",
+ hl("Start Q4W Queue", "Call withdraw on the backstop module",
+ "V1: 21-day cooldown · V2: 17-day cooldown · Backstop token = BLND:USDC 80/20 Comet LP share"),
+ fillcolor=F_DANGER, color=B_DANGER)
+ q.node("q4w_wait",
+ hl("Backstop Funds Locked", "Wind-down continues with remaining steps",
+ "User warned funds unavailable until cooldown ends · shown remaining time"),
+ fillcolor=F_DANGER, color=B_DANGER)
+
+# ── Terminal ──────────────────────────────────────────────────────────────────
+g.node("done",
+ hl("Blend Position Closed", "bTokens redeemed · dToken debt cleared · proceeds in XLM",
+ "Flows into asset conversion step"),
+ shape="ellipse", fillcolor=F_SUCCESS, color=B_SUCCESS, penwidth="2")
+
+# ── Edges ─────────────────────────────────────────────────────────────────────
+g.edge("detect", "ver")
+g.edge("ver", "emissions")
+g.edge("emissions", "has_debt")
+g.edge("has_debt", "has_asset", label="yes - debt exists")
+g.edge("has_debt", "withdraw", label="no debt",
+ color=E_SUCCESS, fontcolor=B_SUCCESS)
+g.edge("has_asset", "repay", label="yes")
+g.edge("has_asset", "acquire", label="no - acquire first")
+g.edge("acquire", "repay")
+g.edge("repay", "hf")
+g.edge("hf", "withdraw", label="yes - safe to withdraw",
+ color=E_SUCCESS, fontcolor=B_SUCCESS)
+g.edge("hf", "hf_block", label="no - would undercollateralize",
+ color=E_DANGER, fontcolor=B_DANGER)
+g.edge("withdraw", "backstop")
+g.edge("backstop", "done", label="no backstop deposit",
+ color=E_SUCCESS, fontcolor=B_SUCCESS)
+g.edge("backstop", "q4w_check", label="has backstop deposit",
+ color=E_WARNING, fontcolor=B_DECISION)
+g.edge("q4w_check", "q4w_wait", label="already queued")
+g.edge("q4w_check", "queue_start", label="not queued -> queue now",
+ color=E_WARNING, fontcolor=B_DECISION)
+g.edge("queue_start","q4w_wait")
+g.edge("q4w_wait", "done",
+ style="dashed", label="after cooldown ends",
+ color=E_SUCCESS, fontcolor=B_SUCCESS)
+
+render(g, "07-blend-unwind")
diff --git a/scripts/diagrams/08-asset-conversion-routing.py b/scripts/diagrams/08-asset-conversion-routing.py
new file mode 100644
index 0000000..9e83209
--- /dev/null
+++ b/scripts/diagrams/08-asset-conversion-routing.py
@@ -0,0 +1,140 @@
+"""
+LumenWipe - 08 Asset Conversion & Routing
+After DeFi positions are unwound, non-XLM balances need a disposition.
+Each asset gets an explicit per-asset user choice: swap to XLM or return to issuer.
+"""
+import sys, os
+sys.path.insert(0, os.path.dirname(__file__))
+from _style import *
+import graphviz
+
+g = graphviz.Digraph("asset-conversion")
+g.attr(**base_graph_attr(
+ rankdir="TB",
+ splines="polyline",
+ size="14,16",
+ nodesep="0.6",
+ ranksep="0.75",
+ label=hl(
+ "LumenWipe - Asset Conversion & Routing",
+ "Every non-XLM balance gets an explicit per-asset disposition confirmed by the user",
+ ),
+))
+g.attr("node", **base_node_attr())
+g.attr("edge", **base_edge_attr())
+
+def diamond(g, nid, label, sub=""):
+ l = hl(label, sub) if sub else label
+ g.node(nid, l, shape="diamond", fillcolor=F_DECISION, color=B_DECISION,
+ penwidth="2", margin="0.35,0.2")
+
+# ── Entry ─────────────────────────────────────────────────────────────────────
+g.node("asset",
+ hl("Non-XLM Balance", "Classic token (trustline) or Soroban token",
+ "Each balance handled independently - no silent batch-swap"),
+ shape="ellipse", fillcolor=F_ACCENT, color=B_ACCENT, penwidth="2")
+
+# ── Quote phase ───────────────────────────────────────────────────────────────
+with g.subgraph(name="cluster_quote") as q:
+ q.attr(label=hl("Quote Phase", "Find best available route at current market price"),
+ style="rounded,dashed", color=B_DEFAULT, fontcolor=T_MED,
+ fontname=FONT, fontsize="10", penwidth="1.2")
+ q.node("soroswap_q",
+ hl("Soroswap API (primary)", "Routes across Soroban DEXes + classic SDEX",
+ "Covers classic tokens, Soroban tokens, and SAC-wrapped assets"),
+ fillcolor=F_EXTERNAL, color=B_EXTERNAL)
+ q.node("sdex_q",
+ hl("SDEX Strict-Send Path (fallback)", "Horizon-compatible /paths/strict-send",
+ "Classic order books + AMM pools · up to 6 hops · used when Soroswap has no route"),
+ fillcolor=F_EXTERNAL, color=B_EXTERNAL)
+
+diamond(g, "has_route",
+ "Route Found?",
+ "A route exists if at least one path from this asset to XLM is available")
+
+# ── User disposition ──────────────────────────────────────────────────────────
+g.node("disp_preview",
+ hl("Per-Asset Disposition - User Confirms in Preview", "Shown in accordion before any transaction is built",
+ "User makes an explicit choice for every non-XLM balance"),
+ fillcolor=F_CLIENT, color=B_CLIENT, penwidth="2")
+
+diamond(g, "disp_choice",
+ "User Disposition?",
+ "swap = default when route exists · return to issuer always requires explicit confirm")
+
+# ── Slippage protection ───────────────────────────────────────────────────────
+g.node("minrecv",
+ hl("Compute Minimum Received", "min_received = quoted_out × (1 − slippage_tolerance)",
+ "Re-quoted at build time - if route lost, falls back to return-to-issuer"),
+ fillcolor=F_BACKEND, color=B_BACKEND)
+
+diamond(g, "token_kind",
+ "Token Kind?",
+ "Determines which Stellar operation to use")
+
+# ── Swap execution ────────────────────────────────────────────────────────────
+g.node("pp",
+ hl("PathPaymentStrictSend", "Classic Stellar operation · dest_min = min_received",
+ "SDEX order books + classic AMM pools · enforces minimum received on-chain"),
+ fillcolor=F_BACKEND, color=B_BACKEND)
+
+g.node("invoke",
+ hl("InvokeHostFunction Swap", "Soroban swap via Soroswap router · min_out = min_received",
+ "Soroswap API builds XDR · client decodes and verifies contract + amounts before signing"),
+ fillcolor=F_BACKEND, color=B_BACKEND)
+
+g.node("swapped",
+ hl("Balance Converted to XLM", "Destination receives XLM ≥ min_received",
+ "Slippage beyond tolerance causes tx to fail - user retries at new price or lowers tolerance"),
+ fillcolor=F_SUCCESS, color=B_SUCCESS, penwidth="2")
+
+# ── Return-to-issuer path ─────────────────────────────────────────────────────
+with g.subgraph(name="cluster_issuer") as i:
+ i.attr(label=hl("Return-to-Issuer Path", "Irreversible · explicit confirmation required"),
+ style="rounded,dashed", color=B_DANGER, fontcolor=B_DANGER,
+ fontname=FONT, fontsize="10", penwidth="1.5")
+ i.node("issuer_confirm",
+ hl("User Confirms Return to Issuer", "Tool states explicitly: this is irreversible",
+ "Never labeled as a 'conversion' · right choice for spam tokens, dust, assets with no route"),
+ fillcolor=F_DANGER, color=B_DANGER)
+ i.node("issuer_pay",
+ hl("Payment to Issuer Address", "Sends full balance back · issuer burns it",
+ "Clears balance to zero · enables trustline removal"),
+ fillcolor=F_DANGER, color=B_DANGER)
+
+# ── Trustline removal ─────────────────────────────────────────────────────────
+g.node("rm_tl",
+ hl("Remove Trustline", "ChangeTrust · limit = 0",
+ "Preconditions: balance = 0 · buying_liabilities = 0 · no pool-share references\n"
+ "Frees 0.5 XLM base reserve (pool-share trustlines free 1.0 XLM)"),
+ fillcolor=F_SUCCESS, color=B_SUCCESS, penwidth="2", shape="ellipse")
+
+# ── Edges ─────────────────────────────────────────────────────────────────────
+g.edge("asset", "soroswap_q")
+g.edge("soroswap_q", "sdex_q", label="no route via Soroswap",
+ style="dashed", fontcolor=T_MED)
+g.edge("soroswap_q", "has_route", label="result")
+g.edge("sdex_q", "has_route", label="result")
+
+g.edge("has_route", "disp_preview", label="yes - route available")
+g.edge("has_route", "issuer_confirm", label="no route",
+ color=E_DANGER, fontcolor=B_DANGER)
+
+g.edge("disp_preview","disp_choice")
+g.edge("disp_choice", "minrecv", label="swap (default)")
+g.edge("disp_choice", "issuer_confirm", label="return to issuer\n(explicit)",
+ color=E_DANGER, fontcolor=B_DANGER)
+
+g.edge("minrecv", "token_kind")
+g.edge("token_kind", "pp", label="classic token")
+g.edge("token_kind", "invoke", label="Soroban token")
+g.edge("pp", "swapped")
+g.edge("invoke", "swapped")
+g.edge("swapped", "rm_tl",
+ color=E_SUCCESS, fontcolor=B_SUCCESS)
+
+g.edge("issuer_confirm", "issuer_pay")
+g.edge("issuer_pay", "rm_tl",
+ color=E_SUCCESS, fontcolor=B_SUCCESS)
+
+render(g, "08-asset-conversion-routing")
diff --git a/scripts/diagrams/09-mediator-flow.py b/scripts/diagrams/09-mediator-flow.py
new file mode 100644
index 0000000..eebc671
--- /dev/null
+++ b/scripts/diagrams/09-mediator-flow.py
@@ -0,0 +1,153 @@
+"""
+LumenWipe - 09 Mediator Account Flow
+Exchanges don't support AccountMerge. They require Payment + memo to credit the user.
+The mediator bridges this: one atomic two-operation transaction.
+User signs the merge half; backend co-signs the forward payment - only after strict validation.
+"""
+import sys, os
+sys.path.insert(0, os.path.dirname(__file__))
+from _style import *
+import graphviz
+
+g = graphviz.Digraph("mediator-flow")
+g.attr(**base_graph_attr(
+ rankdir="TB",
+ splines="polyline",
+ size="13,17",
+ nodesep="0.55",
+ ranksep="0.7",
+ label=hl(
+ "LumenWipe - Mediator Account Flow (Exchange Destinations)",
+ "Exchanges don't support AccountMerge · one atomic two-op transaction bridges the gap",
+ ),
+))
+g.attr("node", **base_node_attr())
+g.attr("edge", **base_edge_attr())
+
+def diamond(g, nid, label, sub=""):
+ l = hl(label, sub) if sub else label
+ g.node(nid, l, shape="diamond", fillcolor=F_DECISION, color=B_DECISION,
+ penwidth="2", margin="0.35,0.2")
+
+# ── Why the mediator exists ───────────────────────────────────────────────────
+with g.subgraph(name="cluster_why") as w:
+ w.attr(label=hl("Why a Mediator is Needed"),
+ style="rounded,dashed", color=B_DEFAULT, fontcolor=T_MED,
+ fontname=FONT, fontsize="10", penwidth="1.2")
+ w.node("why1",
+ hl("Exchange Constraint", "No major exchange supports AccountMerge",
+ "Direct merge to exchange deposit address -> funds typically lost"),
+ fillcolor=F_DANGER, color=B_DANGER)
+ w.node("why2",
+ hl("Exchange Credit Requirement", "Exchanges credit by: deposit address + memo",
+ "Payment operation with correct memo type required for crediting"),
+ fillcolor=F_DANGER, color=B_DANGER)
+
+# ── Detection ─────────────────────────────────────────────────────────────────
+g.node("dest_entry",
+ hl("User Enters Destination Address", "Final step of the setup flow",
+ "Exchange detection happens at destination entry - depends on the destination"),
+ fillcolor=F_CLIENT, color=B_CLIENT)
+
+diamond(g, "is_exchange",
+ "Known Exchange / Anchor?",
+ "Exchange registry sourced from stellar.expert directory")
+
+g.node("direct_merge",
+ hl("Direct AccountMerge", "Destination is a regular wallet address",
+ "Standard flow - no mediator needed"),
+ fillcolor=F_SUCCESS, color=B_SUCCESS)
+
+# ── Exchange path ─────────────────────────────────────────────────────────────
+diamond(g, "has_memo",
+ "Memo Provided?",
+ "Correct memo type required per registry (text / id / hash)")
+
+g.node("memo_block",
+ hl("BLOCKED - Missing Memo", "Submission blocked until memo is provided",
+ "Known exchange without memo -> funds would be lost · tool enforces this"),
+ fillcolor=F_DANGER, color=B_DANGER, penwidth="2")
+
+# ── Build atomic transaction ──────────────────────────────────────────────────
+with g.subgraph(name="cluster_tx") as tx:
+ tx.attr(label=hl("Client Builds One Atomic Transaction (two operations)",
+ "Both operations apply or neither - atomicity guaranteed by Stellar protocol"),
+ style="rounded", color=B_CLIENT, fontcolor=B_CLIENT,
+ fontname=FONT, fontsize="10", penwidth="2")
+ tx.node("op1",
+ hl("op 1 - AccountMerge", "Source account -> Mediator account",
+ "Transfers all source XLM into the mediator · source account deleted"),
+ fillcolor=F_CLIENT, color=B_CLIENT)
+ tx.node("op2",
+ hl("op 2 - Payment", "Mediator -> Exchange deposit address",
+ "Carries required memo · amount = all recovered XLM · source: mediator"),
+ fillcolor=F_CLIENT, color=B_CLIENT)
+
+# ── Signing split ─────────────────────────────────────────────────────────────
+g.node("user_sign",
+ hl("User Signs the Transaction", "User's private key signs op1 (AccountMerge)",
+ "Key stays in browser · never transmitted · wallet adapter or secret-key mode"),
+ fillcolor=F_CLIENT, color=B_CLIENT)
+
+# ── Backend validation + co-sign ─────────────────────────────────────────────
+with g.subgraph(name="cluster_validate") as v:
+ v.attr(label=hl("Backend Co-Sign - Strict Validation First",
+ "Backend holds mediator key · cannot sign for user's account"),
+ style="rounded", color=B_BACKEND, fontcolor=B_BACKEND,
+ fontname=FONT, fontsize="10", penwidth="2")
+ v.node("validate",
+ hl("Validate Exact Transaction Shape", "Decodes and checks every field before signing",
+ "op1 must be AccountMerge into the mediator · op2 must be Payment from mediator\n"
+ "Destination = user's chosen address (cannot be changed) · Amount ≥ 1 XLM"),
+ fillcolor=F_BACKEND, color=B_BACKEND)
+ v.node("cosign",
+ hl("Co-sign op2 with Mediator Key", "Backend signs only the forwarding payment",
+ "Cannot sign for user account · cannot redirect destination · cannot change amount"),
+ fillcolor=F_BACKEND, color=B_BACKEND)
+
+# ── Submission and outcome ────────────────────────────────────────────────────
+g.node("submit",
+ hl("Submit Combined Transaction", "Client submits to Stellar RPC · sendTransaction",
+ "Signed by user (op1) + mediator key (op2) - both signatures present"),
+ fillcolor=F_EXTERNAL, color=B_EXTERNAL)
+
+with g.subgraph(name="cluster_outcome") as o:
+ o.attr(label=hl("Atomic Outcome"),
+ style="rounded,dashed", color=B_SUCCESS, fontcolor=B_SUCCESS,
+ fontname=FONT, fontsize="10", penwidth="1.2")
+ o.node("exchange_credit",
+ hl("Exchange Credits User", "By deposit address + memo (exactly as provided)",
+ "Standard Payment credit - indistinguishable from a normal deposit"),
+ fillcolor=F_SUCCESS, color=B_SUCCESS)
+ o.node("mediator_stays",
+ hl("Mediator Retains ~1 XLM Base Reserve", "Permanent mediator account - reused for every close",
+ "User recovers essentially all XLM · only standard network fees apply\n"
+ "No per-user 1 XLM sacrifice (unlike throwaway intermediary accounts)"),
+ fillcolor=F_DEFAULT, color=B_DEFAULT)
+
+# ── Edges ─────────────────────────────────────────────────────────────────────
+g.edge("why1", "why2", style="invis") # vertical order in cluster
+
+g.edge("dest_entry", "is_exchange")
+g.edge("is_exchange", "direct_merge", label="regular wallet",
+ color=E_SUCCESS, fontcolor=B_SUCCESS)
+g.edge("is_exchange", "has_memo", label="exchange / anchor detected",
+ color=E_WARNING, fontcolor=B_DECISION)
+g.edge("has_memo", "op1", label="memo provided")
+g.edge("has_memo", "memo_block", label="memo missing",
+ color=E_DANGER, fontcolor=B_DANGER)
+
+g.edge("op1", "op2", label="same atomic tx")
+g.edge("op2", "user_sign")
+g.edge("user_sign", "validate")
+g.edge("validate", "cosign", label="shape verified",
+ color=E_SUCCESS, fontcolor=B_SUCCESS)
+g.edge("validate", "memo_block", label="invalid shape -> rejected",
+ color=E_DANGER, fontcolor=B_DANGER, style="dashed")
+g.edge("cosign", "submit")
+g.edge("submit", "exchange_credit",
+ color=E_SUCCESS, fontcolor=B_SUCCESS)
+g.edge("submit", "mediator_stays",
+ color=E_SUCCESS, fontcolor=B_SUCCESS)
+
+render(g, "09-mediator-flow")
diff --git a/scripts/diagrams/_style.py b/scripts/diagrams/_style.py
new file mode 100644
index 0000000..0bad679
--- /dev/null
+++ b/scripts/diagrams/_style.py
@@ -0,0 +1,112 @@
+"""
+Shared design system for all LumenWipe diagrams.
+Professional palette · transparent background · light/dark mode compatible.
+"""
+
+# ── Background ────────────────────────────────────────────────────────────────
+BGCOLOR = "transparent"
+
+# ── Typography ────────────────────────────────────────────────────────────────
+FONT = "Helvetica"
+T_DARK = "#1E293B" # slate-900 - primary text
+T_MED = "#475569" # slate-600 - secondary / edge labels
+T_LITE = "#94A3B8" # slate-400 - minor annotations
+
+# ── Node fills (light, opaque) - show as "cards" on dark backgrounds ─────────
+F_DEFAULT = "#F8FAFC" # slate-50 general nodes
+F_CLIENT = "#EFF6FF" # blue-50 browser / client
+F_BACKEND = "#F0FDF4" # green-50 read-only backend
+F_EXTERNAL = "#FAF5FF" # purple-50 external services / Stellar network
+F_DECISION = "#FFFBEB" # amber-50 decision / gate nodes
+F_SUCCESS = "#ECFDF5" # emerald-50 success / terminal states
+F_DANGER = "#FEF2F2" # red-50 error / blocker states
+F_ACCENT = "#F0F9FF" # sky-50 accent / highlight nodes
+
+# ── Borders ───────────────────────────────────────────────────────────────────
+B_DEFAULT = "#64748B" # slate-500
+B_CLIENT = "#3B82F6" # blue-500
+B_BACKEND = "#16A34A" # green-600
+B_EXTERNAL = "#9333EA" # purple-600
+B_DECISION = "#D97706" # amber-600
+B_SUCCESS = "#059669" # emerald-600
+B_DANGER = "#DC2626" # red-600
+B_ACCENT = "#0284C7" # sky-600
+
+# ── Edges ─────────────────────────────────────────────────────────────────────
+E_DEFAULT = "#94A3B8" # slate-400
+E_SUCCESS = "#059669" # emerald-600
+E_DANGER = "#DC2626" # red-600
+E_WARNING = "#D97706" # amber-600
+
+
+def render(g, name: str, out: str = "docs/diagrams/output") -> None:
+ """Render graph to both SVG and PNG."""
+ from pathlib import Path
+ Path(out).mkdir(parents=True, exist_ok=True)
+ svg = g.pipe(format="svg")
+ png = g.pipe(format="png")
+ Path(f"{out}/{name}.svg").write_bytes(svg)
+ Path(f"{out}/{name}.png").write_bytes(png)
+ print(f" ✓ {name} (.svg + .png)")
+
+
+def base_graph_attr(**extra):
+ return {
+ "bgcolor": BGCOLOR,
+ "fontname": FONT,
+ "fontsize": "13",
+ "fontcolor": T_DARK,
+ "labelloc": "t",
+ "labeljust": "l",
+ "pad": "0.7",
+ "nodesep": "0.55",
+ "ranksep": "0.8",
+ "dpi": "150",
+ **extra,
+ }
+
+
+def base_node_attr(**extra):
+ return {
+ "shape": "box",
+ "style": "filled,rounded",
+ "fillcolor": F_DEFAULT,
+ "color": B_DEFAULT,
+ "fontname": FONT,
+ "fontsize": "11",
+ "fontcolor": T_DARK,
+ "margin": "0.22,0.13",
+ "penwidth": "1.6",
+ **extra,
+ }
+
+
+def base_edge_attr(**extra):
+ return {
+ "color": E_DEFAULT,
+ "fontname": FONT,
+ "fontsize": "10",
+ "fontcolor": T_MED,
+ "arrowsize": "0.85",
+ "penwidth": "1.4",
+ **extra,
+ }
+
+
+def _safe(text: str) -> str:
+ """Sanitize text for Graphviz HTML label content (<...>).
+ - \\n -> newlines break the DOT parser when inside HTML label text
+ - -> -> -> Graphviz 15 parses -> as edge operator even inside <...>
+ > is a supported HTML entity and renders as '>'
+ """
+ return text.replace("\n", " ").replace("->", "->")
+
+
+def hl(title: str, subtitle: str = "", subtitle2: str = "") -> str:
+ """HTML label: bold title + optional smaller subtitle lines."""
+ s = f"{_safe(title)} "
+ if subtitle:
+ s += f'{_safe(subtitle)} '
+ if subtitle2:
+ s += f'{_safe(subtitle2)} '
+ return f"<{s}>"
diff --git a/scripts/diagrams/render-all.py b/scripts/diagrams/render-all.py
new file mode 100644
index 0000000..383db4c
--- /dev/null
+++ b/scripts/diagrams/render-all.py
@@ -0,0 +1,42 @@
+"""
+Render all LumenWipe diagrams to docs/diagrams/output/ (SVG + PNG).
+Usage: python scripts/diagrams/render-all.py (from repo root)
+"""
+import subprocess
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).parent.parent.parent
+OUTPUT = ROOT / "docs" / "diagrams" / "output"
+OUTPUT.mkdir(parents=True, exist_ok=True)
+
+scripts = [
+ "01-system-architecture.py",
+ "02-data-flow.py",
+ "03-state-machine.py",
+ "04-signing-flow.py",
+ "05-defi-adapter-fallback.py",
+ "06-execution-plan.py",
+ "07-blend-unwind.py",
+ "08-asset-conversion-routing.py",
+ "09-mediator-flow.py",
+]
+
+base = Path(__file__).parent
+errors = []
+
+for script in scripts:
+ path = base / script
+ result = subprocess.run([sys.executable, str(path)], cwd=str(ROOT),
+ capture_output=True, text=True)
+ if result.returncode != 0:
+ print(f"\n✗ {script}\n{result.stderr}")
+ errors.append(script)
+ elif result.stdout:
+ print(result.stdout.strip())
+
+if errors:
+ print(f"\n{len(errors)} script(s) failed: {errors}")
+ sys.exit(1)
+else:
+ print(f"\nAll diagrams rendered → {OUTPUT}")
diff --git a/tests/e2e/close-api.spec.ts b/tests/e2e/close-api.spec.ts
index e2f1b78..5d15ce9 100644
--- a/tests/e2e/close-api.spec.ts
+++ b/tests/e2e/close-api.spec.ts
@@ -54,7 +54,7 @@ test("close API: plan -> transactions -> sign -> submit merges a fresh account",
expect(closeTx.covers).toContain("MERGE");
expect(txBody.remaining.requiresAnotherCall).toBe(false);
- // 3. Sign locally — keys never leave the client.
+ // 3. Sign locally - keys never leave the client.
const tx = TransactionBuilder.fromXDR(closeTx.xdr, closeTx.networkPassphrase ?? Networks.TESTNET);
tx.sign(source);
const signedXdr = tx.toEnvelope().toXDR("base64");
diff --git a/tests/unit/close-api-plan-response.test.ts b/tests/unit/close-api-plan-response.test.ts
index 6b0dfd2..c9cba41 100644
--- a/tests/unit/close-api-plan-response.test.ts
+++ b/tests/unit/close-api-plan-response.test.ts
@@ -42,12 +42,22 @@ test("computePlanHash is stable for the same inputs and changes when any input c
test("computePlanHash ignores decision ordering", () => {
const a = computePlanHash({
- source: "GSRC", destination: null, snapshotLedger: 1,
- decisions: [{ id: "b", choice: "x" }, { id: "a", choice: "y" }],
+ source: "GSRC",
+ destination: null,
+ snapshotLedger: 1,
+ decisions: [
+ { id: "b", choice: "x" },
+ { id: "a", choice: "y" },
+ ],
});
const b = computePlanHash({
- source: "GSRC", destination: null, snapshotLedger: 1,
- decisions: [{ id: "a", choice: "y" }, { id: "b", choice: "x" }],
+ source: "GSRC",
+ destination: null,
+ snapshotLedger: 1,
+ decisions: [
+ { id: "a", choice: "y" },
+ { id: "b", choice: "x" },
+ ],
});
expect(a).toBe(b);
});
@@ -57,7 +67,11 @@ test("toExecutionBreakdown collapses a fused-eligible plan into one transaction"
const breakdown = toExecutionBreakdown(steps);
expect(breakdown.estimatedTransactionCount).toBe(1);
expect(breakdown.transactions).toHaveLength(1);
- expect(breakdown.transactions[0].covers).toEqual(["CONVERT_ASSETS", "REMOVE_TRUSTLINES", "MERGE"]);
+ expect(breakdown.transactions[0].covers).toEqual([
+ "CONVERT_ASSETS",
+ "REMOVE_TRUSTLINES",
+ "MERGE",
+ ]);
});
test("toExecutionBreakdown reports zero transactions for an empty plan", () => {
diff --git a/tests/unit/intent-serialize.test.ts b/tests/unit/intent-serialize.test.ts
index 342b400..d0a6d64 100644
--- a/tests/unit/intent-serialize.test.ts
+++ b/tests/unit/intent-serialize.test.ts
@@ -1,5 +1,12 @@
import { test, expect } from "bun:test";
-import { Account, Asset, Operation, TransactionBuilder, Networks, Keypair } from "@stellar/stellar-sdk";
+import {
+ Account,
+ Asset,
+ Operation,
+ TransactionBuilder,
+ Networks,
+ Keypair,
+} from "@stellar/stellar-sdk";
import { intentFromXdr } from "@/lib/stellar/intent/serialize";
const SRC = Keypair.random().publicKey();