Skip to content
Open
Show file tree
Hide file tree
Changes from 9 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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ yarn-error.log*
.pnpm-debug.log*

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

# vercel
.vercel
Expand Down
76 changes: 76 additions & 0 deletions PR_147.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# feat: implement on-chain bounty status sync and event listener (#147)

## Summary

Closes #147. Implements real-time on-chain bounty state syncing by polling Stellar RPC for Soroban contract events, reading contract state directly, and tracking transaction confirmation lifecycle.

---

## Changes

### New files

| File | Purpose |
| -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `lib/contracts/event-listener.ts` | Singleton poller that queries Stellar RPC for contract events and dispatches React Query cache invalidations |
| `hooks/use-onchain-bounty.ts` | Per-bounty hook that reads state directly from the contract and compares it with GraphQL data to detect conflicts |
| `hooks/use-transaction-status.ts` | Hook that tracks a submitted transaction from `pending` → `confirmed` → `finalized` with Stellar Explorer links |
| `components/bounty/onchain-status-badge.tsx` | Badge component that shows verification state and surfaces status conflicts with a tooltip |

### Dependencies added

- `@stellar/stellar-sdk` — Soroban RPC client, XDR parsing, `scValToNative`

---

## How it works

### Contract Event Poller (`lib/contracts/event-listener.ts`)

- Polls `NEXT_PUBLIC_STELLAR_RPC_URL` every **6 s** using `rpc.Server.getEvents()`
- Filters by `NEXT_PUBLIC_BOUNTY_CONTRACT_ID`
- Parses XDR topics to typed `ParsedContractEvent` objects for all 9 event types
- Calls `QueryClient` cache invalidations scoped to affected bounty keys
- Exposes `contractEventPoller.start(queryClient)` / `.stop()` / `.subscribe(cb)`

### On-Chain Bounty Hook (`hooks/use-onchain-bounty.ts`)

- Fetches `BountyStatus` storage entry from contract using `rpc.Server.getContractData()`
- Subscribes to `contractEventPoller` to receive push updates for the specific bounty
- Maps GraphQL status → on-chain enum for cross-layer comparison
- Returns `consistencyState`: `consistent | conflict | unverified | loading`

### Transaction Status Hook (`hooks/use-transaction-status.ts`)

- Call `track(hash)` after sending any Stellar transaction
- Polls `rpc.Server.getTransaction()` every **3 s** up to 40 attempts (≈ 2 min)
- Status progression: `idle → pending → confirmed → finalized` (or `failed / not_found`)
- Returns `explorerUrl` pointing to Stellar Expert

### On-Chain Status Badge (`components/bounty/onchain-status-badge.tsx`)

- Mounts on any bounty detail page: `<OnChainStatusBadge bountyId={id} showConflictDetails />`
- Renders a shield icon: green ✓ consistent, amber ⚠ conflict, grey – unverified
- Tooltip shows ledger number, both statuses when conflicting, and links to explorer

---

## Environment variables required

```env
NEXT_PUBLIC_STELLAR_RPC_URL=https://soroban-testnet.stellar.org
NEXT_PUBLIC_BOUNTY_CONTRACT_ID=<your_contract_id>
NEXT_PUBLIC_STELLAR_EXPLORER_URL=https://stellar.expert/explorer/testnet
```

---

## Proof of build


## Acceptance criteria checklist

- [x] Contract events trigger UI updates (via `contractEventPoller` → query invalidation)
- [x] On-chain status readable for any bounty (`useOnChainBounty`)
- [x] Transaction confirmation tracked after submission (`useTransactionStatus`)
- [x] Status conflicts between GraphQL and on-chain detected and surfaced (`OnChainStatusBadge`)
149 changes: 149 additions & 0 deletions components/bounty/onchain-status-badge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
"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 (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<div className={cn("flex items-center gap-1.5", className)}>
<Badge
variant="outline"
className={cn(
"flex items-center gap-1 text-xs font-medium px-2 py-0.5 cursor-default select-none",
config.badgeClass,
)}
>
{config.icon}
<span>{config.label}</span>
</Badge>

{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>
</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 !== "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>
);
}
Loading