Skip to content

Commit cd88821

Browse files
solve: ca & frontend issue
1 parent cf155ce commit cd88821

16 files changed

Lines changed: 523 additions & 80 deletions

File tree

apps/web/app/api/judge/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ export async function POST(request: Request) {
7676
const evidence = body.evidence ?? "";
7777
const prompt = JUDGE_SYSTEM_RULES.replace("{statement}", body.statement ?? "")
7878
.replace("{policy}", body.policy ?? "")
79-
.concat(`\n\nEVIDENCE (untrusted input):\n${evidence}`);
79+
.concat(`\n\n<untrusted name="evidence">\n${evidence}\n</untrusted>`);
8080
const raw = await llmJudge(prompt);
8181
const parsed = JudgeResultSchema.safeParse(raw);
8282
return NextResponse.json(parsed.success ? parsed.data : judgeEvidenceLocally(evidence));

apps/web/app/create/page.tsx

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,21 @@ import { Button } from "@/components/ui/Button";
1414

1515
const CATEGORIES = ["fitness", "habits", "career", "creator", "money", "general"] as const;
1616

17+
/**
18+
* Every new ledger starts pre-filled with a statement/policy that passes the
19+
* on-chain screening (concrete, measurable, time-boxed) — edit, screen, go.
20+
* A blank or vague start gets rejected by the validators with
21+
* "vague, not measurable".
22+
*/
23+
const DEFAULTS = {
24+
statement: "I will run at least 3 kilometers every day for 30 consecutive days.",
25+
policy: "Strava activity link or screenshot each day showing the date and distance ≥ 3.0 km.",
26+
category: "fitness",
27+
durationDays: 30,
28+
requiredProofs: 24,
29+
selfStake: 1,
30+
} as const;
31+
1732
const StepOneSchema = z.string().min(12, "Too short to be a real promise.");
1833

1934
export default function CreatePage() {
@@ -23,14 +38,14 @@ export default function CreatePage() {
2338

2439
const [step, setStep] = useState(0);
2540
const [direction, setDirection] = useState<1 | -1>(1);
26-
const [statement, setStatement] = useState("");
41+
const [statement, setStatement] = useState<string>(DEFAULTS.statement);
2742
const [screening, setScreening] = useState<Screening | null>(null);
2843
const [screeningPending, setScreeningPending] = useState(false);
29-
const [policy, setPolicy] = useState("");
30-
const [category, setCategory] = useState<string>("habits");
31-
const [durationDays, setDurationDays] = useState(30);
32-
const [requiredProofs, setRequiredProofs] = useState(24);
33-
const [selfStake, setSelfStake] = useState(1);
44+
const [policy, setPolicy] = useState<string>(DEFAULTS.policy);
45+
const [category, setCategory] = useState<string>(DEFAULTS.category);
46+
const [durationDays, setDurationDays] = useState<number>(DEFAULTS.durationDays);
47+
const [requiredProofs, setRequiredProofs] = useState<number>(DEFAULTS.requiredProofs);
48+
const [selfStake, setSelfStake] = useState<number>(DEFAULTS.selfStake);
3449

3550
const go = (next: number) => {
3651
setDirection(next > step ? 1 : -1);
@@ -180,12 +195,12 @@ export default function CreatePage() {
180195
{step === 2 && (
181196
<motion.section key="s2" variants={variants} initial="enter" animate="center" exit="exit" className="space-y-6">
182197
<NumberField
183-
label="Your self-stake (GEN) — you lose it if you fold (min 0.5)"
198+
label={`Your self-stake (GEN) — you lose it if you fold (min ${MIN_STAKE_GEN})`}
184199
value={selfStake}
185200
onChange={setSelfStake}
186201
min={MIN_STAKE_GEN}
187202
max={100000}
188-
step={0.5}
203+
step={0.1}
189204
/>
190205
<div className="grain relative rounded-card bg-ink-soft p-5 shadow-e2">
191206
<p className="font-mono text-[10px] uppercase tracking-widest text-mut">the ticket you&apos;re signing</p>

apps/web/components/ProfileView.tsx

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,18 @@ import { motion } from "framer-motion";
44
import Link from "next/link";
55
import { fadeRise, staggerList, receiptDeal } from "@/lib/motion/variants";
66
import { useReducedMotionSafe } from "@/lib/motion/useReducedMotionSafe";
7-
import { useProfile } from "@/lib/chain/hooks";
7+
import { useClaim, useClaimable, useProfile, useViewer } from "@/lib/chain/hooks";
88
import { EMPTY_STATES } from "@/lib/psychology/copy";
99
import { shortAddress } from "@/lib/utils";
1010
import { GenAmount } from "./ui/GenAmount";
1111
import { EmptyState } from "./ui/EmptyState";
1212

1313
export function ProfileView({ address }: { address: string }) {
1414
const { data: profile, isLoading } = useProfile(address);
15+
const { address: viewer } = useViewer();
16+
const isOwn = viewer.toLowerCase() === address.toLowerCase();
17+
const { data: claimable = 0 } = useClaimable(address);
18+
const claim = useClaim();
1519
const { pick } = useReducedMotionSafe();
1620

1721
if (isLoading || !profile) {
@@ -53,6 +57,28 @@ export function ProfileView({ address }: { address: string }) {
5357
</div>
5458
</motion.div>
5559

60+
{isOwn && claimable > 0 && (
61+
<motion.div
62+
variants={pick(fadeRise)}
63+
className="grain relative mb-12 flex items-center justify-between gap-4 rounded-card bg-ink-soft p-4 shadow-e1"
64+
>
65+
<div>
66+
<GenAmount value={claimable} className="text-xl font-bold text-gold" suffix="" />
67+
<p className="font-mono text-[10px] uppercase tracking-widest text-mut">
68+
GEN waiting to be claimed
69+
</p>
70+
</div>
71+
<button
72+
type="button"
73+
onClick={() => claim.mutate()}
74+
disabled={claim.isPending}
75+
className="rounded-card bg-gold px-5 py-2 font-mono text-xs font-bold uppercase tracking-widest text-ink transition-opacity hover:opacity-90 disabled:opacity-50"
76+
>
77+
{claim.isPending ? "claiming…" : "claim it"}
78+
</button>
79+
</motion.div>
80+
)}
81+
5682
<motion.section variants={pick(fadeRise)}>
5783
<h2 className="mb-4 font-mono text-xs uppercase tracking-[0.25em] text-mut">
5884
&ldquo;Called it&rdquo; receipts · {profile.calledItReceipts.length}

apps/web/components/StakePanel.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ import { MIN_STAKE_GEN, type Challenge, type Side } from "@/lib/chain/types";
1111
import { Button } from "./ui/Button";
1212
import { Modal } from "./ui/Modal";
1313

14-
// testnet-friendly: smallest preset is the 0.5 GEN minimum
15-
const PRESETS = [0.5, 1, 5, 25];
14+
// testnet-friendly: smallest preset is the MIN_STAKE_GEN minimum
15+
const PRESETS = [MIN_STAKE_GEN, 1, 5, 25];
1616

1717
/**
1818
* Stake modal + signature moment #5: on confirm, the staked amount detaches
@@ -110,7 +110,7 @@ export function StakePanel({
110110
placeholder={`min ${MIN_STAKE_GEN}`}
111111
value={custom}
112112
onChange={(e) => {
113-
// digits + a single decimal point (0.5 GEN minimum stake)
113+
// digits + a single decimal point (MIN_STAKE_GEN minimum)
114114
const cleaned = e.target.value.replace(/[^0-9.]/g, "");
115115
const firstDot = cleaned.indexOf(".");
116116
setCustom(

apps/web/lib/chain/client.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ export interface GrudgeClient {
4040

4141
settle(id: string): Promise<SettleResult>;
4242

43+
/** Winnings credited by settle() and not yet withdrawn, in GEN. */
44+
getClaimable(address: string): Promise<number>;
45+
/** Withdraw the caller's settled winnings. Rejects when nothing is claimable. */
46+
claim(from: string): Promise<{ txHash: string; amount: number }>;
47+
4348
getProfile(address: string): Promise<Profile>;
4449
getLeaderboards(): Promise<Leaderboards>;
4550
}

apps/web/lib/chain/errors.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/**
2+
* Maps raw contract/RPC error strings to user-friendly messages.
3+
* Patterns are tested in order — first match wins. The contract-side
4+
* patterns mirror the exact UserError strings in contracts/grudge.py.
5+
*/
6+
7+
interface ErrorMapping {
8+
pattern: RegExp;
9+
message: string;
10+
}
11+
12+
const CONTRACT_ERRORS: ErrorMapping[] = [
13+
// ── challenge lifecycle ───────────────────────────────────────────────
14+
{ pattern: /challenge not found/i, message: "This grudge doesn't exist on-chain." },
15+
{ pattern: /challenge is closed/i, message: "This grudge is closed — no more moves on it." },
16+
{ pattern: /already settled/i, message: "This grudge has already been settled." },
17+
{ pattern: /deadline has not passed/i, message: "The deadline hasn't hit yet — settling opens then." },
18+
{ pattern: /deadline passed/i, message: "The deadline has passed — the only move left is to settle." },
19+
20+
// ── creation ──────────────────────────────────────────────────────────
21+
{ pattern: /self-stake required/i, message: "Put GEN where your mouth is — a self-stake is required." },
22+
{ pattern: /duration must be/i, message: "Duration must be between 1 and 365 days." },
23+
{ pattern: /required_proofs must be/i, message: "Required proofs must be between 1 and the number of days." },
24+
{ pattern: /statement must be/i, message: "The promise must be 12–280 characters." },
25+
{ pattern: /evidence_policy must be/i, message: "The evidence policy must be 4–280 characters." },
26+
{ pattern: /category must be/i, message: "Category is too long (max 32 characters)." },
27+
{ pattern: /statement rejected/i, message: "The validators rejected this promise — make it concrete, time-boxed, and verifiable." },
28+
29+
// ── staking ───────────────────────────────────────────────────────────
30+
{ pattern: /stake amount required/i, message: "Add a GEN amount to stake." },
31+
{ pattern: /side must be/i, message: "Pick a side: believe or doubt." },
32+
{ pattern: /creator cannot doubt themselves/i, message: "You can't doubt your own promise — that's just quitting." },
33+
{ pattern: /staking window closed/i, message: "The staking window has closed for this grudge." },
34+
35+
// ── evidence & disputes ───────────────────────────────────────────────
36+
{ pattern: /only the challenger submits evidence/i, message: "Only the challenger can submit evidence." },
37+
{ pattern: /evidence must be/i, message: "Evidence must be 4–4000 characters." },
38+
{ pattern: /evidence already submitted/i, message: "Already submitted for this proof period — come back next period." },
39+
{ pattern: /creator cannot dispute/i, message: "You can't dispute your own evidence." },
40+
{ pattern: /no such evidence entry/i, message: "That evidence entry doesn't exist." },
41+
{ pattern: /counter_evidence must be/i, message: "Counter-evidence must be 4–4000 characters." },
42+
{ pattern: /only verified evidence can be disputed/i, message: "Only VERIFIED evidence can be disputed." },
43+
{ pattern: /already disputed/i, message: "This evidence has already been disputed." },
44+
45+
// ── claims ────────────────────────────────────────────────────────────
46+
{ pattern: /nothing to claim/i, message: "Nothing to claim for this wallet." },
47+
48+
// ── wallet / network ──────────────────────────────────────────────────
49+
{ pattern: /no wallet (connected|available)/i, message: "Connect your wallet to continue." },
50+
{ pattern: /user rejected|rejected by user|user denied/i, message: "Transaction cancelled." },
51+
{ pattern: /insufficient funds/i, message: "Insufficient GEN balance — top up from the Bradbury faucet." },
52+
{ pattern: /rate limit|429|too many requests/i, message: "The RPC is rate-limiting — wait a moment and try again." },
53+
{ pattern: /switch your wallet/i, message: "Switch your wallet to GenLayer Testnet Bradbury to continue." },
54+
{ pattern: /network|fetch|Failed to fetch|ECONNREFUSED|timeout/i, message: "Network error — check your connection and try again." },
55+
];
56+
57+
const FALLBACK = "Something went wrong. Please try again.";
58+
59+
/**
60+
* Extracts the deepest human-readable string from any error shape thrown
61+
* by genlayer-js, viem, or the contract VM (UserError nests in cause/details).
62+
*/
63+
export function extractRawMessage(err: unknown): string {
64+
if (!err) return FALLBACK;
65+
66+
const e = err as Record<string, unknown>;
67+
const cause = e.cause as Record<string, unknown> | undefined;
68+
const candidates = [e.details, e.shortMessage, cause?.message, cause?.details, e.message].filter(
69+
Boolean,
70+
) as string[];
71+
72+
// The actual UserError string is usually inside a quoted block
73+
for (const raw of candidates) {
74+
const m = String(raw).match(/UserError[^:]*:\s*(.+?)(?:\n|$)/);
75+
if (m?.[1]) return m[1].trim();
76+
}
77+
78+
return candidates[0] ? String(candidates[0]) : FALLBACK;
79+
}
80+
81+
/** Returns a user-friendly message for any contract/wallet error. */
82+
export function friendlyError(err: unknown): string {
83+
const raw = extractRawMessage(err);
84+
85+
for (const { pattern, message } of CONTRACT_ERRORS) {
86+
if (pattern.test(raw)) return message;
87+
}
88+
89+
// Trim noisy viem prefixes before falling back
90+
const cleaned = raw
91+
.replace(/^(ContractFunctionExecutionError|ContractFunctionRevertedError|Error):\s*/i, "")
92+
.replace(/\n.*/s, "")
93+
.trim();
94+
95+
return cleaned || FALLBACK;
96+
}

0 commit comments

Comments
 (0)