Skip to content
Open
Show file tree
Hide file tree
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 Mar 28, 2026
f6aa9bf
chore: add env example for Stellar RPC and contract config
clintjeff2 Mar 28, 2026
0c94f2f
chore: add @stellar/stellar-sdk dependency
clintjeff2 Mar 28, 2026
5018b09
feat: add contract event poller for Stellar RPC bounty events
clintjeff2 Mar 28, 2026
621ad86
feat: add useOnChainBounty hook for on-chain status verification
clintjeff2 Mar 28, 2026
16a8203
feat: add useTransactionStatus hook for tx confirmation tracking
clintjeff2 Mar 28, 2026
b3c000d
feat: add OnChainStatusBadge component with conflict detection
clintjeff2 Mar 28, 2026
34ad076
docs: add PR description for #147 on-chain bounty status sync
clintjeff2 Mar 28, 2026
eb48b5a
removed unwanted file
clintjeff2 Mar 28, 2026
cbb2082
removed unused file
clintjeff2 Mar 28, 2026
326dea5
chore: extract shared Stellar RPC and contract constants into config
clintjeff2 Mar 30, 2026
d6646f5
refactor: import Stellar constants from shared config in event-listener
clintjeff2 Mar 30, 2026
3f2588b
refactor: import Stellar constants from shared config in useOnChainBo…
clintjeff2 Mar 30, 2026
af65ca0
fix: replace faked finalized timeout with real ledger-based finality …
clintjeff2 Mar 30, 2026
f9c4e96
fix: make tooltip trigger keyboard-accessible and remove non-null ass…
clintjeff2 Mar 30, 2026
ad3f908
fix: start contractEventPoller on mount so on-chain sync is active at…
clintjeff2 Mar 30, 2026
f3444b6
fix: ignore all .env* variants while keeping .env.example tracked
clintjeff2 Mar 30, 2026
fe69822
fix: switch CI from npm to pnpm to match project package manager
clintjeff2 Mar 30, 2026
738c56c
fix: add isPolling guard and paginate all events before advancing led…
clintjeff2 Mar 30, 2026
8e1f917
fix: treat DRAFT and DISPUTED as unverified instead of false conflict
clintjeff2 Mar 30, 2026
967b98a
fix: re-validate hash after async boundary, guard interval setup, and…
clintjeff2 Mar 30, 2026
7ea6af8
fixed ci
clintjeff2 Mar 30, 2026
3e3480f
Merge branch 'main' into feat/onchain-bounty-status
clintjeff2 Mar 30, 2026
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
3 changes: 3 additions & 0 deletions .env.example
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
38 changes: 21 additions & 17 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,43 +2,47 @@ name: CI - Build & Lint Check

on:
pull_request:
branches: ['*']
branches: ["*"]
push:
branches: ['main', 'develop']
branches: ["main", "develop"]

jobs:
build-and-lint:
runs-on: ubuntu-latest

strategy:
matrix:
node-version: [24.x]

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}

- name: Cache npm dependencies

- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: latest

- name: Cache pnpm store
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ matrix.node-version }}-npm-${{ hashFiles('**/package-lock.json') }}
path: ~/.local/share/pnpm/store
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-node-${{ matrix.node-version }}-npm-
${{ runner.os }}-node-

${{ runner.os }}-pnpm-

- name: Install dependencies
run: npm ci
run: pnpm install --frozen-lockfile

- name: Run linter
run: npm run lint
run: pnpm lint

- name: Build project
run: npm run build
run: pnpm build
env:
NODE_ENV: production
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ yarn-error.log*

# env files (can opt-in for committing if needed)
.env*
!.env.example

# vercel
.vercel
Expand Down
154 changes: 154 additions & 0 deletions components/bounty/onchain-status-badge.tsx
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>
);
}
166 changes: 166 additions & 0 deletions hooks/use-onchain-bounty.ts
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,
};
}
Loading
Loading