diff --git a/.goutou.json b/.goutou.json new file mode 100644 index 0000000..6036da3 --- /dev/null +++ b/.goutou.json @@ -0,0 +1,4 @@ +{ + "repoId": "yaaa", + "coordProjectId": "03253073-c822-4a5a-b169-cb39976200c3" +} diff --git a/aastar-frontend/app/operator/dvt-register/components/PendingSdkNote.tsx b/aastar-frontend/app/operator/dvt-register/components/PendingSdkNote.tsx new file mode 100644 index 0000000..f3eec30 --- /dev/null +++ b/aastar-frontend/app/operator/dvt-register/components/PendingSdkNote.tsx @@ -0,0 +1,20 @@ +"use client"; + +/** + * Inline notice shown while the DVT SDK actions are not yet published (PR #288). + * Makes it unmistakable that on-chain steps are stubbed pending @aastar/sdk. + * + * @module app/operator/dvt-register/components/PendingSdkNote + */ +import { useTranslation } from "react-i18next"; +import { WrenchIcon } from "@heroicons/react/24/outline"; + +export default function PendingSdkNote() { + const { t } = useTranslation(); + return ( +
+ + {t("dvtRegister.pendingSdk")} +
+ ); +} diff --git a/aastar-frontend/app/operator/dvt-register/page.tsx b/aastar-frontend/app/operator/dvt-register/page.tsx new file mode 100644 index 0000000..fdf1956 --- /dev/null +++ b/aastar-frontend/app/operator/dvt-register/page.tsx @@ -0,0 +1,125 @@ +"use client"; + +/** + * DVT node-operator registration wizard (aastar-sdk#279 / PR #288). + * + * Turns the manual operator registration script into a guided flow, fully + * client-side: the operator's own browser wallet (viem WalletClient from + * WalletContext) signs `registerWithProof` against the AAStarValidator, and the + * BLS secret key never leaves the browser. + * + * connect → eligibility → key & PoP → register → done + * + * The eligibility step short-circuits to `done` when the operator already runs a + * node. On-chain/crypto calls are behind the {@link lib/sdk/dvtOperator} boundary, + * stubbed until @aastar/sdk 0.37.4/0.38.0 publishes the DVT actions. + * + * @module app/operator/dvt-register/page + */ +import { useCallback, useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; +import { useTranslation } from "react-i18next"; +import { CpuChipIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline"; +import Layout from "@/components/Layout"; +import { useWallet } from "@/contexts/WalletContext"; +import WizardProgress from "@/app/operator/deploy/components/WizardProgress"; +import WizardButton from "@/app/operator/deploy/components/WizardButton"; +import { initialDvtWizardData, type DvtStepProps, type DvtWizardData } from "./steps/types"; +import Step1Connect from "./steps/Step1Connect"; +import Step2Eligibility from "./steps/Step2Eligibility"; +import Step3KeyPoP from "./steps/Step3KeyPoP"; +import Step4Register from "./steps/Step4Register"; +import StepComplete from "./steps/StepComplete"; + +const STEP_KEYS = ["connect", "eligibility", "key", "register", "done"] as const; +const LABEL_KEYS: Record<(typeof STEP_KEYS)[number], string> = { + connect: "dvtRegister.labels.connect", + eligibility: "dvtRegister.labels.eligibility", + key: "dvtRegister.labels.key", + register: "dvtRegister.labels.register", + done: "dvtRegister.labels.done", +}; + +export default function DvtRegisterPage() { + const { t } = useTranslation(); + const router = useRouter(); + const { address, walletClient } = useWallet(); + const [step, setStep] = useState(0); + const [data, setData] = useState(initialDvtWizardData); + + const update = useCallback((patch: Partial) => { + setData(prev => ({ ...prev, ...patch })); + }, []); + + const onNext = useCallback(() => setStep(s => Math.min(s + 1, STEP_KEYS.length - 1)), []); + const onBack = useCallback(() => setStep(s => Math.max(s - 1, 0)), []); + const onComplete = useCallback(() => setStep(STEP_KEYS.length - 1), []); + const restart = useCallback(() => { + setData(initialDvtWizardData); + setStep(0); + }, []); + + const currentKey = STEP_KEYS[step]; + const needWallet = step > 0 && (!address || !walletClient); + + const stepProps: DvtStepProps | null = + address && walletClient + ? { address, walletClient, data, update, onNext, onBack, onComplete } + : null; + + const labels = useMemo(() => STEP_KEYS.map(k => t(LABEL_KEYS[k])), [t]); + + const renderStep = () => { + if (currentKey === "connect") { + return ; + } + if (needWallet || !stepProps) { + return ( +
+
+ + {t("dvtRegister.page.walletDisconnected")} +
+

+ {t("dvtRegister.page.reconnectPrompt")} +

+ {t("dvtRegister.page.backToStart")} +
+ ); + } + switch (currentKey) { + case "eligibility": + return ; + case "key": + return ; + case "register": + return ; + case "done": + return ( + router.push("/operator")} onRestart={restart} /> + ); + default: + return null; + } + }; + + return ( + +
+
+

+ + {t("dvtRegister.page.title")} +

+

+ {t("dvtRegister.page.subtitle")} +

+
+ + + + {renderStep()} +
+
+ ); +} diff --git a/aastar-frontend/app/operator/dvt-register/steps/Step1Connect.tsx b/aastar-frontend/app/operator/dvt-register/steps/Step1Connect.tsx new file mode 100644 index 0000000..f8badb0 --- /dev/null +++ b/aastar-frontend/app/operator/dvt-register/steps/Step1Connect.tsx @@ -0,0 +1,69 @@ +"use client"; + +/** + * Step 1 — connect the operator wallet and introduce DVT node registration. + * No on-chain writes here. + * + * @module app/operator/dvt-register/steps/Step1Connect + */ +import { useTranslation } from "react-i18next"; +import { WalletIcon, ShieldCheckIcon } from "@heroicons/react/24/outline"; +import { useWallet } from "@/contexts/WalletContext"; +import StepCard from "@/app/operator/deploy/components/StepCard"; +import WizardButton from "@/app/operator/deploy/components/WizardButton"; + +interface Step1Props { + onNext: () => void; +} + +export default function Step1Connect({ onNext }: Step1Props) { + const { t } = useTranslation(); + const { address, isConnecting, hasInjectedWallet, connect } = useWallet(); + + return ( + } + footer={ + <> + + {address ? ( + + {address.slice(0, 6)}…{address.slice(-4)} + + ) : ( + t("dvtRegister.step1.walletNotConnected") + )} + + {address ? ( + {t("dvtRegister.common.continue")} + ) : ( + + {hasInjectedWallet + ? t("dvtRegister.step1.connectWallet") + : t("dvtRegister.step1.noWalletFound")} + + )} + + } + > +
+
+ + + {t("dvtRegister.step1.aboutTitle")} + +
+
    + {[1, 2, 3].map(n => ( +
  • + + {t(`dvtRegister.step1.about${n}`)} +
  • + ))} +
+
+
+ ); +} diff --git a/aastar-frontend/app/operator/dvt-register/steps/Step2Eligibility.tsx b/aastar-frontend/app/operator/dvt-register/steps/Step2Eligibility.tsx new file mode 100644 index 0000000..6a0a0ca --- /dev/null +++ b/aastar-frontend/app/operator/dvt-register/steps/Step2Eligibility.tsx @@ -0,0 +1,132 @@ +"use client"; + +/** + * Step 2 — check on-chain eligibility. + * + * - `operatorNode(operator)` non-zero → operator already runs a node: short-circuit + * to the final step showing the bound nodeId. + * - else surface stake status (`requireStake` / `minStake`); if the operator has + * not staked ROLE_DVT, guide them to staking before continuing. + * + * While the DVT SDK is unpublished (PR #288) the read is stubbed: the step shows + * the pending notice and lets the operator walk the rest of the wizard. + * + * @module app/operator/dvt-register/steps/Step2Eligibility + */ +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { formatEther } from "viem"; +import { ClipboardDocumentCheckIcon, ArrowPathIcon } from "@heroicons/react/24/outline"; +import StepCard from "@/app/operator/deploy/components/StepCard"; +import WizardButton from "@/app/operator/deploy/components/WizardButton"; +import { + DVT_SDK_READY, + fetchDvtEligibility, + type DvtEligibility, + type Hex, +} from "@/lib/sdk/dvtOperator"; +import PendingSdkNote from "../components/PendingSdkNote"; +import type { DvtStepProps } from "./types"; + +export default function Step2Eligibility({ + address, + walletClient, + data, + update, + onNext, + onBack, + onComplete, +}: DvtStepProps) { + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(); + const eligibility = data.eligibility; + + const load = useCallback(async () => { + if (!DVT_SDK_READY) return; + setLoading(true); + setError(undefined); + try { + const result: DvtEligibility = await fetchDvtEligibility(walletClient, address as Hex); + update({ eligibility: result }); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }, [address, walletClient, update]); + + useEffect(() => { + if (DVT_SDK_READY && !eligibility) void load(); + }, [eligibility, load]); + + const alreadyRegistered = !!eligibility?.boundNodeId; + + return ( + } + error={error} + footer={ + <> + {onBack && ( + + {t("dvtRegister.common.back")} + + )} + {alreadyRegistered ? ( + onComplete?.()}> + {t("dvtRegister.step2.viewNode")} + + ) : ( + + {t("dvtRegister.common.continue")} + + )} + + } + > + {!DVT_SDK_READY && } + + {DVT_SDK_READY && loading && ( +
+ + {t("dvtRegister.step2.checking")} +
+ )} + + {alreadyRegistered && eligibility?.boundNodeId && ( +
+

+ {t("dvtRegister.step2.alreadyRegistered")} +

+

+ {eligibility.boundNodeId} +

+
+ )} + + {!alreadyRegistered && eligibility && ( +
+
+
{t("dvtRegister.step2.stakeOpen")}
+
+ {eligibility.stakeOpen + ? t("dvtRegister.step2.stakeOpenYes") + : t("dvtRegister.step2.stakeOpenNo")} +
+
+
+
{t("dvtRegister.step2.minStake")}
+
+ {formatEther(eligibility.minStake)} GTOKEN +
+
+
+ )} + +

{t("dvtRegister.step2.stakeHint")}

+
+ ); +} diff --git a/aastar-frontend/app/operator/dvt-register/steps/Step3KeyPoP.tsx b/aastar-frontend/app/operator/dvt-register/steps/Step3KeyPoP.tsx new file mode 100644 index 0000000..c750cfe --- /dev/null +++ b/aastar-frontend/app/operator/dvt-register/steps/Step3KeyPoP.tsx @@ -0,0 +1,145 @@ +"use client"; + +/** + * Step 3 — obtain the BLS node key and derive the proof-of-possession. + * + * The operator either generates a fresh key in the browser or pastes an existing + * one. `buildDvtPop` (pure/local in the SDK) then derives `{ publicKey, popPoint, + * popSig, nodeId }`, letting us preview the nodeId before any tx. + * + * SECURITY: the BLS secret key is held only in in-memory wizard state — it is + * never written to storage nor sent to the backend. The UI warns the operator to + * back it up offline. + * + * While the DVT SDK is unpublished (PR #288), `buildDvtPop` is stubbed: key + * generation/import still works locally, and the nodeId preview shows a pending + * notice. + * + * @module app/operator/dvt-register/steps/Step3KeyPoP + */ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { KeyIcon, ExclamationTriangleIcon, SparklesIcon } from "@heroicons/react/24/outline"; +import StepCard from "@/app/operator/deploy/components/StepCard"; +import WizardButton from "@/app/operator/deploy/components/WizardButton"; +import FormField from "@/app/operator/deploy/components/FormField"; +import { + buildDvtPop, + generateBlsSecretKey, + normalizeBlsSecretKey, + type Hex, +} from "@/lib/sdk/dvtOperator"; +import PendingSdkNote from "../components/PendingSdkNote"; +import type { DvtStepProps } from "./types"; + +export default function Step3KeyPoP({ data, update, onNext, onBack }: DvtStepProps) { + const { t } = useTranslation(); + const [keyInput, setKeyInput] = useState(data.blsSecretKey ?? ""); + const [error, setError] = useState(); + + const applyKey = (secret: Hex) => { + setError(undefined); + // Only commit the key once its PoP derives — an out-of-range key (e.g. a + // pasted scalar ≥ curve order) must not advance the wizard to registration. + try { + const pop = buildDvtPop(secret); + update({ blsSecretKey: secret, pop }); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + update({ blsSecretKey: undefined, pop: undefined }); + } + }; + + const handleGenerate = () => { + const secret = generateBlsSecretKey(); + setKeyInput(secret); + applyKey(secret); + }; + + const handleImport = () => { + try { + const secret = normalizeBlsSecretKey(keyInput); + setKeyInput(secret); + applyKey(secret); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }; + + const hasKey = !!data.blsSecretKey; + + return ( + } + error={error} + footer={ + <> + {onBack && ( + + {t("dvtRegister.common.back")} + + )} + + {t("dvtRegister.common.continue")} + + + } + > +
+ + {t("dvtRegister.step3.keyWarning")} +
+ +
+
+
+ +
+ + {t("dvtRegister.step3.import")} + +
+ +
+ + {hasKey && ( +
+

{t("dvtRegister.step3.previewTitle")}

+ {data.pop ? ( + <> + + + + ) : ( + + )} +
+ )} +
+ ); +} + +function Field({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} diff --git a/aastar-frontend/app/operator/dvt-register/steps/Step4Register.tsx b/aastar-frontend/app/operator/dvt-register/steps/Step4Register.tsx new file mode 100644 index 0000000..f13c633 --- /dev/null +++ b/aastar-frontend/app/operator/dvt-register/steps/Step4Register.tsx @@ -0,0 +1,101 @@ +"use client"; + +/** + * Step 4 — submit the registration and confirm on-chain. + * + * `register({ blsSecretKey })` builds the PoP and submits in one tx; after the + * receipt we read `isRegistered(nodeId)` back to confirm. `useTxStep` drives the + * pending/confirmed/error UI, consistent with the deploy wizard. + * + * While the DVT SDK is unpublished (PR #288) the submit is disabled behind the + * pending notice. + * + * @module app/operator/dvt-register/steps/Step4Register + */ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { PaperAirplaneIcon } from "@heroicons/react/24/outline"; +import StepCard from "@/app/operator/deploy/components/StepCard"; +import WizardButton from "@/app/operator/deploy/components/WizardButton"; +import { useTxStep } from "@/app/operator/deploy/components/useTxStep"; +import { DVT_SDK_READY, isDvtNodeRegistered, registerDvtNode } from "@/lib/sdk/dvtOperator"; +import PendingSdkNote from "../components/PendingSdkNote"; +import type { DvtStepProps } from "./types"; + +export default function Step4Register({ + walletClient, + data, + update, + onNext, + onBack, +}: DvtStepProps) { + const { t } = useTranslation(); + const { status, txHash, error, isBusy, run } = useTxStep(); + const [confirmError, setConfirmError] = useState(); + + const nodeId = data.pop?.nodeId; + + const handleRegister = async () => { + if (!data.blsSecretKey) return; + const hash = await run( + async () => { + const { hash } = await registerDvtNode(walletClient, data.blsSecretKey!); + return hash; + }, + { + loadingMsg: t("dvtRegister.step4.submitting"), + successMsg: t("dvtRegister.step4.submitted"), + } + ); + if (!hash) return; + update({ registerTxHash: hash }); + // Best-effort read-back: the tx already confirmed, so a false/failed check + // only warns (e.g. RPC lag) — it never undoes a landed registration. + try { + if (nodeId && !(await isDvtNodeRegistered(walletClient, nodeId))) { + setConfirmError(t("dvtRegister.step4.confirmFailed")); + } + } catch (err) { + setConfirmError(err instanceof Error ? err.message : String(err)); + } + onNext(); + }; + + return ( + } + status={status} + txHash={txHash} + error={error ?? confirmError} + footer={ + <> + {onBack && ( + + {t("dvtRegister.common.back")} + + )} + + {t("dvtRegister.step4.register")} + + + } + > + {!DVT_SDK_READY && } + + {nodeId && ( +
+

{t("dvtRegister.step3.nodeId")}

+

{nodeId}

+
+ )} + +

{t("dvtRegister.step4.hint")}

+
+ ); +} diff --git a/aastar-frontend/app/operator/dvt-register/steps/StepComplete.tsx b/aastar-frontend/app/operator/dvt-register/steps/StepComplete.tsx new file mode 100644 index 0000000..5e182b4 --- /dev/null +++ b/aastar-frontend/app/operator/dvt-register/steps/StepComplete.tsx @@ -0,0 +1,63 @@ +"use client"; + +/** + * Final step — show the registered nodeId (fresh registration or the pre-existing + * bound node from the eligibility short-circuit) and the tx link. + * + * @module app/operator/dvt-register/steps/StepComplete + */ +import { useTranslation } from "react-i18next"; +import { CheckCircleIcon, ArrowTopRightOnSquareIcon } from "@heroicons/react/24/outline"; +import StepCard, { explorerTx } from "@/app/operator/deploy/components/StepCard"; +import WizardButton from "@/app/operator/deploy/components/WizardButton"; +import type { DvtWizardData } from "./types"; + +interface StepCompleteProps { + data: DvtWizardData; + onDone: () => void; + onRestart: () => void; +} + +export default function StepComplete({ data, onDone, onRestart }: StepCompleteProps) { + const { t } = useTranslation(); + const nodeId = data.pop?.nodeId ?? data.eligibility?.boundNodeId ?? undefined; + + return ( + } + footer={ + <> + + {t("dvtRegister.done.registerAnother")} + + {t("dvtRegister.done.backToOperator")} + + } + > + {nodeId && ( +
+

+ {t("dvtRegister.step3.nodeId")} +

+

+ {nodeId} +

+
+ )} + + {data.registerTxHash && ( + + {t("dvtRegister.done.viewTx")} + + + )} +
+ ); +} diff --git a/aastar-frontend/app/operator/dvt-register/steps/types.ts b/aastar-frontend/app/operator/dvt-register/steps/types.ts new file mode 100644 index 0000000..876a041 --- /dev/null +++ b/aastar-frontend/app/operator/dvt-register/steps/types.ts @@ -0,0 +1,44 @@ +"use client"; + +/** + * Shared types for the DVT node-operator registration wizard. + * + * Registration is signed in the operator's own browser wallet (viem WalletClient + * from WalletContext); the BLS **secret key lives only in this in-memory wizard + * state** — never persisted to storage, never sent to the backend. + * + * @module app/operator/dvt-register/steps/types + */ +import type { Address, WalletClient } from "viem"; +import type { DvtEligibility, DvtPop, Hex } from "@/lib/sdk/dvtOperator"; + +/** Cross-step wizard state, owned by page.tsx and threaded into each step. */ +export interface DvtWizardData { + /** On-chain eligibility snapshot (operatorNode / requireStake / minStake). */ + eligibility: DvtEligibility | null; + /** + * BLS node secret key — in-memory only, never persisted or transmitted. + * Present once the operator generates or imports a key in the key step. + */ + blsSecretKey?: Hex; + /** Proof-of-possession + nodeId derived locally from `blsSecretKey`. */ + pop?: DvtPop; + /** Registration tx hash once submitted. */ + registerTxHash?: Hex; +} + +export const initialDvtWizardData: DvtWizardData = { + eligibility: null, +}; + +/** Props every step component receives from the wizard container. */ +export interface DvtStepProps { + address: Address; + walletClient: WalletClient; + data: DvtWizardData; + update: (patch: Partial) => void; + onNext: () => void; + onBack?: () => void; + /** Jump straight to the final step (used by the already-registered short-circuit). */ + onComplete?: () => void; +} diff --git a/aastar-frontend/app/operator/page.tsx b/aastar-frontend/app/operator/page.tsx index 83fbc20..9ddbc68 100644 --- a/aastar-frontend/app/operator/page.tsx +++ b/aastar-frontend/app/operator/page.tsx @@ -10,6 +10,7 @@ import { ArrowPathIcon, RocketLaunchIcon, WrenchScrewdriverIcon, + CpuChipIcon, } from "@heroicons/react/24/outline"; import Layout from "@/components/Layout"; import { operatorAPI } from "@/lib/api"; @@ -135,7 +136,7 @@ export default function OperatorPage() { )} {/* Quick Actions — entry points to the operator write flows */} -
+
+
{/* My Operator Status */} diff --git a/aastar-frontend/lib/i18n/locales/en.json b/aastar-frontend/lib/i18n/locales/en.json index b68ab38..30a2318 100644 --- a/aastar-frontend/lib/i18n/locales/en.json +++ b/aastar-frontend/lib/i18n/locales/en.json @@ -771,6 +771,82 @@ "desc": "Manage xPNTs, top up your Paymaster, and view operator status." } }, + "dvtRegister": { + "pendingSdk": "On-chain actions are waiting on @aastar/sdk 0.37.4/0.38.0 (aastar-sdk#279 / PR #288). The UI is ready and will light up once the SDK ships.", + "common": { + "continue": "Continue", + "back": "Back" + }, + "labels": { + "connect": "Connect", + "eligibility": "Eligibility", + "key": "Key & PoP", + "register": "Register", + "done": "Done" + }, + "card": { + "title": "DVT node registration", + "desc": "Guide operators through stake + PoP + registration — no more manual scripts." + }, + "page": { + "title": "DVT node registration", + "subtitle": "Guide an operator through staking, building the PoP, and registering a DVT signing node.", + "walletDisconnected": "Wallet disconnected", + "reconnectPrompt": "Registration needs your operator wallet connected. Please restart the wizard.", + "backToStart": "Back to start" + }, + "step1": { + "title": "Connect operator wallet", + "description": "DVT node registration is signed by your own EOA in the browser.", + "walletNotConnected": "Wallet not connected", + "connectWallet": "Connect wallet", + "noWalletFound": "No wallet found", + "aboutTitle": "About DVT node registration", + "about1": "Stake gate: ROLE_DVT + GToken ≥ minimum stake", + "about2": "BLS proof-of-possession (PoP) built locally from the node key — the key never leaves the browser", + "about3": "One operator, one node; nodeId = keccak256(public key)" + }, + "step2": { + "title": "Check eligibility", + "description": "Confirm whether you are already registered and whether your stake qualifies.", + "checking": "Reading on-chain state…", + "alreadyRegistered": "This operator already has a bound node:", + "viewNode": "View node", + "stakeOpen": "Stake registration open", + "stakeOpenYes": "Yes", + "stakeOpenNo": "No", + "minStake": "Minimum stake", + "stakeHint": "If you have not staked ROLE_DVT, stake via GTokenStaking before continuing." + }, + "step3": { + "title": "BLS node key & PoP", + "description": "Generate or import the node BLS secret key; derive the PoP and nodeId locally.", + "keyWarning": "⚠️ The BLS secret key is sensitive. It stays only in this browser tab and is never sent to the backend. Back it up offline — losing it means losing the node identity.", + "keyLabel": "BLS secret key (0x + 64 hex chars)", + "keyHint": "Paste an existing key, or generate a fresh one below.", + "import": "Import", + "generate": "Generate a new key in the browser", + "previewTitle": "Derived preview", + "nodeId": "nodeId", + "publicKey": "Public key (G1)" + }, + "step4": { + "title": "Submit registration", + "description": "Build the PoP and call registerWithProof in one transaction.", + "submitting": "Confirm in your wallet…", + "submitted": "Registration transaction submitted", + "register": "Register node", + "hint": "After confirmation we read isRegistered back to confirm the node is on-chain.", + "confirmFailed": "The transaction landed, but isRegistered still reads false (possibly RPC lag). Re-check the node status in the operator hub shortly." + }, + "done": { + "title": "Registration complete", + "description": "Your DVT signing node is registered.", + "registerAnother": "Register another", + "backToOperator": "Back to operator hub", + "viewTx": "View transaction" + } + }, "operatorDashboard": { "title": "Operator Portal", "loadError": "Failed to load operator data", diff --git a/aastar-frontend/lib/i18n/locales/zh.json b/aastar-frontend/lib/i18n/locales/zh.json index a1f8135..006ebdb 100644 --- a/aastar-frontend/lib/i18n/locales/zh.json +++ b/aastar-frontend/lib/i18n/locales/zh.json @@ -771,6 +771,82 @@ "desc": "管理 xPNTs、为 Paymaster 充值、查看运营者状态。" } }, + "dvtRegister": { + "pendingSdk": "链上操作等待 @aastar/sdk 0.37.4/0.38.0 发布(aastar-sdk#279 / PR #288)。界面已就绪,SDK 落地后自动可用。", + "common": { + "continue": "继续", + "back": "返回" + }, + "labels": { + "connect": "连接", + "eligibility": "资格", + "key": "密钥与 PoP", + "register": "注册", + "done": "完成" + }, + "card": { + "title": "DVT 节点注册", + "desc": "把 DVT 签名节点质押 + PoP + 注册做成引导流程,不再手搓脚本。" + }, + "page": { + "title": "DVT 节点注册", + "subtitle": "引导 operator 完成质押、生成 PoP、注册 DVT 签名节点。", + "walletDisconnected": "钱包已断开", + "reconnectPrompt": "注册需要连接你的 operator 钱包,请重新开始向导。", + "backToStart": "回到开始" + }, + "step1": { + "title": "连接 operator 钱包", + "description": "DVT 节点注册由你自己的 EOA 在浏览器内签名。", + "walletNotConnected": "钱包未连接", + "connectWallet": "连接钱包", + "noWalletFound": "未检测到钱包", + "aboutTitle": "关于 DVT 节点注册", + "about1": "质押门槛:ROLE_DVT + GToken ≥ 最低质押额", + "about2": "BLS 拥有权证明(PoP):在本地用节点私钥生成,私钥不出浏览器", + "about3": "一 operator 一节点,nodeId = keccak256(公钥)" + }, + "step2": { + "title": "检查注册资格", + "description": "确认是否已注册,以及质押是否达标。", + "checking": "读取链上状态中…", + "alreadyRegistered": "该 operator 已绑定节点:", + "viewNode": "查看节点", + "stakeOpen": "质押注册已开放", + "stakeOpenYes": "是", + "stakeOpenNo": "否", + "minStake": "最低质押额", + "stakeHint": "若未质押 ROLE_DVT,请先通过 GTokenStaking 质押后再继续。" + }, + "step3": { + "title": "BLS 节点密钥与 PoP", + "description": "生成或导入节点 BLS 私钥,本地推导 PoP 与 nodeId。", + "keyWarning": "⚠️ BLS 私钥属敏感数据,仅保存在本浏览器标签内,绝不上传后端。请务必离线备份,丢失将无法找回节点身份。", + "keyLabel": "BLS 私钥(0x + 64 位十六进制)", + "keyHint": "粘贴已有私钥,或点下方生成一个全新的。", + "import": "导入", + "generate": "在浏览器生成新密钥", + "previewTitle": "推导结果预览", + "nodeId": "nodeId", + "publicKey": "公钥(G1)" + }, + "step4": { + "title": "提交注册", + "description": "构造 PoP 并调用 registerWithProof,一步完成。", + "submitting": "请在钱包中确认…", + "submitted": "注册交易已提交", + "register": "注册节点", + "hint": "交易确认后会读回 isRegistered 以确认节点已上链。", + "confirmFailed": "交易已上链,但读回 isRegistered 暂为 false(可能是节点 RPC 有延迟)。请稍后在运营中心复查节点状态。" + }, + "done": { + "title": "注册完成", + "description": "你的 DVT 签名节点已注册。", + "registerAnother": "再注册一个", + "backToOperator": "返回运营中心", + "viewTx": "查看交易" + } + }, "operatorDashboard": { "title": "运营者门户", "loadError": "加载运营者数据失败", diff --git a/aastar-frontend/lib/sdk/dvtOperator.ts b/aastar-frontend/lib/sdk/dvtOperator.ts new file mode 100644 index 0000000..ec299f4 --- /dev/null +++ b/aastar-frontend/lib/sdk/dvtOperator.ts @@ -0,0 +1,168 @@ +/** + * DVT node-operator registration — SDK boundary (aastar-sdk#279). + * + * DVT signing nodes register on the AAStarValidator via `registerWithProof` + * (YetAnotherAA-Validator #165): stake gate (ROLE_DVT + GToken ≥ minStake) + a + * BLS proof-of-possession (`e(pk, popPoint) == e(G1, popSig)`) + one-operator-one-node, + * with `nodeId = keccak256(pubkey)`. Registration is signed in the operator's own + * browser wallet (viem WalletClient from WalletContext) — never the backend key, + * and the BLS **secret key never leaves the browser**. + * + * Backed by @aastar/sdk 0.38.0 `@aastar/sdk/core` (aastar-sdk#288, E2E LIVE PASS + * on Sepolia, tx 0x216a7ed5…): `buildDvtPop` derives the PoP + nodeId locally + * (no network); `dvtOperatorActions(validator)(client)` decorates a viem client + * with the typed registry actions. The validator address comes from the SDK's + * canonical `DVT_VALIDATOR_ADDRESS` (set by `applyConfig` via `ensureSdkConfig`), + * never hardcoded here. + * + * @module lib/sdk/dvtOperator + */ +import type { WalletClient } from "viem"; +import { + buildDvtPop as sdkBuildDvtPop, + dvtOperatorActions, + DVT_VALIDATOR_ADDRESS, +} from "@aastar/sdk/core"; +import { ensureSdkConfig, getPublicClient } from "./client"; + +/** DVT SDK actions are wired (kept for the wizard's step gating). */ +export const DVT_SDK_READY = true; + +export type Hex = `0x${string}`; + +/** Proof-of-possession tuple produced locally from a BLS secret key. */ +export interface DvtPop { + /** EIP-2537 128-byte G1 public key (hex). */ + publicKey: Hex; + /** G2 message point the PoP signs over (hex). */ + popPoint: Hex; + /** BLS signature `sk · popPoint` (hex). */ + popSig: Hex; + /** `keccak256(publicKey)` — the on-chain node id. */ + nodeId: Hex; +} + +/** Operator's on-chain eligibility snapshot for DVT registration. */ +export interface DvtEligibility { + /** Node id already bound to this operator, or null if unregistered. */ + boundNodeId: Hex | null; + /** Whether stake-gated registration is currently open (`requireStake`). */ + stakeOpen: boolean; + /** ROLE_DVT stake threshold in wei (`minStake`). */ + minStake: bigint; +} + +/** Result of a one-shot `register({ blsSecretKey })`. */ +export interface DvtRegisterResult { + hash: Hex; + pop: DvtPop; +} + +/** A bytes32 that is all zeros — the registry's "unset" sentinel for node ids. */ +function isZeroHash(value: string): boolean { + return /^0x0*$/i.test(value); +} + +// ── Local helpers (SDK-independent) ────────────────────────────────────────── + +/** True if `input` is a 0x-prefixed 32-byte (64 hex char) scalar. */ +export function isBlsSecretKeyHex(input: string): input is Hex { + return /^0x[0-9a-fA-F]{64}$/.test(input.trim()); +} + +/** + * Normalise a pasted BLS secret key to canonical lowercase `0x…64` hex. + * @throws if the input is not a 32-byte hex scalar. + */ +export function normalizeBlsSecretKey(input: string): Hex { + const v = input.trim(); + if (!isBlsSecretKeyHex(v)) { + throw new Error("Invalid BLS secret key: expected a 0x-prefixed 32-byte hex string."); + } + return v.toLowerCase() as Hex; +} + +/** + * BLS12-381 scalar field order r. A random 256-bit value is ≥ r ~55% of the time + * (r ≈ 0.453·2²⁵⁶), so a single draw is NOT reliably a valid secret key. + */ +const BLS12_381_R = 0x73eda753299d7d483339d80809a1d80553bda402fffe5bfeffffffff00000001n; + +/** + * Generate a random BLS secret key in the browser (never leaves the tab). + * Rejection-samples until the 256-bit draw lands in [1, r-1] — the valid scalar + * range `buildDvtPop` requires — so the result never trips its range check. + * Success probability per draw is ~45%, so this loops ~2 times on average. + */ +export function generateBlsSecretKey(): Hex { + for (;;) { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + const hex = Array.from(bytes, b => b.toString(16).padStart(2, "0")).join(""); + const value = BigInt(`0x${hex}`); + if (value >= 1n && value < BLS12_381_R) return `0x${hex}` as Hex; + } +} + +// ── SDK-backed surface (@aastar/sdk 0.38.0) ────────────────────────────────── + +/** DVT registry actions bound to a read-only public client. */ +function readActions() { + ensureSdkConfig(); + return dvtOperatorActions(DVT_VALIDATOR_ADDRESS)(getPublicClient()); +} + +/** DVT registry actions bound to the operator's signing wallet client. */ +function writeActions(walletClient: WalletClient) { + ensureSdkConfig(); + return dvtOperatorActions(DVT_VALIDATOR_ADDRESS)(walletClient); +} + +/** + * Derive the proof-of-possession + nodeId from a BLS secret key. Pure/local + * (no network), so this works offline once the operator has a key. + * @throws if the secret key is out of range (0 or ≥ curve order). + */ +export function buildDvtPop(blsSecretKey: Hex): DvtPop { + return sdkBuildDvtPop(blsSecretKey); +} + +/** Read the operator's registration + stake eligibility. */ +export async function fetchDvtEligibility( + _walletClient: WalletClient, + operator: Hex +): Promise { + const dvt = readActions(); + const [boundNodeId, stakeOpen, minStake] = await Promise.all([ + dvt.operatorNode({ operator }), + dvt.requireStake(), + dvt.minStake(), + ]); + return { boundNodeId: isZeroHash(boundNodeId) ? null : boundNodeId, stakeOpen, minStake }; +} + +/** One-shot: build PoP from the secret key and submit `register`. */ +export async function registerDvtNode( + walletClient: WalletClient, + blsSecretKey: Hex +): Promise { + return writeActions(walletClient).register({ blsSecretKey }); +} + +/** + * HSM path: submit a PoP produced outside the browser via `registerWithProof`. + */ +export async function registerDvtNodeWithProof( + walletClient: WalletClient, + pop: Pick +): Promise { + return writeActions(walletClient).registerWithProof(pop); +} + +/** Confirm a node id is registered on-chain (post-tx read-back). */ +export async function isDvtNodeRegistered( + _walletClient: WalletClient, + nodeId: Hex +): Promise { + return readActions().isRegistered({ nodeId }); +} diff --git a/aastar-frontend/package.json b/aastar-frontend/package.json index f9c50df..6342c90 100644 --- a/aastar-frontend/package.json +++ b/aastar-frontend/package.json @@ -18,7 +18,7 @@ "test:e2e:ui": "playwright test" }, "dependencies": { - "@aastar/sdk": "^0.35.0", + "@aastar/sdk": "^0.38.0", "@headlessui/react": "^2.2.10", "@heroicons/react": "^2.2.0", "@simplewebauthn/browser": "^13.3.0", diff --git a/aastar/package.json b/aastar/package.json index ddb1ef8..31f11f6 100644 --- a/aastar/package.json +++ b/aastar/package.json @@ -50,7 +50,7 @@ "typescript": "^5.9.3" }, "dependencies": { - "@aastar/sdk": "^0.35.0", + "@aastar/sdk": "^0.38.0", "@nestjs/common": "^11.1.6", "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.1.18", diff --git a/package-lock.json b/package-lock.json index c3a16cd..1b579b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "@aastar/sdk": "^0.35.0", + "@aastar/sdk": "^0.38.0", "@nestjs/common": "^11.1.6", "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.1.18", @@ -82,7 +82,7 @@ "aastar-frontend": { "version": "0.1.0", "dependencies": { - "@aastar/sdk": "^0.35.0", + "@aastar/sdk": "^0.38.0", "@headlessui/react": "^2.2.10", "@heroicons/react": "^2.2.0", "@simplewebauthn/browser": "^13.3.0", @@ -1428,9 +1428,9 @@ } }, "node_modules/@aastar/sdk": { - "version": "0.35.0", - "resolved": "https://registry.npmmirror.com/@aastar/sdk/-/sdk-0.35.0.tgz", - "integrity": "sha512-9hm2K88FssUBNtaO7AYA3CkJqWy4AANNRUi8QV77URmXfeBl9pxSj7lJhzkP0BFQG+o3BNiizVDdnCP6XN+Ffg==", + "version": "0.38.0", + "resolved": "https://registry.npmmirror.com/@aastar/sdk/-/sdk-0.38.0.tgz", + "integrity": "sha512-KiBBSrdL60KJIOM1ZHPQtQPc6diFliJkUMpaEbI6zkkL5Lbd5A8TM7aNosGXTToX5wSyc6gRTBDly4DNKQXa6g==", "license": "Apache-2.0", "dependencies": { "@simplewebauthn/browser": "^13.2.2",