plotlink-ows 1.2.98 → 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 }>();
@@ -50,6 +50,9 @@ interface DashboardData {
50
50
 
51
51
  export function Dashboard({ token }: { token: string }) {
52
52
  const [data, setData] = useState<DashboardData | null>(null);
53
+ const [claiming, setClaiming] = useState(false);
54
+ const [claimError, setClaimError] = useState<string | null>(null);
55
+ const [claimResult, setClaimResult] = useState<{ txHash: string; amount: string; basescanUrl?: string } | null>(null);
53
56
  const authFetch = useCallback((url: string, opts?: RequestInit) =>
54
57
  fetch(url, { ...opts, headers: { ...opts?.headers, Authorization: `Bearer ${token}`, "Content-Type": "application/json" } }),
55
58
  [token]);
@@ -69,6 +72,24 @@ export function Dashboard({ token }: { token: string }) {
69
72
  if (isNaN(date.getTime())) return "Unknown date";
70
73
  return date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
71
74
  };
75
+ const canClaimRoyalties = data?.wallet && parseFloat(data.royalties.unclaimed) > 0;
76
+
77
+ const handleClaimRoyalties = async () => {
78
+ setClaiming(true);
79
+ setClaimError(null);
80
+ setClaimResult(null);
81
+ try {
82
+ const res = await authFetch(`${API_BASE}/api/dashboard/royalties/claim`, { method: "POST" });
83
+ const claimData = await res.json();
84
+ if (!res.ok) throw new Error(claimData.error || "Royalty claim failed");
85
+ setClaimResult({ txHash: claimData.txHash, amount: claimData.amount, basescanUrl: claimData.basescanUrl });
86
+ loadDashboard();
87
+ } catch (err: unknown) {
88
+ setClaimError(err instanceof Error ? err.message : "Royalty claim failed");
89
+ } finally {
90
+ setClaiming(false);
91
+ }
92
+ };
72
93
 
73
94
  if (!data) {
74
95
  return (
@@ -145,6 +166,25 @@ export function Dashboard({ token }: { token: string }) {
145
166
  <span className="text-muted">Unclaimed royalties</span>
146
167
  <span className="text-foreground">{data.royalties.unclaimed} PLOT</span>
147
168
  </div>
169
+ <div className="flex items-center justify-between gap-3 pt-2">
170
+ <span className="text-muted text-[10px]">Claims use the active OWS wallet on Base.</span>
171
+ <button
172
+ onClick={handleClaimRoyalties}
173
+ disabled={!canClaimRoyalties || claiming}
174
+ className="bg-accent text-background hover:bg-accent/90 disabled:bg-surface disabled:text-muted rounded px-3 py-1.5 text-[10px] font-bold transition-colors"
175
+ >
176
+ {claiming ? "Claiming..." : "Claim royalties"}
177
+ </button>
178
+ </div>
179
+ {claimError && <p className="text-error text-[10px]">{claimError}</p>}
180
+ {claimResult && (
181
+ <p className="text-accent text-[10px]">
182
+ Claimed {claimResult.amount} PLOT ·{" "}
183
+ <a href={claimResult.basescanUrl || `https://basescan.org/tx/${claimResult.txHash}`} target="_blank" rel="noopener noreferrer" className="underline">
184
+ view tx
185
+ </a>
186
+ </p>
187
+ )}
148
188
  <div className="border-border flex justify-between border-t pt-1.5 text-xs font-medium">
149
189
  <span className="text-muted">Net P&L (USD)</span>
150
190
  <span className={parseFloat(data.pnl.netPnlUsd) >= 0 ? "text-accent" : "text-error"}>
@@ -33,6 +33,14 @@ export function WalletCard({ token }: { token: string }) {
33
33
  const [switching, setSwitching] = useState<string | null>(null);
34
34
  const [copied, setCopied] = useState(false);
35
35
  const [error, setError] = useState<string | null>(null);
36
+ const [sendOpen, setSendOpen] = useState(false);
37
+ const [sendToken, setSendToken] = useState<"ETH" | "PLOT" | "USDC">("ETH");
38
+ const [sendTo, setSendTo] = useState("");
39
+ const [sendAmount, setSendAmount] = useState("");
40
+ const [sendConfirming, setSendConfirming] = useState(false);
41
+ const [sending, setSending] = useState(false);
42
+ const [sendError, setSendError] = useState<string | null>(null);
43
+ const [sendResult, setSendResult] = useState<{ txHash: string; amount: string; token: string; basescanUrl?: string } | null>(null);
36
44
 
37
45
  const authFetch = useCallback((url: string, opts?: RequestInit) =>
38
46
  fetch(url, { ...opts, headers: { ...opts?.headers, Authorization: `Bearer ${token}`, "Content-Type": "application/json" } }),
@@ -91,6 +99,49 @@ export function WalletCard({ token }: { token: string }) {
91
99
  };
92
100
 
93
101
  const truncate = (addr: string) => `${addr.slice(0, 6)}...${addr.slice(-4)}`;
102
+ const selectedBalance = sendToken === "ETH" ? wallet?.ethBalance : sendToken === "PLOT" ? wallet?.plotBalance : wallet?.usdcBalance;
103
+
104
+ const resetSendDraft = () => {
105
+ setSendTo("");
106
+ setSendAmount("");
107
+ setSendConfirming(false);
108
+ setSendError(null);
109
+ };
110
+
111
+ const handleSendReview = () => {
112
+ setSendError(null);
113
+ setSendResult(null);
114
+ if (!sendTo.trim()) {
115
+ setSendError("Recipient address required");
116
+ return;
117
+ }
118
+ if (!sendAmount.trim() || Number(sendAmount) <= 0) {
119
+ setSendError("Positive amount required");
120
+ return;
121
+ }
122
+ setSendConfirming(true);
123
+ };
124
+
125
+ const handleSend = async () => {
126
+ setSending(true);
127
+ setSendError(null);
128
+ setSendResult(null);
129
+ try {
130
+ const res = await authFetch(`${API_BASE}/api/wallet/send`, {
131
+ method: "POST",
132
+ body: JSON.stringify({ token: sendToken, to: sendTo.trim(), amount: sendAmount.trim() }),
133
+ });
134
+ const data = await res.json();
135
+ if (!res.ok) throw new Error(data.error || "Transfer failed");
136
+ setSendResult({ txHash: data.txHash, amount: data.amount, token: data.token, basescanUrl: data.basescanUrl });
137
+ resetSendDraft();
138
+ loadWallet();
139
+ } catch (err: unknown) {
140
+ setSendError(err instanceof Error ? err.message : "Transfer failed");
141
+ } finally {
142
+ setSending(false);
143
+ }
144
+ };
94
145
 
95
146
  return (
96
147
  <div className="border-border rounded border p-4">
@@ -176,6 +227,124 @@ export function WalletCard({ token }: { token: string }) {
176
227
  </div>
177
228
  </div>
178
229
 
230
+ <div className="border-border border-t pt-3">
231
+ <div className="flex items-center justify-between gap-3">
232
+ <div>
233
+ <p className="text-muted text-[10px] font-medium uppercase tracking-wider">Send / Withdraw</p>
234
+ <p className="text-muted text-[10px]">Send ETH, PLOT, or USDC from this OWS wallet on Base.</p>
235
+ </div>
236
+ <button
237
+ onClick={() => {
238
+ setSendOpen((open) => !open);
239
+ setSendResult(null);
240
+ setSendError(null);
241
+ setSendConfirming(false);
242
+ }}
243
+ className="border-accent text-accent hover:bg-accent/10 rounded border px-3 py-1.5 text-[10px] font-bold transition-colors"
244
+ >
245
+ {sendOpen ? "close" : "send"}
246
+ </button>
247
+ </div>
248
+
249
+ {sendOpen && (
250
+ <div className="mt-3 space-y-3 rounded border border-border bg-surface p-3">
251
+ <div className="grid grid-cols-3 gap-2">
252
+ {(["ETH", "PLOT", "USDC"] as const).map((symbol) => (
253
+ <button
254
+ key={symbol}
255
+ type="button"
256
+ onClick={() => {
257
+ setSendToken(symbol);
258
+ setSendConfirming(false);
259
+ setSendError(null);
260
+ }}
261
+ className={sendToken === symbol
262
+ ? "bg-accent text-background rounded px-2 py-1.5 text-[10px] font-bold"
263
+ : "border-border text-muted hover:text-accent rounded border px-2 py-1.5 text-[10px]"}
264
+ >
265
+ {symbol}
266
+ </button>
267
+ ))}
268
+ </div>
269
+ <div className="flex justify-between text-[10px]">
270
+ <span className="text-muted">Available</span>
271
+ <span className="text-foreground">{selectedBalance || "0"} {sendToken}</span>
272
+ </div>
273
+
274
+ <label className="block space-y-1 text-[10px]">
275
+ <span className="text-muted uppercase tracking-wider">Recipient</span>
276
+ <input
277
+ value={sendTo}
278
+ onChange={(e) => {
279
+ setSendTo(e.target.value);
280
+ setSendConfirming(false);
281
+ }}
282
+ placeholder="0x..."
283
+ className="border-border bg-background text-foreground w-full rounded border px-2 py-1.5 font-mono text-xs"
284
+ />
285
+ </label>
286
+
287
+ <label className="block space-y-1 text-[10px]">
288
+ <span className="text-muted uppercase tracking-wider">Amount</span>
289
+ <input
290
+ value={sendAmount}
291
+ onChange={(e) => {
292
+ setSendAmount(e.target.value);
293
+ setSendConfirming(false);
294
+ }}
295
+ inputMode="decimal"
296
+ placeholder="0.0"
297
+ className="border-border bg-background text-foreground w-full rounded border px-2 py-1.5 font-mono text-xs"
298
+ />
299
+ </label>
300
+
301
+ {!sendConfirming ? (
302
+ <button
303
+ type="button"
304
+ onClick={handleSendReview}
305
+ className="bg-accent text-background hover:bg-accent/90 rounded px-3 py-1.5 text-[10px] font-bold transition-colors"
306
+ >
307
+ Review send
308
+ </button>
309
+ ) : (
310
+ <div className="space-y-2 rounded border border-amber-600/30 bg-amber-950/10 p-3">
311
+ <p className="text-xs text-amber-700">
312
+ Confirm sending {sendAmount} {sendToken} to <span className="font-mono">{truncate(sendTo)}</span> on Base.
313
+ </p>
314
+ <div className="flex gap-2">
315
+ <button
316
+ type="button"
317
+ onClick={handleSend}
318
+ disabled={sending}
319
+ className="bg-accent text-background hover:bg-accent/90 disabled:opacity-40 rounded px-3 py-1.5 text-[10px] font-bold transition-colors"
320
+ >
321
+ {sending ? "Sending..." : "Confirm send"}
322
+ </button>
323
+ <button
324
+ type="button"
325
+ onClick={() => setSendConfirming(false)}
326
+ disabled={sending}
327
+ className="border-border text-muted hover:text-accent rounded border px-3 py-1.5 text-[10px] transition-colors"
328
+ >
329
+ edit
330
+ </button>
331
+ </div>
332
+ </div>
333
+ )}
334
+
335
+ {sendError && <p className="text-error text-[10px]">{sendError}</p>}
336
+ {sendResult && (
337
+ <p className="text-accent text-[10px]">
338
+ Sent {sendResult.amount} {sendResult.token} ·{" "}
339
+ <a href={sendResult.basescanUrl || `https://basescan.org/tx/${sendResult.txHash}`} target="_blank" rel="noopener noreferrer" className="underline">
340
+ view tx
341
+ </a>
342
+ </p>
343
+ )}
344
+ </div>
345
+ )}
346
+ </div>
347
+
179
348
  {wallet.wallets && wallet.wallets.length > 1 && (
180
349
  <div className="border-border space-y-2 border-t pt-3">
181
350
  <p className="text-muted text-[10px] font-medium uppercase tracking-wider">Switch Wallet</p>
@@ -1 +1 @@
1
- import{M as w,v as O,t as A,c as M,b as R,s as H,l as I,a as B,d as F}from"./index-CoG6WKyb.js";const z=w;async function G(e){if(typeof document>"u"||!document.fonts||typeof document.fonts.load!="function")return{ready:!0,missing:[]};const n=[];for(const s of e)try{const t=await document.fonts.load(`16px "${s}"`);!t||t.length===0?n.push(s):document.fonts.check(`16px "${s}"`)||n.push(s)}catch{n.push(s)}return{ready:n.length===0,missing:n}}function C(e){return new Promise((n,s)=>{const t=new Image;t.crossOrigin="anonymous",t.onload=()=>n(t),t.onerror=()=>s(new Error("Failed to load image")),t.src=e})}const W="rgba(255, 255, 255, 0.95)",_="#1a1a1a",L="rgba(244, 239, 230, 0.94)",N="rgba(26, 26, 26, 0.55)";function Y(e){return Math.max(2,e*.004)}function K(e,n,s,t,c,d,f){e.beginPath();for(const o of F(n,s,t,c,d,f))o.k==="M"?e.moveTo(o.x,o.y):o.k==="L"?e.lineTo(o.x,o.y):e.arcTo(o.cornerX,o.cornerY,o.x,o.y,o.r);e.closePath()}function P(e,n,s,t,c,d){var f;for(const o of n){const l=o.x*s,i=o.y*t,r=o.width*s,a=o.height*t,y=Y(t);if(o.type==="speech"){const u=R(o,r,a),k=o.tailAnchor?H(l,i,r,a,o.tailAnchor,u):null;K(e,l,i,r,a,k,u),e.fillStyle=W,e.fill(),e.strokeStyle=_,e.lineWidth=y,e.lineJoin="round",e.stroke()}else if(o.type==="narration"){const u=Math.min(r,a)*.12;e.beginPath(),e.roundRect(l,i,r,a,u),e.fillStyle=L,e.fill(),e.strokeStyle=N,e.lineWidth=Math.max(1.5,y*.75),e.lineJoin="round",e.stroke()}const g=o.type==="sfx"?d:c,T=o.type!=="sfx"&&!!o.speaker,h=I((u,k,E=400)=>(e.font=`${E} ${k}px ${g}`,e.measureText(u).width),o.text,r,a,B(o,t,r,a));e.textAlign="center",e.textBaseline="middle";const p=l+r/2,S=T?h.speakerFontSize*1.2:0;T&&(e.fillStyle="#3a3a3a",e.font=`700 ${h.speakerFontSize}px ${g}`,e.fillText(o.speaker,p,i+S/2+a*.04,r-6));const v=i+S,b=a-S,$=h.lines.length*h.lineHeight;let m=v+b/2-$/2+h.lineHeight/2;e.font=`${((f=o.textStyle)==null?void 0:f.fontWeight)??400} ${h.fontSize}px ${g}`;for(const u of h.lines)o.type==="sfx"?(e.fillStyle="#000",e.strokeStyle="#fff",e.lineWidth=3,e.strokeText(u,p,m),e.fillText(u,p,m)):(e.fillStyle="#1a1a1a",e.fillText(u,p,m)),m+=h.lineHeight}}function X(e,n,s,t,c){const d=Math.max(14,Math.min(t*.05,28));e.font=`${d}px ${c}`,e.fillStyle="#1a1a1a",e.textAlign="center",e.textBaseline="middle";const f=[];if(n.dialogue)for(const i of n.dialogue)f.push(`${i.speaker}: ${i.text}`);n.narration&&f.push(n.narration);const o=d*1.6,l=t/2-(f.length-1)*o/2;for(let i=0;i<f.length;i++)e.fillText(f[i],s/2,l+i*o,s-40)}async function Z(e,n,s,t,c,d){const f=O(n);if(!f.valid)throw new Error(f.error??"Overlay geometry is invalid");let o=800,l=600,i=null;if(e)i=await C(e),o=i.naturalWidth,l=i.naturalHeight;else if(d){const y=A(d.aspectRatio);y&&(o=y.width,l=y.height)}const r=document.createElement("canvas");r.width=o,r.height=l;const a=r.getContext("2d");return i?a.drawImage(i,0,0,o,l):(a.fillStyle=(d==null?void 0:d.background)||"#ffffff",a.fillRect(0,0,o,l)),P(a,n,o,l,s,t),c&&n.length===0&&!i&&X(a,c,o,l,s),M(r)}function j(e){return e.size>z?{valid:!1,error:`Image is ${(e.size/1024).toFixed(0)}KB, exceeds 1MB limit`}:{valid:!0}}export{z as MAX_SIZE,G as ensureFontsReady,Z as exportCut,P as renderOverlays,A as textPanelDimensions,j as validateExportSize};
1
+ import{M as w,v as O,t as A,c as M,b as R,s as H,l as I,a as B,d as F}from"./index-D-nLoQ_K.js";const z=w;async function G(e){if(typeof document>"u"||!document.fonts||typeof document.fonts.load!="function")return{ready:!0,missing:[]};const n=[];for(const s of e)try{const t=await document.fonts.load(`16px "${s}"`);!t||t.length===0?n.push(s):document.fonts.check(`16px "${s}"`)||n.push(s)}catch{n.push(s)}return{ready:n.length===0,missing:n}}function C(e){return new Promise((n,s)=>{const t=new Image;t.crossOrigin="anonymous",t.onload=()=>n(t),t.onerror=()=>s(new Error("Failed to load image")),t.src=e})}const W="rgba(255, 255, 255, 0.95)",_="#1a1a1a",L="rgba(244, 239, 230, 0.94)",N="rgba(26, 26, 26, 0.55)";function Y(e){return Math.max(2,e*.004)}function K(e,n,s,t,c,d,f){e.beginPath();for(const o of F(n,s,t,c,d,f))o.k==="M"?e.moveTo(o.x,o.y):o.k==="L"?e.lineTo(o.x,o.y):e.arcTo(o.cornerX,o.cornerY,o.x,o.y,o.r);e.closePath()}function P(e,n,s,t,c,d){var f;for(const o of n){const l=o.x*s,i=o.y*t,r=o.width*s,a=o.height*t,y=Y(t);if(o.type==="speech"){const u=R(o,r,a),k=o.tailAnchor?H(l,i,r,a,o.tailAnchor,u):null;K(e,l,i,r,a,k,u),e.fillStyle=W,e.fill(),e.strokeStyle=_,e.lineWidth=y,e.lineJoin="round",e.stroke()}else if(o.type==="narration"){const u=Math.min(r,a)*.12;e.beginPath(),e.roundRect(l,i,r,a,u),e.fillStyle=L,e.fill(),e.strokeStyle=N,e.lineWidth=Math.max(1.5,y*.75),e.lineJoin="round",e.stroke()}const g=o.type==="sfx"?d:c,T=o.type!=="sfx"&&!!o.speaker,h=I((u,k,E=400)=>(e.font=`${E} ${k}px ${g}`,e.measureText(u).width),o.text,r,a,B(o,t,r,a));e.textAlign="center",e.textBaseline="middle";const p=l+r/2,S=T?h.speakerFontSize*1.2:0;T&&(e.fillStyle="#3a3a3a",e.font=`700 ${h.speakerFontSize}px ${g}`,e.fillText(o.speaker,p,i+S/2+a*.04,r-6));const v=i+S,b=a-S,$=h.lines.length*h.lineHeight;let m=v+b/2-$/2+h.lineHeight/2;e.font=`${((f=o.textStyle)==null?void 0:f.fontWeight)??400} ${h.fontSize}px ${g}`;for(const u of h.lines)o.type==="sfx"?(e.fillStyle="#000",e.strokeStyle="#fff",e.lineWidth=3,e.strokeText(u,p,m),e.fillText(u,p,m)):(e.fillStyle="#1a1a1a",e.fillText(u,p,m)),m+=h.lineHeight}}function X(e,n,s,t,c){const d=Math.max(14,Math.min(t*.05,28));e.font=`${d}px ${c}`,e.fillStyle="#1a1a1a",e.textAlign="center",e.textBaseline="middle";const f=[];if(n.dialogue)for(const i of n.dialogue)f.push(`${i.speaker}: ${i.text}`);n.narration&&f.push(n.narration);const o=d*1.6,l=t/2-(f.length-1)*o/2;for(let i=0;i<f.length;i++)e.fillText(f[i],s/2,l+i*o,s-40)}async function Z(e,n,s,t,c,d){const f=O(n);if(!f.valid)throw new Error(f.error??"Overlay geometry is invalid");let o=800,l=600,i=null;if(e)i=await C(e),o=i.naturalWidth,l=i.naturalHeight;else if(d){const y=A(d.aspectRatio);y&&(o=y.width,l=y.height)}const r=document.createElement("canvas");r.width=o,r.height=l;const a=r.getContext("2d");return i?a.drawImage(i,0,0,o,l):(a.fillStyle=(d==null?void 0:d.background)||"#ffffff",a.fillRect(0,0,o,l)),P(a,n,o,l,s,t),c&&n.length===0&&!i&&X(a,c,o,l,s),M(r)}function j(e){return e.size>z?{valid:!1,error:`Image is ${(e.size/1024).toFixed(0)}KB, exceeds 1MB limit`}:{valid:!0}}export{z as MAX_SIZE,G as ensureFontsReady,Z as exportCut,P as renderOverlays,A as textPanelDimensions,j as validateExportSize};