-
Notifications
You must be signed in to change notification settings - Fork 42
Feat/onchain bounty status #167
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
clintjeff2
wants to merge
23
commits into
boundlessfi:main
Choose a base branch
from
clintjeff2:feat/onchain-bounty-status
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 21 commits
Commits
Show all changes
23 commits
Select commit
Hold shift + click to select a range
ae0d4c9
chore: allow .env.example to be tracked by git
clintjeff2 f6aa9bf
chore: add env example for Stellar RPC and contract config
clintjeff2 0c94f2f
chore: add @stellar/stellar-sdk dependency
clintjeff2 5018b09
feat: add contract event poller for Stellar RPC bounty events
clintjeff2 621ad86
feat: add useOnChainBounty hook for on-chain status verification
clintjeff2 16a8203
feat: add useTransactionStatus hook for tx confirmation tracking
clintjeff2 b3c000d
feat: add OnChainStatusBadge component with conflict detection
clintjeff2 34ad076
docs: add PR description for #147 on-chain bounty status sync
clintjeff2 eb48b5a
removed unwanted file
clintjeff2 cbb2082
removed unused file
clintjeff2 326dea5
chore: extract shared Stellar RPC and contract constants into config
clintjeff2 d6646f5
refactor: import Stellar constants from shared config in event-listener
clintjeff2 3f2588b
refactor: import Stellar constants from shared config in useOnChainBo…
clintjeff2 af65ca0
fix: replace faked finalized timeout with real ledger-based finality …
clintjeff2 f9c4e96
fix: make tooltip trigger keyboard-accessible and remove non-null ass…
clintjeff2 ad3f908
fix: start contractEventPoller on mount so on-chain sync is active at…
clintjeff2 f3444b6
fix: ignore all .env* variants while keeping .env.example tracked
clintjeff2 fe69822
fix: switch CI from npm to pnpm to match project package manager
clintjeff2 738c56c
fix: add isPolling guard and paginate all events before advancing led…
clintjeff2 8e1f917
fix: treat DRAFT and DISPUTED as unverified instead of false conflict
clintjeff2 967b98a
fix: re-validate hash after async boundary, guard interval setup, and…
clintjeff2 7ea6af8
fixed ci
clintjeff2 3e3480f
Merge branch 'main' into feat/onchain-bounty-status
clintjeff2 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| NEXT_PUBLIC_STELLAR_RPC_URL=https://soroban-testnet.stellar.org | ||
| NEXT_PUBLIC_BOUNTY_CONTRACT_ID=contract_id | ||
| NEXT_PUBLIC_STELLAR_EXPLORER_URL=https://stellar.expert/explorer/testnet |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,154 @@ | ||
| "use client"; | ||
|
|
||
| import { cn } from "@/lib/utils"; | ||
| import { | ||
| useOnChainBounty, | ||
| type ConsistencyState, | ||
| type OnChainStatus, | ||
| } from "@/hooks/use-onchain-bounty"; | ||
| import { | ||
| ExternalLink, | ||
| ShieldCheck, | ||
| ShieldAlert, | ||
| ShieldOff, | ||
| Loader2, | ||
| } from "lucide-react"; | ||
| import { Badge } from "@/components/ui/badge"; | ||
| import { | ||
| Tooltip, | ||
| TooltipContent, | ||
| TooltipProvider, | ||
| TooltipTrigger, | ||
| } from "@/components/ui/tooltip"; | ||
|
|
||
| interface OnChainStatusBadgeProps { | ||
| bountyId: string; | ||
| className?: string; | ||
| showConflictDetails?: boolean; | ||
| } | ||
|
|
||
| const statusLabels: Record<OnChainStatus, string> = { | ||
| open: "Open", | ||
| in_progress: "In Progress", | ||
| submitted: "Submitted", | ||
| approved: "Approved", | ||
| claimed: "Claimed", | ||
| cancelled: "Cancelled", | ||
| unknown: "Unknown", | ||
| }; | ||
|
|
||
| const consistencyConfig: Record< | ||
| ConsistencyState, | ||
| { | ||
| icon: React.ReactNode; | ||
| label: string; | ||
| badgeClass: string; | ||
| tooltipText: string; | ||
| } | ||
| > = { | ||
| loading: { | ||
| icon: <Loader2 className="size-3 animate-spin" />, | ||
| label: "Verifying", | ||
| badgeClass: "border-muted-foreground/30 text-muted-foreground", | ||
| tooltipText: "Checking on-chain state…", | ||
| }, | ||
| consistent: { | ||
| icon: <ShieldCheck className="size-3" />, | ||
| label: "Verified on-chain", | ||
| badgeClass: "border-emerald-500/60 text-emerald-500 bg-emerald-500/10", | ||
| tooltipText: "On-chain status matches off-chain data.", | ||
| }, | ||
| conflict: { | ||
| icon: <ShieldAlert className="size-3" />, | ||
| label: "Status conflict", | ||
| badgeClass: "border-amber-500/60 text-amber-500 bg-amber-500/10", | ||
| tooltipText: | ||
| "On-chain status differs from the database. On-chain is the source of truth.", | ||
| }, | ||
| unverified: { | ||
| icon: <ShieldOff className="size-3" />, | ||
| label: "Not verified", | ||
| badgeClass: "border-muted-foreground/30 text-muted-foreground", | ||
| tooltipText: "On-chain status could not be verified.", | ||
| }, | ||
| }; | ||
|
|
||
| export function OnChainStatusBadge({ | ||
| bountyId, | ||
| className, | ||
| showConflictDetails = false, | ||
| }: OnChainStatusBadgeProps) { | ||
| const { consistencyState, onChainData, graphqlBounty, explorerUrl } = | ||
| useOnChainBounty(bountyId); | ||
|
|
||
| const config = consistencyConfig[consistencyState]; | ||
|
|
||
| return ( | ||
| <div className={cn("flex items-center gap-1.5", className)}> | ||
| <TooltipProvider delayDuration={300}> | ||
| <Tooltip> | ||
| <TooltipTrigger asChild> | ||
| <button | ||
| type="button" | ||
| className="cursor-default rounded focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" | ||
| > | ||
| <Badge | ||
| variant="outline" | ||
| className={cn( | ||
| "flex items-center gap-1 text-xs font-medium px-2 py-0.5 select-none pointer-events-none", | ||
| config.badgeClass, | ||
| )} | ||
| > | ||
| {config.icon} | ||
| <span>{config.label}</span> | ||
| </Badge> | ||
| </button> | ||
| </TooltipTrigger> | ||
|
|
||
| <TooltipContent className="max-w-xs text-xs" side="bottom"> | ||
| <p>{config.tooltipText}</p> | ||
|
|
||
| {consistencyState === "conflict" && showConflictDetails && ( | ||
| <div className="mt-1.5 space-y-0.5 border-t border-border/50 pt-1.5"> | ||
| {onChainData?.status && onChainData.status !== "unknown" && ( | ||
| <p> | ||
| <span className="text-muted-foreground">On-chain: </span> | ||
| <span className="font-medium text-foreground"> | ||
| {statusLabels[onChainData.status]} | ||
| </span> | ||
| </p> | ||
| )} | ||
| {graphqlBounty && ( | ||
| <p> | ||
| <span className="text-muted-foreground">Database: </span> | ||
| <span className="font-medium text-foreground"> | ||
| {graphqlBounty.status} | ||
| </span> | ||
| </p> | ||
| )} | ||
| </div> | ||
| )} | ||
|
|
||
| {onChainData?.ledger && ( | ||
| <p className="mt-1 text-muted-foreground"> | ||
| Ledger #{onChainData.ledger} | ||
| </p> | ||
| )} | ||
| </TooltipContent> | ||
| </Tooltip> | ||
| </TooltipProvider> | ||
|
|
||
| {explorerUrl && ( | ||
| <a | ||
| href={explorerUrl} | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| className="text-muted-foreground hover:text-foreground transition-colors" | ||
| aria-label="View on Stellar Explorer" | ||
| > | ||
| <ExternalLink className="size-3" /> | ||
| </a> | ||
| )} | ||
| </div> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,166 @@ | ||
| "use client"; | ||
|
|
||
| import { useEffect, useRef, useState } from "react"; | ||
| import { useQuery, useQueryClient } from "@tanstack/react-query"; | ||
| import { rpc, xdr, scValToNative } from "@stellar/stellar-sdk"; | ||
| import { useBounty } from "./use-bounty"; | ||
| import { | ||
| contractEventPoller, | ||
| type ParsedContractEvent, | ||
| } from "@/lib/contracts/event-listener"; | ||
| import { | ||
| STELLAR_RPC_URL, | ||
| BOUNTY_CONTRACT_ID, | ||
| STELLAR_EXPLORER_URL, | ||
| } from "@/lib/contracts/config"; | ||
|
|
||
| export type OnChainStatus = | ||
| | "open" | ||
| | "in_progress" | ||
| | "submitted" | ||
| | "approved" | ||
| | "claimed" | ||
| | "cancelled" | ||
| | "unknown"; | ||
|
|
||
| export interface OnChainBountyData { | ||
| bountyId: string; | ||
| status: OnChainStatus; | ||
| creator?: string; | ||
| assignee?: string; | ||
| ledger?: number; | ||
| lastTxHash?: string; | ||
| } | ||
|
|
||
| export type ConsistencyState = | ||
| | "consistent" | ||
| | "conflict" | ||
| | "unverified" | ||
| | "loading"; | ||
|
|
||
| function mapGraphQLStatusToOnChain( | ||
| graphqlStatus: string, | ||
| ): OnChainStatus | null { | ||
| const s = graphqlStatus.toUpperCase(); | ||
| if (s === "OPEN") return "open"; | ||
| if (s === "IN_PROGRESS") return "in_progress"; | ||
| if (s === "SUBMITTED" || s === "UNDER_REVIEW") return "submitted"; | ||
| if (s === "COMPLETED") return "approved"; | ||
| if (s === "CANCELLED") return "cancelled"; | ||
| // DRAFT and DISPUTED have no comparable on-chain state; skip conflict detection | ||
| return null; | ||
| } | ||
|
|
||
| async function fetchOnChainBountyStatus( | ||
| bountyId: string, | ||
| ): Promise<OnChainBountyData> { | ||
| if (!BOUNTY_CONTRACT_ID) { | ||
| return { bountyId, status: "unknown" }; | ||
| } | ||
|
|
||
| const server = new rpc.Server(STELLAR_RPC_URL, { allowHttp: false }); | ||
|
|
||
| try { | ||
| const statusKey = xdr.ScVal.scvVec([ | ||
| xdr.ScVal.scvSymbol("BountyStatus"), | ||
| xdr.ScVal.scvString(bountyId), | ||
| ]); | ||
|
|
||
| const response = await server.getContractData( | ||
| BOUNTY_CONTRACT_ID, | ||
| statusKey, | ||
| rpc.Durability.Persistent, | ||
| ); | ||
|
|
||
| const rawVal = response.val; | ||
| let entryVal: xdr.ScVal | null = null; | ||
| try { | ||
| entryVal = rawVal.contractData().val(); | ||
| } catch { | ||
| return { bountyId, status: "unknown" }; | ||
| } | ||
|
|
||
| if (!entryVal) return { bountyId, status: "unknown" }; | ||
|
|
||
| const native = scValToNative(entryVal); | ||
| const statusStr = | ||
| typeof native === "string" ? native.toLowerCase() : "unknown"; | ||
|
|
||
| return { | ||
| bountyId, | ||
| status: statusStr as OnChainStatus, | ||
| ledger: response.lastModifiedLedgerSeq, | ||
| }; | ||
| } catch { | ||
| return { bountyId, status: "unknown" }; | ||
| } | ||
| } | ||
|
|
||
| const onChainQueryKey = (bountyId: string) => | ||
| ["onchain-bounty", bountyId] as const; | ||
|
|
||
| export function useOnChainBounty(bountyId: string) { | ||
| const queryClient = useQueryClient(); | ||
| const { data: graphqlBounty, isLoading: isGraphQLLoading } = | ||
| useBounty(bountyId); | ||
|
|
||
| const [lastEvent, setLastEvent] = useState<ParsedContractEvent | null>(null); | ||
|
|
||
| const { | ||
| data: onChainData, | ||
| isLoading: isOnChainLoading, | ||
| isError: isOnChainError, | ||
| refetch, | ||
| } = useQuery({ | ||
| queryKey: onChainQueryKey(bountyId), | ||
| queryFn: () => fetchOnChainBountyStatus(bountyId), | ||
| enabled: !!bountyId && !!BOUNTY_CONTRACT_ID, | ||
| staleTime: 10_000, | ||
| refetchInterval: 30_000, | ||
| }); | ||
|
|
||
| const refetchRef = useRef(refetch); | ||
| useEffect(() => { | ||
| refetchRef.current = refetch; | ||
| }, [refetch]); | ||
|
|
||
| useEffect(() => { | ||
| if (!bountyId) return; | ||
|
|
||
| const unsub = contractEventPoller.subscribe((event) => { | ||
| if (event.bountyId !== bountyId) return; | ||
| setLastEvent(event); | ||
| queryClient.invalidateQueries({ queryKey: onChainQueryKey(bountyId) }); | ||
| }); | ||
|
|
||
| return unsub; | ||
| }, [bountyId, queryClient]); | ||
|
|
||
| const consistencyState: ConsistencyState = (() => { | ||
| if (isGraphQLLoading || isOnChainLoading) return "loading"; | ||
| if (!onChainData || onChainData.status === "unknown" || isOnChainError) | ||
| return "unverified"; | ||
| if (!graphqlBounty) return "unverified"; | ||
|
|
||
| const expected = mapGraphQLStatusToOnChain(graphqlBounty.status); | ||
| if (expected === null) return "unverified"; | ||
| return expected === onChainData.status ? "consistent" : "conflict"; | ||
| })(); | ||
|
|
||
| const explorerUrl = onChainData?.lastTxHash | ||
| ? `${STELLAR_EXPLORER_URL}/tx/${onChainData.lastTxHash}` | ||
| : BOUNTY_CONTRACT_ID | ||
| ? `${STELLAR_EXPLORER_URL}/contract/${BOUNTY_CONTRACT_ID}` | ||
| : null; | ||
|
|
||
| return { | ||
| onChainData, | ||
| graphqlBounty, | ||
| isLoading: isGraphQLLoading || isOnChainLoading, | ||
| isOnChainError, | ||
| consistencyState, | ||
| lastEvent, | ||
| explorerUrl, | ||
| refetch, | ||
| }; | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.