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 (
+
+ );
+}
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",