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
4 changes: 4 additions & 0 deletions .goutou.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"repoId": "yaaa",
"coordProjectId": "03253073-c822-4a5a-b169-cb39976200c3"
}
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex items-start gap-2 text-sm rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 px-3 py-2 text-amber-800 dark:text-amber-300">
<WrenchIcon className="h-4 w-4 shrink-0 mt-0.5" />
<span>{t("dvtRegister.pendingSdk")}</span>
</div>
);
}
125 changes: 125 additions & 0 deletions aastar-frontend/app/operator/dvt-register/page.tsx
Original file line number Diff line number Diff line change
@@ -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<DvtWizardData>(initialDvtWizardData);

const update = useCallback((patch: Partial<DvtWizardData>) => {
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 <Step1Connect onNext={onNext} />;
}
if (needWallet || !stepProps) {
return (
<section className="bg-white dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700 p-6 space-y-4">
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400">
<ExclamationTriangleIcon className="h-6 w-6" />
<span className="font-semibold">{t("dvtRegister.page.walletDisconnected")}</span>
</div>
<p className="text-sm text-gray-600 dark:text-gray-300">
{t("dvtRegister.page.reconnectPrompt")}
</p>
<WizardButton onClick={restart}>{t("dvtRegister.page.backToStart")}</WizardButton>
</section>
);
}
switch (currentKey) {
case "eligibility":
return <Step2Eligibility {...stepProps} />;
case "key":
return <Step3KeyPoP {...stepProps} />;
case "register":
return <Step4Register {...stepProps} />;
case "done":
return (
<StepComplete data={data} onDone={() => router.push("/operator")} onRestart={restart} />
);
default:
return null;
}
};

return (
<Layout requireAuth>
<div className="max-w-3xl mx-auto px-4 py-8 space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<CpuChipIcon className="h-7 w-7 text-slate-700 dark:text-emerald-400" />
{t("dvtRegister.page.title")}
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{t("dvtRegister.page.subtitle")}
</p>
</div>

<WizardProgress labels={labels} current={step} />

{renderStep()}
</div>
</Layout>
);
}
Original file line number Diff line number Diff line change
@@ -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 (
<StepCard
title={t("dvtRegister.step1.title")}
description={t("dvtRegister.step1.description")}
icon={<WalletIcon className="h-6 w-6" />}
footer={
<>
<span className="text-xs text-gray-400">
{address ? (
<span className="font-mono">
{address.slice(0, 6)}…{address.slice(-4)}
</span>
) : (
t("dvtRegister.step1.walletNotConnected")
)}
</span>
{address ? (
<WizardButton onClick={onNext}>{t("dvtRegister.common.continue")}</WizardButton>
) : (
<WizardButton onClick={connect} loading={isConnecting} disabled={!hasInjectedWallet}>
{hasInjectedWallet
? t("dvtRegister.step1.connectWallet")
: t("dvtRegister.step1.noWalletFound")}
</WizardButton>
)}
</>
}
>
<div className="rounded-xl bg-slate-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-4">
<div className="flex items-center gap-2 mb-2">
<ShieldCheckIcon className="h-5 w-5 text-slate-700 dark:text-emerald-400" />
<span className="font-semibold text-gray-900 dark:text-white">
{t("dvtRegister.step1.aboutTitle")}
</span>
</div>
<ul className="space-y-1">
{[1, 2, 3].map(n => (
<li key={n} className="text-xs text-gray-600 dark:text-gray-300 flex gap-1.5">
<span className="text-slate-400 dark:text-emerald-500">•</span>
{t(`dvtRegister.step1.about${n}`)}
</li>
))}
</ul>
</div>
</StepCard>
);
}
132 changes: 132 additions & 0 deletions aastar-frontend/app/operator/dvt-register/steps/Step2Eligibility.tsx
Original file line number Diff line number Diff line change
@@ -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<string | undefined>();
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 (
<StepCard
title={t("dvtRegister.step2.title")}
description={t("dvtRegister.step2.description")}
icon={<ClipboardDocumentCheckIcon className="h-6 w-6" />}
error={error}
footer={
<>
{onBack && (
<WizardButton variant="secondary" onClick={onBack}>
{t("dvtRegister.common.back")}
</WizardButton>
)}
{alreadyRegistered ? (
<WizardButton onClick={() => onComplete?.()}>
{t("dvtRegister.step2.viewNode")}
</WizardButton>
) : (
<WizardButton onClick={onNext} loading={loading}>
{t("dvtRegister.common.continue")}
</WizardButton>
)}
</>
}
>
{!DVT_SDK_READY && <PendingSdkNote />}

{DVT_SDK_READY && loading && (
<div className="flex items-center gap-2 text-sm text-gray-500">
<ArrowPathIcon className="h-4 w-4 animate-spin" />
{t("dvtRegister.step2.checking")}
</div>
)}

{alreadyRegistered && eligibility?.boundNodeId && (
<div className="rounded-xl bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800 p-4">
<p className="text-sm font-medium text-emerald-800 dark:text-emerald-300 mb-1">
{t("dvtRegister.step2.alreadyRegistered")}
</p>
<p className="text-xs font-mono break-all text-emerald-700 dark:text-emerald-400">
{eligibility.boundNodeId}
</p>
</div>
)}

{!alreadyRegistered && eligibility && (
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="rounded-xl bg-gray-50 dark:bg-gray-900 p-4">
<dt className="text-xs text-gray-500 mb-1">{t("dvtRegister.step2.stakeOpen")}</dt>
<dd className="text-sm font-semibold text-gray-900 dark:text-white">
{eligibility.stakeOpen
? t("dvtRegister.step2.stakeOpenYes")
: t("dvtRegister.step2.stakeOpenNo")}
</dd>
</div>
<div className="rounded-xl bg-gray-50 dark:bg-gray-900 p-4">
<dt className="text-xs text-gray-500 mb-1">{t("dvtRegister.step2.minStake")}</dt>
<dd className="text-sm font-semibold text-gray-900 dark:text-white">
{formatEther(eligibility.minStake)} GTOKEN
</dd>
</div>
</dl>
)}

<p className="text-xs text-gray-400">{t("dvtRegister.step2.stakeHint")}</p>
</StepCard>
);
}
Loading
Loading