Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion wallet/entrypoints/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@
* browser restart / extension reload. We do NOT "encrypt the seed with a key stored
* beside it" (that adds surface for zero gain). Threat model documented in README.
*/
import { createPublicClient, http, parseEther, formatEther } from "viem";
import { createPublicClient, http, parseEther, formatEther, type TypedDataDefinition } from "viem";
import { Keyring } from "../src/keyring/keyring";
import { parseTypedData } from "../src/provider/typed-data";
import { WorkerOperator, NETWORKS, type MinimalPublicClient } from "lightnode-sdk";
import { encryptVault, decryptVault, type EncryptedVault } from "../src/keyring/vault";
import { chainById, lightchainMainnet } from "../src/rpc/chains";
import { type BgMessage, type WalletOp, type JsonRpcRequest, RpcError } from "../src/provider/protocol";
Expand Down Expand Up @@ -96,6 +98,23 @@ async function handleWalletOp(op: WalletOp): Promise<unknown> {
const wei = await publicClient().getBalance({ address: op.address as `0x${string}` });
return { wei: wei.toString(), lcai: formatEther(wei) };
}
case "workerStatus": {
// Read-only worker registry/stake lookup via the SDK. No key needed; we
// return only number/bool fields (chrome.runtime can't structured-clone bigint).
const wo = new WorkerOperator(NETWORKS.mainnet, {
publicClient: publicClient() as unknown as MinimalPublicClient,
workerAddress: op.address as `0x${string}`,
});
const s = await wo.status();
return {
registered: s.registered,
belowFloor: s.belowFloor,
stakeLcai: s.stakeLcai,
minStakeLcai: Number(s.minStakeWei) / 1e18,
headroomLcai: s.headroomLcai,
claimableLcai: s.claimableLcai,
};
}
case "send": {
const kr = await restore();
const acct = kr?.accountFor(op.from);
Expand Down Expand Up @@ -185,6 +204,20 @@ async function fulfilApproved(request: JsonRpcRequest, origin: string): Promise<
if (!acct) throw RpcError.unauthorized;
return signAndSend(acct.account, tx.to, tx.value ? BigInt(tx.value) : 0n);
}
if (request.method === "eth_signTypedData_v4") {
const [address, json] = request.params as [string, string];
const acct = kr.accountFor(address);
if (!acct) throw RpcError.unauthorized;
const td = parseTypedData(json);
if (!td?.domain || !td.primaryType || !td.types || td.message === undefined) throw RpcError.invalidParams;
// Bind the signature to our chains - never sign typed data aimed elsewhere (review H5).
const chainId = td.domain.chainId != null ? Number(td.domain.chainId) : undefined;
if (chainId !== 9200 && chainId !== 8200) throw { code: 4901, message: "Typed data targets a different chain" };
const types = { ...(td.types as Record<string, unknown>) };
delete types.EIP712Domain; // viem derives the domain type itself
const def = { domain: td.domain, types, primaryType: td.primaryType, message: td.message } as unknown as TypedDataDefinition;
return acct.account.signTypedData(def);
}
throw RpcError.unsupported;
}

Expand Down
67 changes: 62 additions & 5 deletions wallet/entrypoints/popup/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { useCallback, useEffect, useState } from "react";
import { createMnemonic, isValidMnemonic } from "../../src/keyring/mnemonic";
import { wallet, type WalletState, type PendingRequest } from "./wallet-api";
import { decodeDangerousCall, type Severity } from "../../src/provider/decode-call";
import { summarizeTypedData } from "../../src/provider/typed-data";
import { wallet, type WalletState, type PendingRequest, type WorkerStatusView } from "./wallet-api";

const SEVERITY_CLASS: Record<Severity, string> = { info: "muted", warn: "warn", danger: "warn" };

const EXPLORER = "https://mainnet.lightscan.app";
const short = (a: string) => `${a.slice(0, 6)}…${a.slice(-4)}`;
Expand Down Expand Up @@ -156,14 +160,45 @@ function WalletHome({ state, onChange }: { state: WalletState; onChange: () => v
<p className="addr" style={{ marginTop: 6 }}>{short(address)} <a href={`${EXPLORER}/address/${address}`} target="_blank" rel="noreferrer">view</a></p>
</div>
<SendForm from={address} onSent={onChange} />
<WorkerPanel address={address} />
<div className="card">
<h2>LightChain superpowers</h2>
<p className="muted">Worker staking + monitoring, encrypted AI inference, and DAO intelligence connect through the lightnode SDK. Manage them at <a href="https://lightchain.ai" target="_blank" rel="noreferrer">lightnode</a>.</p>
<h2>More superpowers</h2>
<p className="muted">Encrypted AI inference, DAO intelligence, and the Ethereum bridge connect through the lightnode SDK and land next. Explore them at <a href="https://lightchain.ai" target="_blank" rel="noreferrer">lightnode</a>.</p>
</div>
</>
);
}

