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 +17 -0
- package/app/routes/dashboard.ts +73 -6
- package/app/routes/wallet.ts +189 -5
- package/app/web/components/Dashboard.tsx +40 -0
- package/app/web/components/WalletCard.tsx +169 -0
- package/app/web/dist/assets/{export-cut-DVpOZ5AO.js → export-cut-uimRac8k.js} +1 -1
- package/app/web/dist/assets/{index-H5_FM885.css → index-9RO6eX-I.css} +1 -1
- package/app/web/dist/assets/{index-CoG6WKyb.js → index-D-nLoQ_K.js} +52 -52
- package/app/web/dist/index.html +2 -2
- package/package.json +1 -1
- package/scripts/preflight.mjs +10 -0
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
|
package/app/routes/dashboard.ts
CHANGED
|
@@ -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 (
|
|
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 = (
|
|
142
|
-
royaltiesClaimed = (
|
|
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:
|
|
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 };
|
package/app/routes/wallet.ts
CHANGED
|
@@ -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:
|
|
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-
|
|
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};
|