plotlink-ows 0.1.18 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +167 -67
- package/app/lib/publish.ts +134 -32
- package/app/routes/dashboard.ts +64 -13
- package/app/routes/publish.ts +52 -1
- package/app/routes/settings.ts +194 -0
- package/app/routes/stories.ts +75 -8
- package/app/routes/terminal.ts +167 -63
- package/app/server.ts +7 -1
- package/app/web/components/Dashboard.tsx +83 -32
- package/app/web/components/PreviewPanel.tsx +280 -41
- package/app/web/components/Settings.tsx +227 -3
- package/app/web/components/StoriesPage.tsx +121 -8
- package/app/web/components/StoryBrowser.tsx +32 -8
- package/app/web/components/TerminalPanel.tsx +384 -78
- package/app/web/dist/assets/index-BuOxhUWG.css +32 -0
- package/app/web/dist/assets/index-De8CpT47.js +129 -0
- package/app/web/dist/index.html +2 -2
- package/app/web/styles.css +18 -0
- package/bin/plotlink-ows.js +16 -61
- package/package.json +7 -2
- package/scripts/fix-index-status.ts +93 -0
- package/app/web/dist/assets/index-D5gfwaEX.css +0 -32
- package/app/web/dist/assets/index-pBt5Q_bN.js +0 -117
package/README.md
CHANGED
|
@@ -1,28 +1,60 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
1
3
|
# PlotLink OWS Writer
|
|
2
4
|
|
|
3
|
-
|
|
5
|
+
### Anyone can become a fiction writer with just an idea.
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
<p>
|
|
8
|
+
<a href="https://plotlink.xyz"><strong>Live App</strong></a> ·
|
|
9
|
+
<a href="#-quick-start"><strong>Quick Start</strong></a> ·
|
|
10
|
+
<a href="#how-it-works"><strong>How it Works</strong></a> ·
|
|
11
|
+
<a href="https://docs.openwallet.sh/"><strong>OWS Docs</strong></a>
|
|
12
|
+
</p>
|
|
6
13
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
14
|
+
<p>
|
|
15
|
+
<a href="https://plotlink.xyz"><img src="https://img.shields.io/badge/Live_App-plotlink.xyz-8B4513" alt="Live App" /></a>
|
|
16
|
+
<a href="https://www.npmjs.com/package/plotlink-ows"><img src="https://img.shields.io/npm/v/plotlink-ows" alt="npm version" /></a>
|
|
17
|
+
<a href="https://openwallet.sh"><img src="https://img.shields.io/badge/OWS-Open_Wallet_Standard-00d4aa" alt="OWS" /></a>
|
|
18
|
+
<a href="https://eips.ethereum.org/EIPS/eip-8004"><img src="https://img.shields.io/badge/ERC--8004-Base-3b82f6" alt="ERC-8004" /></a>
|
|
19
|
+
<img src="https://img.shields.io/badge/social-Farcaster-8b5cf6" alt="Farcaster" />
|
|
20
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT License" /></a>
|
|
21
|
+
</p>
|
|
22
|
+
|
|
23
|
+
<br/>
|
|
24
|
+
|
|
25
|
+
<div>
|
|
26
|
+
<video src="https://github.com/user-attachments/assets/467937eb-bb61-4e5c-a650-dbc44877b139" width="720" controls></video>
|
|
27
|
+
</div>
|
|
28
|
+
|
|
29
|
+
<br/>
|
|
30
|
+
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
## What is PlotLink OWS Writer?
|
|
34
|
+
|
|
35
|
+
A local writing workspace that pairs you with an AI co-writer to create and publish tokenized fiction stories on [plotlink.xyz](https://plotlink.xyz). Claude CLI writes in an embedded terminal, you preview and iterate live, and your OWS wallet signs on-chain transactions — your private key never leaves your machine.
|
|
11
36
|
|
|
12
|
-
|
|
37
|
+
Every story you publish becomes a tradable token on a bonding curve. Readers who believe in your story buy in early, and you earn **5% royalties on every trade**.
|
|
13
38
|
|
|
14
|
-
|
|
39
|
+
### Why it matters
|
|
15
40
|
|
|
16
|
-
|
|
41
|
+
- **No writing experience needed** — AI does the heavy lifting
|
|
42
|
+
- **No crypto complexity** — OWS handles wallet and signing
|
|
43
|
+
- **You keep control** — keys encrypted locally, bring your own AI
|
|
44
|
+
- **Earn from day one** — stories monetize through bonding curves immediately
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## How it Works
|
|
17
49
|
|
|
18
50
|
```
|
|
19
|
-
You: "Let's write a sci-fi story about an AI that discovers
|
|
51
|
+
You: "Let's write a sci-fi story about an AI that discovers dreams"
|
|
20
52
|
|
|
21
|
-
↓ Claude
|
|
53
|
+
↓ Claude brainstorms, outlines, writes chapter files
|
|
22
54
|
|
|
23
55
|
Stories saved to: stories/dreaming-ai/genesis.md, plot-01.md, ...
|
|
24
56
|
|
|
25
|
-
↓ Live preview in the browser —
|
|
57
|
+
↓ Live preview in the browser — review and iterate
|
|
26
58
|
|
|
27
59
|
↓ Click "Publish" on any chapter
|
|
28
60
|
|
|
@@ -40,7 +72,51 @@ On-chain: Story published to PlotLink on Base
|
|
|
40
72
|
5. **Publish** — Click publish on any chapter to go on-chain via your OWS wallet
|
|
41
73
|
6. **Earn** — Your story is live on [plotlink.xyz](https://plotlink.xyz) with a bonding curve
|
|
42
74
|
|
|
43
|
-
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## 🔐 Built on Open Wallet Standard (OWS)
|
|
78
|
+
|
|
79
|
+
All signing operations use **[OWS](https://github.com/open-wallet-standard/core)** — no raw private keys are ever exposed to scripts or environment variables.
|
|
80
|
+
|
|
81
|
+
| Operation | How |
|
|
82
|
+
|-----------|-----|
|
|
83
|
+
| Wallet creation | `npx plotlink-ows init` creates encrypted wallet in `~/.ows/` |
|
|
84
|
+
| Story publishing | viem wallet client with OWS custom account adapter |
|
|
85
|
+
| Transaction signing | OWS decrypts key in memory, signs, zeroes immediately |
|
|
86
|
+
| Policy control | Chain-restricted to Base, passphrase-gated |
|
|
87
|
+
|
|
88
|
+
Your key is **encrypted at rest**, signing happens **in-process**, and the key **never leaves the vault**.
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## 🚀 Quick Start
|
|
93
|
+
|
|
94
|
+
### Prerequisites
|
|
95
|
+
|
|
96
|
+
- Node.js 20+
|
|
97
|
+
- [Claude CLI](https://docs.anthropic.com/en/docs/claude-code) (or any AI assistant)
|
|
98
|
+
- A small amount of ETH on Base for gas (~$0.01 per publish)
|
|
99
|
+
|
|
100
|
+
### Install & Run
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
npx plotlink-ows init # set passphrase + create wallet
|
|
104
|
+
npx plotlink-ows # start app + open browser
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
The setup wizard creates your encrypted OWS wallet. Then the workspace opens with Claude CLI ready to write.
|
|
108
|
+
|
|
109
|
+
### Commands
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
npx plotlink-ows # Start app (Ctrl+C to stop)
|
|
113
|
+
npx plotlink-ows init # Guided setup wizard
|
|
114
|
+
npx plotlink-ows status # Show config + wallet info
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## 🏗️ Architecture
|
|
44
120
|
|
|
45
121
|
```
|
|
46
122
|
┌──────────────────────────────────────────────────┐
|
|
@@ -49,7 +125,6 @@ On-chain: Story published to PlotLink on Base
|
|
|
49
125
|
│ ┌──────────┐ ┌──────────────┐ ┌───────────┐ │
|
|
50
126
|
│ │ Story │ │ Terminal │ │ Preview │ │
|
|
51
127
|
│ │ Browser │ │ (Claude CLI)│ │ (Live MD)│ │
|
|
52
|
-
│ │ │ │ │ │ │ │
|
|
53
128
|
│ └────┬─────┘ └──────┬───────┘ └─────┬─────┘ │
|
|
54
129
|
│ │ │ │ │
|
|
55
130
|
│ └───────┬───────┘ │ │
|
|
@@ -58,89 +133,114 @@ On-chain: Story published to PlotLink on Base
|
|
|
58
133
|
│ │ stories/ │ │ OWS Wallet │ │
|
|
59
134
|
│ │ (local files) │ │ (encrypted) │ │
|
|
60
135
|
│ └────────────────┘ └────────┬────────┘ │
|
|
61
|
-
│
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
└────────────────┘
|
|
136
|
+
│ sign + publish │
|
|
137
|
+
└─────────────────────┬───────────────────────────┘
|
|
138
|
+
↓
|
|
139
|
+
┌────────────────┐ ┌──────────────┐
|
|
140
|
+
│ Base (L2) │ │ IPFS │
|
|
141
|
+
│ StoryFactory │ │ (Filebase) │
|
|
142
|
+
│ Bonding Curve │ │ Content │
|
|
143
|
+
└────────────────┘ └──────────────┘
|
|
144
|
+
↓
|
|
145
|
+
┌────────────────┐
|
|
146
|
+
│ plotlink.xyz │
|
|
147
|
+
│ Live story + │
|
|
148
|
+
│ token trading │
|
|
149
|
+
└────────────────┘
|
|
76
150
|
```
|
|
77
151
|
|
|
78
|
-
|
|
152
|
+
---
|
|
79
153
|
|
|
80
|
-
|
|
154
|
+
## 📁 Story Structure
|
|
155
|
+
|
|
156
|
+
Stories are plain markdown files — no database, no proprietary format.
|
|
157
|
+
|
|
158
|
+
```
|
|
159
|
+
stories/
|
|
160
|
+
my-story/
|
|
161
|
+
structure.md # Outline, characters, arc (not published)
|
|
162
|
+
genesis.md # Synopsis hook (~1,000 chars)
|
|
163
|
+
plot-01.md # Chapter 1 (max 10,000 chars)
|
|
164
|
+
plot-02.md # Chapter 2
|
|
165
|
+
...
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
| File | Purpose | Limit |
|
|
169
|
+
|------|---------|-------|
|
|
170
|
+
| `structure.md` | Story architecture — characters, world, arc | No limit (not published) |
|
|
171
|
+
| `genesis.md` | Synopsis hook that makes readers want more | ~1,000 chars |
|
|
172
|
+
| `plot-*.md` | Story chapters, published sequentially | 10,000 chars each |
|
|
81
173
|
|
|
82
|
-
|
|
174
|
+
---
|
|
83
175
|
|
|
84
|
-
|
|
176
|
+
## 💰 Cost
|
|
85
177
|
|
|
86
|
-
|
|
178
|
+
| Operation | Cost |
|
|
179
|
+
|-----------|------|
|
|
180
|
+
| Publishing a story (genesis) | ~$0.02 gas + creation fee |
|
|
181
|
+
| Chaining a new chapter | ~$0.01 gas |
|
|
182
|
+
| **Total per story** | **< $0.05** |
|
|
183
|
+
|
|
184
|
+
Royalties: **5% on every trade** of your story token, forever.
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
## 🛠️ Tech Stack
|
|
87
189
|
|
|
88
190
|
| Layer | Technology |
|
|
89
191
|
|-------|-----------|
|
|
90
192
|
| **Backend** | Hono (localhost:7777) |
|
|
91
193
|
| **Frontend** | React 19 + Vite |
|
|
92
194
|
| **Terminal** | xterm.js + node-pty (embedded Claude CLI) |
|
|
93
|
-
| **Database** | SQLite + Prisma (auth sessions) |
|
|
94
195
|
| **Wallet** | OWS (`@open-wallet-standard/core`) |
|
|
95
|
-
| **AI** | Claude CLI (or any AI assistant
|
|
196
|
+
| **AI** | Claude CLI (or any AI assistant) |
|
|
96
197
|
| **Chain** | Base (L2) |
|
|
97
|
-
| **Storage** | IPFS via
|
|
198
|
+
| **Storage** | IPFS via PlotLink API |
|
|
98
199
|
| **On-chain** | PlotLink StoryFactory + Mint Club V2 bonding curves |
|
|
99
|
-
| **
|
|
100
|
-
|
|
101
|
-
## Getting Started
|
|
102
|
-
|
|
103
|
-
### Prerequisites
|
|
104
|
-
|
|
105
|
-
- Node.js 20+
|
|
106
|
-
- [Claude CLI](https://docs.anthropic.com/en/docs/claude-code) (or any AI CLI)
|
|
107
|
-
- A small amount of ETH on Base for gas (~$0.01 per publish)
|
|
200
|
+
| **Identity** | ERC-8004 agent registry |
|
|
201
|
+
| **Design** | PlotLink Moleskine — warm cream, Lora serif, literary |
|
|
108
202
|
|
|
109
|
-
|
|
203
|
+
---
|
|
110
204
|
|
|
111
|
-
|
|
112
|
-
npx plotlink-ows init # set passphrase + create wallet
|
|
113
|
-
npx plotlink-ows # start app + open browser
|
|
114
|
-
```
|
|
205
|
+
## What is PlotLink?
|
|
115
206
|
|
|
116
|
-
|
|
207
|
+
[PlotLink](https://plotlink.xyz) is an on-chain storytelling protocol on Base. Writers publish storylines that automatically deploy an ERC-20 token on a bonding curve. Each new chapter drives trading demand, and every trade generates 5% royalties for the author. Stories are stored permanently on IPFS.
|
|
117
208
|
|
|
118
|
-
|
|
209
|
+
PlotLink supports both human writers and AI agent writers via [ERC-8004](https://eips.ethereum.org/EIPS/eip-8004) identity registry.
|
|
119
210
|
|
|
120
|
-
|
|
121
|
-
npx plotlink-ows # Start app + open browser
|
|
122
|
-
npx plotlink-ows init # Guided setup wizard
|
|
123
|
-
npx plotlink-ows stop # Stop the server
|
|
124
|
-
npx plotlink-ows status # Show config + wallet + server status
|
|
125
|
-
```
|
|
211
|
+
---
|
|
126
212
|
|
|
127
|
-
|
|
213
|
+
## Development
|
|
128
214
|
|
|
129
215
|
```bash
|
|
130
216
|
git clone https://github.com/realproject7/plotlink-ows.git
|
|
131
217
|
cd plotlink-ows
|
|
132
218
|
npm install
|
|
133
219
|
npm run app:dev # Start local writer app (Hono + Vite dev)
|
|
134
|
-
npm run app:build # Build for production
|
|
220
|
+
npm run app:build # Build frontend for production
|
|
135
221
|
npm run app:start # Serve production build
|
|
136
222
|
```
|
|
137
223
|
|
|
138
|
-
### Environment Variables
|
|
139
|
-
|
|
140
224
|
See [`.env.example`](.env.example) for configuration options.
|
|
141
225
|
|
|
142
|
-
|
|
226
|
+
---
|
|
227
|
+
|
|
228
|
+
## 🔗 Links
|
|
229
|
+
|
|
230
|
+
- **Live App**: [plotlink.xyz](https://plotlink.xyz)
|
|
231
|
+
- **npm**: [plotlink-ows](https://www.npmjs.com/package/plotlink-ows)
|
|
232
|
+
- **OWS**: [openwallet.sh](https://openwallet.sh) · [Docs](https://docs.openwallet.sh/) · [GitHub](https://github.com/open-wallet-standard/core)
|
|
233
|
+
- **ERC-8004 Registry**: [`0x8004...a432`](https://basescan.org/address/0x8004A169FB4a3325136EB29fA0ceB6D2e539a432) on Base
|
|
234
|
+
- **StoryFactory**: [`0x9D2A...44Cf`](https://basescan.org/address/0x9D2AE1E99D0A6300bfcCF41A82260374e38744Cf) on Base
|
|
235
|
+
|
|
236
|
+
---
|
|
237
|
+
|
|
238
|
+
## License
|
|
239
|
+
|
|
240
|
+
MIT
|
|
241
|
+
|
|
242
|
+
---
|
|
143
243
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
244
|
+
<div align="center">
|
|
245
|
+
<sub>Built by <a href="https://plotlink.xyz">Project7</a></sub>
|
|
246
|
+
</div>
|
package/app/lib/publish.ts
CHANGED
|
@@ -31,7 +31,7 @@ function parseEvmSignature(sigHex: string, recoveryId?: number): { r: Hex; s: He
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
/** Create a viem-compatible account backed by OWS wallet (same pattern as claw-on-chain) */
|
|
34
|
-
function createOwsAccount(walletName: string, address: `0x${string}`) {
|
|
34
|
+
export function createOwsAccount(walletName: string, address: `0x${string}`) {
|
|
35
35
|
const passphrase = process.env.OWS_PASSPHRASE;
|
|
36
36
|
return toAccount({
|
|
37
37
|
address,
|
|
@@ -55,11 +55,13 @@ export interface PublishResult {
|
|
|
55
55
|
txHash: string;
|
|
56
56
|
contentCid: string;
|
|
57
57
|
storylineId?: number;
|
|
58
|
+
plotIndex?: number;
|
|
58
59
|
gasCost?: string;
|
|
60
|
+
indexError?: string;
|
|
59
61
|
}
|
|
60
62
|
|
|
61
63
|
export interface PublishProgress {
|
|
62
|
-
step: "uploading" | "estimating" | "signing" | "broadcasting" | "confirming" | "done" | "error";
|
|
64
|
+
step: "uploading" | "estimating" | "signing" | "broadcasting" | "confirming" | "indexing" | "done" | "error";
|
|
63
65
|
message: string;
|
|
64
66
|
txHash?: string;
|
|
65
67
|
contentCid?: string;
|
|
@@ -67,6 +69,75 @@ export interface PublishProgress {
|
|
|
67
69
|
error?: string;
|
|
68
70
|
}
|
|
69
71
|
|
|
72
|
+
// Indexing retry tuning (per #103 RCA — combo A+B):
|
|
73
|
+
// - 8s initial delay lets the on-chain tx propagate to plotlink.xyz's RPC
|
|
74
|
+
// and gives Filebase → public IPFS gateway propagation a head start.
|
|
75
|
+
// - 10 attempts × 30s interval ≈ 4.5 min total, fitting inside plotlink.xyz's
|
|
76
|
+
// 5-min indexable window so users don't escalate to a full Retry Publish
|
|
77
|
+
// (which would mint another chainPlot tx and inflate the on-chain index).
|
|
78
|
+
const INDEX_INITIAL_DELAY_MS = 8_000;
|
|
79
|
+
const INDEX_RETRY_ATTEMPTS = 10;
|
|
80
|
+
const INDEX_RETRY_INTERVAL_MS = 30_000;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* POST to plotlink.xyz's indexer with an initial delay and a retry loop.
|
|
84
|
+
* Streams "Indexing… (attempt N/M)" progress so the publish flow surfaces
|
|
85
|
+
* the in-progress state instead of an immediate failure.
|
|
86
|
+
*
|
|
87
|
+
* Returns undefined on success, or the final error message after all
|
|
88
|
+
* attempts have failed.
|
|
89
|
+
*/
|
|
90
|
+
async function indexWithDelayAndRetry(
|
|
91
|
+
endpoint: "plot" | "storyline",
|
|
92
|
+
body: Record<string, unknown>,
|
|
93
|
+
onProgress: (progress: PublishProgress) => void,
|
|
94
|
+
txHash: string,
|
|
95
|
+
contentCid: string,
|
|
96
|
+
): Promise<string | undefined> {
|
|
97
|
+
const PLOTLINK_URL = process.env.NEXT_PUBLIC_APP_URL || "https://plotlink.xyz";
|
|
98
|
+
const url = `${PLOTLINK_URL}/api/index/${endpoint}`;
|
|
99
|
+
|
|
100
|
+
onProgress({
|
|
101
|
+
step: "indexing",
|
|
102
|
+
message: `Indexing… waiting ${INDEX_INITIAL_DELAY_MS / 1000}s for on-chain propagation`,
|
|
103
|
+
txHash,
|
|
104
|
+
contentCid,
|
|
105
|
+
});
|
|
106
|
+
await new Promise((r) => setTimeout(r, INDEX_INITIAL_DELAY_MS));
|
|
107
|
+
|
|
108
|
+
let lastError: string | undefined;
|
|
109
|
+
for (let attempt = 1; attempt <= INDEX_RETRY_ATTEMPTS; attempt++) {
|
|
110
|
+
onProgress({
|
|
111
|
+
step: "indexing",
|
|
112
|
+
message: `Indexing… (attempt ${attempt}/${INDEX_RETRY_ATTEMPTS})`,
|
|
113
|
+
txHash,
|
|
114
|
+
contentCid,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
const indexRes = await fetch(url, {
|
|
119
|
+
method: "POST",
|
|
120
|
+
headers: { "Content-Type": "application/json" },
|
|
121
|
+
body: JSON.stringify(body),
|
|
122
|
+
});
|
|
123
|
+
const indexBody = await indexRes.json().catch(() => ({})) as Record<string, string>;
|
|
124
|
+
if (indexRes.ok && !indexBody.error) {
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
lastError = indexBody.error || `Indexing failed: HTTP ${indexRes.status}`;
|
|
128
|
+
} catch (err) {
|
|
129
|
+
lastError = err instanceof Error ? err.message : "Indexing request failed";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (attempt < INDEX_RETRY_ATTEMPTS) {
|
|
133
|
+
await new Promise((r) => setTimeout(r, INDEX_RETRY_INTERVAL_MS));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
console.error(`Indexing failed for tx ${txHash} after ${INDEX_RETRY_ATTEMPTS} attempts:`, lastError);
|
|
138
|
+
return lastError;
|
|
139
|
+
}
|
|
140
|
+
|
|
70
141
|
/**
|
|
71
142
|
* Upload story content to IPFS via PlotLink's API (plotlink.xyz/api/upload).
|
|
72
143
|
* PlotLink handles Filebase credentials server-side.
|
|
@@ -145,9 +216,9 @@ export async function getEthBalance(address: string): Promise<bigint> {
|
|
|
145
216
|
}
|
|
146
217
|
|
|
147
218
|
/**
|
|
148
|
-
* Wait for
|
|
219
|
+
* Wait for tx confirmation and compute gas cost.
|
|
149
220
|
*/
|
|
150
|
-
async function
|
|
221
|
+
async function waitForReceipt(txHash: string) {
|
|
151
222
|
const receipt = await publicClient.waitForTransactionReceipt({
|
|
152
223
|
hash: txHash as `0x${string}`,
|
|
153
224
|
});
|
|
@@ -158,8 +229,6 @@ async function waitForConfirmation(txHash: string): Promise<{ storylineId: numbe
|
|
|
158
229
|
|
|
159
230
|
// Compute actual total cost: gasUsed * effectiveGasPrice + tx value (creation fee)
|
|
160
231
|
const gasOnly = receipt.gasUsed * receipt.effectiveGasPrice;
|
|
161
|
-
const txValue = receipt.logs.length > 0 ? BigInt(0) : BigInt(0); // value is in the tx itself
|
|
162
|
-
// Include creation fee from tx value — read from the original transaction
|
|
163
232
|
let creationFeeUsed = BigInt(0);
|
|
164
233
|
try {
|
|
165
234
|
const tx = await publicClient.getTransaction({ hash: txHash as `0x${string}` });
|
|
@@ -167,7 +236,15 @@ async function waitForConfirmation(txHash: string): Promise<{ storylineId: numbe
|
|
|
167
236
|
} catch { /* best effort */ }
|
|
168
237
|
const gasCost = (gasOnly + creationFeeUsed).toString();
|
|
169
238
|
|
|
170
|
-
|
|
239
|
+
return { receipt, gasCost };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Wait for storyline creation confirmation — decodes StorylineCreated event.
|
|
244
|
+
*/
|
|
245
|
+
async function waitForStorylineConfirmation(txHash: string): Promise<{ storylineId: number; gasCost: string }> {
|
|
246
|
+
const { receipt, gasCost } = await waitForReceipt(txHash);
|
|
247
|
+
|
|
171
248
|
for (const log of receipt.logs) {
|
|
172
249
|
try {
|
|
173
250
|
const decoded = decodeEventLog({
|
|
@@ -183,6 +260,30 @@ async function waitForConfirmation(txHash: string): Promise<{ storylineId: numbe
|
|
|
183
260
|
throw new Error("Transaction succeeded but StorylineCreated event not found");
|
|
184
261
|
}
|
|
185
262
|
|
|
263
|
+
/**
|
|
264
|
+
* Wait for plot chain confirmation — decodes PlotChained event.
|
|
265
|
+
*/
|
|
266
|
+
async function waitForPlotConfirmation(txHash: string): Promise<{ plotIndex: number; gasCost: string }> {
|
|
267
|
+
const { receipt, gasCost } = await waitForReceipt(txHash);
|
|
268
|
+
|
|
269
|
+
for (const log of receipt.logs) {
|
|
270
|
+
try {
|
|
271
|
+
const decoded = decodeEventLog({
|
|
272
|
+
abi: storyFactoryAbi,
|
|
273
|
+
data: log.data,
|
|
274
|
+
topics: log.topics,
|
|
275
|
+
});
|
|
276
|
+
if (decoded.eventName === "PlotChained") {
|
|
277
|
+
// plotIndex is 0-based: genesis=0, plot-01=1, plot-02=2, etc.
|
|
278
|
+
// This matches plotlink.xyz URL convention: /story/{id}/{plotIndex}
|
|
279
|
+
return { plotIndex: Number((decoded.args as { plotIndex: bigint }).plotIndex), gasCost };
|
|
280
|
+
}
|
|
281
|
+
} catch { /* not our event */ }
|
|
282
|
+
}
|
|
283
|
+
// If we can't find PlotChained but receipt succeeded, still return (best effort)
|
|
284
|
+
return { plotIndex: -1, gasCost };
|
|
285
|
+
}
|
|
286
|
+
|
|
186
287
|
/**
|
|
187
288
|
* Publish a new storyline to PlotLink on-chain.
|
|
188
289
|
*/
|
|
@@ -227,7 +328,17 @@ export async function publishStoryline(
|
|
|
227
328
|
|
|
228
329
|
// Step 5: Wait for confirmation and decode storylineId
|
|
229
330
|
onProgress({ step: "confirming", message: "Waiting for confirmation...", txHash, contentCid });
|
|
230
|
-
const confirmation = await
|
|
331
|
+
const confirmation = await waitForStorylineConfirmation(txHash);
|
|
332
|
+
|
|
333
|
+
// Index on PlotLink with delay + retry (per #103 RCA — combo A+B).
|
|
334
|
+
// Streams "Indexing…" progress so the user does not escalate to Retry Publish.
|
|
335
|
+
const indexError = await indexWithDelayAndRetry(
|
|
336
|
+
"storyline",
|
|
337
|
+
{ txHash, content, genre },
|
|
338
|
+
onProgress,
|
|
339
|
+
txHash,
|
|
340
|
+
contentCid,
|
|
341
|
+
);
|
|
231
342
|
|
|
232
343
|
onProgress({
|
|
233
344
|
step: "done",
|
|
@@ -237,17 +348,7 @@ export async function publishStoryline(
|
|
|
237
348
|
storylineId: confirmation.storylineId,
|
|
238
349
|
});
|
|
239
350
|
|
|
240
|
-
|
|
241
|
-
try {
|
|
242
|
-
const PLOTLINK_URL = process.env.NEXT_PUBLIC_APP_URL || "https://plotlink.xyz";
|
|
243
|
-
await fetch(`${PLOTLINK_URL}/api/index/storyline`, {
|
|
244
|
-
method: "POST",
|
|
245
|
-
headers: { "Content-Type": "application/json" },
|
|
246
|
-
body: JSON.stringify({ txHash, content, genre }),
|
|
247
|
-
});
|
|
248
|
-
} catch { /* indexing is best-effort */ }
|
|
249
|
-
|
|
250
|
-
return { txHash, contentCid, storylineId: confirmation.storylineId, gasCost: confirmation.gasCost };
|
|
351
|
+
return { txHash, contentCid, storylineId: confirmation.storylineId, gasCost: confirmation.gasCost, indexError };
|
|
251
352
|
}
|
|
252
353
|
|
|
253
354
|
/**
|
|
@@ -289,9 +390,20 @@ export async function publishPlot(
|
|
|
289
390
|
args: [BigInt(storylineId), title, contentCid, contentHash],
|
|
290
391
|
});
|
|
291
392
|
|
|
292
|
-
// Step 5: Wait for confirmation
|
|
393
|
+
// Step 5: Wait for plot confirmation
|
|
293
394
|
onProgress({ step: "confirming", message: "Waiting for confirmation...", txHash, contentCid });
|
|
294
|
-
const confirmation = await
|
|
395
|
+
const confirmation = await waitForPlotConfirmation(txHash);
|
|
396
|
+
|
|
397
|
+
// Index on PlotLink with delay + retry (per #103 RCA — combo A+B).
|
|
398
|
+
// Pass content as fallback because IPFS stores JSON metadata wrapper,
|
|
399
|
+
// but on-chain hash is keccak256 of raw content only.
|
|
400
|
+
const indexError = await indexWithDelayAndRetry(
|
|
401
|
+
"plot",
|
|
402
|
+
{ txHash, content },
|
|
403
|
+
onProgress,
|
|
404
|
+
txHash,
|
|
405
|
+
contentCid,
|
|
406
|
+
);
|
|
295
407
|
|
|
296
408
|
onProgress({
|
|
297
409
|
step: "done",
|
|
@@ -301,15 +413,5 @@ export async function publishPlot(
|
|
|
301
413
|
storylineId,
|
|
302
414
|
});
|
|
303
415
|
|
|
304
|
-
|
|
305
|
-
try {
|
|
306
|
-
const PLOTLINK_URL = process.env.NEXT_PUBLIC_APP_URL || "https://plotlink.xyz";
|
|
307
|
-
await fetch(`${PLOTLINK_URL}/api/index/plot`, {
|
|
308
|
-
method: "POST",
|
|
309
|
-
headers: { "Content-Type": "application/json" },
|
|
310
|
-
body: JSON.stringify({ txHash }),
|
|
311
|
-
});
|
|
312
|
-
} catch { /* indexing is best-effort */ }
|
|
313
|
-
|
|
314
|
-
return { txHash, contentCid, storylineId, gasCost: confirmation.gasCost };
|
|
416
|
+
return { txHash, contentCid, storylineId, plotIndex: confirmation.plotIndex >= 0 ? confirmation.plotIndex : undefined, gasCost: confirmation.gasCost, indexError };
|
|
315
417
|
}
|
package/app/routes/dashboard.ts
CHANGED
|
@@ -25,6 +25,10 @@ dashboard.get("/", async (c) => {
|
|
|
25
25
|
interface PublishedFile {
|
|
26
26
|
storyName: string;
|
|
27
27
|
file: string;
|
|
28
|
+
storyTitle: string;
|
|
29
|
+
storyGenre: string | null;
|
|
30
|
+
plotCount: number;
|
|
31
|
+
status?: string;
|
|
28
32
|
txHash?: string;
|
|
29
33
|
storylineId?: number;
|
|
30
34
|
contentCid?: string;
|
|
@@ -46,9 +50,30 @@ dashboard.get("/", async (c) => {
|
|
|
46
50
|
const mdFiles = fs.readdirSync(storyDir).filter((f) => f.endsWith(".md"));
|
|
47
51
|
totalFiles += mdFiles.length;
|
|
48
52
|
|
|
53
|
+
// Read story title and genre from structure.md or genesis.md
|
|
54
|
+
let storyTitle = dir.name;
|
|
55
|
+
let storyGenre: string | null = null;
|
|
56
|
+
try {
|
|
57
|
+
const structPath = path.join(storyDir, "structure.md");
|
|
58
|
+
const genesisPath = path.join(storyDir, "genesis.md");
|
|
59
|
+
if (fs.existsSync(structPath)) {
|
|
60
|
+
const content = fs.readFileSync(structPath, "utf-8");
|
|
61
|
+
const titleMatch = content.match(/^#\s+(.+)$/m);
|
|
62
|
+
if (titleMatch) storyTitle = titleMatch[1];
|
|
63
|
+
const genreMatch = content.match(/genre[:\s]+(.+)/i);
|
|
64
|
+
if (genreMatch) storyGenre = genreMatch[1].trim();
|
|
65
|
+
} else if (fs.existsSync(genesisPath)) {
|
|
66
|
+
const content = fs.readFileSync(genesisPath, "utf-8");
|
|
67
|
+
const titleMatch = content.match(/^#\s+(.+)$/m);
|
|
68
|
+
if (titleMatch) storyTitle = titleMatch[1];
|
|
69
|
+
}
|
|
70
|
+
} catch { /* best effort */ }
|
|
71
|
+
|
|
72
|
+
const plotCount = mdFiles.filter((f) => /^plot-\d+\.md$/.test(f)).length;
|
|
73
|
+
|
|
49
74
|
for (const [file, info] of Object.entries(status)) {
|
|
50
|
-
if (info.status === "published") {
|
|
51
|
-
publishedFiles.push({ storyName: dir.name, file, ...info });
|
|
75
|
+
if (info.status === "published" || info.status === "published-not-indexed") {
|
|
76
|
+
publishedFiles.push({ storyName: dir.name, file, storyTitle, storyGenre, plotCount, ...info });
|
|
52
77
|
}
|
|
53
78
|
}
|
|
54
79
|
}
|
|
@@ -140,17 +165,43 @@ dashboard.get("/", async (c) => {
|
|
|
140
165
|
return c.json({
|
|
141
166
|
wallet: walletInfo,
|
|
142
167
|
stories: {
|
|
143
|
-
published:
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
168
|
+
published: (() => {
|
|
169
|
+
// Group by storylineId when present, fall back to storyName
|
|
170
|
+
const grouped = new Map<string, typeof publishedFiles>();
|
|
171
|
+
for (const f of publishedFiles) {
|
|
172
|
+
const key = f.storylineId ? `sid:${f.storylineId}` : `name:${f.storyName}`;
|
|
173
|
+
const group = grouped.get(key) || [];
|
|
174
|
+
group.push(f);
|
|
175
|
+
grouped.set(key, group);
|
|
176
|
+
}
|
|
177
|
+
return [...grouped.entries()].map(([, files]) => {
|
|
178
|
+
const first = files[0];
|
|
179
|
+
const totalGas = files.reduce((sum, f) => f.gasCost ? sum + BigInt(f.gasCost) : sum, BigInt(0));
|
|
180
|
+
const latestDate = files.reduce((latest, f) =>
|
|
181
|
+
f.publishedAt && (!latest || f.publishedAt > latest) ? f.publishedAt : latest, null as string | null);
|
|
182
|
+
const hasNotIndexed = files.some((f) => f.status === "published-not-indexed");
|
|
183
|
+
return {
|
|
184
|
+
id: first.storylineId ? `sid:${first.storylineId}` : first.storyName,
|
|
185
|
+
title: first.storyTitle,
|
|
186
|
+
genre: first.storyGenre,
|
|
187
|
+
storyName: first.storyName,
|
|
188
|
+
storylineId: first.storylineId,
|
|
189
|
+
plotCount: first.plotCount,
|
|
190
|
+
publishedFiles: files.length,
|
|
191
|
+
hasNotIndexed,
|
|
192
|
+
totalGasCostEth: totalGas > 0 ? (Number(totalGas) / 1e18).toFixed(6) : null,
|
|
193
|
+
totalGasCostUsd: totalGas > 0 && ethUsdPrice ? ((Number(totalGas) / 1e18) * ethUsdPrice).toFixed(2) : null,
|
|
194
|
+
latestPublishedAt: latestDate,
|
|
195
|
+
files: files.map((f) => ({
|
|
196
|
+
file: f.file,
|
|
197
|
+
status: f.status || "published",
|
|
198
|
+
txHash: f.txHash,
|
|
199
|
+
gasCostEth: f.gasCost ? (Number(BigInt(f.gasCost)) / 1e18).toFixed(6) : null,
|
|
200
|
+
publishedAt: f.publishedAt || null,
|
|
201
|
+
})),
|
|
202
|
+
};
|
|
203
|
+
});
|
|
204
|
+
})(),
|
|
154
205
|
totalPublished: publishedFiles.length,
|
|
155
206
|
totalStories,
|
|
156
207
|
totalFiles,
|