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
106 changes: 75 additions & 31 deletions frontend/components/create-group/target-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useState, useCallback, useRef, useEffect } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Plus, X, Loader2, AlertCircle } from "lucide-react"
import { Plus, X, Loader2, AlertCircle, Info } from "lucide-react"
import { useRouter } from "next/navigation"
import { useStellar } from "@/components/web3-provider"
import {
Expand All @@ -24,7 +24,6 @@ import {
validateGroupName,
validateStellarAddress,
validatePositiveAmount,
validateDeadline,
findDuplicateAddresses,
} from "@/lib/form-validation"
import { MAX_POOL_MEMBERS } from "@/lib/constants"
Expand All @@ -33,17 +32,15 @@ function isValidStellarAddress(addr: string) {
return /^G[A-Z2-7]{55}$/.test(addr)
}

// Convert a JS Date to an approximate Stellar ledger sequence number.
// Stellar testnet: ~5 ledgers/sec. We fetch current ledger and extrapolate.
async function dateToLedger(date: Date): Promise<number> {
const rpc = getRpc()
const ledger = await rpc.getLatestLedger()
const secsFromNow = Math.max(0, Math.floor((date.getTime() - Date.now()) / 1000))
return ledger.sequence + Math.floor(secsFromNow * 5)
// Stellar: ~6 seconds per ledger
const SECONDS_PER_LEDGER = 6

function daysToLedgers(days: number): number {
return Math.floor((days * 24 * 60 * 60) / SECONDS_PER_LEDGER)
}

type FieldErrors = Partial<Record<"name" | "targetAmount" | "deadline", string>>
type Touched = Partial<Record<"name" | "targetAmount" | "deadline", boolean>>
type FieldErrors = Partial<Record<"name" | "targetAmount" | "deadlineDays", string>>
type Touched = Partial<Record<"name" | "targetAmount" | "deadlineDays", boolean>>

export function TargetForm() {
const router = useRouter()
Expand All @@ -62,16 +59,24 @@ export function TargetForm() {
name: "",
description: "",
targetAmount: "",
deadline: "",
deadlineDays: "",
})
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({})
const [touched, setTouched] = useState<Touched>({})
const [currentLedger, setCurrentLedger] = useState<number | null>(null)
const errorRef = useRef<HTMLDivElement>(null)

useEffect(() => {
if (error) errorRef.current?.scrollIntoView({ behavior: "smooth", block: "start" })
}, [error])

useEffect(() => {
getRpc()
.getLatestLedger()
.then((l) => setCurrentLedger(l.sequence))
.catch(() => {})
}, [])

const { deploy } = useDeployPool()
const { initTarget } = useInitializePool()
const { register } = useRegisterPool("target")
Expand All @@ -96,7 +101,12 @@ export function TargetForm() {
if (name === "name") message = validateGroupName(value).message
else if (name === "targetAmount")
message = validatePositiveAmount(value, "Target amount").message
else if (name === "deadline") message = validateDeadline(value).message
else if (name === "deadlineDays") {
const d = parseInt(value)
if (!value) message = "Deadline is required"
else if (isNaN(d) || d < 1) message = "Deadline must be at least 1 day"
else if (d > 3650) message = "Deadline cannot exceed 10 years"
}
setFieldErrors((prev) => ({ ...prev, [name]: message }))
}, [])

Expand All @@ -123,14 +133,20 @@ export function TargetForm() {
e.preventDefault()
setError("")

setTouched({ name: true, targetAmount: true, deadline: true })
setTouched({ name: true, targetAmount: true, deadlineDays: true })
const nameResult = validateGroupName(formData.name)
const amountResult = validatePositiveAmount(formData.targetAmount, "Target amount")
const deadlineResult = validateDeadline(formData.deadline)
const deadlineDays = parseInt(formData.deadlineDays)
const deadlineDaysValid =
formData.deadlineDays && !isNaN(deadlineDays) && deadlineDays >= 1 && deadlineDays <= 3650
setFieldErrors({
name: nameResult.message,
targetAmount: amountResult.message,
deadline: deadlineResult.message,
deadlineDays: deadlineDaysValid
? ""
: formData.deadlineDays
? "Deadline must be between 1 and 3650 days"
: "Deadline is required",
})

if (!address) return setError("Please connect your wallet first")
Expand All @@ -140,14 +156,16 @@ export function TargetForm() {
)
if (validMembers.length < 2)
return setError("Need at least 2 valid Stellar addresses (you + 1 other)")
if (!nameResult.valid || !amountResult.valid || !deadlineResult.valid) return
if (!nameResult.valid || !amountResult.valid || !deadlineDaysValid) return

try {
setStep("deploying")
const contractId = await deploy("target")

setStep("initializing")
const deadlineLedger = await dateToLedger(new Date(formData.deadline))
// Fetch fresh ledger at submit time for the most accurate deadline
const ledger = await getRpc().getLatestLedger()
const deadlineLedger = ledger.sequence + daysToLedgers(deadlineDays)
await initTarget(contractId, {
token: resolveTokenAddress(token.address),
decimals: token.decimals,
Expand All @@ -165,6 +183,11 @@ export function TargetForm() {
console.warn("Factory registration skipped:", (regErr as Error).message)
}

// Derive ISO deadline from days so the DB has a human-readable date
const estimatedDeadlineISO = new Date(
Date.now() + deadlineDays * 24 * 60 * 60 * 1000
).toISOString()

setStep("saving")
const res = await fetch("/api/pools", {
method: "POST",
Expand All @@ -180,7 +203,7 @@ export function TargetForm() {
tokenDecimals: token.decimals,
members: validMembers,
targetAmount: formData.targetAmount,
deadline: formData.deadline,
deadline: estimatedDeadlineISO,
}),
})
if (!res.ok) throw new Error("Failed to save pool metadata")
Expand All @@ -205,13 +228,17 @@ export function TargetForm() {
? (parseFloat(formData.targetAmount || "0") / validMembers.length).toFixed(2)
: "0"

const days = parseInt(formData.deadlineDays) || 0
const estimatedDeadlineLedger =
currentLedger !== null && days > 0 ? currentLedger + daysToLedgers(days) : null

const progressFields: ProgressField[] = [
{ label: "Group name", valid: validateGroupName(formData.name).valid },
{
label: "Target amount",
valid: validatePositiveAmount(formData.targetAmount, "Amount").valid,
},
{ label: "Deadline", valid: validateDeadline(formData.deadline).valid },
{ label: "Deadline (days)", valid: days >= 1 && days <= 3650 },
{ label: "Members (2+)", valid: validMembers.length >= 2 },
]

Expand Down Expand Up @@ -316,22 +343,34 @@ export function TargetForm() {

<div className="space-y-1">
<FieldTooltip
htmlFor="deadline"
label="Target Deadline"
tooltip="The date by which the group aims to reach the savings target. Must be at least 1 day in the future."
htmlFor="deadlineDays"
label="Deadline (days from now)"
tooltip="How many days until the savings target deadline. Stored as a Stellar ledger sequence number (~6 sec/ledger)."
required
/>
<Input
id="deadline"
type="date"
value={formData.deadline}
id="deadlineDays"
type="number"
min="1"
max="3650"
step="1"
placeholder="30"
value={formData.deadlineDays}
onChange={(e) => {
setFormData({ ...formData, deadline: e.target.value })
if (touched.deadline) validateField("deadline", e.target.value)
setFormData({ ...formData, deadlineDays: e.target.value })
if (touched.deadlineDays) validateField("deadlineDays", e.target.value)
}}
onBlur={(e) => handleBlur("deadline", e.target.value)}
onBlur={(e) => handleBlur("deadlineDays", e.target.value)}
/>
{touched.deadline && <FieldError message={fieldErrors.deadline} />}
{days > 0 && (
<p className="text-xs text-muted-foreground flex items-center gap-1">
<Info className="h-3 w-3" />
{estimatedDeadlineLedger
? `Ledger ~${estimatedDeadlineLedger.toLocaleString()} · Est. ${new Date(Date.now() + days * 86_400_000).toLocaleDateString()}`
: "Fetching current ledger…"}
</p>
)}
{touched.deadlineDays && <FieldError message={fieldErrors.deadlineDays} />}
</div>
</div>

Expand Down Expand Up @@ -421,7 +460,12 @@ export function TargetForm() {
<li>Members: {validMembers.length}</li>
<li>Target Amount: {formData.targetAmount || "0"} XLM</li>
<li>Each member contributes: {contributionPerMember} XLM</li>
<li>Deadline: {formData.deadline || "Not set"}</li>
<li>
Deadline:{" "}
{days > 0
? `~${days} day${days !== 1 ? "s" : ""}${estimatedDeadlineLedger ? ` (ledger ~${estimatedDeadlineLedger.toLocaleString()})` : ""}`
: "Not set"}
</li>
</ul>
</div>
<Button
Expand Down
53 changes: 45 additions & 8 deletions frontend/components/group/group-details.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client"

import { useState } from "react"
import { useState, useEffect } from "react"
import type { FC } from "react"
import { Card } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
Expand All @@ -26,12 +26,36 @@ import {
TargetPoolState,
FlexiblePoolState,
useBumpPoolState,
ledgerToEstimatedDate,
getRpc,
} from "@/hooks/useJointSaveContracts"
import { usePoolData } from "@/lib/data-layer/PoolDataProvider"
import { useToast } from "@/hooks/use-toast"
import { useOptimisticTransactions } from "@/hooks/useOptimisticTransactions"
import { GroupMuteNotificationsToggle } from "@/components/group/GroupMuteNotificationsToggle"

interface GroupData {
id: string
name: string
type: "rotational" | "target" | "flexible"
status: string
description: string | null
total_saved: number
target_amount: number | null
progress: number
members_count: number
next_payout: string | null
next_recipient: string | null
created_at: string
contribution_amount: number | null
frequency: string | null
deadline: string | null
contract_address: string
token_symbol?: string
token_decimals?: number
members?: string[]
}

interface GroupDetailsProps {
groupId: string
/** Contract address if already known — avoids a redundant /api/pools fetch */
Expand All @@ -40,6 +64,7 @@ interface GroupDetailsProps {

export function GroupDetails({ groupId, contractAddress }: GroupDetailsProps) {
const [copied, setCopied] = useState(false)
const [currentLedger, setCurrentLedger] = useState<number | null>(null)
const { toast } = useToast()

// Use contract address as cache key when available; otherwise key on DB id.
Expand All @@ -49,9 +74,18 @@ export function GroupDetails({ groupId, contractAddress }: GroupDetailsProps) {

const { data, isLoading, isStale, isPaused, ttlDays, error, refetch } = usePoolData(cacheKey)
const { optimisticState } = useOptimisticTransactions(cacheKey)
const { bumpPoolState, isLoading: isBumping } = useBumpPoolState(data?.db?.contract_address || "")
const { bumpPoolState, isLoading: isBumping } = useBumpPoolState(
(data?.db?.contract_address as string) || ""
)

const group = (data?.db ?? null) as GroupData | null

const group = data?.db ?? null
useEffect(() => {
getRpc()
.getLatestLedger()
.then((l) => setCurrentLedger(l.sequence))
.catch(() => {})
}, [])

const handleExtendStorage = async () => {
try {
Expand Down Expand Up @@ -214,11 +248,14 @@ export function GroupDetails({ groupId, contractAddress }: GroupDetailsProps) {
label: "Target",
value: `${fmt(s.targetAmount).toFixed(2)} ${tokenSymbol}`,
})
base.push({
icon: Clock,
label: "Deadline",
value: group.deadline ? new Date(group.deadline).toLocaleDateString() : "N/A",
})
let deadlineValue = "N/A"
if (s.deadlineLedger && currentLedger) {
const estimated = ledgerToEstimatedDate(s.deadlineLedger, currentLedger)
deadlineValue = `${estimated.toLocaleDateString()} (ledger ${s.deadlineLedger.toLocaleString()})`
} else if (group.deadline) {
deadlineValue = new Date(group.deadline).toLocaleDateString()
}
base.push({ icon: Clock, label: "Deadline", value: deadlineValue })
} else if (group.type === "flexible" && onchainState) {
const s = onchainState as FlexiblePoolState
let userBalanceDisplay = fmt(s.userBalance).toFixed(2)
Expand Down
2 changes: 1 addition & 1 deletion frontend/e2e/create-pool.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ const cases = [
submit: "Create Target Pool",
fill: async (page: import("@playwright/test").Page) => {
await page.locator("#target").fill("5000")
await page.locator("#deadline").fill("2027-06-21")
await page.locator("#deadlineDays").fill("365")
},
},
{
Expand Down
24 changes: 21 additions & 3 deletions frontend/hooks/useJointSaveContracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ function e2eViewResult(method: string): xdr.ScVal {
return i128Val(BigInt((s.totalBalance as number | undefined) ?? 0))
case "balance_of":
return i128Val(BigInt((s.balanceOf as number | undefined) ?? 0))
case "deadline":
return u32Val((s.deadlineLedger as number | undefined) ?? 0)
default:
return boolVal(false)
}
Expand Down Expand Up @@ -704,6 +706,7 @@ export interface TargetPoolState {
totalDeposited: bigint
targetAmount: bigint
userBalance: bigint
deadlineLedger: number
}

export interface FlexiblePoolState {
Expand Down Expand Up @@ -774,8 +777,8 @@ async function fetchContractStorage(
): Promise<xdr.ScVal | null> {
if (IS_E2E) {
const s = e2eState()
if (keySymbol === "TreasuryFeeBps") return u32Val(s.treasuryFeeBps ?? 100)
if (keySymbol === "RelayerFeeBps") return u32Val(s.relayerFeeBps ?? 50)
if (keySymbol === "TreasuryFeeBps") return u32Val((s.treasuryFeeBps as number) ?? 100)
if (keySymbol === "RelayerFeeBps") return u32Val((s.relayerFeeBps as number) ?? 50)
return null
}
try {
Expand Down Expand Up @@ -943,10 +946,11 @@ export async function fetchTargetState(
contractId: string,
userAddress?: string
): Promise<TargetPoolState> {
const [unlockedVal, totalVal, targetVal] = await Promise.all([
const [unlockedVal, totalVal, targetVal, deadlineVal] = await Promise.all([
viewCall(contractId, "is_unlocked"),
viewCall(contractId, "total_deposited"),
viewCall(contractId, "target_amount"),
viewCall(contractId, "deadline"),
])

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

Expand Down Expand Up @@ -1061,6 +1066,19 @@ export async function fetchContractEvents(
return events.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
}

/**
* Estimate a wall-clock Date from an on-chain ledger sequence number.
* Uses the current ledger as an anchor and assumes ~6 seconds per ledger.
*/
export function ledgerToEstimatedDate(
deadlineLedger: number,
currentLedger: number,
secondsPerLedger = 6
): Date {
const secsOffset = (deadlineLedger - currentLedger) * secondsPerLedger
return new Date(Date.now() + secsOffset * 1000)
}

export async function fetchFlexibleState(
contractId: string,
userAddress?: string
Expand Down
Loading
Loading