plotlink-ows 1.2.97 → 1.2.99

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 CHANGED
@@ -271,6 +271,23 @@ npm run app:start # Serve production build
271
271
 
272
272
  See [`.env.example`](.env.example) for configuration options.
273
273
 
274
+ ### Release Checklist
275
+
276
+ For manual npm releases, always update GitHub Releases as part of publish
277
+ preparation. The release tag must match the npm version (`vX.Y.Z`), and the
278
+ release page should be created or updated before running `npm publish`.
279
+
280
+ ```bash
281
+ npm run preflight
282
+ git push origin main --follow-tags
283
+ gh release create "v$(node -p 'require("./package.json").version')" --generate-notes --latest
284
+ npm publish
285
+ ```
286
+
287
+ Prefer `npm run release:patch`, `npm run release:minor`, or
288
+ `npm run release:major` when the version bump is straightforward; those scripts
289
+ already include GitHub Release creation.
290
+
274
291
  ---
275
292
 
276
293
  ## 🔗 Links
@@ -1,9 +1,9 @@
1
1
  import { Hono } from "hono";
2
- import { createPublicClient, http } from "viem";
2
+ import { createPublicClient, createWalletClient, formatUnits, http, type Address } from "viem";
3
3
  import { base } from "viem/chains";
4
4
  import fs from "fs";
5
5
  import path from "path";
6
- import { getEthBalance } from "../lib/publish";
6
+ import { createOwsAccount, getEthBalance } from "../lib/publish";
7
7
  import { resolveActiveWallet } from "../lib/active-wallet";
8
8
  import { mcv2BondAbi } from "../../packages/cli/src/sdk/abi";
9
9
  import { STORIES_DIR, readPublishStatus } from "./stories";
