From a19aebbe6e5a89fd4d351e8acf4d11a43664e8bd Mon Sep 17 00:00:00 2001 From: JustABiologist Date: Tue, 12 May 2026 21:59:57 +0200 Subject: [PATCH] Add bounty award and payout actions --- app/components/dialogs/DialogAwardBounty.tsx | 203 +++++++++++++++++++ app/components/governance/BountyDetails.tsx | 174 ++++++++++++++-- app/translations/en.json | 6 + 3 files changed, 364 insertions(+), 19 deletions(-) create mode 100644 app/components/dialogs/DialogAwardBounty.tsx diff --git a/app/components/dialogs/DialogAwardBounty.tsx b/app/components/dialogs/DialogAwardBounty.tsx new file mode 100644 index 0000000..44b4aee --- /dev/null +++ b/app/components/dialogs/DialogAwardBounty.tsx @@ -0,0 +1,203 @@ +import { Icon, InputGroup, Intent } from "@blueprintjs/core"; +import keyring from "@polkadot/ui-keyring"; +import { lastSelectedAccountAtom } from "app/atoms"; +import { useApi } from "app/components/Api"; +import { AddressIcon } from "app/components/common/AddressIcon"; +import useToaster from "app/hooks/useToaster"; +import { isValidPolkadotAddress } from "app/utils/address"; +import { signAndSend } from "app/utils/sign"; +import { useAtom } from "jotai"; +import { type ChangeEvent, useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import BaseDialog from "./BaseDialog"; + +interface DialogAwardBountyProps { + bountyId: string; + isOpen: boolean; + onClose: () => void; + onAwarded?: () => void; +} + +export default function DialogAwardBounty({ + bountyId, + isOpen, + onClose, + onAwarded, +}: DialogAwardBountyProps) { + const { t } = useTranslation(); + const api = useApi(); + const toaster = useToaster(); + const [selectedAccount] = useAtom(lastSelectedAccountAtom); + const [beneficiary, setBeneficiary] = useState(""); + const [loading, setLoading] = useState(false); + const [isBeneficiaryValid, setIsBeneficiaryValid] = useState(false); + const beneficiaryAddress = beneficiary.trim(); + + useEffect(() => { + if (!isOpen) { + setBeneficiary(""); + setLoading(false); + } + }, [isOpen]); + + useEffect(() => { + setIsBeneficiaryValid( + beneficiaryAddress === "" || isValidPolkadotAddress(beneficiaryAddress) + ); + }, [beneficiaryAddress]); + + const pair = (() => { + try { + if (selectedAccount && selectedAccount.trim() !== "") { + return keyring.getPair(selectedAccount); + } + return null; + } catch (error) { + console.warn("Failed to get keyring pair:", error); + return null; + } + })(); + + const handleBeneficiaryChange = useCallback( + (e: ChangeEvent) => { + setBeneficiary(e.target.value); + }, + [] + ); + + const handleSubmit = useCallback(() => { + if (!api) { + toaster.show({ + icon: "error", + intent: Intent.DANGER, + message: "API not ready", + }); + return; + } + if (!selectedAccount || !beneficiaryAddress) { + toaster.show({ + icon: "error", + intent: Intent.DANGER, + message: + t("messages.lbl_fill_required_fields") || + "Please fill all required fields.", + }); + return; + } + if (!isBeneficiaryValid) { + toaster.show({ + icon: "error", + intent: Intent.DANGER, + message: + t("messages.lbl_invalid_address") || "Please enter a valid address.", + }); + return; + } + if (!pair) { + toaster.show({ + icon: "error", + intent: Intent.DANGER, + message: + t("messages.lbl_no_account_selected") || + "No account selected or unable to get keyring pair.", + }); + return; + } + const isLocked = pair.isLocked && !pair.meta.isInjected; + if (isLocked) { + toaster.show({ + icon: "error", + intent: Intent.DANGER, + message: t("messages.lbl_account_locked") || "Account is locked.", + }); + return; + } + + setLoading(true); + try { + const tx = api.tx.bounties.awardBounty(bountyId, beneficiaryAddress); + void signAndSend(tx, pair, {}, ({ status }) => { + if (!status.isInBlock) return; + toaster.show({ + icon: "endorsed", + intent: Intent.SUCCESS, + message: + t("governance.bounty_awarded") || "Bounty awarded successfully!", + }); + if (onAwarded) onAwarded(); + setLoading(false); + onClose(); + }).catch((error) => { + const errorMessage = + error instanceof Error ? error.message : String(error); + toaster.show({ + icon: "error", + intent: Intent.DANGER, + message: errorMessage, + }); + setLoading(false); + }); + } catch (e) { + const errorMessage = e instanceof Error ? e.message : String(e); + toaster.show({ + icon: "error", + intent: Intent.DANGER, + message: errorMessage, + }); + setLoading(false); + } + }, [ + api, + beneficiaryAddress, + bountyId, + isBeneficiaryValid, + onAwarded, + onClose, + pair, + selectedAccount, + t, + toaster, + ]); + + const addressIcon = + beneficiaryAddress !== "" && isBeneficiaryValid ? ( + + ) : ( + + ); + + return ( + + + + ); +} diff --git a/app/components/governance/BountyDetails.tsx b/app/components/governance/BountyDetails.tsx index f791c8a..8a09b7a 100644 --- a/app/components/governance/BountyDetails.tsx +++ b/app/components/governance/BountyDetails.tsx @@ -1,12 +1,26 @@ -import { H4, HTMLTable, Icon, Intent, Spinner, Tag } from "@blueprintjs/core"; +import { + Button, + H4, + HTMLTable, + Icon, + Intent, + Spinner, + Tag, +} from "@blueprintjs/core"; import type { DeriveCollectiveProposal } from "@polkadot/api-derive/types"; import type { Bytes, Option } from "@polkadot/types"; import type { Bounty } from "@polkadot/types/interfaces"; +import keyring from "@polkadot/ui-keyring"; import { hexToString } from "@polkadot/util"; +import { lastSelectedAccountAtom } from "app/atoms"; import { useApi } from "app/components/Api"; import { AccountName } from "app/components/common/AccountName"; import { FormattedAmount } from "app/components/common/FormattedAmount"; +import DialogAwardBounty from "app/components/dialogs/DialogAwardBounty"; +import useToaster from "app/hooks/useToaster"; import { mockBounties } from "app/utils/mock"; +import { signAndSend } from "app/utils/sign"; +import { useAtom } from "jotai"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { BountyNextAction } from "./BountyNextAction"; @@ -73,12 +87,17 @@ export function BountyDetails({ }: BountyDetailsProps) { const { t } = useTranslation(); const api = useApi(); + const toaster = useToaster(); + const [selectedAccount] = useAtom(lastSelectedAccountAtom); const [bountyData, setBountyData] = useState( null ); const [description, setDescription] = useState(null); const [loading, setLoading] = useState(true); const [bestNumber, setBestNumber] = useState(undefined); + const [awardDialogOpen, setAwardDialogOpen] = useState(false); + const [claimLoading, setClaimLoading] = useState(false); + const [refreshTrigger, setRefreshTrigger] = useState(0); const isMockMode = process.env.NODE_ENV === "development" && mockBounties.size > 0; @@ -86,6 +105,7 @@ export function BountyDetails({ if (!api) return; const loadBounty = async () => { + setLoading(true); try { if (isMockMode) { const mockBounty = mockBounties.get(bountyId); @@ -172,7 +192,7 @@ export function BountyDetails({ unsubscribe(); } }; - }, [api, bountyId, isMockMode]); + }, [api, bountyId, isMockMode, refreshTrigger]); const title = { approval: "Approve Bounty", @@ -180,6 +200,10 @@ export function BountyDetails({ close: "Close Bounty", }[type]; + const reloadBounty = () => { + setRefreshTrigger((prev) => prev + 1); + }; + if (loading) { return (
@@ -208,8 +232,108 @@ export function BountyDetails({ ); } + const status = bountyData.status?.type; + const curatorAddress = + (status === "CuratorProposed" && + bountyData.status.asCuratorProposed?.curator?.toString()) || + (status === "Active" && + bountyData.status.asActive?.curator?.toString()) || + (status === "PendingPayout" && + bountyData.status.asPendingPayout?.curator?.toString()) || + ""; + const pendingPayoutUnlockAt = + status === "PendingPayout" + ? bountyData.status.asPendingPayout?.unlockAt?.toBigInt() + : undefined; + const canClaim = + pendingPayoutUnlockAt !== undefined && + bestNumber !== undefined && + pendingPayoutUnlockAt <= bestNumber; + + const pair = (() => { + try { + if (selectedAccount && selectedAccount.trim() !== "") { + return keyring.getPair(selectedAccount); + } + return null; + } catch (error) { + console.warn("Failed to get keyring pair:", error); + return null; + } + })(); + + const handleClaimPayout = () => { + if (!api) { + toaster.show({ + icon: "error", + intent: Intent.DANGER, + message: "API not ready", + }); + return; + } + if (!pair) { + toaster.show({ + icon: "error", + intent: Intent.DANGER, + message: + t("messages.lbl_no_account_selected") || + "No account selected or unable to get keyring pair.", + }); + return; + } + const isLocked = pair.isLocked && !pair.meta.isInjected; + if (isLocked) { + toaster.show({ + icon: "error", + intent: Intent.DANGER, + message: t("messages.lbl_account_locked") || "Account is locked.", + }); + return; + } + + setClaimLoading(true); + try { + const tx = api.tx.bounties.claimBounty(bountyId); + void signAndSend(tx, pair, {}, ({ status }) => { + if (!status.isInBlock) return; + toaster.show({ + icon: "endorsed", + intent: Intent.SUCCESS, + message: + t("governance.bounty_payout_claimed") || + "Bounty payout claimed successfully!", + }); + setClaimLoading(false); + reloadBounty(); + }).catch((error) => { + const errorMessage = + error instanceof Error ? error.message : String(error); + toaster.show({ + icon: "error", + intent: Intent.DANGER, + message: errorMessage, + }); + setClaimLoading(false); + }); + } catch (e) { + const errorMessage = e instanceof Error ? e.message : String(e); + toaster.show({ + icon: "error", + intent: Intent.DANGER, + message: errorMessage, + }); + setClaimLoading(false); + } + }; + return (
+ setAwardDialogOpen(false)} + /> {showHeader && (

@@ -246,29 +370,41 @@ export function BountyDetails({ )} + {(status === "Active" || status === "PendingPayout") && ( + + + {t("governance.bounty_actions") || "Actions"} + + + {status === "Active" && ( +