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
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ NEXT_PUBLIC_DAILY_PASS_PRICE_USDC=0.5
# backend enforces its own ADMIN_ADDRESSES.
NEXT_PUBLIC_ADMIN_ADDRESSES=

# Mobile wallets over WalletConnect (LOBSTR, Freighter Mobile). Empty turns it off.
# Get a project id at https://cloud.reown.com (WalletConnect Cloud). Add this app's
# origin to the project's allowed origins, or pairing is refused.
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
# This app's public URL, shown to the wallet while it pairs.
NEXT_PUBLIC_APP_URL=http://localhost:3000

# IPFS gateway for reading reasoning traces.
NEXT_PUBLIC_IPFS_GATEWAY_URL=https://ipfs.io/ipfs
# The contracts repo's x402 trace service (per-trace unlocks). Empty if not deployed.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ To trade on testnet you need XLM and test USDC; follow [docs/manual-test.md](doc

All variables are listed and explained in [.env.example](.env.example). Everything defaults to Stellar Testnet and the contract ids in the synced deployments file. Every `NEXT_PUBLIC_*` value is compiled into browser JavaScript, so never put a secret in one.

**Mobile wallets (WalletConnect).** Set `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID` to show WalletConnect in the connect modal, so phone wallets such as LOBSTR or Freighter Mobile can pair with a QR code. The kit and WalletConnect load only when this is set, behind a dynamic import, so pages that don't need them are unchanged. Set `NEXT_PUBLIC_APP_URL` to the deployed origin and allow that origin in the WalletConnect Cloud project.

## Scripts

| Script | What it does |
Expand Down
58 changes: 23 additions & 35 deletions app/admin/create/page.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,27 @@
"use client";

import React, { useState, useEffect, useRef } from "react";
import { RequireWallet } from "@/components/require-wallet";
import { useWallet } from "@/lib/contexts/WalletContext";
import { useGenerateMarket } from "@/lib/hooks/useMarkets";
import { UserFacingError } from "@/lib/stellar/errors";
import type { MarketCategory } from "@/lib/api/markets";
import { isAdminAddress } from "@/lib/stellar/config";
import { BACKEND_CATEGORIES, BACKEND_CATEGORY_LABELS, BACKEND_TO_CONTRACT_CATEGORY } from "@/lib/stellar/categories";

export default function AdminPage() {
const { isConnected, address, openModal, ensureSession } = useWallet();
return (
<RequireWallet
title="Restricted Access"
description="The Market Deployment Terminal requires a secure wallet connection for institutional authentication."
>
<AdminCreateContent />
</RequireWallet>
);
}

function AdminCreateContent() {
const { address, withSession } = useWallet();
const generateMarket = useGenerateMarket();

const [question, setQuestion] = useState("");
Expand Down Expand Up @@ -64,49 +77,24 @@ export default function AdminPage() {
}

addLog("INITIATING_GEN_AI_ORACLE...");
if (!(await ensureSession())) {
addLog("ERROR: WALLET_SIGN_IN_REQUIRED");
return;
}
try {
const result = await generateMarket.mutateAsync({
question,
category,
expiry
});
// Signs in to the backend only now, right before the protected call.
const result = await withSession(
() => generateMarket.mutateAsync({ question, category, expiry }),
"generate a market",
);
addLog(`SUCCESS: JOB_${result.jobId.substring(0,8)}`);
setQuestion("");
} catch (err) {
if (err instanceof UserFacingError) {
addLog(`ERROR: ${err.message}`);
return;
}
console.error("Market generation failed:", err);
addLog("ERROR: DEPLOYMENT_FAILED");
}
};

if (!isConnected) {
return (
<main className="flex-grow flex items-center justify-center p-gutter">
<div className="max-w-md w-full text-center space-y-6 bg-white p-10 rounded-xl border border-outline-variant shadow-sm">
<div className="w-20 h-20 bg-primary-container text-primary rounded-full flex items-center justify-center mx-auto">
<span className="material-symbols-outlined text-4xl">lock</span>
</div>
<div className="space-y-2">
<h2 className="font-headline-md text-headline-md">Restricted Access</h2>
<p className="text-on-surface-variant">
The Market Deployment Terminal requires a secure wallet connection for institutional authentication.
</p>
</div>
<button
onClick={() => openModal()}
className="w-full bg-primary text-primary-foreground py-4 rounded-lg font-label-caps text-label-caps hover:brightness-110 transition-all flex items-center justify-center gap-2 shadow-lg cursor-pointer"
>
<span className="material-symbols-outlined">account_balance_wallet</span>
CONNECT WALLET TO ACCESS
</button>
</div>
</main>
);
}