@@ -19,6 +19,10 @@ const publicClient = createPublicClient({
19
19
 
20
20
  const dashboard = new Hono();
21
21
 
22
+ function formatPlot(value: bigint): string {
23
+ return Number(formatUnits(value, 18)).toFixed(6);
24
+ }
25
+
22
26
  /** GET /api/dashboard — writer dashboard data */
23
27
  dashboard.get("/", async (c) => {
24
28
  // Scan stories/ for publish status
@@ -125,9 +129,10 @@ dashboard.get("/", async (c) => {
125
129
  }, BigInt(0));
126
130
  const totalGasCostEth = (Number(totalGasCostWei) / 1e18).toFixed(6);
127
131
 
128
- // Query on-chain royalties (WETH on Base — bonding curve reserve)
132
+ // Query on-chain royalties (PLOT on Base — bonding curve reserve)
129
133
  let royaltiesEarned = "0";
130
134
  let royaltiesClaimed = "0";
135
+ let royaltiesUnclaimed = "0";
131
136
  if (walletInfo?.address) {
132
137
  try {
133
138
  // getRoyaltyInfo returns (unclaimed, totalClaimed)
@@ -138,8 +143,9 @@ dashboard.get("/", async (c) => {
138
143
  args: [walletInfo.address as `0x${string}`, RESERVE_TOKEN],
139
144
  }) as [bigint, bigint];
140
145
  // Total earned = unclaimed + previously claimed
141
- royaltiesEarned = (Number(unclaimed + totalClaimed) / 1e18).toFixed(6);
142
- royaltiesClaimed = (Number(totalClaimed) / 1e18).toFixed(6);
146
+ royaltiesEarned = formatPlot(unclaimed + totalClaimed);
147
+ royaltiesClaimed = formatPlot(totalClaimed);
148
+ royaltiesUnclaimed = formatPlot(unclaimed);
143
149
  } catch { /* no royalties or contract not available */ }
144
150
  }
145
151
 
@@ -219,7 +225,7 @@ dashboard.get("/", async (c) => {
219
225
  royalties: {
220
226
  earned: royaltiesEarned,
221
227
  claimed: royaltiesClaimed,
222
- unclaimed: (parseFloat(royaltiesEarned) - parseFloat(royaltiesClaimed)).toFixed(6),
228
+ unclaimed: royaltiesUnclaimed,
223
229
  token: "PLOT",
224
230
  },
225
231
  pnl: {
@@ -233,4 +239,65 @@ dashboard.get("/", async (c) => {
233
239
  });
234
240
  });
235
241
 
242
+ /** POST /api/dashboard/royalties/claim — claim active wallet PLOT royalties */
243
+ dashboard.post("/royalties/claim", async (c) => {
244
+ try {
245
+ const resolved = await resolveActiveWallet();
246
+ const activeWallet = resolved.activeWallet;
247
+ const address = activeWallet?.address as Address | undefined;
248
+
249
+ if (!activeWallet || !address) {
250
+ return c.json({
251
+ error: resolved.error || "No active OWS wallet selected",
252
+ selectionRequired: resolved.selectionRequired,
253
+ wallets: resolved.wallets,
254
+ }, 400);
255
+ }
256
+
257
+ const [unclaimed] = await publicClient.readContract({
258
+ address: MCV2_BOND,
259
+ abi: mcv2BondAbi,
260
+ functionName: "getRoyaltyInfo",
261
+ args: [address, RESERVE_TOKEN],
262
+ }) as [bigint, bigint];
263
+
264
+ if (unclaimed <= 0n) {
265
+ return c.json({ error: "No PLOT royalties available to claim", unclaimed: "0", token: "PLOT" }, 400);
266
+ }
267
+
268
+ const rpcUrl = process.env.NEXT_PUBLIC_RPC_URL || "https://mainnet.base.org";
269
+ const account = createOwsAccount(activeWallet.name, address);
270
+ const walletClient = createWalletClient({
271
+ account,
272
+ chain: base,
273
+ transport: http(rpcUrl),
274
+ });
275
+
276
+ const { request } = await publicClient.simulateContract({
277
+ account,
278
+ address: MCV2_BOND,
279
+ abi: mcv2BondAbi,
280
+ functionName: "claimRoyalties",
281
+ args: [RESERVE_TOKEN],
282
+ });
283
+ const txHash = await walletClient.writeContract(request);
284
+ const receipt = await publicClient.waitForTransactionReceipt({ hash: txHash });
285
+
286
+ if (receipt.status !== "success") {
287
+ return c.json({ error: "Royalty claim transaction reverted", txHash }, 500);
288
+ }
289
+
290
+ return c.json({
291
+ ok: true,
292
+ txHash,
293
+ amount: formatPlot(unclaimed),
294
+ token: "PLOT",
295
+ basescanUrl: `https://basescan.org/tx/${txHash}`,
296
+ });
297
+ } catch (err: unknown) {
298
+ const message = err instanceof Error ? err.message : "Royalty claim failed";
299
+ return c.json({ error: message }, 500);
300
+ }
301
+ });
302
+
236
303
  export { dashboard as dashboardRoutes };
@@ -1,12 +1,87 @@
1
1
  import { Hono } from "hono";
2
2
  import fs from "fs";
3
+ import {
4
+ createPublicClient,
5
+ createWalletClient,
6
+ encodeFunctionData,
7
+ erc20Abi,
8
+ formatUnits,
9
+ http,
10
+ isAddress,
11
+ parseUnits,
12
+ type Address,
13
+ } from "viem";
14
+ import { base } from "viem/chains";
3
15
  import { ENV_FILE } from "../lib/paths";
4
16
  import { nextPlotlinkWalletName, resolveActiveWallet, selectActiveWallet, toPublicActiveWallet } from "../lib/active-wallet";
17
+ import { createOwsAccount } from "../lib/publish";
5
18
 
6
19
  const envPath = ENV_FILE;
20
+ const rpcUrl = process.env.NEXT_PUBLIC_RPC_URL || "https://mainnet.base.org";
21
+ const USDC_BASE = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" as const;
22
+ const PLOT_BASE = "0x4F567DACBF9D15A6acBe4A47FC2Ade0719Fb63C4" as const;
23
+
24
+ const publicClient = createPublicClient({
25
+ chain: base,
26
+ transport: http(rpcUrl),
27
+ });
7
28
 
8
29
  const wallet = new Hono();
9
30
 
31
+ const KNOWN_TOKENS = {
32
+ ETH: { symbol: "ETH", decimals: 18, address: null },
33
+ USDC: { symbol: "USDC", decimals: 6, address: USDC_BASE },
34
+ PLOT: { symbol: "PLOT", decimals: 18, address: PLOT_BASE },
35
+ } as const;
36
+
37
+ type SendToken = {
38
+ symbol: string;
39
+ decimals: number;
40
+ address: Address | null;
41
+ };
42
+
43
+ function amountLooksValid(amount: unknown): amount is string {
44
+ return typeof amount === "string" && /^(?:0|[1-9]\d*)(?:\.\d+)?$/.test(amount.trim()) && !/^0(?:\.0*)?$/.test(amount.trim());
45
+ }
46
+
47
+ async function resolveSendToken(rawToken?: string, rawTokenAddress?: string): Promise<SendToken> {
48
+ const tokenInput = (rawToken || "").trim();
49
+ const upper = tokenInput.toUpperCase();
50
+ if (upper in KNOWN_TOKENS) {
51
+ return KNOWN_TOKENS[upper as keyof typeof KNOWN_TOKENS];
52
+ }
53
+
54
+ const candidate = rawTokenAddress?.trim() || tokenInput;
55
+ if (!isAddress(candidate)) {
56
+ throw new Error("Token must be ETH, PLOT, USDC, or a valid ERC-20 address");
57
+ }
58
+
59
+ const address = candidate as Address;
60
+ const [symbol, decimals] = await Promise.all([
61
+ publicClient.readContract({ address, abi: erc20Abi, functionName: "symbol" }),
62
+ publicClient.readContract({ address, abi: erc20Abi, functionName: "decimals" }),
63
+ ]);
64
+
65
+ return {
66
+ symbol: String(symbol || "TOKEN"),
67
+ decimals: Number(decimals),
68
+ address,
69
+ };
70
+ }
71
+
72
+ async function erc20Balance(token: Address, owner: Address): Promise<bigint> {
73
+ return publicClient.readContract({
74
+ address: token,
75
+ abi: erc20Abi,
76
+ functionName: "balanceOf",
77
+ args: [owner],
78
+ }) as Promise<bigint>;
79
+ }
80
+
81
+ function formatGasRequirement(gasCost: bigint): string {
82
+ return `${formatUnits(gasCost, 18)} ETH`;
83
+ }
84
+
10
85
  function readEnvPassphrase(): string | null {
11
86
  if (process.env.OWS_PASSPHRASE) return process.env.OWS_PASSPHRASE;
12
87
  try {
@@ -29,8 +104,6 @@ wallet.get("/", async (c) => {
29
104
  let ethBalance = "0";
30
105
  let usdcBalance = "0";
31
106
  let plotBalance = "0";
32
- const rpcUrl = process.env.NEXT_PUBLIC_RPC_URL || "https://mainnet.base.org";
33
-
34
107
  if (activeWallet?.address) {
35
108
  const addrPadded = "000000000000000000000000" + activeWallet.address.slice(2).toLowerCase();
36
109
  const balanceOfSig = "0x70a08231" + addrPadded;
@@ -48,7 +121,6 @@ wallet.get("/", async (c) => {
48
121
  }
49
122
 
50
123
  // USDC balance (6 decimals)
51
- const USDC_BASE = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
52
124
  const usdcRes = await fetch(rpcUrl, {
53
125
  method: "POST",
54
126
  headers: { "Content-Type": "application/json" },
@@ -60,11 +132,10 @@ wallet.get("/", async (c) => {
60
132
  }
61
133
 
62
134
  // PLOT balance (18 decimals)
63
- const PLOT = "0x4F567DACBF9D15A6acBe4A47FC2Ade0719Fb63C4";
64
135
  const plotRes = await fetch(rpcUrl, {
65
136
  method: "POST",
66
137
  headers: { "Content-Type": "application/json" },
67
- body: JSON.stringify({ jsonrpc: "2.0", id: 3, method: "eth_call", params: [{ to: PLOT, data: balanceOfSig }, "latest"] }),
138
+ body: JSON.stringify({ jsonrpc: "2.0", id: 3, method: "eth_call", params: [{ to: PLOT_BASE, data: balanceOfSig }, "latest"] }),
68
139
  });
69
140
  const plotData = await plotRes.json() as { result?: string };
70
141
  if (plotData.result && plotData.result !== "0x") {
@@ -101,6 +172,119 @@ wallet.get("/", async (c) => {
101
172
  }
102
173
  });
103
174
 
175
+ /** POST /api/wallet/send — send ETH or ERC-20 tokens from the active OWS wallet */
176
+ wallet.post("/send", async (c) => {
177
+ try {
178
+ const body = await c.req.json<{ token?: string; tokenAddress?: string; to?: string; amount?: string }>();
179
+ const to = body.to?.trim();
180
+ if (!to || !isAddress(to)) {
181
+ return c.json({ error: "Valid recipient address required" }, 400);
182
+ }
183
+ if (!amountLooksValid(body.amount)) {
184
+ return c.json({ error: "Positive amount required" }, 400);
185
+ }
186
+
187
+ let token: SendToken;
188
+ try {
189
+ token = await resolveSendToken(body.token, body.tokenAddress);
190
+ } catch (err: unknown) {
191
+ const message = err instanceof Error ? err.message : "Invalid token";
192
+ return c.json({ error: message }, 400);
193
+ }
194
+
195
+ let amount: bigint;
196
+ try {
197
+ amount = parseUnits(body.amount.trim(), token.decimals);
198
+ } catch {
199
+ return c.json({ error: `Amount has too many decimals for ${token.symbol}` }, 400);
200
+ }
201
+
202
+ const resolved = await resolveActiveWallet();
203
+ const activeWallet = resolved.activeWallet;
204
+ const from = activeWallet?.address as Address | undefined;
205
+ if (!activeWallet || !from || !activeWallet.name) {
206
+ return c.json({
207
+ error: resolved.error || "No active OWS wallet selected",
208
+ selectionRequired: resolved.selectionRequired,
209
+ wallets: resolved.wallets,
210
+ }, 400);
211
+ }
212
+
213
+ const ethBalance = await publicClient.getBalance({ address: from });
214
+ const account = createOwsAccount(activeWallet.name, from);
215
+ const walletClient = createWalletClient({
216
+ account,
217
+ chain: base,
218
+ transport: http(rpcUrl),
219
+ });
220
+
221
+ let txHash: `0x${string}`;
222
+ if (!token.address) {
223
+ const gas = await publicClient.estimateGas({ account, to: to as Address, value: amount });
224
+ const gasPrice = await publicClient.getGasPrice();
225
+ const gasCost = gas * gasPrice;
226
+ if (ethBalance < amount + gasCost) {
227
+ return c.json({
228
+ error: `Insufficient ETH for amount plus gas. Estimated gas: ${formatGasRequirement(gasCost)}`,
229
+ }, 400);
230
+ }
231
+ txHash = await walletClient.sendTransaction({ to: to as Address, value: amount });
232
+ } else {
233
+ const balance = await erc20Balance(token.address, from);
234
+ if (balance < amount) {
235
+ return c.json({
236
+ error: `Insufficient ${token.symbol} balance`,
237
+ available: formatUnits(balance, token.decimals),
238
+ required: body.amount.trim(),
239
+ token: token.symbol,
240
+ }, 400);
241
+ }
242
+
243
+ const data = encodeFunctionData({
244
+ abi: erc20Abi,
245
+ functionName: "transfer",
246
+ args: [to as Address, amount],
247
+ });
248
+ const gas = await publicClient.estimateGas({ account, to: token.address, data });
249
+ const gasPrice = await publicClient.getGasPrice();
250
+ const gasCost = gas * gasPrice;
251
+ if (ethBalance < gasCost) {
252
+ return c.json({
253
+ error: `Insufficient ETH for transfer gas. Estimated gas: ${formatGasRequirement(gasCost)}`,
254
+ }, 400);
255
+ }
256
+
257
+ const { request } = await publicClient.simulateContract({
258
+ account,
259
+ address: token.address,
260
+ abi: erc20Abi,
261
+ functionName: "transfer",
262
+ args: [to as Address, amount],
263
+ });
264
+ txHash = await walletClient.writeContract(request);
265
+ }
266
+
267
+ const receipt = await publicClient.waitForTransactionReceipt({ hash: txHash });
268
+ if (receipt.status !== "success") {
269
+ return c.json({ error: "Transfer transaction reverted", txHash }, 500);
270
+ }
271
+
272
+ return c.json({
273
+ ok: true,
274
+ txHash,
275
+ from,
276
+ to,
277
+ token: token.symbol,
278
+ tokenAddress: token.address,
279
+ amount: formatUnits(amount, token.decimals),
280
+ basescanUrl: `https://basescan.org/tx/${txHash}`,
281
+ });
282
+ } catch (err: unknown) {
283
+ const message = err instanceof Error ? err.message : "Wallet transfer failed";
284
+ return c.json({ error: message }, 500);
285
+ }
286
+ });
287
+
104
288
  /** POST /api/wallet/active — select active OWS wallet */
105
289
  wallet.post("/active", async (c) => {
106
290
  const body = await c.req.json<{ walletId?: string; name?: string; address?: string }>();
@@ -3,9 +3,9 @@
3
3
  *
4
4
  * A normal webtoon creator should not need the file tree: this compact tab bar
5
5
  * sits above the right-panel content whenever a CARTOON story is selected and
6
- * routes between the workflow pages — Progress, Story Info, Whitepaper, Genesis /
7
- * Episode 1, Episodes, Publish. The left file tree stays for power users; opening
8
- * a file directly just reflects the closest workflow tab here.
6
+ * routes between the workflow pages — Progress, Story Info, Episodes, Publish.
7
+ * Episode files are selected from the left story browser; opening any episode
8
+ * keeps the workflow tab on Episodes.
9
9
  *
10
10
  * Fiction renders no nav (the caller only mounts this for cartoon stories), so
11
11
  * the fiction UX is unchanged.
@@ -14,16 +14,12 @@
14
14
  export type CartoonWorkflowTab =
15
15
  | "progress"
16
16
  | "story-info"
17
- | "whitepaper"
18
- | "genesis"
19
17
  | "episodes"
20
18
  | "publish";
21
19
 
22
20
  const TABS: { key: CartoonWorkflowTab; label: string }[] = [
23
21
  { key: "progress", label: "Progress" },
24
22
  { key: "story-info", label: "Story Info" },
25
- { key: "whitepaper", label: "Whitepaper" },
26
- { key: "genesis", label: "Genesis / Ep 1" },
27
23
  { key: "episodes", label: "Episodes" },
28
24
  { key: "publish", label: "Publish" },
29
25
  ];