function WorkerPanel({ address }: { address: string }) {
const [s, setS] = useState<WorkerStatusView | "loading" | "error">("loading");
useEffect(() => {
setS("loading");
wallet<WorkerStatusView>({ type: "workerStatus", address }).then(setS).catch(() => setS("error"));
}, [address]);
if (s === "loading") return <div className="card"><h2>Worker status</h2><p className="muted">Checking the registry…</p></div>;
if (s === "error") return <div className="card"><h2>Worker status</h2><p className="muted">Could not reach the worker registry. Try again later.</p></div>;
if (!s.registered) {
return (
<div className="card">
<h2>Worker status</h2>
<p className="muted">This address is not a registered LightChain worker. <a href="https://lightchain.ai/onboard" target="_blank" rel="noreferrer">Run a worker →</a></p>
</div>
);
}
const fmt = (n: number) => n.toLocaleString(undefined, { maximumFractionDigits: 2 });
return (
<div className="card">
<div className="row between"><h2 style={{ margin: 0 }}>Worker</h2><span className="pill">registered</span></div>
<div className="row between" style={{ marginTop: 8 }}><span className="muted">Stake</span><span className="addr">{fmt(s.stakeLcai)} LCAI</span></div>
<div className="row between"><span className="muted">Min stake</span><span className="addr">{fmt(s.minStakeLcai)}</span></div>
<div className="row between"><span className="muted">Headroom</span><span className="addr">{fmt(s.headroomLcai)}</span></div>
{s.claimableLcai > 0 && <div className="row between"><span className="muted">Claimable</span><span className="ok">{fmt(s.claimableLcai)} LCAI</span></div>}
{s.belowFloor && <p className="warn">Below the stake floor - top up to keep earning. Manage at lightnode.</p>}
<a href="https://lightchain.ai" target="_blank" rel="noreferrer" style={{ display: "inline-block", marginTop: 8, fontSize: 12 }}>Manage worker →</a>
</div>
);
}

function SendForm({ from, onSent }: { from: string; onSent: () => void }) {
const [to, setTo] = useState("");
const [amount, setAmount] = useState("");
Expand Down Expand Up @@ -234,18 +269,40 @@ function labelFor(method: string): string {
if (method === "eth_requestAccounts") return "Connect this site to your wallet";
if (method === "personal_sign") return "Sign a message";
if (method === "eth_sendTransaction") return "Send a transaction";
if (method === "eth_signTypedData_v4") return "Sign typed data (EIP-712)";
return method;
}

function RequestDetail({ req }: { req: PendingRequest }) {
if (req.method === "eth_sendTransaction") {
const tx = (req.params?.[0] ?? {}) as { to?: string; value?: string; data?: string };
const hasData = tx.data && tx.data !== "0x";
const decoded = decodeDangerousCall(tx.data as `0x${string}` | undefined);
return (
<div className="muted" style={{ fontSize: 12 }}>
<p className="addr">to: {tx.to ?? "(contract creation)"}</p>
<p>value: {tx.value ? Number(BigInt(tx.value)) / 1e18 : 0} LCAI</p>
{hasData && <p className="warn">This is a contract interaction (data {String(tx.data).slice(0, 12)}…). Only approve if you trust this site - contract calls can move tokens.</p>}
{decoded.kind !== "empty" && (
<p className={SEVERITY_CLASS[decoded.severity]}>
<b>{decoded.label}.</b> {decoded.detail}
</p>
)}
</div>
);
}
if (req.method === "eth_signTypedData_v4") {
const s = summarizeTypedData(req.params?.[1], [9200, 8200]);
return (
<div className="muted" style={{ fontSize: 12 }}>
{s.error ? (
<p className="warn">{s.error} Reject unless you trust this site.</p>
) : (
<>
<p>type: <b>{s.primaryType}</b>{s.domainName ? ` · ${s.domainName}` : ""}</p>
{s.verifyingContract && <p className="addr">contract: {s.verifyingContract}</p>}
{!s.chainIdOk && <p className="warn">Domain chain ({s.chainId ?? "?"}) is not LightChain - reject unless you are sure.</p>}
{s.warning && <p className="warn">{s.warning}</p>}
</>
)}
</div>
);
}
Expand Down
8 changes: 8 additions & 0 deletions wallet/entrypoints/popup/wallet-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,11 @@ export interface PendingRequest {
origin: string;
params?: unknown[];
}
export interface WorkerStatusView {
registered: boolean;
belowFloor: boolean;
stakeLcai: number;
minStakeLcai: number;
headroomLcai: number;
claimableLcai: number;
}
Loading
Loading