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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ NEXT_PUBLIC_TREASURY_CONTRACT_ID=
NEXT_PUBLIC_RESOLVER_CONTRACT_ID=
NEXT_PUBLIC_REASONING_REGISTRY_CONTRACT_ID=
NEXT_PUBLIC_USDC_CONTRACT_ID=
# USDC asset identifier (format CODE:ISSUER). Empty defaults to USDC:<deployer>
# on Stellar Testnet; required when running on a different network.
NEXT_PUBLIC_USDC_ASSET=

# Where premium payments go. Must match the backend's PAYMENTS_RECIPIENT.
# Empty means the treasury contract (see docs/api.md).
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ jobs:
- uses: actions/checkout@v4
with:
submodules: true
fetch-depth: 0

- uses: actions/setup-node@v4
with:
Expand All @@ -25,6 +26,12 @@ jobs:
- name: Generated bindings match the contracts submodule
run: npm run check:bindings

- name: Single SDK copy and major alignment guard
run: npm run check:sdk

- name: Report contract interface changes
run: node scripts/spec-diff.mjs --base "origin/${{ github.base_ref || 'main' }}" --summary "$GITHUB_STEP_SUMMARY"

- run: npm run typecheck
- run: npm run lint
- run: npm test
Expand Down
28 changes: 21 additions & 7 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,11 +197,25 @@ The site header navigation routes are declared in a single source of truth: `com
- Desktop dropdown navigation is powered by Radix `NavigationMenu` (disclosure navigation with keyboard arrow navigation and Escape support).
- Mobile navigation is rendered by `MobileNav.tsx` using Radix `Dialog` as an accessible sheet that traps focus and auto-closes on route changes.

## Never commit secrets

No `.env*` files (only `.env.example`), no Stellar secret seeds (`S…`), no
API keys. Every `NEXT_PUBLIC_*` variable ends up in browser JavaScript, so a
secret must never go in one. If you commit a secret by accident, tell a
maintainer privately (see [SECURITY.md](SECURITY.md)) so it can be rotated;
removing it in a later commit is not enough.
## Bumping the contracts submodule

When the contracts repository changes:

1. Update the pinned submodule:
```bash
git -C contracts checkout <commit-or-tag>
npm run sync:bindings
```
2. Run the interface change report:
```bash
npm run spec:diff
```
This decodes embedded contract specifications from both the base branch and the new bindings, producing a structured diff of functions, types, error codes, and events.

### Clearing CI spec-diff failures

- **Uncovered Contract Errors**: If a new contract error code was added without a friendly user-facing message, add an entry describing it to `FRIENDLY` in `lib/stellar/errors.ts` (or add it to `UNFRIENDLY_ALLOWLIST` with justification if it is internal/admin-only).
- **Breaking Call-Site References**: If a contract function was removed or its signature changed, update the calling code in `lib/`, `app/`, `components/`, or `e2e/` so no dead or mismatched invocations remain.
- **Renumbered Error Codes**: Renumbering existing error codes breaks client-side error mapping and is rejected. Coordinate with contract maintainers to deprecate rather than renumber codes.
- **Event Changes**: Changes to `Trade` or `TracePublished` events flag a requirement for backend indexer review.

144 changes: 18 additions & 126 deletions app/admin/create/page.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
"use client";
import { Icon } from "@/components/ui/icon";

import React, { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { Icon } from "@/components/ui/icon";
import { RequireWallet } from "@/components/require-wallet";
import { SourceTag } from "@/components/ui/source-tag";
import { useWallet } from "@/lib/contexts/WalletContext";
import { useGenerateMarket, useMarketGenerationStatus } from "@/lib/hooks/useMarkets";
import { isTerminalStatus } from "@/lib/hooks/generation-status";
import { ApiError } from "@/lib/api/client";
import { UserFacingError } from "@/lib/stellar/errors";
import type { MarketCategory } from "@/lib/api/markets";
import { formatUsdcDisplay } from "@/lib/format/money";
import { isAdminAddress } from "@/lib/stellar/config";
import { tryParseUsdc } from "@/lib/stellar/units";
import { BACKEND_CATEGORIES, BACKEND_CATEGORY_LABELS, BACKEND_TO_CONTRACT_CATEGORY } from "@/lib/stellar/categories";
import { DEFAULTS, serializeDiscovery } from "@/lib/markets/discovery-params";
import { runsInLastHour, saveJob, useAdminJobs, type StoredJob } from "@/lib/admin/job-store";
import { completedWithoutChain, elapsedFor, formatElapsed, jobFeed, shortJobId, type JobEventTone } from "@/lib/admin/job-events";
import { JobStatusChip } from "@/components/admin/job-status-chip";

/** Backend limit on POST /markets/generate (per IP; each run calls paid LLM APIs). */
const RUNS_PER_HOUR = 5;
Expand Down Expand Up @@ -116,95 +116,6 @@ function AdminCreateContent() {
not used by the backend yet, so this page sends none.
</p>
</div>

<form className="space-y-6 bg-white p-8 rounded-xl border border-outline-variant shadow-sm">
{/* Market Question */}
<div className="space-y-2">
<label className="font-label-caps text-label-caps text-on-surface-variant flex items-center justify-between">
MARKET_QUESTION
<span className="text-[10px] opacity-50">REQUIRED</span>
</label>
<input
className="w-full bg-surface border border-outline-variant rounded p-3 focus:ring-2 focus:ring-primary focus:border-primary transition-all font-body-md text-body-md outline-none"
id="market_question"
placeholder="Will ETH exceed $5,000 by year end?"
type="text"
value={question}
onChange={handleQuestionChange}
/>
</div>

<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Category */}
<div className="space-y-2">
<label className="font-label-caps text-label-caps text-on-surface-variant">CATEGORY</label>
<select
className="w-full bg-surface border border-outline-variant rounded p-3 font-body-md text-body-md outline-none focus:ring-2 focus:ring-primary cursor-pointer"
id="market_category"
value={category}
onChange={handleCategoryChange}
>
{BACKEND_CATEGORIES.map((c) => (
<option key={c} value={c}>
{BACKEND_CATEGORY_LABELS[c]} (on-chain: {BACKEND_TO_CONTRACT_CATEGORY[c]})
</option>
))}
</select>
</div>

{/* Expiry */}
<div className="space-y-2">
<label className="font-label-caps text-label-caps text-on-surface-variant">EXPIRY_DATE</label>
<input
className="w-full bg-surface border border-outline-variant rounded p-3 font-body-md text-body-md outline-none focus:ring-2 focus:ring-primary cursor-pointer"
id="market_expiry"
type="date"
value={expiry}
onChange={handleExpiryChange}
/>
</div>
</div>

{/* Initial Liquidity */}
<div className="space-y-2">
<label className="font-label-caps text-label-caps text-on-surface-variant flex justify-between">
INITIAL_LIQUIDITY (USDC)
<span className="text-secondary font-bold">Min: 100 USDC</span>
</label>
<div className="relative">
<input
className="w-full bg-surface border border-outline-variant rounded p-3 pl-12 font-data-mono text-data-mono outline-none focus:ring-2 focus:ring-primary"
id="market_liquidity"
placeholder="1000.00"
type="number"
value={liquidity}
onChange={handleLiquidityChange}
/>
<Icon name="account_balance_wallet" className="absolute left-3 top-3 text-on-surface-variant" />
</div>
</div>

{/* AI Toggle */}
<div className="flex items-center justify-between p-4 bg-surface-container-low rounded border border-outline-variant">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded bg-primary-container flex items-center justify-center text-on-primary-container">
<Icon name="psychology" />
</div>
<div>
<h3 className="font-headline-sm text-headline-sm">AI Oracle Reasoning</h3>
<p className="text-[12px] text-on-surface-variant">Generate autonomous probability analysis reports.</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
checked={aiEnabled}
onChange={handleAiToggle}
/>
<div className="w-11 h-6 bg-outline-variant peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"></div>
</label>
</div>

<div className="space-y-4 bg-white p-8 rounded-xl border border-outline-variant shadow-sm">
<p className="text-sm text-on-surface-variant">
Expand All @@ -224,7 +135,7 @@ function AdminCreateContent() {
disabled={generateMarket.isPending}
>
<Icon name="rocket_launch" />
{generateMarket.isPending ? "GENERATING..." : "DEPLOY INSTITUTIONAL MARKET"}
{generateMarket.isPending ? "STARTING…" : "START MARKET-MAKER RUN"}
</button>
{startError && (
<p role="alert" className="text-sm text-error">
Expand Down Expand Up @@ -253,37 +164,18 @@ function AdminCreateContent() {
)}
</section>

<div className="grid grid-cols-2 gap-4 border-t border-outline-variant pt-6">
<div className="space-y-1">
<span className="font-label-caps text-label-caps text-on-surface-variant block">LIQUIDITY</span>
<span className="font-data-mono text-data-mono text-on-surface">
{(() => {
const parsed = tryParseUsdc(liquidity);
return parsed !== null ? formatUsdcDisplay(parsed) : "0.00 USDC";
})()}
</span>
</div>
<div className="space-y-1">
<span className="font-label-caps text-label-caps text-on-surface-variant block">EXPIRES</span>
<span className="font-data-mono text-data-mono text-on-surface">
{expiry || "-- -- ----"}
</span>
</div>
</div>

{/* AI Reasoning Ghost Card */}
<div
className={`p-4 bg-[#f0f9fa] rounded border border-primary-container/20 space-y-2 transition-opacity duration-300 ${aiEnabled ? "opacity-100" : "opacity-0"}`}
>
<div className="flex items-center gap-2 text-primary">
<Icon name="auto_awesome" className="text-[18px]" />
<span className="font-label-caps text-label-caps">ORACLE INSIGHT ENABLED</span>
</div>
<div className="h-2 w-3/4 bg-primary-container/10 rounded"></div>
<div className="h-2 w-1/2 bg-primary-container/10 rounded"></div>
</div>
</div>
</div>
<section aria-label="Job events" className="bg-inverse-surface text-inverse-on-surface rounded-xl p-6 font-data-mono text-[12px] space-y-1">
<h2 className="font-label-caps text-label-caps opacity-70 mb-2">JOB EVENTS</h2>
{feed.length === 0 ? (
<p className="opacity-70">Events appear here once a run starts.</p>
) : (
feed.map((event) => (
<p key={event.key} className={TONE_CLASS[event.tone]}>
{event.text}
</p>
))
)}
</section>
</div>
</div>
</main>
Expand Down
24 changes: 21 additions & 3 deletions app/copy-trade/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ function CopyTradeContent() {
const onChainMarketId = market?.onChainMarketId ?? null;
const onChainMarket = useOnChainMarket(onChainMarketId);

const { viewed, isWatching } = useViewedAddress();
const balance = useUsdcBalance(address);
const balanceState = useUsdcBalanceState(address);
const ledger = useLedgerNow();
Expand Down Expand Up @@ -470,6 +471,11 @@ function CopyTradeContent() {
</div>

<div className="space-y-3">
{isWatching && !isConnected && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-900 font-medium">
Watching with no wallet connected. Connect your own wallet to size allocations and trade.
</div>
)}
<div className="flex justify-between items-end flex-wrap gap-2">
<label className="font-label-caps text-label-caps text-on-surface-variant uppercase text-[10px]">Amount to copy</label>
<span className="font-data-mono text-data-mono text-primary font-bold text-xs sm:text-sm">
Expand All @@ -488,6 +494,7 @@ function CopyTradeContent() {
type="button"
role="radio"
aria-checked={sizingKind === option.kind}
disabled={isWatching && !isConnected}
onClick={() => setSizingKind(option.kind)}
className={`px-3 py-2 text-[11px] font-bold border rounded transition-all ${
sizingKind === option.kind
Expand All @@ -507,10 +514,11 @@ function CopyTradeContent() {
inputMode="decimal"
autoComplete="off"
value={fixedInput}
disabled={isWatching && !isConnected}
onChange={(e) => setFixedInput(e.target.value)}
placeholder="e.g. 25"
aria-invalid={fixedInput.trim() !== "" && fixedAmount === null}
className="w-full border border-outline-variant rounded px-3 py-2 font-data-mono text-sm bg-white"
className="w-full border border-outline-variant rounded px-3 py-2 font-data-mono text-sm bg-white disabled:opacity-50"
/>
{fixedInput.trim() !== "" && fixedAmount === null && (
<p className="text-[11px] text-error">Enter an amount like 10 or 2.5 (up to 7 decimal places).</p>
Expand All @@ -533,10 +541,11 @@ function CopyTradeContent() {
inputMode="decimal"
autoComplete="off"
value={capInput}
disabled={isWatching && !isConnected}
onChange={(e) => setCapInput(e.target.value)}
placeholder="No cap"
aria-invalid={capInput.trim() !== "" && userCap === null}
className="w-full border border-outline-variant rounded px-3 py-2 font-data-mono text-sm bg-white"
className="w-full border border-outline-variant rounded px-3 py-2 font-data-mono text-sm bg-white disabled:opacity-50"
/>
{capInput.trim() !== "" && userCap === null && (
<p className="text-[11px] text-error">Enter an amount like 10 or 2.5.</p>
Expand Down Expand Up @@ -739,15 +748,19 @@ function CopyTradeContent() {
disabled={
isSubmitting ||
buy.isPending ||
isWatching ||
!side ||
edgeBlocked ||
(thinEdge && !acknowledgeThinEdge) ||
(isConnected && isUnfunded) ||
(isConnected && isInsufficientXlm)
}
aria-describedby={isWatching ? "watching-copy-desc" : undefined}
>
<Icon name="bolt" className="text-xl" />
{isSubmitting || buy.isPending
{isWatching
? "Watching. Connect your own wallet to trade."
: isSubmitting || buy.isPending
? (buy.phase.name === "awaiting-signature" ? "Signing…" : "Submitting…")
: isUnfunded
? "Fund Account"
Expand All @@ -756,6 +769,11 @@ function CopyTradeContent() {
: "Confirm Copy Trade"}
</button>
</div>
{isWatching && (
<p id="watching-copy-desc" className="sr-only">
You are viewing another address in watch-only mode. Connect your own wallet to copy trades.
</p>
)}
<p className="text-center font-body-md text-body-md text-on-surface-variant text-xs">
Your wallet signs a buy on the OracleDesk market contract on Stellar Testnet. The trade fails instead of filling below the slippage limit.
</p>
Expand Down
3 changes: 2 additions & 1 deletion app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,8 @@ export default function RootLayout({
</InboxProvider>
</HealthProvider>
</RealtimeProvider>
</WalletProvider>
</ViewedAddressProvider>
</WalletProvider>
</PwaRegistrar>
</body>
</html>
Expand Down
13 changes: 2 additions & 11 deletions app/markets/page-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,15 +99,12 @@ interface MarketCardProps {
reasoningTraces?: MarketTracePreview[];
expiry: string;
title: string;
backendYesProb: number | null;
backendYesProb?: number | null;
backendLiquidity: string | null;
marketId?: string;
metaUri?: string | null;
questionHash?: string | null;
disableEntrance?: boolean;
marketId?: string | null;
metaUri?: string | null;
questionHash?: string | null;
disableEntrance?: boolean;
}

const MarketCard = ({ href, category, onChainMarketId, reasoningTraces, expiry, title, backendYesProb, backendLiquidity, disableEntrance, marketId, metaUri, questionHash }: MarketCardProps) => {
Expand All @@ -126,12 +123,6 @@ const MarketCard = ({ href, category, onChainMarketId, reasoningTraces, expiry,
const aiSignal = latestTrace ? (latestTrace.edge > 0.05 ? "BULLISH" : latestTrace.edge < -0.05 ? "BEARISH" : "NEUTRAL") : "ANALYZING";
const aiStatus = latestTrace ? `${Math.abs(latestTrace.edge * 100).toFixed(1)}% EDGE` : "STABLE";
const volume24h = chainStatusLabel(view);
const questionIntegrity = useMarketQuestionIntegrity({
onChainMarketId: onChainMarketId === null ? null : String(onChainMarketId),
displayedQuestion: title,
metaUri: view?.market.meta_uri,
questionHash: view?.market.question_hash,
});

const questionIntegrity = useMarketQuestionIntegrity({
marketId,
Expand Down
Loading