Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 19 additions & 1 deletion frontend/components/dashboard/pool-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Card } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Skeleton } from "@/components/ui/skeleton"
import { Users, TrendingUp, Calendar, ArrowRight } from "lucide-react"
import { Users, TrendingUp, Calendar, ArrowRight, AlertTriangle } from "lucide-react"
import Link from "next/link"
import { motion } from "framer-motion"
import { usePoolData } from "@/lib/data-layer/PoolDataProvider"
Expand All @@ -17,6 +17,8 @@ import {
import { usePoolHealth } from "@/hooks/usePoolHealth"
import { PoolHealthBadge } from "@/components/dashboard/pool-health-badge"
import { PoolSparkline } from "@/components/dashboard/pool-sparkline"
import { isContractVersionUnknown } from "@/lib/contract-version"
import { KNOWN_CONTRACT_VERSIONS } from "@/lib/constants"

export interface Pool {
id: string
Expand Down Expand Up @@ -149,6 +151,14 @@ export function PoolCard({ pool }: { pool: Pool }) {
const { totalSaved, progress, progressLabel } = getLiveStats()
const formatXlm = (n: number) => `${n.toFixed(2)} ${tokenSymbol}`

const contractVersion =
data?.onchain && "contractVersion" in data.onchain
? (data.onchain.contractVersion as number | null)
: null
const showVersionWarning =
contractVersion !== null &&
isContractVersionUnknown(contractVersion, KNOWN_CONTRACT_VERSIONS[pool.type])

return (
<motion.div variants={item}>
<Card className="p-6 hover:shadow-lg transition-all duration-300 hover:-translate-y-1 h-full flex flex-col">
Expand Down Expand Up @@ -209,6 +219,14 @@ export function PoolCard({ pool }: { pool: Pool }) {
</div>
{progressLabel && <p className="text-xs text-muted-foreground mt-1">{progressLabel}</p>}
</div>
{showVersionWarning && (
<div className="flex items-center gap-2 p-3 rounded-lg bg-yellow-500/10 text-yellow-700 dark:text-yellow-400 mb-4 text-sm font-medium">
<AlertTriangle className="h-4 w-4 flex-shrink-0" />
<span>
Contract v{contractVersion} — This pool may run a newer version than expected.
</span>
</div>
)}
<Button className="w-full bg-transparent" variant="outline" asChild>
<Link href={`/dashboard/group/${pool.id}`}>
View Details <ArrowRight className="ml-2 h-4 w-4" />
Expand Down
34 changes: 34 additions & 0 deletions frontend/components/group/group-details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,40 @@ import {
getRpc,
} from "@/hooks/useJointSaveContracts"
import { usePoolData } from "@/lib/data-layer/PoolDataProvider"
import { KNOWN_CONTRACT_VERSIONS } from "@/lib/constants"
import { isContractVersionUnknown } from "@/lib/contract-version"
import { useToast } from "@/hooks/use-toast"
import { useOptimisticTransactions } from "@/hooks/useOptimisticTransactions"
import { GroupMuteNotificationsToggle } from "@/components/group/GroupMuteNotificationsToggle"

function VersionWarning({
onchainState,
poolType,
}: {
onchainState: RotationalPoolState | TargetPoolState | FlexiblePoolState
poolType: string
}) {
if (!(poolType in KNOWN_CONTRACT_VERSIONS)) return null
const cv = (onchainState as { contractVersion?: number | null }).contractVersion
if (
isContractVersionUnknown(
cv ?? null,
KNOWN_CONTRACT_VERSIONS[poolType as keyof typeof KNOWN_CONTRACT_VERSIONS]
)
) {
return (
<div className="flex items-center gap-2 p-3 rounded-lg bg-yellow-500/10 text-yellow-700 dark:text-yellow-400 mb-4 text-sm font-medium">
<AlertTriangle className="h-4 w-4 flex-shrink-0" />
<span>
⚠️ Contract v{cv} — This pool may run a newer version than expected. Some features may not
be supported.
</span>
</div>
)
}
return null
}

interface GroupData {
id: string
name: string
Expand Down Expand Up @@ -559,6 +589,10 @@ export function GroupDetails({ groupId, contractAddress, poolAdmin }: GroupDetai
</div>
)}

{!isPending(group.contract_address) && onchainState && (
<VersionWarning onchainState={onchainState} poolType={group.type} />
)}

{!isPending(group.contract_address) && (
<div className="mb-4 p-2 rounded bg-muted/30 flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground font-mono break-all min-w-0">
Expand Down
52 changes: 42 additions & 10 deletions frontend/hooks/useJointSaveContracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import {
type PendingTransactionType,
} from "@/lib/pending-transactions"
import { TX_TIMEOUT } from "@/lib/constants"
import { isContractVersionUnknown } from "@/lib/contract-version"
export { isContractVersionUnknown }

// ── Constants ─────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -85,6 +87,8 @@ function e2eViewResult(method: string): xdr.ScVal {
return i128Val(BigInt((s.balanceOf as number | undefined) ?? 0))
case "deadline":
return u32Val((s.deadlineLedger as number | undefined) ?? 0)
case "get_version":
return u32Val(1)
default:
return boolVal(false)
}
Expand Down Expand Up @@ -115,6 +119,11 @@ export const NATIVE_TOKEN_METADATA: TokenMetadata = {
decimals: 7,
}

// ── Contract version cache ──────────────────────────────────────────────────
// Module-level cache for contract versions to avoid redundant RPC calls when
// loading many pool cards. Cleared on full page reload (module re-evaluation).
const contractVersionCache = new Map<string, number | null>()

export interface TokenMetadata {
name: string
symbol: string
Expand Down Expand Up @@ -758,6 +767,7 @@ export interface RotationalPoolState {
depositCount: number // number of members who deposited in the current round
treasuryFeeBps: number | null
relayerFeeBps: number | null
contractVersion: number | null
}

export interface TargetPoolState {
Expand All @@ -766,12 +776,14 @@ export interface TargetPoolState {
targetAmount: bigint
userBalance: bigint
deadlineLedger: number
contractVersion: number | null
}

export interface FlexiblePoolState {
isActive: boolean
totalBalance: bigint
userBalance: bigint
contractVersion: number | null
}

export interface ReputationScore {
Expand Down Expand Up @@ -942,14 +954,16 @@ export async function fetchRotationalState(
contractId: string,
userAddress?: string
): Promise<RotationalPoolState> {
const [activeVal, roundVal, membersVal, payoutVal, treasurySc, relayerSc] = await Promise.all([
viewCall(contractId, "is_active"),
viewCall(contractId, "current_round"),
viewCall(contractId, "members"),
viewCall(contractId, "next_payout_time"),
fetchContractStorage(contractId, "TreasuryFeeBps"),
fetchContractStorage(contractId, "RelayerFeeBps"),
])
const [activeVal, roundVal, membersVal, payoutVal, treasurySc, relayerSc, versionVal] =
await Promise.all([
viewCall(contractId, "is_active"),
viewCall(contractId, "current_round"),
viewCall(contractId, "members"),
viewCall(contractId, "next_payout_time"),
fetchContractStorage(contractId, "TreasuryFeeBps"),
fetchContractStorage(contractId, "RelayerFeeBps"),
fetchContractVersion(contractId),
])

const members =
activeVal.switch().name !== "scvBool" ? [] : (membersVal.vec()?.map(scValToString) ?? [])
Expand Down Expand Up @@ -994,18 +1008,20 @@ export async function fetchRotationalState(
depositCount,
treasuryFeeBps,
relayerFeeBps,
contractVersion: versionVal,
}
}

export async function fetchTargetState(
contractId: string,
userAddress?: string
): Promise<TargetPoolState> {
const [unlockedVal, totalVal, targetVal, deadlineVal] = await Promise.all([
const [unlockedVal, totalVal, targetVal, deadlineVal, versionVal] = await Promise.all([
viewCall(contractId, "is_unlocked"),
viewCall(contractId, "total_deposited"),
viewCall(contractId, "target_amount"),
viewCall(contractId, "deadline"),
fetchContractVersion(contractId),
])

let userBalance = 0n
Expand All @@ -1022,6 +1038,7 @@ export async function fetchTargetState(
targetAmount: scValToBigInt(targetVal),
userBalance,
deadlineLedger: deadlineVal.switch().name === "scvU32" ? deadlineVal.u32() : 0,
contractVersion: versionVal,
}
}

Expand Down Expand Up @@ -1138,9 +1155,10 @@ export async function fetchFlexibleState(
contractId: string,
userAddress?: string
): Promise<FlexiblePoolState> {
const [activeVal, totalVal] = await Promise.all([
const [activeVal, totalVal, versionVal] = await Promise.all([
viewCall(contractId, "is_active"),
viewCall(contractId, "total_balance"),
fetchContractVersion(contractId),
])

let userBalance = 0n
Expand All @@ -1155,6 +1173,7 @@ export async function fetchFlexibleState(
isActive: activeVal.switch().name === "scvBool" ? activeVal.b() : false,
totalBalance: scValToBigInt(totalVal),
userBalance,
contractVersion: versionVal,
}
}

Expand Down Expand Up @@ -1227,6 +1246,19 @@ export async function fetchPoolAdmin(contractId: string): Promise<string | null>
}
}

export async function fetchContractVersion(contractId: string): Promise<number | null> {
const cached = contractVersionCache.get(contractId)
if (cached !== undefined) return cached
try {
const val = await viewCall(contractId, "get_version")
const version = val.switch().name === "scvU32" ? val.u32() : null
contractVersionCache.set(contractId, version)
return version
} catch {
return null
}
}

// ── Admin hooks ───────────────────────────────────────────────────────────────

export function useAddPoolMember(contractId: string) {
Expand Down
14 changes: 14 additions & 0 deletions frontend/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,20 @@ export const MAX_DESCRIPTION_LENGTH = 300 as const
*/
export const MAX_DEADLINE_DAYS = 3650 as const

// ── Contract Versioning ─────────────────────────────────────────────────────

/**
* Latest known contract versions. The frontend uses these to detect
* when a pool contract is running a newer version than expected.
* If `contract_version > known_version`, a warning banner is displayed.
*/
export const KNOWN_CONTRACT_VERSIONS = {
rotational: 1,
target: 1,
flexible: 1,
factory: 1,
} as const

// ── Chat ──────────────────────────────────────────────────────────────────────

/**
Expand Down
24 changes: 24 additions & 0 deletions frontend/lib/contract-version.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Unit tests for contract version checking.
import { test } from "node:test"
import assert from "node:assert"
import { isContractVersionUnknown } from "./contract-version"

test("isContractVersionUnknown - returns false when contract version matches known version", () => {
assert.strictEqual(isContractVersionUnknown(1, 1), false)
})

test("isContractVersionUnknown - returns false when contract version is older than known", () => {
assert.strictEqual(isContractVersionUnknown(1, 2), false)
})

test("isContractVersionUnknown - returns true when contract version is newer than known", () => {
assert.strictEqual(isContractVersionUnknown(2, 1), true)
})

test("isContractVersionUnknown - returns false when contract version is null", () => {
assert.strictEqual(isContractVersionUnknown(null, 1), false)
})

test("isContractVersionUnknown - returns true for v3 vs known v1", () => {
assert.strictEqual(isContractVersionUnknown(3, 1), true)
})
16 changes: 16 additions & 0 deletions frontend/lib/contract-version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* Pure contract-version utility — no React, SDK, or browser dependencies.
* Shared between production code and unit tests.
*/

/**
* Check if a contract version is newer than the frontend's known version.
* Returns true if the contract is running a version the frontend doesn't know about.
*/
export function isContractVersionUnknown(
contractVersion: number | null,
knownVersion: number
): boolean {
if (contractVersion === null) return false
return contractVersion > knownVersion
}
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"format": "prettier --write .",
"format:check": "prettier --check .",
"start": "next start",
"test:unit": "tsx --test lib/csv-export.test.ts lib/analytics.test.ts lib/pool-health.test.ts lib/form-validation.test.ts lib/member-filters.test.ts lib/error-reporting.test.ts hooks/use-keyboard-shortcuts.test.ts app/api/admin/audit-log/route.test.ts app/api/admin/actions/route.test.ts app/api/errors/route.test.ts components/error-boundary.test.ts app/api/notifications/route.test.ts app/api/user-profile/route.test.ts",
"test:unit": "tsx --test lib/csv-export.test.ts lib/analytics.test.ts lib/pool-health.test.ts lib/form-validation.test.ts lib/member-filters.test.ts lib/contract-version.test.ts lib/error-reporting.test.ts lib/pending-transactions.test.ts hooks/use-keyboard-shortcuts.test.ts app/api/admin/audit-log/route.test.ts app/api/admin/actions/route.test.ts app/api/errors/route.test.ts components/error-boundary.test.ts app/api/notifications/route.test.ts app/api/user-profile/route.test.ts",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:report": "playwright show-report"
Expand Down
7 changes: 7 additions & 0 deletions smartcontract/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions smartcontract/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ members = [
"contracts/flexible",
"contracts/yield-strategy",
"contracts/reputation",
"migration",
]
resolver = "2"

Expand Down
Loading
Loading