// UI gate only; the backend enforces ADMIN_ADDRESSES on /markets/generate.
if (!isAdminAddress(address)) {
return (
Expand Down
42 changes: 15 additions & 27 deletions app/admin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import React from "react";
import Link from "next/link";
import { RequireWallet } from "@/components/require-wallet";
import { useWallet } from "@/lib/contexts/WalletContext";
import { useMarkets } from "@/lib/hooks/useMarkets";
import { usePlatformStats } from "@/lib/hooks/usePortfolio";
Expand Down Expand Up @@ -36,36 +37,22 @@ const MarketItem = ({ title, status, volume }: { title: string, status: string,
);

export default function AdminDashboard() {
const { isConnected, address, openModal } = useWallet();
return (
<RequireWallet
title="Restricted Access"
description="The Admin Control Center requires a secure wallet connection for institutional authentication."
>
<AdminContent />
</RequireWallet>
);
}

function AdminContent() {
const { address, disconnect, openModal } = useWallet();
const { data: marketsData, isLoading } = useMarkets({ limit: 10 });
const { data: stats } = usePlatformStats();
const markets = marketsData?.markets ?? [];

if (!isConnected) {
return (
<main className="flex-grow flex items-center justify-center p-gutter">
<div className="max-w-md w-full text-center space-y-6 bg-white p-10 rounded-xl border border-outline-variant shadow-sm">
<div className="w-20 h-20 bg-primary-container text-primary rounded-full flex items-center justify-center mx-auto">
<span className="material-symbols-outlined text-4xl">lock</span>
</div>
<div className="space-y-2">
<h2 className="font-headline-md text-headline-md">Restricted Access</h2>
<p className="text-on-surface-variant">
The Admin Control Center requires a secure wallet connection for institutional authentication.
</p>
</div>
<button
onClick={openModal}
className="w-full bg-primary text-primary-foreground py-4 rounded-lg font-label-caps text-label-caps hover:brightness-110 transition-all flex items-center justify-center gap-2 shadow-lg cursor-pointer"
>
<span className="material-symbols-outlined">account_balance_wallet</span>
CONNECT WALLET TO ACCESS
</button>
</div>
</main>
);
}

// UI gate only. The backend checks ADMIN_ADDRESSES itself on every admin
// request; hiding this page does not protect anything.
const isAuthorized = isAdminAddress(address);
Expand All @@ -87,7 +74,8 @@ export default function AdminDashboard() {
</p>
</div>
<button
onClick={openModal}
// Disconnect first so the kit's picker lets the user choose another account.
onClick={() => void disconnect().then(() => openModal())}
className="w-full bg-outline text-on-outline py-4 rounded-lg font-label-caps text-label-caps hover:brightness-90 transition-all flex items-center justify-center gap-2 shadow-lg cursor-pointer"
>
<span className="material-symbols-outlined">swap_horiz</span>
Expand Down
10 changes: 4 additions & 6 deletions app/premium/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -162,12 +162,10 @@ export default function PremiumPage() {
? fees.totalFee - reserve.data.spendable
: null;

const openCheckout = (tier: Tier) => {
const openCheckout = async (tier: Tier) => {
if (!tier.priceRaw) return;
if (!isConnected) {
void openModal();
return;
}
// Connecting stays on this page; keep the chosen tier if the wallet connects.
if (!isConnected && !(await openModal())) return;
setStatusMessage(null);
setSelectedTier(tier);
};
Expand All @@ -186,7 +184,7 @@ export default function PremiumPage() {

setIsSubmitting(true);
try {
setStatusMessage("Signing in to OracleDesk…");
setStatusMessage("One more signature: this logs you in to OracleDesk and doesn't move funds.");
if (!(await ensureSession())) {
setStatusMessage("You need to sign in with your wallet before paying.");
return;
Expand Down
42 changes: 39 additions & 3 deletions components/layout/WalletDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
import React, { useState, useRef, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useWallet } from "@/lib/contexts/WalletContext";
import { useEnableUsdc, useUsdcBalance, useUsdcTrustline } from "@/lib/hooks/useStellar";
import { NETWORK_LABEL, explorerAccount } from "@/lib/stellar/config";
import { formatUsdc, shortAddress } from "@/lib/stellar/units";
import { useAccountReserve, useEnableUsdc, useRpcNetwork, useUsdcBalance, useUsdcTrustline } from "@/lib/hooks/useStellar";
import { NETWORK_LABEL, NETWORK_PASSPHRASE, TESTNET_PASSPHRASE, explorerAccount } from "@/lib/stellar/config";
import { formatUsdc, formatXlm, shortAddress } from "@/lib/stellar/units";

const WalletDropdown = () => {
const { address, isConnected, isConnecting, isWrongNetwork, openModal, disconnect, error } = useWallet();
Expand All @@ -15,6 +15,8 @@ const WalletDropdown = () => {
const balance = useUsdcBalance(address);
const trustline = useUsdcTrustline(address);
const enableUsdc = useEnableUsdc();
const reserve = useAccountReserve(address);
const rpcNetwork = useRpcNetwork();

// Close dropdown when clicking outside
useEffect(() => {
Expand Down Expand Up @@ -48,6 +50,15 @@ const WalletDropdown = () => {
: balance.data !== undefined
? formatUsdc(balance.data, { maxDecimals: 2, minDecimals: 2, grouping: true })
: balance.isError ? "—" : "…";
const notActivated = !isWrongNetwork && reserve.data?.funded === false;
const xlmText = reserve.data?.funded
? formatXlm(reserve.data.balance, { maxDecimals: 2, minDecimals: 2, grouping: true })
: reserve.isError ? "—" : "…";
// Friendbot exists only on testnet; the RPC says where it is.
const friendbotUrl =
NETWORK_PASSPHRASE === TESTNET_PASSPHRASE && rpcNetwork.data?.friendbotUrl
? `${rpcNetwork.data.friendbotUrl}?addr=${encodeURIComponent(address)}`
: null;

const handleEnableUsdc = async () => {
try {
Expand Down Expand Up @@ -115,6 +126,31 @@ const WalletDropdown = () => {
USDC
</span>
</div>
{!isWrongNetwork && (
<div className="mt-2 flex items-baseline gap-1">
<span className="text-sm font-bold text-on-surface font-data-mono">{xlmText}</span>
<span className="text-xs font-bold text-on-surface-variant uppercase">XLM</span>
<span className="ml-1 text-[10px] text-on-surface-variant">(fees and reserve)</span>
</div>
)}
{notActivated && (
<div role="status" className="mt-3 rounded-md border border-outline-variant bg-white p-2">
<span className="block text-xs font-bold text-on-surface">Account not activated</span>
<p className="mt-1 text-[11px] text-on-surface-variant">
This Stellar account needs a little XLM before it can trade. Fund it to continue.
</p>
{friendbotUrl && (
<a
href={friendbotUrl}
target="_blank"
rel="noopener noreferrer"
className="mt-2 inline-block rounded-md bg-primary px-3 py-1.5 text-xs font-bold text-primary-foreground"
>
Fund with Friendbot
</a>
)}
</div>
)}
{trustlineMissing && !isWrongNetwork && (
<button
type="button"
Expand Down
44 changes: 44 additions & 0 deletions components/require-wallet.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"use client";

import type { ReactNode } from "react";
import { useWallet } from "@/lib/contexts/WalletContext";

/**
* Renders `children` once a wallet is connected. Until then it shows a card
* with a Connect button that keeps the user on the current page.
*/
export function RequireWallet({
children,
title,
description,
}: {
children: ReactNode;
title: string;
description: string;
}) {
const { isConnected, isConnecting, openModal } = useWallet();
if (isConnected) return <>{children}</>;

return (
<main className="flex-grow flex items-center justify-center p-gutter">
<div className="max-w-md w-full text-center space-y-6 bg-white p-10 rounded-xl border border-outline-variant shadow-sm">
<div className="w-20 h-20 bg-primary-container text-primary rounded-full flex items-center justify-center mx-auto">
<span className="material-symbols-outlined text-4xl">lock</span>
</div>
<div className="space-y-2">
<h2 className="font-headline-md text-headline-md">{title}</h2>
<p className="text-on-surface-variant">{description}</p>
</div>
<button
type="button"
onClick={() => void openModal()}
disabled={isConnecting}
className="w-full bg-primary text-primary-foreground py-4 rounded-lg font-label-caps text-label-caps hover:brightness-110 transition-all flex items-center justify-center gap-2 shadow-lg cursor-pointer disabled:opacity-60"
>
<span className="material-symbols-outlined">account_balance_wallet</span>
{isConnecting ? "CONNECTING…" : "CONNECT WALLET"}
</button>
</div>
</main>
);
}
22 changes: 17 additions & 5 deletions docs/manual-test.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@ These steps cover what automated checks can't: a real browser wallet signing rea

## 3. Fund the account with Friendbot

- [ ] Open `https://friendbot.stellar.org/?addr=<YOUR_G_ADDRESS>` (or Freighter's "Fund with Friendbot" button).
- [ ] Freighter shows about 10,000 XLM.
- [ ] Before funding, the wallet menu shows **Account not activated** with a **Fund with Friendbot** link (testnet only). A buy is refused with "Your Stellar account isn't activated yet", not a raw SDK error.
- [ ] Open `https://friendbot.stellar.org/?addr=<YOUR_G_ADDRESS>` (or the menu's **Fund with Friendbot** link).
- [ ] Freighter shows about 10,000 XLM. Within about 10 seconds the menu flips from "Account not activated" to an XLM balance (about 10,000 XLM).

## 4. Get test USDC

Expand All @@ -50,9 +51,20 @@ stellar contract invoke --id CA2WQQJ4OHQCLHQW6XN4BCLILGRV6V4YDYDT3GVIWXB53BTOO7E
## 5. Connect

- [ ] Open `http://localhost:3000`, click **Connect Wallet**, choose Freighter, approve.
- [ ] You're taken to `/markets` once. Reload the page: you stay where you are and are still connected (the old app redirected on every reload).
- [ ] Freighter asks you to sign a transaction for OracleDesk login. It is never submitted; it proves you own the key. Approve it.
- [ ] Expected: no error under the button. In DevTools → Application → Local Storage, `oracledesk_wallet` equals your address.
- [ ] After approving, you **stay on the page you were on**. No login signature is requested yet. Reload the page: you stay where you are and are still connected.
- [ ] No login signature appears until you do something that needs OracleDesk's server (copy trade, daily pass, admin). Then Freighter asks you to sign a transaction for OracleDesk login. It is never submitted; it proves you own the key. Approve it, and a sentence explains why it appears.
- [ ] Sub-step: start the connect from `/copy-trade?traceId=…` with a non-default slider value. After approving, the URL and the slider value are unchanged.
- [ ] Expected: no error under the button. In DevTools → Application → Local Storage, `oracledesk_wallet` equals your address once you have signed in.

## 5a. Mobile wallet (WalletConnect)

Needs `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID` set and this origin allowed in the project.

- [ ] Desktop: click **Connect Wallet**, pick the WalletConnect option, scan the QR code with LOBSTR or Freighter Mobile on **testnet**.
- [ ] Approve the pairing on the phone. The app shows the account and stays on the current page.
- [ ] Log in (step 5) and buy. Each signature is approved on the phone; the page waits for the wallet's answer.
- [ ] Reload: the session restores without a prompt.
- [ ] If the phone's session has ended, a trade shows "Your mobile wallet session ended. Connect your mobile wallet again." (not a raw error).

## 6. See your balance

Expand Down
27 changes: 27 additions & 0 deletions lib/__tests__/navigation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { safeReturnTo } from "../navigation";

const BASE = "https://oracledesk.test";

describe("safeReturnTo", () => {
it("accepts same-origin paths with query and hash", () => {
expect(safeReturnTo("/copy-trade?traceId=x", BASE)).toBe("/copy-trade?traceId=x");
expect(safeReturnTo("/markets/quick-view?onChainId=3#ticket", BASE)).toBe("/markets/quick-view?onChainId=3#ticket");
});

it("rejects protocol-relative and backslash hosts", () => {
expect(safeReturnTo("//evil.com", BASE)).toBeNull();
expect(safeReturnTo("/\\evil.com", BASE)).toBeNull();
expect(safeReturnTo("/\t/evil.com", BASE)).toBeNull();
});

it("rejects absolute URLs and script schemes", () => {
expect(safeReturnTo("https://evil.com", BASE)).toBeNull();
expect(safeReturnTo("javascript:alert(1)", BASE)).toBeNull();
});

it("rejects empty input", () => {
expect(safeReturnTo(null, BASE)).toBeNull();
expect(safeReturnTo("", BASE)).toBeNull();
});
});
Loading