Challenge period end:{" "}
-
+
{pendingAction === "challenge"
? "Challenge confirmed onchain. Waiting for indexed dispute details"
: "The request was challenged"}
@@ -665,7 +713,7 @@ export default function ActionBar({
<>
{" "}
for{" "}
-
+
{currentChallenge.reason.id}
>
@@ -674,29 +722,42 @@ export default function ActionBar({
{pendingAction !== "challenge" && currentChallenge && (
-
-
+
+ {!appealActive && (
+
+
+ When a profile is challenged, a case is opened in Kleros
+ Court, where jurors review the evidence from both sides
+ before voting.
+
+
+ You can submit evidence for this case from the Evidence
+ section below.
+
+
+ )}
-
- View case #{currentChallenge.disputeId}
-
+
+
+
+
+ View case #{currentChallenge.disputeId}
+
+
)}
>
@@ -710,7 +771,12 @@ export default function ActionBar({
? "Removal request was rejected"
: effectiveRequestStatus === RequestStatus.REJECTED
? "Request was rejected"
- : "Request was accepted"}
+ : effectiveRequestStatus === RequestStatus.PUNISHED_VOUCH
+ ? "Profile was removed for vouching for a punished profile"
+ : effectiveRequestStatus ===
+ RequestStatus.RESOLVED_REVOCATION
+ ? "Profile was removed"
+ : "Request was accepted"}
+ setWithdrawModalOpen(false)}
+ canClose={!isWithdrawLoading}
+ >
+
+ Withdraw this Request
+ >
+ }
+ description="Cancel this pending request before it advances beyond the vouching stage."
+ />
+
+
+
+
+
+
+ This cancels only the pending request and returns the fees and rewards
+ available to its requester. It does not remove an existing verified
+ profile from the Proof of Humanity registry. Previously uploaded
+ information, such as photos and videos, remains stored on IPFS.
+
+ setWithdrawModalOpen(false)}
+ returnDisabled={isWithdrawLoading}
+ primaryLabel={isWithdrawLoading ? "Withdrawing" : "Withdraw"}
+ onPrimary={withdraw}
+ primaryDisabled={withdrawTrigger.disabled}
+ primaryLoading={isWithdrawLoading}
+ primaryTooltip={withdrawTrigger.tooltip}
+ primaryClassName="sm:w-[244px]"
+ />
+
+ {canVouch && effectiveStatus === "vouching" && (
+ setVouchModalOpen(false)}
+ pohId={pohId}
+ claimer={requester}
+ chain={chain}
+ address={address}
+ />
+ )}
);
}
diff --git a/src/app/[pohid]/[chain]/[request]/Appeal.tsx b/src/app/[pohid]/[chain]/[request]/Appeal.tsx
index 3807d5bf..6d743d5e 100644
--- a/src/app/[pohid]/[chain]/[request]/Appeal.tsx
+++ b/src/app/[pohid]/[chain]/[request]/Appeal.tsx
@@ -1,19 +1,26 @@
"use client";
-import { useEffectOnce } from "@legendapp/state/react";
-import Arrow from "components/Arrow";
-import BulletedNumber from "components/BulletedNumber";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
import ExternalLink from "components/ExternalLink";
-import CurrencyField from "components/CurrencyField";
-import Identicon from "components/Identicon";
-import Modal from "components/Modal";
+import ExternalLinkIcon from "components/ExternalLinkIcon";
+import InfoTooltip from "components/InfoTooltip";
+import CurrencyField, { CurrencyIcon } from "components/CurrencyField";
import Progress from "components/Progress";
import TimeAgo from "components/TimeAgo";
import ActionButton from "components/ActionButton";
-import { SupportedChainId, idToChain } from "config/chains";
+import RequestModal, {
+ RequestModalActions,
+ RequestAmountPill,
+ RequestModalHeader,
+} from "components/RequestModal";
+import {
+ SupportedChainId,
+ explorerTxLink,
+ idToChain,
+ nativeCurrencyLabel,
+} from "config/chains";
import {
APIArbitrator,
- ArbitratorsData,
DisputeStatusEnum,
SideEnum,
} from "contracts/apis/APIArbitrator";
@@ -21,99 +28,133 @@ import { APIPoH, StakeMultipliers } from "contracts/apis/APIPoH";
import usePoHWrite from "contracts/hooks/usePoHWrite";
import { RequestQuery } from "generated/graphql";
import { useLoading } from "hooks/useLoading";
-import useEnoughFunds from "hooks/useEnoughFunds";
+import useFundingAmount from "hooks/useFundingAmount";
import { resolveTxState } from "utils/txState";
-import Image from "next/image";
import { useRequestOptimistic } from "optimistic/request";
-import { useMemo, useRef, useState } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "react-toastify";
-import { RequestStatus } from "utils/status";
import { formatEth } from "utils/misc";
-import { Address, parseEther } from "viem";
-import { useAccount, useChainId } from "wagmi";
+import { shortenAddress } from "utils/address";
+import { Address, Hash } from "viem";
import { useRouter } from "next/navigation";
+import { getWriteErrorMessage } from "hooks/useActionFeedback";
+
+type AppealChallenge = ArrayElement<
+ NonNullable["request"]>["challenges"]
+>;
+
+const toBigIntOrZero = (amount: bigint | string | number | null | undefined) =>
+ BigInt(amount ?? 0);
+
+// Stake multipliers are expressed in basis points on the PoH contract.
+const MULTIPLIER_DIVISOR = 10_000n;
+
+// Total amount a side must raise: the raw appeal cost plus that side's stake.
+// The stake depends on whether the side is currently winning or losing; on a
+// shared ruling ("neither side") both sides pay the shared stake.
+const getSideAppealCosts = (
+ appealCost: bigint,
+ currentRulingSide: SideEnum,
+ {
+ winnerStakeMultiplier,
+ loserStakeMultiplier,
+ sharedStakeMultiplier,
+ }: StakeMultipliers,
+) => {
+ const withStake = (multiplier: bigint | undefined) =>
+ appealCost + (appealCost * toBigIntOrZero(multiplier)) / MULTIPLIER_DIVISOR;
+
+ if (currentRulingSide === SideEnum.shared) {
+ const sharedCost = withStake(sharedStakeMultiplier);
+ return { claimerCost: sharedCost, challengerCost: sharedCost };
+ }
+
+ const winnerCost = withStake(winnerStakeMultiplier);
+ const loserCost = withStake(loserStakeMultiplier);
+ return currentRulingSide === SideEnum.claimer
+ ? { claimerCost: winnerCost, challengerCost: loserCost }
+ : { claimerCost: loserCost, challengerCost: winnerCost };
+};
+
+// The subgraph appends a new round as soon as appeal funding starts, while
+// nbRounds only counts completed rounds — an extra entry in `rounds` therefore
+// means the last round is the in-progress (partially funded) appeal round.
+const getPendingAppealRoundFunds = (challenge: AppealChallenge) => {
+ const hasInProgressAppealRound =
+ Number(challenge.nbRounds) + 1 === challenge.rounds.length;
+ if (!hasInProgressAppealRound)
+ return { claimerFunds: 0n, challengerFunds: 0n };
+
+ const currentRound = challenge.rounds.at(-1); //latest round
+ return {
+ claimerFunds: toBigIntOrZero(currentRound?.requesterFund?.amount),
+ challengerFunds: toBigIntOrZero(currentRound?.challengerFund?.amount),
+ };
+};
interface SideFundingProps {
side: SideEnum;
arbitrator: Address;
disputeId: bigint;
- requester: Address;
- requesterFunds: bigint;
+ /** The party being crowdfunded (submitter or challenger). */
+ partyAddress: Address;
+ partyFunds: bigint;
appealCost: bigint;
chainId: SupportedChainId;
- loosingSideHasEnd: boolean;
- onSuccess?: () => void;
- disabled?: boolean;
+ losingSideDeadlinePassed: boolean;
+ onSuccess?: (amount: bigint, txHash?: Hash) => void;
+ onLoadingChange?: (loading: boolean) => void;
+ isReconciling?: boolean;
+ fundingPending?: boolean;
}
const SideFunding: React.FC = ({
side,
disputeId,
arbitrator,
- requester,
- requesterFunds,
+ partyAddress,
+ partyFunds,
appealCost,
chainId,
- loosingSideHasEnd,
+ losingSideDeadlinePassed,
onSuccess,
- disabled = false,
+ onLoadingChange,
+ isReconciling = false,
+ fundingPending = false,
}) => {
- const userChainId = useChainId();
- const { isConnected } = useAccount();
- const title = side === SideEnum.claimer ? "Claimer" : "Challenger";
- const shrunkAddress: string =
- requester.substring(0, 6) + " ... " + requester.slice(-4);
- const [requesterInput, setRequesterInput] = useState("");
+ const title = side === SideEnum.claimer ? "Submitter" : "Challenger";
+ const shrunkAddress = shortenAddress(partyAddress);
const loading = useLoading();
const [isLoading, loadingMessage] = loading.use();
- const errorRef = useRef(false);
-
- const value = (formatEth(requesterFunds) * 100) / formatEth(appealCost);
- const valueProgress = value > 100 ? 100 : value;
- const unit = idToChain(chainId)?.nativeCurrency.symbol;
-
- const remainingAmount =
- appealCost > requesterFunds ? appealCost - requesterFunds : 0n;
- const inputAmount = useMemo(() => {
- if (!requesterInput) return 0n;
- try {
- const parsed = parseEther(requesterInput);
- return parsed < 0n ? 0n : parsed;
- } catch {
- return null;
- }
- }, [requesterInput]);
+ const errorToastShownRef = useRef(false);
- const isInvalidInput = inputAmount === null;
- const isZeroInput = inputAmount === 0n;
- const exceedsRemaining = !isInvalidInput && inputAmount! > remainingAmount;
- const funds = useEnoughFunds({
+ const appealCostEth = formatEth(appealCost);
+ const fundedPercent =
+ appealCostEth > 0 ? (formatEth(partyFunds) * 100) / appealCostEth : 0;
+ const progressPercent = fundedPercent > 100 ? 100 : fundedPercent;
+ const isFullyFunded = appealCost > 0n && partyFunds >= appealCost;
+ const {
+ input: fundInput,
+ setInput: setFundInput,
+ inputAmount,
+ remainingAmount,
+ unit,
+ disabled: isDisabled,
+ tooltip: submitTooltip,
+ } = useFundingAmount({
chainId,
- amount: !isInvalidInput && inputAmount ? inputAmount : undefined,
+ funded: partyFunds,
+ totalCost: appealCost,
+ defaultToRemaining: true,
+ checks: [
+ { active: isFullyFunded, message: "Already funded" },
+ { active: isReconciling, message: "Waiting for indexer" },
+ {
+ active: losingSideDeadlinePassed,
+ message: "Appeal time has ended for this side",
+ },
+ ],
});
-
- const { disabled: isDisabled, tooltip: submitTooltip } = resolveTxState([
- { active: disabled, message: "Syncing" },
- {
- active: loosingSideHasEnd,
- message: "Appeal time has ended for this side",
- },
- { active: !isConnected, message: "Please connect your wallet" },
- {
- active: userChainId !== chainId,
- message: `Switch your chain above to ${idToChain(chainId)?.name || "the correct chain"}`,
- },
- { active: !requesterInput, message: "Please enter an amount to fund" },
- { active: isInvalidInput, message: "Please enter a valid amount" },
- { active: isZeroInput, message: "Amount must be greater than 0" },
- {
- active: exceedsRemaining,
- message: `Amount exceeds remaining needed (${formatEth(remainingAmount)} ${unit})`,
- },
- { active: funds.isLoading, message: "Checking balance" },
- { active: funds.insufficient, message: funds.message },
- ]);
-
const [prepareFundAppeal] = usePoHWrite(
"fundAppeal",
useMemo(
@@ -121,92 +162,107 @@ const SideFunding: React.FC = ({
onReady(fire) {
fire();
},
- onError() {
+ onError(error, errorCtx) {
loading.stop();
- toast.error("Transaction rejected");
+ onLoadingChange?.(false);
+ toast.error(getWriteErrorMessage(error, errorCtx));
},
- onSuccess() {
+ onSuccess(ctx) {
loading.stop();
+ onLoadingChange?.(false);
toast.success("Funded appeal successfully");
- onSuccess?.();
+ onSuccess?.(ctx.value ?? 0n, ctx.txHash);
},
onFail() {
loading.stop();
- !errorRef.current &&
+ onLoadingChange?.(false);
+ // Only surface the failure toast once per mount.
+ !errorToastShownRef.current &&
toast.info(
"Transaction is not possible! Do you have enough funds?",
);
- errorRef.current = true;
+ errorToastShownRef.current = true;
},
}),
- [loading, onSuccess],
+ [loading, onLoadingChange, onSuccess],
),
);
return (
-
+ When someone challenges a profile, a case is opened in Kleros Court.
+
+
+ A group of random jurors is selected to review the case. They look at
+ the evidence from both sides and vote. The side with the most votes
+ wins the dispute.
+
+
+ If either side disagrees with the decision, they can appeal. The case
+ is reviewed again by a new group of jurors.
+
+
+ Providing clear evidence is important. It helps the jurors understand
+ the case and make a fair decision.
+
-
- Check how the jury voted
-
-
+ View the transaction
+
+ )}
+
+ >
+ ) : (
+ <>
+
+ Case #{disputeId}
+
+ Appeal the Decision
+
+
+ The jury decided in favor of {rulingParty}.
+
+
+ Check how the jury voted
+
+
-
-
- {loosingSideHasEnd ? (
-
- The losing party's appeal time ended
- .
+
+
+ Each side has its own crowdfunding target. A new appeal is
+ created only after both the submitter and challenger sides are
+ fully funded. If only one side reaches its target before the
+ deadline, the final ruling is set in favor of that funded side.
+
-
- In order to appeal the decision, you need to fully fund the
- crowdfunding deposit. The dispute will be sent to the jurors
- when the full deposit is reached. Note that if the previous
- round loser funds its side, the previous round winner should
- also fully fund its side, in order not to lose the case.
-
-
-
-
+
External contributors can also crowdfund the appeal.
-
-
-
-
+
+
+
+
-
-
-
+ >
+ )}
+
>
- ) : null;
+ );
};
export default Appeal;
diff --git a/src/app/[pohid]/[chain]/[request]/Challenge.tsx b/src/app/[pohid]/[chain]/[request]/Challenge.tsx
index b3fb2411..e1edc4a9 100644
--- a/src/app/[pohid]/[chain]/[request]/Challenge.tsx
+++ b/src/app/[pohid]/[chain]/[request]/Challenge.tsx
@@ -1,8 +1,11 @@
import { useState, useMemo, useCallback } from "react";
import ALink from "components/ExternalLink";
-import Field from "components/Field";
-import Label from "components/Label";
-import Modal from "components/Modal";
+import EvidenceFormFields from "components/EvidenceFormFields";
+import RequestModal, {
+ RequestAmountPill,
+ RequestModalHeader,
+ RequestWarning,
+} from "components/RequestModal";
import TimeAgo from "components/TimeAgo";
import { useLoading } from "hooks/useLoading";
import useEnoughFunds from "hooks/useEnoughFunds";
@@ -19,14 +22,17 @@ import { Hash } from "viem";
import DocumentIcon from "icons/NoteMajor.svg";
import { ObservablePrimitiveBaseFns } from "@legendapp/state";
import { ContractData } from "data/contract";
-import { useAtlasProvider, Roles } from "@kleros/kleros-app";
+import { useAtlasProvider } from "@kleros/kleros-app";
import { toast } from "react-toastify";
import AuthGuard from "components/AuthGuard";
import ActionButton from "components/ActionButton";
+import { CurrencyIcon } from "components/CurrencyField";
import { useChainId } from "wagmi";
-import { idToChain } from "config/chains";
+import { idToChain, nativeCurrencyLabel } from "config/chains";
import { getDisputedRequestStatus } from "utils/status";
import { useRequestOptimistic } from "optimistic/request";
+import { uploadEvidence } from "data/uploadEvidence";
+import { getWriteErrorMessage } from "hooks/useActionFeedback";
type Reason =
| "none"
@@ -35,30 +41,49 @@ type Reason =
| "sybilAttack"
| "deceased";
-const reasonToImage: Record = {
- none: "",
- incorrectSubmission: "/reason/incorrect.png",
- identityTheft: "/reason/duplicate.png",
- sybilAttack: "/reason/dne.png",
- deceased: "/reason/deceased.png",
+const reasonToImages: Partial> = {
+ incorrectSubmission: ["/reason/incorrect.png"],
+ identityTheft: ["/reason/duplicate.png"],
+ sybilAttack: ["/reason/dne.png"],
+ deceased: ["/reason/deceased.png"],
};
-function reasonToIdx(reason: Reason) {
- switch (reason) {
- case "none":
- return 0;
- case "incorrectSubmission":
- return 1;
- case "identityTheft":
- return 2;
- case "sybilAttack":
- return 3;
- case "deceased":
- return 4;
- default:
- return 0;
- }
-}
+const REASON_INDEX: Record = {
+ none: 0,
+ incorrectSubmission: 1,
+ identityTheft: 2,
+ sybilAttack: 3,
+ deceased: 4,
+};
+
+const CHALLENGE_REASON_CARDS: {
+ reason: Reason;
+ label: string;
+ description: string;
+}[] = [
+ {
+ reason: "incorrectSubmission",
+ label: "Incorrect Submission",
+ description: "The profile does not follow the submission rules.",
+ },
+ {
+ reason: "identityTheft",
+ label: "Identity Theft",
+ description:
+ "The submitter is trying to claim a Humanity ID that belongs to someone else.",
+ },
+ {
+ reason: "sybilAttack",
+ label: "Sybil Attack",
+ description:
+ "The submitter is already registered, is a duplicate, or does not exist.",
+ },
+ {
+ reason: "deceased",
+ label: "Deceased",
+ description: "The person previously existed but is no longer alive.",
+ },
+];
export const buildChallengeSuccessPatch = (
revocation: boolean,
@@ -69,46 +94,76 @@ export const buildChallengeSuccessPatch = (
});
interface ReasonCardInterface {
- text: string;
+ label: string;
+ description: string;
reason: Reason;
current: ObservablePrimitiveBaseFns;
isUsed?: boolean;
}
const ReasonCard: React.FC = ({
- text,
+ label,
+ description,
reason,
current,
isUsed = false,
}) => {
const isSelected = reason === current.get();
+ const images = reasonToImages[reason];
return (
-
-
-
-
- Registration Policy
-
-
- (at the time of submission)
-
-
-
- Updated:
-
-
- {!revocation && (
+
+
-
-
- {reasonCards.map((card) => (
-
- ))}
-
+ Challenge this Profile
>
- )}
-
- setJustification(e.target.value)}
+ }
+ description="In order to challenge this profile you need to deposit:"
+ />
+
+ )}
-
+
+
+
+ When someone challenges a profile, a case is opened in Kleros Court. A
+ group of random jurors is selected to review the case. They look at
+ the evidence from both sides and vote. The side with the most votes
+ wins the dispute. Deposits, reimbursements, and rewards are
+ distributed according to the final ruling and the contract rules. A
+ losing challenger can lose their deposit. Before challenging, make
+ sure you have read and understood the Policy below.
+
+
+
+
+ Relevant Policy
+
+
+ (updated )
+
+
+
+
+
+
+
+
-
+
>
);
}
diff --git a/src/app/[pohid]/[chain]/[request]/Evidence.tsx b/src/app/[pohid]/[chain]/[request]/Evidence.tsx
index 25391f3c..f9af7924 100644
--- a/src/app/[pohid]/[chain]/[request]/Evidence.tsx
+++ b/src/app/[pohid]/[chain]/[request]/Evidence.tsx
@@ -1,90 +1,39 @@
"use client";
-import { enableReactUse } from "@legendapp/state/config/enableReactUse";
-import { useObservable } from "@legendapp/state/react";
import Accordion from "components/Accordion";
+import ActionButton from "components/ActionButton";
+import AddEvidenceModal from "components/AddEvidenceModal";
import Attachment from "components/Attachment";
import ExternalLink from "components/ExternalLink";
-import Field from "components/Field";
+import ExternalLinkIcon from "components/ExternalLinkIcon";
import Identicon from "components/Identicon";
-import Label from "components/Label";
-import Modal from "components/Modal";
import TimeAgo from "components/TimeAgo";
-import FileUploadZone from "components/FileUploadZone";
import { explorerLink, idToChain } from "config/chains";
-import { Effects } from "contracts/hooks/types";
-import usePoHWrite from "contracts/hooks/usePoHWrite";
-import { RequestQuery } from "generated/graphql";
+import type { EvidenceSubmitterProfile } from "data/evidence";
import useChainParam from "hooks/useChainParam";
import useIPFS from "hooks/useIPFS";
-import { useLoading } from "hooks/useLoading";
-import DocumentIcon from "icons/NoteMajor.svg";
-import type {
- OptimisticEvidenceItem,
- RequestOptimisticOverlay,
-} from "optimistic/types";
-import { useCallback, useEffect, useMemo, useState } from "react";
-import { toast } from "react-toastify";
-import useSWR from "swr";
-import ActionButton from "components/ActionButton";
-import { EvidenceFile, MetaEvidenceFile } from "types/docs";
+import Image from "next/image";
+import Link from "next/link";
+import type { OptimisticEvidenceItem } from "optimistic/types";
+import { useRequestOptimistic } from "optimistic/request";
+import { useEffect, useState } from "react";
+import { EvidenceFile } from "types/docs";
import { shortenAddress } from "utils/address";
-import { ipfsFetch, ipfs } from "utils/ipfs";
-import { romanize } from "utils/misc";
+import { prettifyId } from "utils/identifier";
+import { safeIpfsUrl } from "utils/ipfs";
import { resolveTxState } from "utils/txState";
-import { Address, Hash } from "viem";
-import { useAccount, useChainId } from "wagmi";
-import { useAtlasProvider, Roles } from "@kleros/kleros-app";
-import AuthGuard from "components/AuthGuard";
-import { useRequestOptimistic } from "optimistic/request";
-
-enableReactUse();
-
-export const buildEvidenceSuccessItem = (
- uri: string,
- submitter: Address,
- name: string,
- description: string,
- fileURI?: string,
- txHash?: string,
-): OptimisticEvidenceItem => ({
- id: `optimistic-evidence-${txHash ?? Date.now()}`,
- uri,
- creationTime: Math.floor(Date.now() / 1000),
- submitter,
- name,
- description,
- fileURI,
-});
-
-export const buildEvidenceSuccessPatch = (
- uri: string,
- submitter: Address,
- name: string,
- description: string,
- fileURI?: string,
- txHash?: string,
-): RequestOptimisticOverlay => ({
- evidenceList: [
- buildEvidenceSuccessItem(
- uri,
- submitter,
- name,
- description,
- fileURI,
- txHash,
- ),
- ],
-});
+import { Hash } from "viem";
+import { useChainId } from "wagmi";
interface ItemInterface {
- index: number;
+ number: number;
item: OptimisticEvidenceItem;
isPending?: boolean;
+ profile?: EvidenceSubmitterProfile;
}
-function Item({ index, item, isPending }: ItemInterface) {
- const chain = useChainParam()!;
+function Item({ number, item, isPending, profile }: ItemInterface) {
+ const chain = useChainParam();
const [evidence] = useIPFS(item.uri);
const ipfsUri = evidence?.fileURI
? evidence?.fileURI
@@ -94,40 +43,68 @@ function Item({ index, item, isPending }: ItemInterface) {
const title = evidence?.name || item.name;
const description = evidence?.description || item.description;
+ if (!chain) return null;
+
+ const shortAddress = shortenAddress(item.submitter);
+ const photoUrl = safeIpfsUrl(profile?.photo);
+
return (
+ A PoH ID is a unique, non-transferable identity for a verified
+ human on Proof of Humanity.
+
+
+ For a first registration, it is normally derived from the
+ registering wallet address. The identifier stays attached to the
+ human and can be reclaimed from a different wallet when the
+ protocol's recovery conditions are met.
+
+
+ This POH ID had {nbRequests} requests claimed in this chain.
+
+
+ }
/>
-
- The Proof of Humanity ID is a soulbound ID. It corresponds to each
- unique human registered on Proof of Humanity.
-
-
- This POH ID had {nbRequests} requests claimed in this
- chain
-
+
)
);
}
diff --git a/src/app/[pohid]/[chain]/[request]/RequestErrorState.tsx b/src/app/[pohid]/[chain]/[request]/RequestErrorState.tsx
index cdd5b5e5..d893f218 100644
--- a/src/app/[pohid]/[chain]/[request]/RequestErrorState.tsx
+++ b/src/app/[pohid]/[chain]/[request]/RequestErrorState.tsx
@@ -23,7 +23,7 @@ export function ChainDataUnavailableCard({ chainName }: { chainName: string }) {
elsewhere in the app may also be incomplete or out of date. Nothing
on-chain is affected — please try again in a few minutes.
-
+
);
}
diff --git a/src/app/[pohid]/[chain]/[request]/RequestEvidenceSection.tsx b/src/app/[pohid]/[chain]/[request]/RequestEvidenceSection.tsx
index fb1ff1dd..60428ff8 100644
--- a/src/app/[pohid]/[chain]/[request]/RequestEvidenceSection.tsx
+++ b/src/app/[pohid]/[chain]/[request]/RequestEvidenceSection.tsx
@@ -1,3 +1,5 @@
+import { Suspense } from "react";
+import { getEvidenceSubmitterProfiles } from "data/evidence";
import { RequestOptimisticProvider } from "optimistic/request";
import type { RequestOptimisticBase } from "optimistic/types";
import type { Address } from "viem";
@@ -7,20 +9,50 @@ import type {
RequestPageRequest,
} from "./RequestIdentityCard.types";
+function EvidenceSectionSkeleton() {
+ return (
+
+ );
+}
+
+/**
+ * @notice Resolves the submitter → profile map, then renders the evidence list.
+ * @dev Split out so the (potentially slow) cross-chain profile lookup suspends
+ * behind a skeleton instead of blocking the whole evidence section from
+ * painting. Profiles only decide internal-profile vs explorer links per item.
+ */
+async function EvidenceWithProfiles({
+ pohId,
+ requestIndex,
+ submitters,
+}: {
+ pohId: `0x${string}`;
+ requestIndex: number;
+ submitters: Address[];
+}) {
+ const submitterProfiles = await getEvidenceSubmitterProfiles(submitters);
+
+ return (
+
+ );
+}
+
/**
* @notice Resolves and renders the request evidence section.
* @dev The caller chooses whether this is current-request evidence or
* historical identity evidence. The nested optimistic provider only overrides
* `evidenceList` and reuses the parent optimistic state.
*/
-export default async function RequestEvidenceSection({
- arbitrationInfo,
+export default function RequestEvidenceSection({
evidenceSource,
optimisticBase,
pohId,
request,
}: {
- arbitrationInfo: RequestPageRequest["arbitratorHistory"];
evidenceSource: RequestEvidenceSource;
optimisticBase: RequestOptimisticBase;
pohId: `0x${string}`;
@@ -45,11 +77,13 @@ export default async function RequestEvidenceSection({
evidenceList,
}}
>
-
+ }>
+ item.submitter)}
+ />
+
);
}
diff --git a/src/app/[pohid]/[chain]/[request]/RequestIdentityCard.tsx b/src/app/[pohid]/[chain]/[request]/RequestIdentityCard.tsx
index fbc227e8..d4d80b95 100644
--- a/src/app/[pohid]/[chain]/[request]/RequestIdentityCard.tsx
+++ b/src/app/[pohid]/[chain]/[request]/RequestIdentityCard.tsx
@@ -1,10 +1,11 @@
-import Arrow from "components/Arrow";
import Attachment from "components/Attachment";
-import ChainLogo from "components/ChainLogo";
import DocumentIcon from "components/DocumentIcon";
import ExternalLink from "components/ExternalLink";
import Identicon from "components/Identicon";
+import IdentityReferenceRow from "components/IdentityReferenceRow";
import Label from "components/Label";
+import LoadableImage from "components/LoadableImage";
+import MediaFallback from "components/MediaFallback";
import Previewed from "components/Previewed";
import TimeAgo from "components/TimeAgo";
import VideoThumbnail from "components/VideoThumbnail";
@@ -108,7 +109,7 @@ export async function RevocationBanner({
Requested by
{request.requester}
@@ -142,13 +143,11 @@ function ProfileSummary({
}
/>
@@ -168,28 +167,35 @@ export async function DesktopProfileAside({
identity,
identityFiles,
request,
+ vouchedFor,
+ vouchers,
}: {
identity: RequestIdentitySource;
identityFiles: RequestIdentityFiles;
request: RequestPageRequest;
+ vouchedFor: ReactNode;
+ vouchers: ReactNode;
}) {
const registrationFile = await identityFiles.registrationFilePromise;
const displayedClaimerName =
registrationFile?.name || identity.claimer.name || "";
return (
-
-
- Make sure the person exists and only vouch for people you have
- physically encountered. Note that in case a profile is removed for
- (Sybil attack) or (Identity theft), all people who had vouched for
- it get removed as well. Profiles that do not follow the Policy
- risk being challenged and removed. Make sure you read and
- understand the Policy before proceeding. Also take into account
- that although a gasless vouch is possible, it cannot be removed.
- Gasless vouches expire after one year.
-
-
- {
- if (isOnchainLoading || isReconciling) return;
- addVouch();
- }}
- aria-disabled={isOnchainLoading || isReconciling}
- aria-busy={isOnchainLoading || isReconciling}
- >
- or vouch on chain
-
-