From e5c89d5aa9ea408a2c36fbaafdac8124c174e2d7 Mon Sep 17 00:00:00 2001 From: Alosnireda Date: Tue, 20 Oct 2026 22:16:40 +0100 Subject: [PATCH] fix #144 contract integrity, #143 spec diff, #142 sdk check, and #141 watch-only mode --- .env.example | 3 + .github/workflows/ci.yml | 7 + CONTRIBUTING.md | 28 +- app/admin/create/page.tsx | 144 +--- app/copy-trade/page.tsx | 25 +- app/layout.tsx | 17 +- app/markets/page-client.tsx | 13 +- app/markets/quick-view/page.tsx | 6 +- app/portfolio/analytics/page.tsx | 6 +- app/portfolio/page.tsx | 164 +++- app/portfolio/wallets/page.tsx | 38 +- app/reasoning/page.tsx | 4 + components/layout/IntegrityBanner.tsx | 112 +++ components/layout/ViewingBanner.tsx | 78 ++ components/layout/WalletDropdown.tsx | 17 + components/market/buy-ticket.tsx | 24 +- components/ui/icon.tsx | 4 + docs/STATUS.md | 9 + docs/architecture.md | 32 + docs/manual-test.md | 26 + lib/api/traces.ts | 2 - lib/hooks/__tests__/write-guard.test.ts | 135 +++ lib/hooks/useMarkets.ts | 10 + lib/hooks/useMyPositions.ts | 27 +- lib/hooks/useStellar.ts | 62 +- lib/hooks/useViewedAddress.tsx | 228 +++++ lib/portfolio/export.worker.ts | 2 +- lib/portfolio/useExportData.ts | 17 +- lib/stellar/__tests__/contract-check.test.ts | 269 ++++++ lib/stellar/__tests__/errors-coverage.test.ts | 40 + lib/stellar/__tests__/identity.test.ts | 81 ++ lib/stellar/__tests__/positions.test.ts | 158 ++++ lib/stellar/__tests__/sdk-major.test.ts | 124 +++ lib/stellar/config.ts | 10 + lib/stellar/contract-check.ts | 806 ++++++++++++++++++ lib/stellar/errors.ts | 17 + lib/stellar/identity.ts | 74 ++ lib/stellar/positions.ts | 143 ++++ lib/stellar/sdk-major.ts | 124 +++ lib/stellar/write-guard.ts | 60 ++ package-lock.json | 94 ++ package.json | 4 + scripts/__tests__/spec-diff.test.ts | 277 ++++++ scripts/check-sdk-major.mjs | 41 + scripts/lib/files.mjs | 17 + scripts/lib/spec.mjs | 243 ++++++ scripts/spec-diff.mjs | 411 +++++++++ scripts/sync-bindings.mjs | 10 +- vitest.config.mts | 2 +- 49 files changed, 3988 insertions(+), 257 deletions(-) create mode 100644 components/layout/IntegrityBanner.tsx create mode 100644 components/layout/ViewingBanner.tsx create mode 100644 lib/hooks/__tests__/write-guard.test.ts create mode 100644 lib/hooks/useViewedAddress.tsx create mode 100644 lib/stellar/__tests__/contract-check.test.ts create mode 100644 lib/stellar/__tests__/errors-coverage.test.ts create mode 100644 lib/stellar/__tests__/identity.test.ts create mode 100644 lib/stellar/__tests__/positions.test.ts create mode 100644 lib/stellar/__tests__/sdk-major.test.ts create mode 100644 lib/stellar/contract-check.ts create mode 100644 lib/stellar/identity.ts create mode 100644 lib/stellar/positions.ts create mode 100644 lib/stellar/sdk-major.ts create mode 100644 lib/stellar/write-guard.ts create mode 100644 scripts/__tests__/spec-diff.test.ts create mode 100644 scripts/check-sdk-major.mjs create mode 100644 scripts/lib/files.mjs create mode 100644 scripts/lib/spec.mjs create mode 100644 scripts/spec-diff.mjs diff --git a/.env.example b/.env.example index e051e26..f3f5534 100644 --- a/.env.example +++ b/.env.example @@ -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: +# 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). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef4a728..7d4883d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,7 @@ jobs: - uses: actions/checkout@v4 with: submodules: true + fetch-depth: 0 - uses: actions/setup-node@v4 with: @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a8e4176..3607cb2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 + 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. diff --git a/app/admin/create/page.tsx b/app/admin/create/page.tsx index d54bc48..546811f 100644 --- a/app/admin/create/page.tsx +++ b/app/admin/create/page.tsx @@ -1,8 +1,8 @@ "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"; @@ -10,11 +10,11 @@ import { useGenerateMarket, useMarketGenerationStatus } from "@/lib/hooks/useMar 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; @@ -116,95 +116,6 @@ function AdminCreateContent() { not used by the backend yet, so this page sends none.

- -
- {/* Market Question */} -
- - -
- -
- {/* Category */} -
- - -
- - {/* Expiry */} -
- - -
-
- - {/* Initial Liquidity */} -
- -
- - -
-
- - {/* AI Toggle */} -
-
-
- -
-
-

AI Oracle Reasoning

-

Generate autonomous probability analysis reports.

-
-
- -

@@ -224,7 +135,7 @@ function AdminCreateContent() { disabled={generateMarket.isPending} > - {generateMarket.isPending ? "GENERATING..." : "DEPLOY INSTITUTIONAL MARKET"} + {generateMarket.isPending ? "STARTING…" : "START MARKET-MAKER RUN"} {startError && (

@@ -253,37 +164,18 @@ function AdminCreateContent() { )} -

-
- LIQUIDITY - - {(() => { - const parsed = tryParseUsdc(liquidity); - return parsed !== null ? formatUsdcDisplay(parsed) : "0.00 USDC"; - })()} - -
-
- EXPIRES - - {expiry || "-- -- ----"} - -
-
- - {/* AI Reasoning Ghost Card */} -
-
- - ORACLE INSIGHT ENABLED -
-
-
-
-
- +
+

JOB EVENTS

+ {feed.length === 0 ? ( +

Events appear here once a run starts.

+ ) : ( + feed.map((event) => ( +

+ {event.text} +

+ )) + )} +
diff --git a/app/copy-trade/page.tsx b/app/copy-trade/page.tsx index c715e1a..446e564 100644 --- a/app/copy-trade/page.tsx +++ b/app/copy-trade/page.tsx @@ -15,6 +15,7 @@ import { confirmCopyTrade, initiateCopyTrade } from "@/lib/api/trade"; import { ApiError } from "@/lib/api/client"; import { useMarket } from "@/lib/hooks/useMarkets"; import { useBuy, useQuoteBuy, useUsdcBalance, useAccountReserve, usePrepareBuy, useOnChainMarket, fetchOnChainMarketBySimulation, useUsdcTrustline, useEnableUsdc, useUsdcBalanceState, type PreparedWriteReview } from "@/lib/hooks/useStellar"; +import { useViewedAddress } from "@/lib/hooks/useViewedAddress"; import { explorerTx } from "@/lib/stellar/config"; import { formatUsdc, formatXlm, minOut, bpsToPercent, probabilityToBps, tryParseUsdc, BPS } from "@/lib/stellar/units"; import { chooseCollateral, maxCollateralForEdge, quoteCopy, type CopyMarket, type CopyQuote, type SizingMode } from "@/lib/stellar/quote"; @@ -122,6 +123,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(); @@ -480,6 +482,11 @@ function CopyTradeContent() {
+ {isWatching && !isConnected && ( +
+ Watching with no wallet connected. Connect your own wallet to size allocations and trade. +
+ )}
@@ -498,6 +505,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 @@ -517,10 +525,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 && (

Enter an amount like 10 or 2.5 (up to 7 decimal places).

@@ -543,10 +552,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 && (

Enter an amount like 10 or 2.5.

@@ -748,15 +758,19 @@ function CopyTradeContent() { disabled={ isSubmitting || buy.isPending || + isWatching || !side || edgeBlocked || (thinEdge && !acknowledgeThinEdge) || (isConnected && isUnfunded) || (isConnected && isInsufficientXlm) } + aria-describedby={isWatching ? "watching-copy-desc" : undefined} > - {isSubmitting || buy.isPending + {isWatching + ? "Watching. Connect your own wallet to trade." + : isSubmitting || buy.isPending ? (buy.phase.name === "awaiting-signature" ? "Signing…" : "Submitting…") : isUnfunded ? "Fund Account" @@ -765,6 +779,11 @@ function CopyTradeContent() { : "Confirm Copy Trade"}
+ {isWatching && ( +

+ You are viewing another address in watch-only mode. Connect your own wallet to copy trades. +

+ )}

Your wallet signs a buy on the OracleDesk market contract on Stellar Testnet. The trade fails instead of filling below the slippage limit.

diff --git a/app/layout.tsx b/app/layout.tsx index ea0e381..e7433e6 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -10,6 +10,9 @@ import { InboxProvider } from "@/lib/notifications/InboxProvider"; import { PwaRegistrar } from "@/components/pwa/pwa-registrar"; import { DebugMount } from "@/components/debug/debug-mount"; import { RpcDegradedBanner } from "@/components/debug/RpcPanel"; +import { IntegrityBanner } from "@/components/layout/IntegrityBanner"; +import { ViewingBanner } from "@/components/layout/ViewingBanner"; +import { ViewedAddressProvider } from "@/lib/hooks/useViewedAddress"; import { WebVitals } from "@/app/web-vitals"; const geistSans = Geist({ @@ -64,10 +67,13 @@ export default function RootLayout({ - - - - + + + + + + +
{children} @@ -78,7 +84,8 @@ export default function RootLayout({ - + + diff --git a/app/markets/page-client.tsx b/app/markets/page-client.tsx index 45e8088..72c4bd8 100644 --- a/app/markets/page-client.tsx +++ b/app/markets/page-client.tsx @@ -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) => { @@ -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, diff --git a/app/markets/quick-view/page.tsx b/app/markets/quick-view/page.tsx index 79697ef..3f0f482 100644 --- a/app/markets/quick-view/page.tsx +++ b/app/markets/quick-view/page.tsx @@ -12,6 +12,7 @@ import { getMarketByOnChainId, getResolution } from "@/lib/api/markets"; import { ApiError } from "@/lib/api/client"; import { useOnChainMarket, usePosition } from "@/lib/hooks/useStellar"; import { useWalletAddress } from "@/lib/contexts/WalletContext"; +import { useViewedAddress } from "@/lib/hooks/useViewedAddress"; import { floatUsdcToRaw, formatBps, formatUsdcDisplay } from "@/lib/format/money"; import { formatUsdc, bpsToPercent } from "@/lib/stellar/units"; import { useRecentPriceWindow } from "@/lib/hooks/usePriceHistory"; @@ -20,6 +21,7 @@ import { MIN_TX_TIMEOUT_S, SAFETY_MARGIN_S } from "@/lib/stellar/deadline"; import { TradeTape } from "@/components/market/trade-tape"; import { BuyTicket } from "@/components/market/buy-ticket"; import { DevProfiler } from "@/lib/dev/profiler"; +import { ledgerClock, useLedgerClock } from "@/lib/stellar/ledger-clock"; import { PushVerifiedStatus } from "@/components/push-verified-status"; import { SourceTag } from "@/components/ui/source-tag"; import { @@ -93,7 +95,9 @@ function MarketQuickViewContent() { }); const { address } = useWalletAddress(); - const position = usePosition(onChainMarketId, address); + const { viewed } = useViewedAddress(); + const activeHolder = viewed ?? address; + const position = usePosition(onChainMarketId, activeHolder); const ledger = useLedgerClock(); const [side, setSide] = useState<"Yes" | "No">(initialSide); const view = chain.data; diff --git a/app/portfolio/analytics/page.tsx b/app/portfolio/analytics/page.tsx index 412fdef..474df26 100644 --- a/app/portfolio/analytics/page.tsx +++ b/app/portfolio/analytics/page.tsx @@ -20,6 +20,7 @@ import type { LedgerEntry } from "@/lib/portfolio/equity"; import { getMarketByOnChainId } from "@/lib/api/markets"; import { explorerTx } from "@/lib/stellar/config"; import { bpsToPercent, formatUsdc, usdcToNumber } from "@/lib/stellar/units"; +import { ExportHistoryDialog } from "@/components/portfolio/export-history-dialog"; const RANGES: { label: string; seconds: number }[] = [ { label: "1D", seconds: 86_400 }, @@ -75,6 +76,7 @@ const StateCard = ({ children }: { children: React.ReactNode }) => ( export default function PortfolioAnalytics() { const { address, openModal } = useWallet(); const [rangeS, setRangeS] = useState(86_400); + const [exportOpen, setExportOpen] = useState(false); const equity = useEquity(rangeS); if (!address) { @@ -287,8 +289,8 @@ export default function PortfolioAnalytics() {
- - + + )} setExportOpen(false)} /> diff --git a/app/portfolio/page.tsx b/app/portfolio/page.tsx index 428288f..b938f0e 100644 --- a/app/portfolio/page.tsx +++ b/app/portfolio/page.tsx @@ -3,7 +3,8 @@ import { Icon } from "@/components/ui/icon"; import React, { useState, useEffect, useRef, useCallback } from "react"; import { usePortfolio, usePositions } from "@/lib/hooks/usePortfolio"; -import { useMyPositions } from "@/lib/hooks/useMyPositions"; +import { useAddressPositions, useMyPositions } from "@/lib/hooks/useMyPositions"; +import { useViewedAddress } from "@/lib/hooks/useViewedAddress"; import { floatUsdcToRaw, formatBps, @@ -13,7 +14,9 @@ import { ratioBps, } from "@/lib/format/money"; import { useTreasuryValuation } from "@/lib/portfolio/useValuation"; -import { formatUsdc } from "@/lib/stellar/units"; +import { formatUsdc, shortAddress } from "@/lib/stellar/units"; +import { SourceTag } from "@/components/ui/source-tag"; +import deployments from "@/lib/stellar/generated/deployments.testnet.json"; import { useWallet } from "@/lib/contexts/WalletContext"; import { useRedeemAll, type PreparedWriteReview } from "@/lib/hooks/useStellar"; import { shouldSkipTxReview, TxReview, TxReviewPreferenceControl } from "@/components/tx-review"; @@ -205,7 +208,16 @@ export default function PortfolioPage() { const { data: summary, isLoading: isSummaryLoading } = usePortfolio(); const { data: positionsData, isLoading: isPositionsLoading } = usePositions({ status }); const positions = positionsData?.positions ?? []; - const treasuryValuation = useTreasuryValuation(positions); + const rawTreasuryValuation = useTreasuryValuation(positions); + const treasuryValuation = { + ...rawTreasuryValuation, + basis: rawTreasuryValuation.mode, + setBasis: rawTreasuryValuation.setMode, + }; + const { viewed, isWatching, setWatch, clearWatch } = useViewedAddress(); + const [watchInput, setWatchInput] = useState(""); + const activeHolder = viewed ?? wallet.address; + const addressPositions = useAddressPositions(activeHolder); const myPositions = useMyPositions(); const currentRedeemIds = new Set(redeemBatch.discovery?.redeemables.map((item) => item.marketId.toString()) ?? []); const currentRedeemed = redeemBatch.discovery?.redeemables.filter( @@ -335,16 +347,30 @@ export default function PortfolioPage() { - {/* User's own on-chain positions (issue #50), read from ledger entries */} -
-
+ {/* Per-address on-chain positions (issue #141 & #50), read from ledger entries */} +
+
-

My positions

+
+

+ Positions for {activeHolder ? shortAddress(activeHolder, 6, 4) : "Address"} +

+ {isWatching && ( + + Watching + + )} + {activeHolder && activeHolder === deployments.agent && ( + + Treasury Agent + + )} +

- From your wallet's market-core ledger entries · not the agent's book + Read directly from market-core ledger entries on Stellar Testnet · not the agent's book

- {wallet.isConnected && ( + {wallet.isConnected && activeHolder === wallet.address && ( )}
-
+ + {/* Address Input & Quick Picks */} +
+ { + e.preventDefault(); + const trimmed = watchInput.trim(); + if (!trimmed) return; + if (setWatch(trimmed)) { + setWatchInput(""); + } + }} + className="flex flex-col sm:flex-row gap-2 mb-3" + > + setWatchInput(e.target.value)} + className="flex-1 bg-white border border-outline-variant rounded-lg px-3 py-2 text-xs font-mono outline-none focus:ring-2 focus:ring-primary" + /> + + +
+ Quick picks: + {wallet.isConnected && wallet.address && ( + + )} + + {isWatching && ( + + )} +
+
+ + {/* Positions Table */} +
MARKET VALUE - - {myPositions.isLoading ? "Scanning ledger…" : formatUsdcDisplay(myPositions.totalValueRaw)} + + {addressPositions.isLoading ? "Scanning ledger…" : formatUsdcDisplay(addressPositions.totalValueRaw)} +
- {myPositions.error ? ( -

{myPositions.error}

- ) : !wallet.isConnected ? ( + {addressPositions.error ? ( +

{addressPositions.error}

+ ) : !activeHolder ? (

- Connect a Stellar wallet to see your on-chain positions. + Connect a Stellar wallet or enter an address above to inspect on-chain positions.

- ) : myPositions.isLoading ? ( + ) : addressPositions.isLoading ? (

Reading Position entries from the market-core contract…

- ) : myPositions.rows.length === 0 ? ( + ) : addressPositions.rows.length === 0 ? (

- {myPositions.totalMarkets === undefined || myPositions.totalMarkets === 0n + {addressPositions.totalMarkets === undefined || addressPositions.totalMarkets === 0n ? "No on-chain markets exist yet." - : "Your wallet holds no position shares on-chain."} + : "This address holds no position shares on-chain."}

) : (
    - {myPositions.rows.map((row) => { + {addressPositions.rows.map((row) => { const settled = row.settlement !== null; return (
  • @@ -400,15 +494,18 @@ export default function PortfolioPage() { {row.valueRaw === null ? "—" : `${formatUsdc(row.valueRaw, { maxDecimals: 2, grouping: true })} USDC`} - - {settled ? `Settled · ${row.settlement}` : "Open"} - +
    + + {settled ? `Settled · ${row.settlement}` : "Open"} + + +
    {row.archived && ( archived )} @@ -417,12 +514,17 @@ export default function PortfolioPage() { })}
)} - {myPositions.scanLimited && ( + {addressPositions.scanLimited && (

The scan limit was reached; the newest markets were checked first.

)}
+ {isWatching && ( +

+ Note: Trade and transaction history isn't available for watched addresses yet. +

+ )}
{/* J4: Unified Gateway Balance Widget */} diff --git a/app/portfolio/wallets/page.tsx b/app/portfolio/wallets/page.tsx index 6095710..9bbe5d3 100644 --- a/app/portfolio/wallets/page.tsx +++ b/app/portfolio/wallets/page.tsx @@ -3,6 +3,7 @@ import { Icon, type IconName } from "@/components/ui/icon"; import React from "react"; import { useWallet } from "@/lib/contexts/WalletContext"; +import { useViewedAddress } from "@/lib/hooks/useViewedAddress"; import { useUsdcBalance } from "@/lib/hooks/useStellar"; import { NETWORK_LABEL, explorerAccount } from "@/lib/stellar/config"; import { formatUsdc, shortAddress } from "@/lib/stellar/units"; @@ -62,7 +63,9 @@ const TransactionRow = ({ type, icon, iconColor, asset, amount, amountColor, sta export default function WalletsPage() { const { address, isConnected, openModal, isWrongNetwork } = useWallet(); - const balance = useUsdcBalance(address); + const { viewed, isWatching } = useViewedAddress(); + const activeAddress = viewed ?? address; + const balance = useUsdcBalance(activeAddress); const formattedBalance = balance.data !== undefined ? formatUsdc(balance.data, { minDecimals: 2, maxDecimals: 2, grouping: true }) : "0.00"; @@ -97,7 +100,14 @@ export default function WalletsPage() {
- TOTAL EQUITY +
+ TOTAL EQUITY + {isWatching && ( + + Watching + + )} +
{isWrongNetwork && Wrong network}
@@ -144,7 +154,29 @@ export default function WalletsPage() { {/* Wallet Connections */}
-

Connected Wallets

+

+ {isWatching ? "Inspected Identity" : "Connected Wallets"} +

+ {isWatching && viewed && ( +
+
+
+ +
+
+
+ Watched Address + WATCHING +
+
{shortAddress(viewed, 6, 4)}
+
+
+
+
${formattedBalance}
+ USDC +
+
+ )} {isConnected && address ? ( mergeTracePages(data?.pages.map((page) => page.traces) ?? []), [data]); const totalCount = data?.pages[0]?.meta?.total ?? traces.length; + const { address } = useWallet(); + const cart = useUnlockCart(address); + const quotedPassRaw = traces.find((t) => /^\d+$/.test(t.dailyPassPriceRaw ?? ""))?.dailyPassPriceRaw; + const dailyPassPriceRaw = quotedPassRaw ? BigInt(quotedPassRaw) : DAILY_PASS_PRICE_RAW; return (
diff --git a/components/layout/IntegrityBanner.tsx b/components/layout/IntegrityBanner.tsx new file mode 100644 index 0000000..9944a3f --- /dev/null +++ b/components/layout/IntegrityBanner.tsx @@ -0,0 +1,112 @@ +"use client"; + +import React from "react"; +import { useContractIntegrity, type IntegrityResult } from "@/lib/stellar/contract-check"; +import { explorerAccount } from "@/lib/stellar/config"; +import { AlertTriangle, ShieldAlert, ExternalLink, RefreshCw } from "lucide-react"; + +export function IntegrityBanner() { + const { data: integrity, isLoading, refetch, isFetching } = useContractIntegrity(); + + if (isLoading || !integrity) { + return null; + } + + const results = Object.values(integrity) as IntegrityResult[]; + const failingContracts = results.filter( + (r) => r.status === "mismatch" || r.status === "not-found" || r.status === "wrong-kind", + ); + const uncheckedContracts = results.filter((r) => r.status === "unchecked"); + + const allNotFound = results.length > 0 && results.every((r) => r.status === "not-found"); + + if (failingContracts.length > 0) { + return ( +
+
+
+ +
+
+ Contract Interface Mismatch · Trading Disabled +
+

+ {allNotFound + ? "None of the configured contracts were found on-chain. The Stellar testnet may have been reset. Contact the operator." + : "The frontend was built against a different contract specification than what is running on-chain. Contact the operator."} +

+
    + {failingContracts.map((f) => ( +
  • + {f.contract}: + + {f.id.slice(0, 6)}…{f.id.slice(-6)} + + + — + + {f.status === "not-found" + ? "Instance entry not found on-chain" + : f.status === "wrong-kind" + ? (f.detail ?? "Executable kind differs") + : (f.detail ?? "Contract spec differs")} + +
  • + ))} +
+
+
+ +
+
+ ); + } + + if (uncheckedContracts.length > 0) { + const isArchived = uncheckedContracts.some((u) => u.detail === "archived"); + return ( +
+
+
+ + + {isArchived + ? "Contract entry archived (TTL expired). Reads will attempt state restoration." + : "Contract integrity check is temporarily unchecked due to RPC connectivity issues. Writes are still enabled."} + +
+ +
+
+ ); + } + + return null; +} diff --git a/components/layout/ViewingBanner.tsx b/components/layout/ViewingBanner.tsx new file mode 100644 index 0000000..5eb85bb --- /dev/null +++ b/components/layout/ViewingBanner.tsx @@ -0,0 +1,78 @@ +"use client"; + +import React from "react"; +import { useViewedAddress } from "@/lib/hooks/useViewedAddress"; +import { explorerAccount } from "@/lib/stellar/config"; +import { shortAddress } from "@/lib/stellar/units"; +import { Eye, ExternalLink, X, AlertCircle } from "lucide-react"; + +export function ViewingBanner() { + const { viewed, isWatching, watchError, clearWatch } = useViewedAddress(); + + if (watchError) { + return ( +
+
+
+ + + Watch Error: {watchError} + +
+ +
+
+ ); + } + + if (!isWatching || !viewed) { + return null; + } + + return ( +
+
+
+ + + Viewing {shortAddress(viewed, 6, 4)} in Watch-Only Mode. + {" "}Balances and positions reflect this address; signing is bound to your connected wallet. + +
+
+ + Stellar Expert + + + +
+
+
+ ); +} diff --git a/components/layout/WalletDropdown.tsx b/components/layout/WalletDropdown.tsx index 9a5ea1b..8871b01 100644 --- a/components/layout/WalletDropdown.tsx +++ b/components/layout/WalletDropdown.tsx @@ -4,6 +4,7 @@ import { Icon } from "@/components/ui/icon"; import React, { useState } from "react"; import { DropdownMenu as DM } from "radix-ui"; import { useWalletActions, useWalletAddress, useWalletNetwork, useWalletSession } from "@/lib/contexts/WalletContext"; +import { useViewedAddress } from "@/lib/hooks/useViewedAddress"; import { useAccountReserve, useEnableUsdc, useRpcNetwork, useUsdcBalance, useUsdcTrustline, useUsdcBalanceState } from "@/lib/hooks/useStellar"; import { NETWORK_LABEL, NETWORK_PASSPHRASE, TESTNET_PASSPHRASE, explorerAccount } from "@/lib/stellar/config"; import { PendingValue } from "@/components/pending-value"; @@ -12,6 +13,7 @@ import { formatUsdc, formatXlm, shortAddress } from "@/lib/stellar/units"; const WalletDropdownBody = () => { const { address, isConnected } = useWalletAddress(); + const { isWatching, viewed, clearWatch } = useViewedAddress(); const { isWrongNetwork } = useWalletNetwork(); const { isConnecting, error } = useWalletSession(); const { openModal, disconnect } = useWalletActions(); @@ -192,6 +194,21 @@ const WalletDropdownBody = () => { + {isWatching && viewed && ( + clearWatch()} + className="w-full flex items-center gap-3 px-3 py-2.5 hover:bg-surface-container focus:bg-surface-container rounded-lg transition-colors group cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-primary" + > + +
+ Stop watching + + Viewing {shortAddress(viewed, 6, 4)} + +
+
+ )} + - {!isConnected + {isWatching + ? "Watching. Connect your own wallet to trade." + : !isConnected ? "Connect" : buy.isPending ? (buy.phase.name === "awaiting-signature" ? "Signing…" : "Buying…") @@ -365,6 +372,11 @@ export function BuyTicket({ onChainMarketId, side, amountInputRef }: BuyTicketPr : `Buy ${side.toUpperCase()}`}
+ {isWatching && ( +

+ You are viewing another address in watch-only mode. Connect your own wallet to trade. +

+ )}
{amountRaw === null @@ -438,7 +450,7 @@ export function BuyTicket({ onChainMarketId, side, amountInputRef }: BuyTicketPr {trustlineUnauthorized &&

The issuer hasn't authorized your USDC trustline.

} {position.data && (position.data.yes > 0n || position.data.no > 0n || positionState.data?.provisional || positionState.data?.awaitingRpc) && (

- Your position: {formatUsdc(position.data.yes, { maxDecimals: 2 })} YES · {formatUsdc(position.data.no, { maxDecimals: 2 })} NO + {isWatching ? "Watched position:" : "Your position:"} {formatUsdc(position.data.yes, { maxDecimals: 2 })} YES · {formatUsdc(position.data.no, { maxDecimals: 2 })} NO } />

)} diff --git a/components/ui/icon.tsx b/components/ui/icon.tsx index b18f15d..4664c99 100644 --- a/components/ui/icon.tsx +++ b/components/ui/icon.tsx @@ -35,6 +35,8 @@ import { Copy, Download, ExternalLink, + Eye, + EyeOff, Filter, Gem, Globe, @@ -152,6 +154,8 @@ export const ICONS = { trending_up: TrendingUp, verified: BadgeCheck, verified_user: ShieldCheck, + visibility: Eye, + visibility_off: EyeOff, workspace_premium: Award, } as const; diff --git a/docs/STATUS.md b/docs/STATUS.md index 7634a27..abad26a 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -102,6 +102,15 @@ new checklist in [manual-test.md](manual-test.md). Node v24.13.1, npm 11.8.0 (CI: Node 22 via `.nvmrc`), Next.js 16.2.6 (Turbopack), React 19.2.4, TypeScript 5.9.3, `@tanstack/react-query` 5.100.10, `@stellar/stellar-sdk` 16.3.1, `@creit.tech/stellar-wallets-kit` 2.5.0 (pinned exactly), Vitest 5.0.3, gitleaks 8.30.1, Stellar CLI 27.1.0. Contracts submodule at `OracleDesk-SmartContract@eb5f3fd` (that repo's `main` HEAD). +## Coordinated Stellar SDK 17 & Wallets Kit Upgrade (Issue #142) + +The app pins `@stellar/stellar-sdk` to 16.3.1 and `@creit.tech/stellar-wallets-kit` to 2.5.0 to ensure XDR class identity and avoid duplicate SDK installations in the bundle. +- **CI Guard**: `npm run check:sdk` runs in CI and fails if multiple versions of `@stellar/stellar-sdk` exist in `package-lock.json` or if the app's SDK major deviates from the bindings or wallet kit declared major. +- **Upstream Coordination Order**: + 1. In `OracleDesk-SmartContract`: Pin a Stellar CLI version targeting SDK 17, regenerate bindings (`make bindings`), and export `bindings-package.json`. + 2. Bump the `contracts` submodule pointer in this repo and run `npm run sync:bindings`. + 3. Upgrade `@stellar/stellar-sdk` to 17.x and `@creit.tech/stellar-wallets-kit` to the matching release in `package.json`, then run `npm run check:sdk`. + ## Phases | Phase | Status | Evidence | diff --git a/docs/architecture.md b/docs/architecture.md index 931b20b..db1b736 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -231,3 +231,35 @@ Instead of independent intervals across components, `LedgerClock` (`lib/stellar/ - **Event-Driven Invalidation**: Mounted in `WalletProvider` via `LedgerInvalidator` (`lib/stellar/ledger-invalidation.ts`). On new ledgers, `getEvents` queries `marketCore` and `usdc` to invalidate queries matching affected market IDs and user balances. Gaps > 120 ledgers perform a broad invalidation. - **Query Cache Policies**: On-chain queries have `staleTime: Infinity` with a 5-minute fallback poll; backend queries set `refetchIntervalInBackground: false`. +## Contract integrity check (`lib/stellar/contract-check.ts`) + +At startup, the frontend verifies that the deployed contracts match the bundled TypeScript bindings: + +1. **Instance Check**: Queries persistent contract instance keys for all configured contracts (`marketCore`, `treasury`, `resolver`, `reasoningRegistry`, and `usdc`) in a single batched `getLedgerEntries` call. +2. **Wasm Spec Fingerprint**: For Wasm contracts, extracts the executable's `wasmHash`, fetches the contract code entry if not cached, parses the embedded `contractspecv0` section, normalizes spec entries (stripping doc fields and sorting by kind and name), and computes a SHA-256 fingerprint. The fingerprint is cached in `sessionStorage` per content-addressed `wasmHash`. +3. **Stellar Asset Contract (SAC) Verification**: For USDC, verifies that the instance executable is `contractExecutableStellarAsset`, checks that the contract ID matches `new Asset(code, issuer).contractId(NETWORK_PASSPHRASE)` for `NEXT_PUBLIC_USDC_ASSET`, and simulates `name()`. +4. **Write-Path Gating**: `useContractIntegrity()` evaluates network status. If any contract status is `mismatch`, `not-found`, or `wrong-kind`, `useWriteGuard()` blocks transaction signing with a `UserFacingError({ kind: "contract" })`, and `IntegrityBanner.tsx` renders a non-dismissable alert detailing the discrepancies. Temporary RPC connectivity failures yield `unchecked` and do not block writes. + +## Viewed address vs signer (Watch-only mode) + +To allow inspecting any Stellar address's positions, balances, and history without connecting a wallet or risking accidental transactions from a signer wallet: + +1. **Branded Types (`lib/stellar/identity.ts`)**: + - `ViewedAddress`: A validated Stellar G-address (`G[A-Z2-7]{55}`) that is being inspected in the UI. + - `SignerAddress`: A connected wallet's address that can sign transactions. + - Distinct branded types ensure write paths cannot mistakenly pass a watched address to a signer client. +2. **State & URL Synchronization (`lib/hooks/useViewedAddress.tsx`)**: + - Tracks `viewed`, `source` (`"url"` | `"session"` | `"signer"`), and `isWatching`. + - Priority order: URL query param `?watch=` takes highest precedence, followed by active `sessionStorage.getItem("oracledesk_watch")`, falling back to the connected wallet's `address`. + - Never stored in `localStorage` or `oracledesk_wallet`, preventing session mixups with the backend. + - Navigating or clicking "Clear watch" purges the watch session and invalidates user-scoped query caches. +3. **Batched Position Reads (`lib/stellar/positions.ts`)**: + - `fetchPositionsFor(holder: ViewedAddress, marketIds: bigint[])` encodes `DataKey::Position(holder)` keys and batches up to 200 keys per chunk via `getLedgerEntries`. + - Missing or unbatched positions fallback to simulating `get_position(holder)`. Zero-balance positions are filtered out. +4. **Write-Path Protection (`lib/stellar/write-guard.ts`)**: + - `assertCanWrite()` is evaluated in `useWriteGuard()`. + - If `isWatching` is true and `watchedAddress !== signer.publicKey`, it throws `UserFacingError({ kind: "watch-only", title: "Watch-only mode", message: "You are currently inspecting another address in watch-only mode. Connect or switch to this wallet to perform transactions." })`. + - UI trade buttons (Buy, Sell, Copy-Trade confirm) are disabled with explicit helper text ("Watching. Connect your own wallet to trade."). + - Copy-trade position sizing is strictly bound to the signer's actual USDC balance, never the watched address's funds. + + diff --git a/docs/manual-test.md b/docs/manual-test.md index 20030c7..ec0f547 100644 --- a/docs/manual-test.md +++ b/docs/manual-test.md @@ -251,3 +251,29 @@ Needs a local x402 service (`contracts/oracledesk-stellar/x402`, `npm start`) be - /reasoning: **Add to unlock list** on three locked traces, open **Unlock list (3)**: each row gets its price from the 402. Start checkout: three prompts, one after another. Raise `TRACE_PRICE_USDC` mid-run: the run pauses with accept / accept for all / skip. Kill the service on the second item: the receipt marks it failed and flags it; reopening that trace retries delivery without paying. **Download receipt (JSON)** totals equal the sum of paid amounts. - With a total at or above the daily-pass price, the drawer recommends a daily pass and links to /premium. - `NEXT_PUBLIC_X402_URL` empty: no cart, no unlock list buttons, no panel, no banner. + +## Watch-only mode (#141) + +Inspect any public Stellar address's positions and portfolio without connecting its private key. + +1. **Direct URL navigation**: + - [ ] Navigate to `http://localhost:3000/portfolio?watch=GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN7`. + - [ ] The top banner shows: **Viewing GAAZI4TC...CCWN7 in Watch-Only Mode · Stop watching**. + - [ ] Portfolio balance and positions reflect the watched address. + - [ ] Reload the page without `?watch=`: the watch mode persists via `sessionStorage`. + +2. **Watch from Portfolio UI**: + - [ ] Open `/portfolio` and locate the "Watch any Stellar address" card. + - [ ] Enter a valid G-address or click a quick-pick button (e.g., "Treasury Agent"). + - [ ] Click **Watch Address**. The page updates to display positions for that address with on-chain verification tags (`chain`). + - [ ] If an invalid address or muxed address (`M...`) is entered, the form displays a validation error ("Invalid Stellar public key format..."). + +3. **Write protection (Watch vs Signer)**: + - [ ] While in watch mode, navigate to `/markets/quick-view?onChainId=0`. + - [ ] The trade ticket detects watch-only mode: the Buy button is disabled and displays **Watching. Connect your own wallet to trade.** with an explanatory note below. + - [ ] Connect your own wallet (e.g. Wallet A) while watching Wallet B: + - Positions displayed remain Wallet B. + - Attempting to submit a trade blocks with a clear user-facing error explaining you cannot transact on behalf of a watched address. + - [ ] Click **Stop watching** (in banner or wallet dropdown): + - The watch state clears, URL parameter is stripped, and the UI immediately switches to displaying Wallet A's own positions and balances. + diff --git a/lib/api/traces.ts b/lib/api/traces.ts index c8b1b05..d38674d 100644 --- a/lib/api/traces.ts +++ b/lib/api/traces.ts @@ -116,8 +116,6 @@ export interface PaymentEvent { /** Backend verification state; absent on the unlock receipt shape. */ status?: "PENDING" | "CONFIRMED" | "FAILED" | string; createdAt: string; - amount?: number; - status?: "PENDING" | "CONFIRMED" | "FAILED"; confirmedAt?: string | null; } diff --git a/lib/hooks/__tests__/write-guard.test.ts b/lib/hooks/__tests__/write-guard.test.ts new file mode 100644 index 0000000..e693e50 --- /dev/null +++ b/lib/hooks/__tests__/write-guard.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect } from "vitest"; +import { assertCanWrite, type WriteGuardContext } from "../../stellar/write-guard"; +import { UserFacingError } from "../../stellar/errors"; +import type { Signer } from "../../stellar/clients"; + +describe("assertCanWrite", () => { + const SIGNER_ADDRESS = "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; + const WATCHED_ADDRESS = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; + + const validSigner: Signer = { + publicKey: SIGNER_ADDRESS, + signTransaction: async (tx: string) => ({ signedTxXdr: tx }), + }; + + const baseContext: WriteGuardContext = { + signer: validSigner, + isWrongNetwork: false, + isWatching: false, + watchedAddress: null, + accountFunded: true, + updatePending: false, + }; + + it("returns branded SignerAddress when all conditions pass", () => { + const signerAddr = assertCanWrite(baseContext); + expect(signerAddr).toBe(SIGNER_ADDRESS); + }); + + it("throws when watching another address", () => { + expect(() => + assertCanWrite({ + ...baseContext, + isWatching: true, + watchedAddress: WATCHED_ADDRESS, + }), + ).toThrowError(UserFacingError); + + try { + assertCanWrite({ + ...baseContext, + isWatching: true, + watchedAddress: WATCHED_ADDRESS, + }); + } catch (e: unknown) { + const err = e as UserFacingError; + expect(err.described.kind).toBe("disconnected"); + expect(err.message).toBe("You're viewing another address. Connect your own wallet to trade."); + } + }); + + it("allows when watching own address", () => { + const signerAddr = assertCanWrite({ + ...baseContext, + isWatching: true, + watchedAddress: SIGNER_ADDRESS, + }); + expect(signerAddr).toBe(SIGNER_ADDRESS); + }); + + it("throws when disconnected", () => { + expect(() => + assertCanWrite({ + ...baseContext, + signer: null, + }), + ).toThrowError(UserFacingError); + + try { + assertCanWrite({ + ...baseContext, + signer: null, + }); + } catch (e: unknown) { + const err = e as UserFacingError; + expect(err.described.kind).toBe("disconnected"); + expect(err.message).toBe("Connect your wallet first."); + } + }); + + it("throws when on wrong network", () => { + expect(() => + assertCanWrite({ + ...baseContext, + isWrongNetwork: true, + }), + ).toThrowError(UserFacingError); + + try { + assertCanWrite({ + ...baseContext, + isWrongNetwork: true, + }); + } catch (e: unknown) { + const err = e as UserFacingError; + expect(err.described.kind).toBe("wrong-network"); + expect(err.message).toContain("Switch your wallet to Stellar Testnet"); + } + }); + + it("throws when app update is pending", () => { + expect(() => + assertCanWrite({ + ...baseContext, + updatePending: true, + }), + ).toThrowError(UserFacingError); + }); + + it("throws when account is not funded", () => { + expect(() => + assertCanWrite({ + ...baseContext, + accountFunded: false, + }), + ).toThrowError(UserFacingError); + }); + + it("throws when target contract fails integrity", () => { + expect(() => + assertCanWrite({ + ...baseContext, + target: "marketCore", + integrity: { + marketCore: { + contract: "marketCore", + id: "C123", + status: "mismatch", + detail: "WASM mismatch", + checkedAtLedger: 100, + }, + }, + }), + ).toThrowError(UserFacingError); + }); +}); diff --git a/lib/hooks/useMarkets.ts b/lib/hooks/useMarkets.ts index 7927383..bb57bd9 100644 --- a/lib/hooks/useMarkets.ts +++ b/lib/hooks/useMarkets.ts @@ -1,5 +1,6 @@ "use client"; +import { useEffect, useRef } from "react"; import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { getMarket, @@ -8,6 +9,15 @@ import { type ListMarketsParams, } from "@/lib/api/markets"; import { nextPageParam } from "@/lib/ui/virtual-math"; +import { + fetchTrackedJob, + isSessionError, + nextPollInterval, + shouldRetryStatus, + statusRetryDelay, + toJobPatch, +} from "@/lib/hooks/generation-status"; +import { saveJob } from "@/lib/admin/job-store"; export function useMarkets(params: ListMarketsParams = {}) { return useQuery({ diff --git a/lib/hooks/useMyPositions.ts b/lib/hooks/useMyPositions.ts index 8e5b33d..1537a43 100644 --- a/lib/hooks/useMyPositions.ts +++ b/lib/hooks/useMyPositions.ts @@ -7,28 +7,22 @@ import { useWalletAddress } from "@/lib/contexts/WalletContext"; import { describeError } from "@/lib/stellar/errors"; import { scanHolderPositions, withBackendQuestions } from "@/lib/stellar/my-positions"; import { useMarketCount, stellarKeys } from "@/lib/hooks/useStellar"; +import type { ViewedAddress } from "@/lib/stellar/identity"; /** - * The connected wallet's own on-chain positions (issue #50), driven by - * market-core ledger entries rather than the agent's backend book. - * - * The ledger scan is the source of truth and the only thing that can fail the - * query. Question text is joined from the backend best-effort: if the join - * rejects (backend down, unreachable, unauthenticated), rows keep a null - * question and the caller falls back to "Market #". + * On-chain positions for any holder address, driven by market-core ledger entries. */ -export function useMyPositions() { - const { address } = useWalletAddress(); +export function useAddressPositions(holder: ViewedAddress | string | null | undefined) { const count = useMarketCount(); const totalMarkets = count.data; const query = useQuery({ - queryKey: stellarKeys.myPositions(address ?? "", totalMarkets?.toString() ?? "?"), - enabled: Boolean(address) && totalMarkets !== undefined, + queryKey: stellarKeys.myPositions(holder ?? "", totalMarkets?.toString() ?? "?"), + enabled: Boolean(holder) && totalMarkets !== undefined, staleTime: 30_000, refetchInterval: 60_000, queryFn: async () => { - const scan = await scanHolderPositions(address as string, totalMarkets as bigint); + const scan = await scanHolderPositions(holder as string, totalMarkets as bigint); let backend: { onChainMarketId: string | null; question: string }[] = []; try { const { markets } = await listMarkets({ onChain: true, limit: 500 }); @@ -64,3 +58,12 @@ export function useMyPositions() { totalMarkets, }; } + +/** + * The connected wallet's own on-chain positions (issue #50), driven by + * market-core ledger entries rather than the agent's backend book. + */ +export function useMyPositions() { + const { address } = useWalletAddress(); + return useAddressPositions(address); +} diff --git a/lib/hooks/useStellar.ts b/lib/hooks/useStellar.ts index da994b4..dade126 100644 --- a/lib/hooks/useStellar.ts +++ b/lib/hooks/useStellar.ts @@ -29,6 +29,10 @@ import { import { useTxTracker } from "@/lib/stellar/tx-tracker"; import { tradeInvalidationKeys } from "@/lib/stellar/tx-tracker-core"; import { CONTRACTS, NETWORK_PASSPHRASE } from "@/lib/stellar/config"; +import { useContractIntegrity, type ContractKey } from "@/lib/stellar/contract-check"; +import { assertCanWrite } from "@/lib/stellar/write-guard"; +import { useViewedAddress } from "@/lib/hooks/useViewedAddress"; +import type { ViewedAddress } from "@/lib/stellar/identity"; import { preflightBuy, preflightRedeem, type Preflight, type PreflightFailure, type PreflightInput } from "@/lib/stellar/preflight"; import { getSubmitQueue } from "@/lib/stellar/submit-queue"; @@ -333,14 +337,14 @@ function positionOptions(client: ReturnType, id: bigint | refetchIntervalInBackground: false, }; } -export function usePosition(id: bigint | string | null | undefined, holder: string | null | undefined) { +export function usePosition(id: bigint | string | null | undefined, holder: ViewedAddress | string | null | undefined) { const client = useQueryClient(); return useQuery({ ...positionOptions(client, id, holder), select: (data: Stamped) => data.value }); } -export function usePositionState(id: bigint | string | null | undefined, holder: string | null | undefined) { +export function usePositionState(id: bigint | string | null | undefined, holder: ViewedAddress | string | null | undefined) { return useQuery(positionOptions(useQueryClient(), id, holder)); } -function balanceOptions(client: ReturnType, owner: string | null | undefined) { +function balanceOptions(client: ReturnType, owner: ViewedAddress | string | null | undefined) { const queryKey = stellarKeys.balance(owner ?? ""); return { queryKey, @@ -355,10 +359,10 @@ function balanceOptions(client: ReturnType, owner: string refetchIntervalInBackground: false, }; } -export function useUsdcBalance(owner: string | null | undefined) { +export function useUsdcBalance(owner: ViewedAddress | string | null | undefined) { return useQuery({ ...balanceOptions(useQueryClient(), owner), select: (data: Stamped) => data.value }); } -export function useUsdcBalanceState(owner: string | null | undefined) { +export function useUsdcBalanceState(owner: ViewedAddress | string | null | undefined) { return useQuery(balanceOptions(useQueryClient(), owner)); } @@ -669,35 +673,33 @@ export function useVerifiedTrace(onChainTraceId: bigint | string | null | undefi // ─── Writes ───────────────────────────────────────────────────────────────── -function useWriteGuard(): () => Signer { +export function useWriteGuard(): (target?: ContractKey | "usdc") => Signer { const signer = useWalletSigner(); const { isWrongNetwork } = useWalletNetwork(); const queryClient = useQueryClient(); const updatePending = useUpdatePending(); - // The guard's identity is stable, so the write hooks don't get a new - // mutation function on every wallet change. - return useCallback(() => { - // Never sign with an outdated bundle (stale contract ids or slippage logic). - if (updatePending) { - throw new UserFacingError({ kind: "unknown", message: "Reload to update OracleDesk before signing." }); - } - if (!signer) { - throw new UserFacingError({ kind: "disconnected", message: "Connect your wallet first." }); - } - if (isWrongNetwork) { - throw new UserFacingError({ kind: "wrong-network", message: "Switch your wallet to Stellar Testnet and try again." }); - } - // An unfunded account has no ledger entry, so building any transaction - // from it would fail with a raw SDK error. Refuse here, in plain words. - const account = queryClient.getQueryData(stellarKeys.account(signer.publicKey)); - if (account && !account.funded) { - throw new UserFacingError({ - kind: "account-not-found", - message: "Your Stellar account isn't activated yet. Fund it with XLM first.", + const { data: integrity } = useContractIntegrity(); + const { isWatching, viewed } = useViewedAddress(); + + return useCallback( + (target: ContractKey | "usdc" = "marketCore") => { + const account = queryClient.getQueryData( + signer ? stellarKeys.account(signer.publicKey) : [], + ); + assertCanWrite({ + signer, + isWrongNetwork, + isWatching, + watchedAddress: viewed, + updatePending, + accountFunded: account ? account.funded : undefined, + integrity: integrity ?? null, + target, }); - } - return signer; - }, [signer, isWrongNetwork, queryClient, updatePending]); + return signer!; + }, + [signer, isWrongNetwork, isWatching, viewed, queryClient, updatePending, integrity], + ); } export function useInvalidateAfterTrade() { @@ -1534,7 +1536,7 @@ export function useUsdcTransfer() { const mutation = useMutation({ mutationFn: ({ to, amount, followUp, review }: UsdcTransferParams) => withPinnedRpc(async () => { - const signer = guard(); + const signer = guard("usdc"); if (amount <= 0n) throw new UserFacingError({ kind: "contract", message: "Enter an amount greater than zero." }); await requireUsdcTrustline(signer.publicKey); diff --git a/lib/hooks/useViewedAddress.tsx b/lib/hooks/useViewedAddress.tsx new file mode 100644 index 0000000..234cbc1 --- /dev/null +++ b/lib/hooks/useViewedAddress.tsx @@ -0,0 +1,228 @@ +"use client"; + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useWalletAddress } from "@/lib/contexts/WalletContext"; +import { + toViewedAddress, + validateWatchAddress, + type ViewedAddress, +} from "@/lib/stellar/identity"; + +const SESSION_WATCH_KEY = "oracledesk_watch"; + +export interface ViewedAddressContextValue { + /** The currently viewed address (watched address if active, else connected address). */ + viewed: ViewedAddress | null; + /** Where the viewed address originated from: 'watch', 'connected', or null if none. */ + source: "connected" | "watch" | null; + /** True when inspecting an address different from the connected wallet. */ + isWatching: boolean; + /** If the address in URL or input is invalid, stores the user-facing explanation. */ + watchError: string | null; + /** Sets a new watched address, validating format and updating URL & sessionStorage. */ + setWatch: (address: string) => boolean; + /** Clears the active watch, clearing sessionStorage, URL param, and relevant query caches. */ + clearWatch: () => void; +} + +const ViewedAddressContext = createContext(null); + +function updateUrlParam(watched: string | null) { + if (typeof window === "undefined") return; + try { + const url = new URL(window.location.href); + if (watched) { + url.searchParams.set("watch", watched); + } else { + url.searchParams.delete("watch"); + } + window.history.replaceState(null, "", url.toString()); + } catch { + // History replace error in non-standard environment + } +} + +export function ViewedAddressProvider({ children }: { children: ReactNode }) { + const { address: connectedAddress } = useWalletAddress(); + const queryClient = useQueryClient(); + + const [watchedAddress, setWatchedAddress] = useState(() => { + if (typeof window === "undefined") return null; + try { + const urlParam = new URLSearchParams(window.location.search).get("watch"); + const candidate = urlParam ?? window.sessionStorage.getItem(SESSION_WATCH_KEY); + if (candidate) { + const val = validateWatchAddress(candidate); + if (val.ok) return val.address; + } + } catch { + // Ignore sessionStorage / SSR errors + } + return null; + }); + + const [watchError, setWatchError] = useState(() => { + if (typeof window === "undefined") return null; + try { + const urlParam = new URLSearchParams(window.location.search).get("watch"); + if (urlParam) { + const val = validateWatchAddress(urlParam); + if (!val.ok) return val.error; + } + } catch { + // Ignore SSR errors + } + return null; + }); + + // Sync with URL search params on mount or when popstate fires + useEffect(() => { + const syncFromUrl = () => { + try { + const urlParam = new URLSearchParams(window.location.search).get("watch"); + if (urlParam !== null) { + const val = validateWatchAddress(urlParam); + if (val.ok) { + setWatchedAddress(val.address); + setWatchError(null); + window.sessionStorage.setItem(SESSION_WATCH_KEY, val.address); + } else { + setWatchedAddress(null); + setWatchError(val.error); + window.sessionStorage.removeItem(SESSION_WATCH_KEY); + } + } + } catch { + // Ignore + } + }; + + syncFromUrl(); + window.addEventListener("popstate", syncFromUrl); + return () => window.removeEventListener("popstate", syncFromUrl); + }, []); + + const clearWatch = useCallback(() => { + const previous = watchedAddress; + setWatchedAddress(null); + setWatchError(null); + + if (typeof window !== "undefined") { + try { + window.sessionStorage.removeItem(SESSION_WATCH_KEY); + } catch { + // Ignore + } + updateUrlParam(null); + } + + if (previous) { + // Clean up cached balances and positions for the watched address to prevent memory leaks + queryClient.removeQueries({ queryKey: ["stellar", "usdcBalance", previous] }); + queryClient.removeQueries({ + predicate: (query) => { + const k = query.queryKey; + return Array.isArray(k) && k[0] === "stellar" && k[1] === "position" && k[3] === previous; + }, + }); + queryClient.removeQueries({ + predicate: (query) => { + const k = query.queryKey; + return Array.isArray(k) && k[0] === "stellar" && k[1] === "myPositions" && k[2] === previous; + }, + }); + } + }, [watchedAddress, queryClient]); + + const setWatch = useCallback( + (input: string): boolean => { + const val = validateWatchAddress(input); + if (!val.ok) { + setWatchError(val.error); + return false; + } + + setWatchedAddress(val.address); + setWatchError(null); + + if (typeof window !== "undefined") { + try { + window.sessionStorage.setItem(SESSION_WATCH_KEY, val.address); + } catch { + // Ignore + } + updateUrlParam(val.address); + } + + return true; + }, + [], + ); + + const connectedViewed = useMemo( + () => toViewedAddress(connectedAddress), + [connectedAddress], + ); + + const contextValue = useMemo(() => { + // If watching own address, isWatching is false (no-op so trading remains enabled) + if (watchedAddress) { + const isSelf = Boolean(connectedViewed && watchedAddress === connectedViewed); + return { + viewed: watchedAddress, + source: isSelf ? "connected" : "watch", + isWatching: !isSelf, + watchError, + setWatch, + clearWatch, + }; + } + + if (connectedViewed) { + return { + viewed: connectedViewed, + source: "connected", + isWatching: false, + watchError, + setWatch, + clearWatch, + }; + } + + return { + viewed: null, + source: null, + isWatching: false, + watchError, + setWatch, + clearWatch, + }; + }, [watchedAddress, connectedViewed, watchError, setWatch, clearWatch]); + + return ( + + {children} + + ); +} + +/** + * Accesses the currently viewed address, whether in watch mode, and controls to + * set or clear the watched address. + */ +export function useViewedAddress(): ViewedAddressContextValue { + const ctx = useContext(ViewedAddressContext); + if (!ctx) { + throw new Error("useViewedAddress must be used within "); + } + return ctx; +} diff --git a/lib/portfolio/export.worker.ts b/lib/portfolio/export.worker.ts index 7434b1c..6e551cd 100644 --- a/lib/portfolio/export.worker.ts +++ b/lib/portfolio/export.worker.ts @@ -50,7 +50,7 @@ export function handleMessage( message: ExportWorkerRequest, deps: ExportWorkerDeps, ): void { - if ("cancel" in message && message.cancel) { + if ("cancel" in message) { deps.cancelledIds.add(message.id); return; } diff --git a/lib/portfolio/useExportData.ts b/lib/portfolio/useExportData.ts index def63d0..01580c5 100644 --- a/lib/portfolio/useExportData.ts +++ b/lib/portfolio/useExportData.ts @@ -369,12 +369,13 @@ export function useExportData() { kind: "trade", marketId: t.marketId, ledger: t.raw.ledger, - eventId: t.raw.id, - outcome: t.outcome, + id: t.raw.id, + outcome: t.outcome === "YES" ? "Yes" : "No", isBuy: t.isBuy, collateral: t.collateral, shares: t.shares, fee: t.fee, + time: iso, }); } @@ -385,8 +386,9 @@ export function useExportData() { kind: "resolved", marketId, ledger: res.raw.ledger, - eventId: res.raw.id, - outcome: res.outcome, + id: res.raw.id, + outcome: res.outcome === "YES" ? "Yes" : res.outcome === "NO" ? "No" : "Void", + time: iso, }); } @@ -394,11 +396,12 @@ export function useExportData() { const iso = toLedgerIsoTime(r.raw.ledgerClosedAt); eventTimeById.set(r.raw.id, iso); pnlEvents.push({ - kind: "redeemed", + kind: "redeem", marketId: r.marketId, ledger: r.raw.ledger, - eventId: r.raw.id, + id: r.raw.id, payout: r.payout, + time: iso, }); } @@ -407,7 +410,7 @@ export function useExportData() { for (const rel of realizations) { rows.push({ type: "pnl", - time: eventTimeById.get(rel.eventId) ?? new Date(0).toISOString(), + time: rel.time || eventTimeById.get(rel.id) || new Date(0).toISOString(), marketId: rel.marketId, outcome: rel.outcome, realized: rel.realized, diff --git a/lib/stellar/__tests__/contract-check.test.ts b/lib/stellar/__tests__/contract-check.test.ts new file mode 100644 index 0000000..0f55e39 --- /dev/null +++ b/lib/stellar/__tests__/contract-check.test.ts @@ -0,0 +1,269 @@ +import { describe, expect, it } from "vitest"; +import { Address, Asset, rpc, xdr } from "@stellar/stellar-sdk"; +import { + bundledSpec, + specFingerprint, + parseWasmCustomSections, + parseSpecFromWasm, + diffSpecs, + checkContractIntegrity, + WASM_MAX_CODE_SIZE, +} from "../contract-check"; +import { CONTRACTS, TESTNET_PASSPHRASE } from "../config"; +import deployments from "../generated/deployments.testnet.json"; + +function encodeUleb128(val: number): Buffer { + const bytes: number[] = []; + do { + let b = val & 0x7f; + val >>>= 7; + if (val !== 0) b |= 0x80; + bytes.push(b); + } while (val !== 0); + return Buffer.from(bytes); +} + +function buildSyntheticWasm(customSectionName: string, payload: Buffer): Buffer { + const nameBytes = Buffer.from(customSectionName, "utf8"); + const sectionContent = Buffer.concat([ + encodeUleb128(nameBytes.length), + nameBytes, + payload, + ]); + return Buffer.concat([ + Buffer.from([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]), // magic + version 1 + Buffer.from([0x00]), // custom section id + encodeUleb128(sectionContent.length), + sectionContent, + ]); +} + +describe("Contract Integrity & Spec Fingerprinting (Issue #144)", () => { + it("bundledSpec fingerprints are stable across repeated calls", async () => { + const mcEntries1 = bundledSpec("marketCore"); + const mcEntries2 = bundledSpec("marketCore"); + const fp1 = await specFingerprint(mcEntries1); + const fp2 = await specFingerprint(mcEntries2); + + expect(fp1).toBe(fp2); + expect(fp1.length).toBe(64); // SHA-256 hex length + }); + + it("doc-only differences produce the exact same fingerprint", async () => { + const fn1 = xdr.ScSpecEntry.scSpecEntryFunctionV0( + new xdr.ScSpecFunctionV0({ + doc: "This is some original documentation", + name: "test_fn", + inputs: [ + new xdr.ScSpecFunctionInputV0({ + doc: "Doc for arg", + name: "arg1", + type: xdr.ScSpecTypeDef.scSpecTypeU64(), + }), + ], + outputs: [xdr.ScSpecTypeDef.scSpecTypeBool()], + }), + ); + + const fn2 = xdr.ScSpecEntry.scSpecEntryFunctionV0( + new xdr.ScSpecFunctionV0({ + doc: "Different completely altered documentation text", + name: "test_fn", + inputs: [ + new xdr.ScSpecFunctionInputV0({ + doc: "Different doc", + name: "arg1", + type: xdr.ScSpecTypeDef.scSpecTypeU64(), + }), + ], + outputs: [xdr.ScSpecTypeDef.scSpecTypeBool()], + }), + ); + + const fp1 = await specFingerprint([fn1]); + const fp2 = await specFingerprint([fn2]); + + expect(fp1).toBe(fp2); + }); + + it("added or altered function produces mismatch with readable detail", async () => { + const bundledFn = xdr.ScSpecEntry.scSpecEntryFunctionV0( + new xdr.ScSpecFunctionV0({ + doc: "", + name: "buy", + inputs: [ + new xdr.ScSpecFunctionInputV0({ + doc: "", + name: "trader", + type: xdr.ScSpecTypeDef.scSpecTypeAddress(), + }), + ], + outputs: [], + }), + ); + + const onChainFn = xdr.ScSpecEntry.scSpecEntryFunctionV0( + new xdr.ScSpecFunctionV0({ + doc: "", + name: "buy", + inputs: [ + new xdr.ScSpecFunctionInputV0({ + doc: "", + name: "trader", + type: xdr.ScSpecTypeDef.scSpecTypeAddress(), + }), + new xdr.ScSpecFunctionInputV0({ + doc: "", + name: "min_shares_out", + type: xdr.ScSpecTypeDef.scSpecTypeI128(), + }), + ], + outputs: [], + }), + ); + + const extraFn = xdr.ScSpecEntry.scSpecEntryFunctionV0( + new xdr.ScSpecFunctionV0({ + doc: "", + name: "sweep_fees", + inputs: [], + outputs: [], + }), + ); + + const fpBundled = await specFingerprint([bundledFn]); + const fpOnChain = await specFingerprint([onChainFn, extraFn]); + expect(fpBundled).not.toBe(fpOnChain); + + const diff = diffSpecs([bundledFn], [onChainFn, extraFn]); + expect(diff).toContain("function buy: args differ"); + expect(diff).toContain("function sweep_fees added on-chain"); + }); + + it("wasm parser extracts contractspecv0 from a synthetic WASM binary", () => { + const fn = xdr.ScSpecEntry.scSpecEntryFunctionV0( + new xdr.ScSpecFunctionV0({ + doc: "hello", + name: "synthetic_test_fn", + inputs: [], + outputs: [], + }), + ); + const wasm = buildSyntheticWasm("contractspecv0", fn.toXDR()); + + const entries = parseSpecFromWasm(wasm); + expect(entries.length).toBe(1); + expect(entries[0].switch().name).toBe("scSpecEntryFunctionV0"); + expect((entries[0].value() as xdr.ScSpecFunctionV0).name().toString()).toBe("synthetic_test_fn"); + }); + + it("wasm parser rejects oversized or malformed modules", () => { + const oversized = Buffer.alloc(WASM_MAX_CODE_SIZE + 1); + expect(() => parseWasmCustomSections(oversized)).toThrow(/exceeds maximum allowed size/); + + const invalidMagic = Buffer.from([0x01, 0x02, 0x03, 0x04, 0x01, 0x00, 0x00, 0x00]); + expect(() => parseWasmCustomSections(invalidMagic)).toThrow(/Invalid WASM magic header/); + + const invalidVersion = Buffer.from([0x00, 0x61, 0x73, 0x6d, 0x02, 0x00, 0x00, 0x00]); + expect(() => parseWasmCustomSections(invalidVersion)).toThrow(/Invalid WASM version/); + }); + + it("SAC id check: correct asset matches, wrong issuer mismatches", () => { + const correctIssuer = deployments.deployer; + const wrongIssuer = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; + + const correctId = new Asset("USDC", correctIssuer).contractId(TESTNET_PASSPHRASE); + const wrongId = new Asset("USDC", wrongIssuer).contractId(TESTNET_PASSPHRASE); + + expect(correctId).toBe(CONTRACTS.usdc); + expect(wrongId).not.toBe(CONTRACTS.usdc); + }); + + it("instance executable stellarAsset for a wasm contract is wrong-kind", async () => { + const fakeServer = { + getLedgerEntries: async () => { + // Return stellarAsset executable for marketCore + const instanceData = new xdr.ContractDataEntry({ + contract: new Address(CONTRACTS.marketCore).toScAddress(), + key: xdr.ScVal.scvLedgerKeyContractInstance(), + durability: xdr.ContractDataDurability.persistent(), + val: xdr.ScVal.scvContractInstance( + new xdr.ScContractInstance({ + executable: xdr.ContractExecutable.contractExecutableStellarAsset(), + storage: null, + }), + ), + ext: new xdr.ExtensionPoint(0), + }); + + const key = xdr.LedgerKey.contractData( + new xdr.LedgerKeyContractData({ + contract: new Address(CONTRACTS.marketCore).toScAddress(), + key: xdr.ScVal.scvLedgerKeyContractInstance(), + durability: xdr.ContractDataDurability.persistent(), + }), + ); + + return { + latestLedger: 12345, + entries: [ + { + lastModifiedLedgerSeq: 1000, + liveUntilLedgerSeq: 20000, + key, + val: xdr.LedgerEntryData.contractData(instanceData), + }, + ], + }; + }, + } as unknown as rpc.Server; + + const res = await checkContractIntegrity(fakeServer); + expect(res.marketCore.status).toBe("wrong-kind"); + expect(res.marketCore.detail).toContain("Expected WASM contract"); + }); + + it("archived instance yields unchecked, not not-found", async () => { + const fakeServer = { + getLedgerEntries: async () => { + const instanceData = new xdr.ContractDataEntry({ + contract: new Address(CONTRACTS.marketCore).toScAddress(), + key: xdr.ScVal.scvLedgerKeyContractInstance(), + durability: xdr.ContractDataDurability.persistent(), + val: xdr.ScVal.scvContractInstance( + new xdr.ScContractInstance({ + executable: xdr.ContractExecutable.contractExecutableWasm(Buffer.alloc(32)), + storage: null, + }), + ), + ext: new xdr.ExtensionPoint(0), + }); + + const key = xdr.LedgerKey.contractData( + new xdr.LedgerKeyContractData({ + contract: new Address(CONTRACTS.marketCore).toScAddress(), + key: xdr.ScVal.scvLedgerKeyContractInstance(), + durability: xdr.ContractDataDurability.persistent(), + }), + ); + + return { + latestLedger: 25000, + entries: [ + { + lastModifiedLedgerSeq: 1000, + // liveUntilLedgerSeq < latestLedger implies archived TTL expired + liveUntilLedgerSeq: 20000, + key, + val: xdr.LedgerEntryData.contractData(instanceData), + }, + ], + }; + }, + } as unknown as rpc.Server; + + const res = await checkContractIntegrity(fakeServer); + expect(res.marketCore.status).toBe("unchecked"); + expect(res.marketCore.detail).toBe("archived"); + }); +}); diff --git a/lib/stellar/__tests__/errors-coverage.test.ts b/lib/stellar/__tests__/errors-coverage.test.ts new file mode 100644 index 0000000..b6e30db --- /dev/null +++ b/lib/stellar/__tests__/errors-coverage.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { Errors as MarketCoreErrors } from "../generated/market-core"; +import { Errors as TreasuryErrors } from "../generated/treasury"; +import { Errors as ResolverErrors } from "../generated/resolver"; +import { Errors as ReasoningRegistryErrors } from "../generated/reasoning-registry"; +import { FRIENDLY_NAMES, UNFRIENDLY_ALLOWLIST } from "../errors"; + +const TABLES = { + marketCore: MarketCoreErrors, + treasury: TreasuryErrors, + resolver: ResolverErrors, + reasoningRegistry: ReasoningRegistryErrors, +} as const; + +describe("Contract Errors Coverage (Issue #143)", () => { + it("every contract error name in every binding is in FRIENDLY or UNFRIENDLY_ALLOWLIST", () => { + const allowed = new Set([...FRIENDLY_NAMES, ...UNFRIENDLY_ALLOWLIST]); + const uncovered: { contract: string; code: number; name: string }[] = []; + + for (const [contract, table] of Object.entries(TABLES)) { + for (const [codeStr, entry] of Object.entries(table)) { + const code = Number(codeStr); + const name = (entry as { message: string }).message; + if (!allowed.has(name)) { + uncovered.push({ contract, code, name }); + } + } + } + + if (uncovered.length > 0) { + const details = uncovered.map((u) => `${u.contract} #${u.code} (${u.name})`).join(", "); + expect.fail( + `Found uncovered contract error names without friendly explanations: ${details}. ` + + "Add an entry to FRIENDLY or UNFRIENDLY_ALLOWLIST in lib/stellar/errors.ts.", + ); + } + + expect(uncovered).toEqual([]); + }); +}); diff --git a/lib/stellar/__tests__/identity.test.ts b/lib/stellar/__tests__/identity.test.ts new file mode 100644 index 0000000..ef9bc31 --- /dev/null +++ b/lib/stellar/__tests__/identity.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from "vitest"; +import { toViewedAddress, validateWatchAddress, asSignerAddress } from "../identity"; +import type { Signer } from "../clients"; + +describe("identity - ViewedAddress & SignerAddress", () => { + const VALID_G_ADDRESS = "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; + const VALID_CONTRACT_ID = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; + + it("accepts valid G... account addresses", () => { + const res = validateWatchAddress(VALID_G_ADDRESS); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.address).toBe(VALID_G_ADDRESS); + } + expect(toViewedAddress(VALID_G_ADDRESS)).toBe(VALID_G_ADDRESS); + }); + + it("accepts valid C... contract addresses", () => { + const res = validateWatchAddress(VALID_CONTRACT_ID); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.address).toBe(VALID_CONTRACT_ID); + expect(res.warning).toBeDefined(); + } + expect(toViewedAddress(VALID_CONTRACT_ID)).toBe(VALID_CONTRACT_ID); + }); + + it("rejects lowercase addresses with a descriptive error", () => { + const lower = VALID_G_ADDRESS.toLowerCase(); + const res = validateWatchAddress(lower); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.error).toContain("Address must be uppercase"); + } + expect(toViewedAddress(lower)).toBeNull(); + }); + + it("rejects muxed (M...) addresses with a descriptive error", () => { + // Muxed address starts with M + const muxed = "MA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVAAAAAAAAAAAAAJLK"; + const res = validateWatchAddress(muxed); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.error).toContain("Muxed addresses (M...) are not supported"); + } + expect(toViewedAddress(muxed)).toBeNull(); + }); + + it("rejects invalid checksums", () => { + // Replace the last char with another char to break checksum + const corrupted = VALID_G_ADDRESS.slice(0, -1) + (VALID_G_ADDRESS.endsWith("A") ? "B" : "A"); + const res = validateWatchAddress(corrupted); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.error).toMatch(/Invalid checksum|Invalid Stellar address/); + } + expect(toViewedAddress(corrupted)).toBeNull(); + }); + + it("rejects null, undefined, and empty string", () => { + expect(validateWatchAddress(null).ok).toBe(false); + expect(validateWatchAddress(undefined).ok).toBe(false); + expect(validateWatchAddress("").ok).toBe(false); + expect(toViewedAddress(null)).toBeNull(); + expect(toViewedAddress(undefined)).toBeNull(); + expect(toViewedAddress("")).toBeNull(); + }); + + it("creates SignerAddress strictly from authenticated Signer", () => { + const signer: Signer = { + publicKey: VALID_G_ADDRESS, + signTransaction: async (tx: string) => ({ signedTxXdr: tx }), + }; + const signerAddr = asSignerAddress(signer); + expect(signerAddr).toBe(VALID_G_ADDRESS); + + // Throws on missing or empty signer + expect(() => asSignerAddress(null as unknown as Signer)).toThrow(); + expect(() => asSignerAddress({} as unknown as Signer)).toThrow(); + }); +}); diff --git a/lib/stellar/__tests__/positions.test.ts b/lib/stellar/__tests__/positions.test.ts new file mode 100644 index 0000000..d103ab5 --- /dev/null +++ b/lib/stellar/__tests__/positions.test.ts @@ -0,0 +1,158 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Keypair, rpc } from "@stellar/stellar-sdk"; + +const decodeValue = vi.fn(); +const mockGetPosition = vi.fn(); + +vi.mock("../clients", () => ({ + marketCore: () => ({ + spec: { scValToNative: decodeValue }, + get_position: mockGetPosition, + }), + rpcServer: () => ({ getLedgerEntries: vi.fn() }), +})); + +vi.mock("../typed-call", () => ({ + simulated: vi.fn((val: unknown) => val), +})); + +import { fetchPositionsFor } from "../positions"; +import { marketLedgerKey, positionLedgerKey } from "../ledger-entries"; +import { toViewedAddress, ViewedAddress } from "../identity"; + +interface FakeEntry { + key: { toXDR(format: "base64"): string }; + val: { contractData(): { val(): { id: bigint; kind: "Position" | "Market" } } }; + liveUntilLedgerSeq?: number; +} + +function fakeEntry( + key: FakeEntry["key"], + kind: "Position" | "Market", + id: bigint, + liveUntilLedgerSeq?: number, +): FakeEntry { + return { + key, + val: { contractData: () => ({ val: () => ({ kind, id }) }) }, + ...(liveUntilLedgerSeq === undefined ? {} : { liveUntilLedgerSeq }), + } as FakeEntry; +} + +describe("positions - fetchPositionsFor", () => { + const holder = toViewedAddress(Keypair.random().publicKey())!; + + beforeEach(() => { + decodeValue.mockImplementation((value: { id: bigint; kind: string }) => + value.kind === "Position" + ? value.id === 5n + ? { yes: 0n, no: 0n } // zero position + : { yes: 1_000_000n, no: 500_000n } + : { + close_time: 0n, + sets_minted: 1n, + reserve_yes: 1n, + reserve_no: 1n, + status: { tag: "Open" }, + }, + ); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("encodes DataKey::Position ledger keys properly", () => { + const key = positionLedgerKey(10n, holder); + expect(key.switch().name).toBe("contractData"); + const contractData = key.contractData(); + expect(contractData.durability().name).toBe("persistent"); + }); + + it("batches getLedgerEntries in chunks of 200 and excludes zero positions", async () => { + const marketIds = Array.from({ length: 250 }, (_, i) => BigInt(i)); + const keyToId = new Map(); + for (const id of marketIds) { + keyToId.set(positionLedgerKey(id, holder).toXDR("base64"), id); + keyToId.set(marketLedgerKey(id).toXDR("base64"), id); + } + + const server = { + getLedgerEntries: vi.fn(async (...keys: FakeEntry["key"][]) => { + const entries = keys + .map((k) => { + const id = keyToId.get(k.toXDR("base64")); + if (id === undefined) return null; + return fakeEntry(k, "Position", id); + }) + .filter(Boolean); + return { entries, latestLedger: 1000 }; + }), + }; + + const results = await fetchPositionsFor( + holder, + marketIds, + server as unknown as rpc.Server, + ); + + // 250 market IDs chunked at 200 -> 2 position calls, then market calls for found holdings + expect(server.getLedgerEntries.mock.calls[0].length).toBe(200); + expect(server.getLedgerEntries.mock.calls[1].length).toBe(50); + + // Market 5 had { yes: 0, no: 0 } and must be excluded + expect(results.some((r) => r.marketId === 5n)).toBe(false); + expect(results.length).toBe(249); + // Must be sorted descending (newest first) + expect(results[0].marketId).toBe(249n); + expect(results[results.length - 1].marketId).toBe(0n); + }); + + it("falls back to get_position simulation when ledger-entry decoding throws", async () => { + // Decoding fails for position + decodeValue.mockImplementation((value: { id: bigint; kind: string }) => { + if (value.kind === "Position") { + throw new Error("UDT decode error"); + } + return { + close_time: 0n, + sets_minted: 1n, + reserve_yes: 1n, + reserve_no: 1n, + status: { tag: "Open" }, + }; + }); + + mockGetPosition.mockResolvedValue({ + yes: 2_000_000n, + no: 1_000_000n, + }); + + const marketIds = [42n]; + const key = positionLedgerKey(42n, holder); + const server = { + getLedgerEntries: vi.fn(async () => ({ + entries: [fakeEntry(key, "Position", 42n)], + latestLedger: 1000, + })), + }; + + const results = await fetchPositionsFor( + holder, + marketIds, + server as unknown as rpc.Server, + ); + + expect(mockGetPosition).toHaveBeenCalledWith({ market_id: 42n, holder }); + expect(results.length).toBe(1); + expect(results[0].marketId).toBe(42n); + expect(results[0].yes).toBe(2_000_000n); + }); + + it("returns empty array for empty marketIds or missing holder", async () => { + expect( + await fetchPositionsFor(null as unknown as ViewedAddress, [1n]), + ).toEqual([]); + expect(await fetchPositionsFor(holder, [])).toEqual([]); + }); +}); diff --git a/lib/stellar/__tests__/sdk-major.test.ts b/lib/stellar/__tests__/sdk-major.test.ts new file mode 100644 index 0000000..847ae94 --- /dev/null +++ b/lib/stellar/__tests__/sdk-major.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; +import { validateSdkMajor } from "../sdk-major"; + +describe("SDK Major & Deduplication Guard (Issue #142)", () => { + it("passes when single copy of @stellar/stellar-sdk is installed and majors align", () => { + const input = { + packageLock: { + packages: { + "": { + dependencies: { + "@stellar/stellar-sdk": "^16.3.1", + }, + }, + "node_modules/@stellar/stellar-sdk": { + version: "16.3.1", + }, + }, + }, + bindingsPackage: { + dependencies: { + "@stellar/stellar-sdk": "^16.0.0", + }, + }, + kitPackage: { + peerDependencies: { + "@stellar/stellar-sdk": "^16.0.0", + }, + }, + }; + + const result = validateSdkMajor(input); + expect(result.ok).toBe(true); + expect(result.errors).toEqual([]); + expect(result.appSdkMajor).toBe(16); + expect(result.bindingsSdkMajor).toBe(16); + expect(result.kitSdkMajor).toBe(16); + }); + + it("fails when multiple distinct versions of @stellar/stellar-sdk are installed", () => { + const input = { + packageLock: { + packages: { + "": { + dependencies: { + "@stellar/stellar-sdk": "^17.0.0", + }, + }, + "node_modules/@stellar/stellar-sdk": { + version: "17.1.0", + }, + "node_modules/@creit.tech/stellar-wallets-kit/node_modules/@stellar/stellar-sdk": { + version: "16.3.1", + }, + }, + }, + bindingsPackage: { + dependencies: { + "@stellar/stellar-sdk": "^17.0.0", + }, + }, + }; + + const result = validateSdkMajor(input); + expect(result.ok).toBe(false); + expect(result.errors.some((e) => e.includes("Multiple distinct versions"))).toBe(true); + }); + + it("fails when app SDK major does not match bindings declared SDK major", () => { + const input = { + packageLock: { + packages: { + "": { + dependencies: { + "@stellar/stellar-sdk": "^17.0.0", + }, + }, + "node_modules/@stellar/stellar-sdk": { + version: "17.0.0", + }, + }, + }, + bindingsPackage: { + dependencies: { + "@stellar/stellar-sdk": "^16.0.1", + }, + }, + }; + + const result = validateSdkMajor(input); + expect(result.ok).toBe(false); + expect(result.errors.some((e) => e.includes("does not match bindings declared major version"))).toBe(true); + }); + + it("fails when wallet kit expected SDK major does not match app major", () => { + const input = { + packageLock: { + packages: { + "": { + dependencies: { + "@stellar/stellar-sdk": "^17.0.0", + }, + }, + "node_modules/@stellar/stellar-sdk": { + version: "17.0.0", + }, + }, + }, + bindingsPackage: { + dependencies: { + "@stellar/stellar-sdk": "^17.0.0", + }, + }, + kitPackage: { + peerDependencies: { + "@stellar/stellar-sdk": "^16.3.0", + }, + }, + }; + + const result = validateSdkMajor(input); + expect(result.ok).toBe(false); + expect(result.errors.some((e) => e.includes("Wallet kit expected @stellar/stellar-sdk major"))).toBe(true); + }); +}); diff --git a/lib/stellar/config.ts b/lib/stellar/config.ts index c5890be..5a7b469 100644 --- a/lib/stellar/config.ts +++ b/lib/stellar/config.ts @@ -49,6 +49,16 @@ export const CONTRACTS = { usdc: env(process.env.NEXT_PUBLIC_USDC_CONTRACT_ID, deployments.contracts.usdc, "CONTRACTS.usdc"), } as const; +/** + * Expected USDC asset identifier (CODE:ISSUER). Defaults to USDC:deployer on testnet. + */ +export const USDC_ASSET = env( + process.env.NEXT_PUBLIC_USDC_ASSET, + NETWORK_PASSPHRASE === TESTNET_PASSPHRASE ? `USDC:${deployments.deployer}` : "", + "USDC_ASSET" +); + + /** * Recipient of premium payments. Must match the backend's PAYMENTS_RECIPIENT * or the backend will reject the payment. Defaults to the treasury, like the diff --git a/lib/stellar/contract-check.ts b/lib/stellar/contract-check.ts new file mode 100644 index 0000000..985fb88 --- /dev/null +++ b/lib/stellar/contract-check.ts @@ -0,0 +1,806 @@ +import { Address, Asset, rpc, xdr } from "@stellar/stellar-sdk"; +import { Spec } from "@stellar/stellar-sdk/contract"; +import { + marketCore, + treasury, + resolver, + reasoningRegistry, + token, + rpcServer, +} from "./clients"; +import { + CONTRACTS, + NETWORK_PASSPHRASE, + USDC_ASSET, +} from "./config"; +import { useQuery } from "@tanstack/react-query"; + +export type ContractKey = "marketCore" | "treasury" | "resolver" | "reasoningRegistry"; + +export interface IntegrityResult { + contract: ContractKey | "usdc"; + id: string; + status: "match" | "mismatch" | "not-found" | "wrong-kind" | "unchecked"; + wasmHash?: string; + onChainFingerprint?: string; + bundledFingerprint?: string; + detail?: string; + checkedAtLedger: number; +} + +export const WASM_MAX_CODE_SIZE = 256 * 1024; // 256 KiB cap + +/** + * In-memory fallback cache for wasmHash -> fingerprint when sessionStorage is unavailable (e.g. Node/SSR). + */ +const MEMORY_FINGERPRINT_CACHE = new Map(); + +function getCachedFingerprint(wasmHashHex: string): string | null { + if (typeof window !== "undefined" && window.sessionStorage) { + try { + return window.sessionStorage.getItem(`oracledesk_wasm_fp_${wasmHashHex}`); + } catch { + // Ignore security errors in restricted iframe/browser contexts + } + } + return MEMORY_FINGERPRINT_CACHE.get(wasmHashHex) ?? null; +} + +function setCachedFingerprint(wasmHashHex: string, fingerprint: string): void { + if (typeof window !== "undefined" && window.sessionStorage) { + try { + window.sessionStorage.setItem(`oracledesk_wasm_fp_${wasmHashHex}`, fingerprint); + } catch { + // Ignore quota/security errors + } + } + MEMORY_FINGERPRINT_CACHE.set(wasmHashHex, fingerprint); +} + +/** + * Returns the bundled ScSpecEntry array for the specified Wasm contract. + */ +export function bundledSpec(key: ContractKey): xdr.ScSpecEntry[] { + switch (key) { + case "marketCore": + return marketCore().spec.entries; + case "treasury": + return treasury().spec.entries; + case "resolver": + return resolver().spec.entries; + case "reasoningRegistry": + return reasoningRegistry().spec.entries; + } +} + +function getArm(obj: unknown): string { + if (!obj) return ""; + const o = obj as { arm?: () => string; switch?: () => { name?: string } }; + if (typeof o.arm === "function") return o.arm(); + if (typeof o.switch === "function") return o.switch()?.name ?? ""; + return ""; +} + +/** + * Strips all documentation fields (`doc`) from a spec entry so that documentation changes + * do not alter the cryptographic fingerprint. + */ +export function stripDoc(entry: xdr.ScSpecEntry): xdr.ScSpecEntry { + const arm = getArm(entry); + const val = entry.value(); + + switch (arm) { + case "functionV0": { + const fn = val as xdr.ScSpecFunctionV0; + return xdr.ScSpecEntry.scSpecEntryFunctionV0( + new xdr.ScSpecFunctionV0({ + doc: "", + name: fn.name(), + inputs: fn.inputs().map( + (i) => + new xdr.ScSpecFunctionInputV0({ + doc: "", + name: i.name(), + type: i.type(), + }), + ), + outputs: fn.outputs(), + }), + ); + } + case "udtStructV0": { + const st = val as xdr.ScSpecUdtStructV0; + return xdr.ScSpecEntry.scSpecEntryUdtStructV0( + new xdr.ScSpecUdtStructV0({ + doc: "", + lib: st.lib(), + name: st.name(), + fields: st.fields().map( + (f) => + new xdr.ScSpecUdtStructFieldV0({ + doc: "", + name: f.name(), + type: f.type(), + }), + ), + }), + ); + } + case "udtUnionV0": { + const un = val as xdr.ScSpecUdtUnionV0; + return xdr.ScSpecEntry.scSpecEntryUdtUnionV0( + new xdr.ScSpecUdtUnionV0({ + doc: "", + lib: un.lib(), + name: un.name(), + cases: un.cases().map((c) => { + const cArm = getArm(c); + const cVal = c.value(); + if (cArm === "voidV0") { + const v = cVal as xdr.ScSpecUdtUnionCaseVoidV0; + return xdr.ScSpecUdtUnionCaseV0.scSpecUdtUnionCaseVoidV0( + new xdr.ScSpecUdtUnionCaseVoidV0({ doc: "", name: v.name() }), + ); + } + if (cArm === "tupleV0") { + const t = cVal as xdr.ScSpecUdtUnionCaseTupleV0; + return xdr.ScSpecUdtUnionCaseV0.scSpecUdtUnionCaseTupleV0( + new xdr.ScSpecUdtUnionCaseTupleV0({ doc: "", name: t.name(), type: t.type() }), + ); + } + return c; + }), + }), + ); + } + case "udtEnumV0": { + const en = val as xdr.ScSpecUdtEnumV0; + return xdr.ScSpecEntry.scSpecEntryUdtEnumV0( + new xdr.ScSpecUdtEnumV0({ + doc: "", + lib: en.lib(), + name: en.name(), + cases: en.cases().map( + (cs) => + new xdr.ScSpecUdtEnumCaseV0({ + doc: "", + name: cs.name(), + value: cs.value(), + }), + ), + }), + ); + } + case "udtErrorEnumV0": { + const err = val as xdr.ScSpecUdtErrorEnumV0; + return xdr.ScSpecEntry.scSpecEntryUdtErrorEnumV0( + new xdr.ScSpecUdtErrorEnumV0({ + doc: "", + lib: err.lib(), + name: err.name(), + cases: err.cases().map( + (cs) => + new xdr.ScSpecUdtErrorEnumCaseV0({ + doc: "", + name: cs.name(), + value: cs.value(), + }), + ), + }), + ); + } + case "eventV0": { + const ev = val as xdr.ScSpecEventV0; + return xdr.ScSpecEntry.scSpecEntryEventV0( + new xdr.ScSpecEventV0({ + doc: "", + lib: ev.lib(), + name: ev.name(), + prefixTopics: ev.prefixTopics(), + params: ev.params().map( + (p) => + new xdr.ScSpecEventParamV0({ + doc: "", + name: p.name(), + type: p.type(), + location: p.location(), + }), + ), + dataFormat: ev.dataFormat(), + }), + ); + } + default: + return entry; + } +} + +/** + * Extracts entry name safely for sorting and diffing. + */ +export function getSpecEntryName(entry: xdr.ScSpecEntry): string { + try { + const val = entry.value() as { name?: () => { toString(): string } | string }; + if (val && typeof val.name === "function") { + const n = val.name(); + return typeof n === "string" ? n : n.toString(); + } + } catch { + // Fallback if structure varies + } + return ""; +} + +/** + * Computes a deterministic SHA-256 fingerprint over sorted canonical XDR entries. + */ +export async function specFingerprint(entries: xdr.ScSpecEntry[]): Promise { + const normalized = entries.map(stripDoc); + + // Deterministically sort by (kind, name) + normalized.sort((a, b) => { + const kindOrder = getArm(a).localeCompare(getArm(b)); + if (kindOrder !== 0) return kindOrder; + return getSpecEntryName(a).localeCompare(getSpecEntryName(b)); + }); + + const buffers = normalized.map((e) => e.toXDR()); + const totalLength = buffers.reduce((acc, b) => acc + b.length, 0); + const combined = new Uint8Array(totalLength); + let offset = 0; + for (const b of buffers) { + combined.set(new Uint8Array(b.buffer, b.byteOffset, b.byteLength), offset); + offset += b.byteLength; + } + + const hashBuffer = await globalThis.crypto.subtle.digest("SHA-256", combined); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +/** + * Minimal WebAssembly custom section extractor with LEB128 decoding and 256 KiB cap. + */ +export function parseWasmCustomSections(buffer: Uint8Array | Buffer): Map { + if (buffer.length > WASM_MAX_CODE_SIZE) { + throw new Error(`WASM module exceeds maximum allowed size of ${WASM_MAX_CODE_SIZE} bytes (got ${buffer.length})`); + } + + const sections = new Map(); + let offset = 0; + + const readBytes = (len: number): Uint8Array => { + if (offset + len > buffer.length) throw new Error("Unexpected end of WASM binary"); + const slice = buffer.subarray(offset, offset + len); + offset += len; + return slice; + }; + + const readVarUint32 = (): number => { + let value = 0; + let shift = 0; + while (true) { + if (offset >= buffer.length) throw new Error("Unexpected end of WASM binary reading LEB128"); + const byte = buffer[offset++]; + value |= (byte & 0x7f) << shift; + if ((byte & 0x80) === 0) break; + shift += 7; + if (shift >= 32) throw new Error("Invalid LEB128 integer"); + } + return value >>> 0; + }; + + // Check magic \0asm and version 1 + const magic = readBytes(4); + if (magic[0] !== 0x00 || magic[1] !== 0x61 || magic[2] !== 0x73 || magic[3] !== 0x6d) { + throw new Error("Invalid WASM magic header"); + } + const version = readBytes(4); + if (version[0] !== 0x01 || version[1] !== 0x00 || version[2] !== 0x00 || version[3] !== 0x00) { + throw new Error("Invalid WASM version"); + } + + while (offset < buffer.length) { + const sectionId = buffer[offset++]; + const sectionLength = readVarUint32(); + const sectionStart = offset; + + if (sectionId === 0) { + // Custom section + const nameLen = readVarUint32(); + if (nameLen > 0 && offset + nameLen <= sectionStart + sectionLength) { + const nameBytes = readBytes(nameLen); + const name = new TextDecoder("utf-8", { fatal: true }).decode(nameBytes); + const payloadLength = sectionLength - (offset - sectionStart); + const payload = readBytes(payloadLength); + sections.set(name, payload); + } + } + + offset = sectionStart + sectionLength; + } + + return sections; +} + +/** + * Parses contract spec entries from WASM bytecode. + */ +export function parseSpecFromWasm(wasm: Uint8Array | Buffer): xdr.ScSpecEntry[] { + if (wasm.length > WASM_MAX_CODE_SIZE) { + throw new Error(`WASM code exceeds ${WASM_MAX_CODE_SIZE} bytes`); + } + + // Attempt using Stellar SDK's Spec.fromWasm helper + try { + const wasmBuf = Buffer.isBuffer(wasm) ? wasm : Buffer.from(wasm); + const spec = Spec.fromWasm(wasmBuf); + if (spec && Array.isArray(spec.entries) && spec.entries.length > 0) { + return spec.entries; + } + } catch { + // Fall back to manual section parse + } + + const sections = parseWasmCustomSections(wasm); + const payload = sections.get("contractspecv0"); + if (!payload || payload.length === 0) { + throw new Error("Could not obtain contractspecv0 custom section from WASM"); + } + + // Parse entries via Spec + const spec = new Spec(Buffer.from(payload)); + return spec.entries; +} + +/** + * Compares two spec sets and returns up to 3 human-readable differences. + */ +export function diffSpecs(bundled: xdr.ScSpecEntry[], onChain: xdr.ScSpecEntry[]): string { + const diffs: string[] = []; + + const bundledFns = new Map(); + const onChainFns = new Map(); + + for (const e of bundled) { + if (getArm(e) === "functionV0") { + const fn = e.value() as xdr.ScSpecFunctionV0; + bundledFns.set(fn.name().toString(), fn); + } + } + for (const e of onChain) { + if (getArm(e) === "functionV0") { + const fn = e.value() as xdr.ScSpecFunctionV0; + onChainFns.set(fn.name().toString(), fn); + } + } + + // Check functions on-chain that differ or are missing + for (const [name, onFn] of onChainFns) { + const bFn = bundledFns.get(name); + if (!bFn) { + diffs.push(`function ${name} added on-chain`); + continue; + } + const bInputs = bFn.inputs().map((i) => `${i.name().toString()}`).join(", "); + const onInputs = onFn.inputs().map((i) => `${i.name().toString()}`).join(", "); + if (bInputs !== onInputs) { + diffs.push(`function ${name}: args differ (bundled: (${bInputs}), on-chain: (${onInputs}))`); + } + } + + for (const name of bundledFns.keys()) { + if (!onChainFns.has(name)) { + diffs.push(`function ${name} missing on-chain`); + } + } + + // Check errors + const bundledErrors = new Map(); + const onChainErrors = new Map(); + for (const e of bundled) { + if (getArm(e) === "udtErrorEnumV0") { + const err = e.value() as xdr.ScSpecUdtErrorEnumV0; + for (const cs of err.cases()) { + bundledErrors.set(cs.value(), cs.name().toString()); + } + } + } + for (const e of onChain) { + if (getArm(e) === "udtErrorEnumV0") { + const err = e.value() as xdr.ScSpecUdtErrorEnumV0; + for (const cs of err.cases()) { + onChainErrors.set(cs.value(), cs.name().toString()); + } + } + } + + for (const [code, onName] of onChainErrors) { + const bName = bundledErrors.get(code); + if (!bName) { + diffs.push(`error #${code} (${onName}) added on-chain`); + } else if (bName !== onName) { + diffs.push(`error #${code} renamed from ${bName} to ${onName}`); + } + } + + if (diffs.length === 0) { + return "spec fingerprint mismatch"; + } + + return diffs.slice(0, 3).join("; "); +} + +/** + * Builds the persistent contract instance ledger key for a given contract ID. + */ +export function contractInstanceLedgerKey(contractId: string): xdr.LedgerKey { + return xdr.LedgerKey.contractData( + new xdr.LedgerKeyContractData({ + contract: new Address(contractId).toScAddress(), + key: xdr.ScVal.scvLedgerKeyContractInstance(), + durability: xdr.ContractDataDurability.persistent(), + }), + ); +} + +/** + * Checks integrity of all 4 configured Wasm contracts and the USDC SAC contract. + */ +export async function checkContractIntegrity( + server: rpc.Server = rpcServer(), +): Promise> { + const wasmContracts: ContractKey[] = ["marketCore", "treasury", "resolver", "reasoningRegistry"]; + const results: Partial> = {}; + + // Build the 5 keys: 4 Wasm contracts + USDC + const contractIds: Record = { + marketCore: CONTRACTS.marketCore, + treasury: CONTRACTS.treasury, + resolver: CONTRACTS.resolver, + reasoningRegistry: CONTRACTS.reasoningRegistry, + usdc: CONTRACTS.usdc, + }; + + const keysToFetch: { key: xdr.LedgerKey; name: ContractKey | "usdc"; id: string }[] = []; + for (const name of [...wasmContracts, "usdc" as const]) { + const id = contractIds[name]; + try { + keysToFetch.push({ key: contractInstanceLedgerKey(id), name, id }); + } catch (err) { + results[name] = { + contract: name, + id, + status: "not-found", + detail: `Invalid contract address: ${String(err)}`, + checkedAtLedger: 0, + }; + } + } + + let ledgerResponse: rpc.Api.GetLedgerEntriesResponse; + try { + ledgerResponse = await server.getLedgerEntries(...keysToFetch.map((k) => k.key)); + } catch (err) { + const detail = `RPC getLedgerEntries failed: ${String(err)}`; + for (const item of keysToFetch) { + results[item.name] = { + contract: item.name, + id: item.id, + status: "unchecked", + detail, + checkedAtLedger: 0, + }; + } + return results as Record; + } + + const latestLedger = ledgerResponse.latestLedger; + const entriesByKey = new Map(); + for (const entry of ledgerResponse.entries) { + entriesByKey.set(entry.key.toXDR("base64"), entry); + } + + // Check USDC SAC first + const usdcKey = keysToFetch.find((k) => k.name === "usdc"); + if (usdcKey) { + const entry = entriesByKey.get(usdcKey.key.toXDR("base64")); + if (!entry) { + results.usdc = { + contract: "usdc", + id: usdcKey.id, + status: "not-found", + detail: "USDC contract instance not found on-chain", + checkedAtLedger: latestLedger, + }; + } else if (entry.liveUntilLedgerSeq !== undefined && entry.liveUntilLedgerSeq < latestLedger) { + results.usdc = { + contract: "usdc", + id: usdcKey.id, + status: "unchecked", + detail: "archived", + checkedAtLedger: latestLedger, + }; + } else { + const contractData = entry.val.contractData(); + const instance = contractData.val().instance(); + const executable = instance.executable(); + + if (executable.switch().name !== "contractExecutableStellarAsset") { + results.usdc = { + contract: "usdc", + id: usdcKey.id, + status: "wrong-kind", + detail: `Expected Stellar Asset Contract, got ${getArm(executable)}`, + checkedAtLedger: latestLedger, + }; + } else { + // Validate against expected NEXT_PUBLIC_USDC_ASSET + const assetStr = USDC_ASSET; + if (!assetStr || !assetStr.includes(":")) { + results.usdc = { + contract: "usdc", + id: usdcKey.id, + status: "mismatch", + detail: "NEXT_PUBLIC_USDC_ASSET must be in CODE:ISSUER format", + checkedAtLedger: latestLedger, + }; + } else { + const [code, issuer] = assetStr.split(":"); + try { + const expectedId = new Asset(code, issuer).contractId(NETWORK_PASSPHRASE); + if (expectedId !== usdcKey.id) { + results.usdc = { + contract: "usdc", + id: usdcKey.id, + status: "mismatch", + detail: `Expected SAC contract id ${expectedId} for ${assetStr}, got ${usdcKey.id}`, + checkedAtLedger: latestLedger, + }; + } else { + // Simulate SAC name() as belt-and-braces + try { + const nameTx = await token.name(usdcKey.id); + const simResult = nameTx.result; + if (simResult && simResult !== assetStr) { + results.usdc = { + contract: "usdc", + id: usdcKey.id, + status: "mismatch", + detail: `SAC simulated name '${simResult}' does not match expected '${assetStr}'`, + checkedAtLedger: latestLedger, + }; + } else { + results.usdc = { + contract: "usdc", + id: usdcKey.id, + status: "match", + checkedAtLedger: latestLedger, + }; + } + } catch { + // If name simulation fails, still match based on verified on-chain SAC executable + contractId + results.usdc = { + contract: "usdc", + id: usdcKey.id, + status: "match", + checkedAtLedger: latestLedger, + }; + } + } + } catch (err) { + results.usdc = { + contract: "usdc", + id: usdcKey.id, + status: "mismatch", + detail: `Error verifying SAC asset: ${String(err)}`, + checkedAtLedger: latestLedger, + }; + } + } + } + } + } + + // Wasm contracts processing + const unknownWasmHashes = new Map(); + + for (const name of wasmContracts) { + const item = keysToFetch.find((k) => k.name === name); + if (!item) continue; + + const entry = entriesByKey.get(item.key.toXDR("base64")); + if (!entry) { + results[name] = { + contract: name, + id: item.id, + status: "not-found", + detail: "Contract instance not found on-chain (network may have been reset)", + checkedAtLedger: latestLedger, + }; + continue; + } + + if (entry.liveUntilLedgerSeq !== undefined && entry.liveUntilLedgerSeq < latestLedger) { + results[name] = { + contract: name, + id: item.id, + status: "unchecked", + detail: "archived", + checkedAtLedger: latestLedger, + }; + continue; + } + + const contractData = entry.val.contractData(); + const instance = contractData.val().instance(); + const executable = instance.executable(); + + if (executable.switch().name !== "contractExecutableWasm") { + results[name] = { + contract: name, + id: item.id, + status: "wrong-kind", + detail: `Expected WASM contract, got ${getArm(executable)}`, + checkedAtLedger: latestLedger, + }; + continue; + } + + const wasmHashBuf = executable.wasmHash(); + const wasmHashHex = wasmHashBuf.toString("hex"); + + const cachedFp = getCachedFingerprint(wasmHashHex); + const bundledEntries = bundledSpec(name); + const bundledFp = await specFingerprint(bundledEntries); + + if (cachedFp) { + if (cachedFp === bundledFp) { + results[name] = { + contract: name, + id: item.id, + status: "match", + wasmHash: wasmHashHex, + onChainFingerprint: cachedFp, + bundledFingerprint: bundledFp, + checkedAtLedger: latestLedger, + }; + } else { + results[name] = { + contract: name, + id: item.id, + status: "mismatch", + wasmHash: wasmHashHex, + onChainFingerprint: cachedFp, + bundledFingerprint: bundledFp, + detail: "On-chain contract spec differs from bundled bindings", + checkedAtLedger: latestLedger, + }; + } + } else { + const existing = unknownWasmHashes.get(wasmHashHex); + if (existing) { + existing.contracts.push(name); + } else { + unknownWasmHashes.set(wasmHashHex, { hash: wasmHashBuf, contracts: [name] }); + } + } + } + + // Fetch contract code for unknown Wasm hashes in one batched call + if (unknownWasmHashes.size > 0) { + const codeKeys: xdr.LedgerKey[] = []; + const hashHexByBase64 = new Map(); + + for (const [hashHex, { hash }] of unknownWasmHashes) { + const codeKey = xdr.LedgerKey.contractCode(new xdr.LedgerKeyContractCode({ hash })); + codeKeys.push(codeKey); + hashHexByBase64.set(codeKey.toXDR("base64"), hashHex); + } + + try { + const codeResponse = await server.getLedgerEntries(...codeKeys); + for (const entry of codeResponse.entries) { + const hashHex = hashHexByBase64.get(entry.key.toXDR("base64")); + if (!hashHex) continue; + + const info = unknownWasmHashes.get(hashHex); + if (!info) continue; + + const codeEntry = entry.val.contractCode(); + const wasmBytes = codeEntry.code(); + + let onChainEntries: xdr.ScSpecEntry[]; + try { + onChainEntries = parseSpecFromWasm(wasmBytes); + } catch (err) { + for (const contractKey of info.contracts) { + results[contractKey] = { + contract: contractKey, + id: contractIds[contractKey], + status: "mismatch", + wasmHash: hashHex, + detail: `Failed to extract spec from on-chain WASM: ${String(err)}`, + checkedAtLedger: latestLedger, + }; + } + continue; + } + + const onChainFp = await specFingerprint(onChainEntries); + setCachedFingerprint(hashHex, onChainFp); + + for (const contractKey of info.contracts) { + const bEntries = bundledSpec(contractKey); + const bFp = await specFingerprint(bEntries); + + if (onChainFp === bFp) { + results[contractKey] = { + contract: contractKey, + id: contractIds[contractKey], + status: "match", + wasmHash: hashHex, + onChainFingerprint: onChainFp, + bundledFingerprint: bFp, + checkedAtLedger: latestLedger, + }; + } else { + const detail = diffSpecs(bEntries, onChainEntries); + results[contractKey] = { + contract: contractKey, + id: contractIds[contractKey], + status: "mismatch", + wasmHash: hashHex, + onChainFingerprint: onChainFp, + bundledFingerprint: bFp, + detail, + checkedAtLedger: latestLedger, + }; + } + } + } + + // Any contract whose code entry was not returned + for (const [hashHex, info] of unknownWasmHashes) { + for (const contractKey of info.contracts) { + if (!results[contractKey]) { + results[contractKey] = { + contract: contractKey, + id: contractIds[contractKey], + status: "not-found", + wasmHash: hashHex, + detail: "Contract WASM code entry not found on-chain", + checkedAtLedger: latestLedger, + }; + } + } + } + } catch (err) { + for (const [, info] of unknownWasmHashes) { + for (const contractKey of info.contracts) { + results[contractKey] = { + contract: contractKey, + id: contractIds[contractKey], + status: "unchecked", + detail: `Failed to fetch WASM code: ${String(err)}`, + checkedAtLedger: latestLedger, + }; + } + } + } + } + + return results as Record; +} + +/** + * React Query hook providing contract integrity status. + */ +export function useContractIntegrity() { + return useQuery({ + queryKey: ["stellar", "integrity", NETWORK_PASSPHRASE], + queryFn: () => checkContractIntegrity(), + staleTime: Infinity, + refetchInterval: 10 * 60 * 1000, // 10 minutes + }); +} diff --git a/lib/stellar/errors.ts b/lib/stellar/errors.ts index d4207b2..7b99ce8 100644 --- a/lib/stellar/errors.ts +++ b/lib/stellar/errors.ts @@ -61,9 +61,21 @@ export const FRIENDLY: Record = { DailyCapExceeded: "The treasury has reached its daily limit.", InsufficientBalance: "You don't have enough USDC for this.", // resolver + MarketAlreadyConfigured: "This market's resolution rule has already been configured.", MarketNotConfigured: "This market's resolution rule hasn't been revealed yet.", SpecHashMismatch: "That resolution rule doesn't match what this market committed to.", DisputeWindowOpen: "The dispute window is still open.", + InvalidPrice: "The oracle returned an invalid price for this asset.", + NotSigner: "Your wallet isn't one of this market's signers.", + AlreadyAttested: "You've already attested this outcome for this market.", + ThresholdNotReached: "Not enough signers have attested this outcome yet.", + DisputeActive: "A dispute is active for this market's resolution.", + NoProposal: "There's no resolution proposal for this market yet.", + AlreadyFinalized: "This market has already been finalized.", + MarketNotClosed: "This market hasn't closed yet.", + WrongMode: "This action doesn't match this market's resolution mode.", + StalePrice: "The price feed is too stale to resolve this market.", + PriceUnavailable: "No price is available to resolve this market yet.", // reasoning-registry TraceNotFound: "This reasoning trace isn't on-chain.", AgentAlreadyRegistered: "This agent is already registered.", @@ -74,8 +86,13 @@ export const FRIENDLY: Record = { // shared across contracts NotInitialized: "This contract hasn't been initialized yet.", InvalidFee: "Fees can be at most 10% (1000 bps).", + InvalidProbability: "Initial probabilities must add up to 100%.", + InvalidCloseTime: "The market's close time must be in the future.", }; +export const FRIENDLY_NAMES = Object.keys(FRIENDLY) as readonly string[]; +export const UNFRIENDLY_ALLOWLIST = ["NotInitialized", "InvalidConfig"] as const; + import type { rpc } from "@stellar/stellar-sdk"; export interface DescribedError { diff --git a/lib/stellar/identity.ts b/lib/stellar/identity.ts new file mode 100644 index 0000000..b2d2acd --- /dev/null +++ b/lib/stellar/identity.ts @@ -0,0 +1,74 @@ +import { StrKey } from "@stellar/stellar-sdk"; +import type { Signer } from "./clients"; + +declare const viewed: unique symbol; +declare const signerBrand: unique symbol; + +export type ViewedAddress = string & { readonly [viewed]: true }; +export type SignerAddress = string & { readonly [signerBrand]: true }; + +export type WatchAddressValidation = + | { ok: true; address: ViewedAddress; warning?: string } + | { ok: false; error: string }; + +/** + * Validates a watch address string with user-friendly error messages for + * invalid format, lowercase characters, or unsupported muxed addresses. + */ +export function validateWatchAddress(s: string | null | undefined): WatchAddressValidation { + if (!s || typeof s !== "string" || s.trim().length === 0) { + return { ok: false, error: "Please enter a valid Stellar address." }; + } + + const trimmed = s.trim(); + + // StrKey is case-sensitive; uppercase check is strictly enforced + if (trimmed !== trimmed.toUpperCase()) { + return { + ok: false, + error: "Address must be uppercase. Stellar public keys and contract IDs are case-sensitive.", + }; + } + + // Muxed addresses are not valid holders for market-core + if (trimmed.startsWith("M")) { + return { + ok: false, + error: "Muxed addresses (M...) are not supported. Please use the underlying G... account address.", + }; + } + + try { + if (StrKey.isValidEd25519PublicKey(trimmed)) { + return { ok: true, address: trimmed as ViewedAddress }; + } + if (StrKey.isValidContract(trimmed)) { + return { ok: true, address: trimmed as ViewedAddress, warning: "Contract address: display is limited to available on-chain data." }; + } + } catch { + return { ok: false, error: "Invalid checksum for Stellar address." }; + } + + return { ok: false, error: "Invalid Stellar address. Expected a G... account or C... contract ID." }; +} + +/** + * Validates and brands an address as a ViewedAddress. + * Accepts only valid Ed25519 public keys (G...) and Soroban contracts (C...). + * Rejects muxed (M...) addresses, lowercase addresses, and invalid checksums. + */ +export function toViewedAddress(s: string | null | undefined): ViewedAddress | null { + const result = validateWatchAddress(s); + return result.ok ? result.address : null; +} + +/** + * Constructs a branded SignerAddress from an authenticated Signer. + * Only call this inside useWriteGuard / assertCanWrite. + */ +export function asSignerAddress(signer: Signer): SignerAddress { + if (!signer || !signer.publicKey) { + throw new Error("Cannot create SignerAddress from null or invalid signer"); + } + return signer.publicKey as SignerAddress; +} diff --git a/lib/stellar/positions.ts b/lib/stellar/positions.ts new file mode 100644 index 0000000..1000a30 --- /dev/null +++ b/lib/stellar/positions.ts @@ -0,0 +1,143 @@ +import { rpc } from "@stellar/stellar-sdk"; +import { marketCore, rpcServer } from "./clients"; +import { simulated } from "./typed-call"; +import type { ViewedAddress } from "./identity"; +import type { Market, Position } from "./generated/market-core"; +import { + chunks, + isMarket, + isPosition, + marketLedgerKey, + positionLedgerKey, + readUdt, + settlementOf, + LEDGER_ENTRY_CHUNK_SIZE, +} from "./ledger-entries"; +import { positionValue, type HolderPosition } from "./my-positions"; + +export type { HolderPosition }; + +/** + * Fallback to per-market get_position contract simulation if ledger-entry + * decoding fails or encounters an unreadable format. + */ +export async function fallbackGetPosition( + marketId: bigint, + holder: string, +): Promise { + try { + const tx = await marketCore().get_position({ market_id: marketId, holder }); + const pos = simulated(tx) as Position; + return isPosition(pos) ? pos : null; + } catch { + return null; + } +} + +/** + * Reads positions for a ViewedAddress across specified market IDs using batched + * getLedgerEntries (up to 200 keys per RPC round trip). + * + * Gracefully falls back to get_position simulation if ledger-entry decoding fails. + * Filters out zero positions (yes == 0 and no == 0). + */ +export async function fetchPositionsFor( + holder: ViewedAddress, + marketIds: bigint[], + server: rpc.Server = rpcServer(), +): Promise { + if (!holder || marketIds.length === 0) return []; + + const found = new Map(); + + for (const idChunk of chunks(marketIds, LEDGER_ENTRY_CHUNK_SIZE)) { + const keys = idChunk.map((id) => ({ id, key: positionLedgerKey(id, holder) })); + let entries: rpc.Api.LedgerEntryResult[] = []; + try { + const response = await server.getLedgerEntries(...keys.map(({ key }) => key)); + entries = response.entries; + } catch { + // If batched getLedgerEntries fails, fall back to individual simulation for this chunk + for (const { id } of keys) { + const simPos = await fallbackGetPosition(id, holder); + if (simPos && (simPos.yes > 0n || simPos.no > 0n)) { + found.set(id, simPos); + } + } + continue; + } + + const idByKey = new Map(keys.map(({ id, key }) => [key.toXDR("base64"), id])); + for (const entry of entries) { + const id = idByKey.get(entry.key.toXDR("base64")); + if (id === undefined) continue; + + let position: Position | null = null; + try { + const udt = readUdt("Position", entry.val.contractData().val()); + if (isPosition(udt)) { + position = udt; + } + } catch { + // Fallback to contract simulation on decode failure + position = await fallbackGetPosition(id, holder); + } + + if (position && (position.yes > 0n || position.no > 0n)) { + found.set(id, position); + } + } + } + + if (found.size === 0) return []; + + // Batch read market entries for markets where user holds positions + const holdingIds = [...found.keys()]; + const markets = new Map(); + const archivedIds = new Map(); + let latestLedger = 0; + + for (const idChunk of chunks(holdingIds, LEDGER_ENTRY_CHUNK_SIZE)) { + const keys = idChunk.map((id) => ({ id, key: marketLedgerKey(id) })); + try { + const response = await server.getLedgerEntries(...keys.map(({ key }) => key)); + latestLedger = Math.max(latestLedger, response.latestLedger); + const idByKey = new Map(keys.map(({ id, key }) => [key.toXDR("base64"), id])); + for (const entry of response.entries) { + const id = idByKey.get(entry.key.toXDR("base64")); + if (id === undefined) continue; + try { + const market = readUdt("Market", entry.val.contractData().val()); + if (isMarket(market)) { + markets.set(id, market); + archivedIds.set(id, entry.liveUntilLedgerSeq !== undefined && entry.liveUntilLedgerSeq < latestLedger); + } + } catch { + // ignore unreadable market entries + } + } + } catch { + // ignore getLedgerEntries errors for markets + } + } + + const positions: HolderPosition[] = [...found.entries()] + .map(([marketId, position]) => { + const market = markets.get(marketId) ?? null; + const settlement = market ? settlementOf(market.status) : null; + const valuation = market ? positionValue(position, market) : null; + return { + marketId, + yes: position.yes, + no: position.no, + market, + settlement, + valueRaw: valuation?.valueRaw ?? null, + yesBps: valuation?.yesBps ?? null, + archived: archivedIds.get(marketId) ?? false, + }; + }) + .sort((a, b) => (a.marketId < b.marketId ? 1 : a.marketId > b.marketId ? -1 : 0)); + + return positions; +} diff --git a/lib/stellar/sdk-major.ts b/lib/stellar/sdk-major.ts new file mode 100644 index 0000000..f3919b7 --- /dev/null +++ b/lib/stellar/sdk-major.ts @@ -0,0 +1,124 @@ +export interface PackageLockData { + packages?: Record< + string, + { + version?: string; + dependencies?: Record; + peerDependencies?: Record; + } + >; +} + +export interface PackageJsonData { + version?: string; + dependencies?: Record; + peerDependencies?: Record; + oracledeskSdkMajor?: number; +} + +export interface CheckSdkMajorInput { + packageLock: PackageLockData; + bindingsPackage?: PackageJsonData | null; + kitPackage?: PackageJsonData | null; +} + +export interface CheckSdkMajorResult { + ok: boolean; + appSdkMajor?: number; + bindingsSdkMajor?: number; + kitSdkMajor?: number; + installedSdkVersions: string[]; + errors: string[]; +} + +function parseMajor(rangeOrVersion: string | undefined | null): number | null { + if (!rangeOrVersion) return null; + const clean = rangeOrVersion.trim().replace(/^[v^~>=<\s]+/, ""); + const m = clean.match(/^(\d+)/); + return m ? parseInt(m[1], 10) : null; +} + +/** + * Validates that exactly one copy of @stellar/stellar-sdk is installed, and that + * the app's SDK major version aligns with the bindings' and wallet kit's expected major. + */ +export function validateSdkMajor(input: CheckSdkMajorInput): CheckSdkMajorResult { + const errors: string[] = []; + const packages = input.packageLock.packages || {}; + + // 1. Scan for all installed copies of @stellar/stellar-sdk + const installedSdkVersionsSet = new Set(); + const dependentPaths: string[] = []; + + for (const [pkgPath, pkgInfo] of Object.entries(packages)) { + // Hardware wallet internals (e.g. Trezor, Ledger) carry separate transitive versions per docs/architecture.md + if (pkgPath.includes("@trezor") || pkgPath.includes("ledger-live") || pkgPath.includes("@ledgerhq")) { + continue; + } + if (pkgPath.endsWith("node_modules/@stellar/stellar-sdk") && pkgInfo.version) { + installedSdkVersionsSet.add(pkgInfo.version); + dependentPaths.push(`${pkgPath} (${pkgInfo.version})`); + } + } + + const installedSdkVersions = Array.from(installedSdkVersionsSet); + if (installedSdkVersions.length > 1) { + errors.push( + `Multiple distinct versions of @stellar/stellar-sdk installed: ${installedSdkVersions.join(", ")}. Dependents:\n ` + + dependentPaths.join("\n "), + ); + } + + // 2. Identify app SDK major + const rootPkg = packages[""] || {}; + const appSdkRange = + rootPkg.dependencies?.["@stellar/stellar-sdk"] || + installedSdkVersions[0] || + "^16.3.1"; + const appSdkMajor = parseMajor(appSdkRange) ?? 16; + + // 3. Compare with bindings declared SDK major + let bindingsSdkMajor: number | undefined; + if (input.bindingsPackage) { + if (typeof input.bindingsPackage.oracledeskSdkMajor === "number") { + bindingsSdkMajor = input.bindingsPackage.oracledeskSdkMajor; + } else { + const bRange = + input.bindingsPackage.dependencies?.["@stellar/stellar-sdk"] || + input.bindingsPackage.peerDependencies?.["@stellar/stellar-sdk"]; + const parsed = parseMajor(bRange); + if (parsed !== null) bindingsSdkMajor = parsed; + } + + if (bindingsSdkMajor !== undefined && bindingsSdkMajor !== appSdkMajor) { + errors.push( + `App @stellar/stellar-sdk major version (${appSdkMajor}) does not match bindings declared major version (${bindingsSdkMajor}).`, + ); + } + } + + // 4. Compare with wallet kit expected major + let kitSdkMajor: number | undefined; + if (input.kitPackage) { + const kRange = + input.kitPackage.dependencies?.["@stellar/stellar-sdk"] || + input.kitPackage.peerDependencies?.["@stellar/stellar-sdk"]; + const parsed = parseMajor(kRange); + if (parsed !== null) kitSdkMajor = parsed; + + if (kitSdkMajor !== undefined && kitSdkMajor !== appSdkMajor) { + errors.push( + `Wallet kit expected @stellar/stellar-sdk major (${kitSdkMajor}) does not match app SDK major (${appSdkMajor}).`, + ); + } + } + + return { + ok: errors.length === 0, + appSdkMajor, + bindingsSdkMajor, + kitSdkMajor, + installedSdkVersions, + errors, + }; +} diff --git a/lib/stellar/write-guard.ts b/lib/stellar/write-guard.ts new file mode 100644 index 0000000..c5e1fb6 --- /dev/null +++ b/lib/stellar/write-guard.ts @@ -0,0 +1,60 @@ +import { UserFacingError } from "./errors"; +import type { Signer } from "./clients"; +import { asSignerAddress, type SignerAddress } from "./identity"; +import type { IntegrityResult, ContractKey } from "./contract-check"; + +export interface WriteGuardContext { + signer: Signer | null; + isWrongNetwork: boolean; + isWatching?: boolean; + watchedAddress?: string | null; + accountFunded?: boolean; + updatePending?: boolean; + integrity?: Record | null; + target?: ContractKey | "usdc"; +} + +/** + * Pure assertion validating all write prerequisites: + * 1. App bundle is up to date (no pending service worker update) + * 2. Wallet is connected + * 3. Connected wallet is on the correct network (Testnet) + * 4. User is not currently in watch-only mode for another address + * 5. On-chain contract specification matches bundled bindings + * 6. Account is funded with XLM + * + * Returns a branded SignerAddress. + */ +export function assertCanWrite(ctx: WriteGuardContext): SignerAddress { + if (ctx.updatePending) { + throw new UserFacingError({ kind: "unknown", message: "Reload to update OracleDesk before signing." }); + } + if (!ctx.signer) { + throw new UserFacingError({ kind: "disconnected", message: "Connect your wallet first." }); + } + if (ctx.isWrongNetwork) { + throw new UserFacingError({ kind: "wrong-network", message: "Switch your wallet to Stellar Testnet and try again." }); + } + if (ctx.isWatching && ctx.watchedAddress && ctx.watchedAddress !== ctx.signer.publicKey) { + throw new UserFacingError({ + kind: "disconnected", + message: "You're viewing another address. Connect your own wallet to trade.", + }); + } + if (ctx.integrity && ctx.target) { + const res = ctx.integrity[ctx.target]; + if (res && (res.status === "mismatch" || res.status === "not-found" || res.status === "wrong-kind")) { + throw new UserFacingError({ + kind: "contract", + message: "This app is out of date with the deployed contracts. Trading is disabled until it's updated.", + }); + } + } + if (ctx.accountFunded === false) { + throw new UserFacingError({ + kind: "account-not-found", + message: "Your Stellar account isn't activated yet. Fund it with XLM first.", + }); + } + return asSignerAddress(ctx.signer); +} diff --git a/package-lock.json b/package-lock.json index c688036..a556de9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,8 @@ "@stellar/stellar-sdk": "^16.3.1", "@tanstack/react-query": "^5.100.10", "@tanstack/react-virtual": "3.14.21", + "@x402/core": "^2.26.0", + "@x402/stellar": "^2.26.0", "axios": "^1.16.1", "buffer": "^6.0.3", "class-variance-authority": "^0.7.1", @@ -884,6 +886,21 @@ } } }, + "node_modules/@creit.tech/stellar-wallets-kit/node_modules/typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, "node_modules/@creit.tech/xbull-wallet-connect": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@creit.tech/xbull-wallet-connect/-/xbull-wallet-connect-0.4.0.tgz", @@ -10920,6 +10937,21 @@ "ws": "^7.5.1" } }, + "node_modules/@walletconnect/jsonrpc-ws-connection/node_modules/utf-8-validate": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", + "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, "node_modules/@walletconnect/jsonrpc-ws-connection/node_modules/ws": { "version": "7.5.13", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", @@ -11414,6 +11446,37 @@ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "license": "0BSD" }, + "node_modules/@x402/core": { + "version": "2.26.0", + "resolved": "https://registry.npmjs.org/@x402/core/-/core-2.26.0.tgz", + "integrity": "sha512-8dlXip9u0JjyELzzFz48vntuMajvUjA8IYpitfaW423m53bR49amZDIn3n4gXRYoNEKzGB9Rtn8at35WHz61cA==", + "license": "Apache-2.0", + "dependencies": { + "zod": "^3.24.2" + } + }, + "node_modules/@x402/core/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@x402/stellar": { + "version": "2.26.0", + "resolved": "https://registry.npmjs.org/@x402/stellar/-/stellar-2.26.0.tgz", + "integrity": "sha512-kyAwdRmHgme8sHVAuNwR2pCqBc4CQ4H1cfDFeR7eVsLKo4BpiPGfnVH5Bf24XlgRFkWz8Svj8QT7+8rtvt6NBg==", + "license": "Apache-2.0", + "dependencies": { + "@stellar/stellar-sdk": "^16.3.0", + "@x402/core": "~2.26.0" + }, + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/@xrplf/isomorphic": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@xrplf/isomorphic/-/isomorphic-1.0.2.tgz", @@ -17765,6 +17828,21 @@ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "license": "MIT" }, + "node_modules/jayson/node_modules/utf-8-validate": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", + "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, "node_modules/jayson/node_modules/ws": { "version": "7.5.13", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", @@ -18333,6 +18411,22 @@ "semver": "bin/semver" } }, + "node_modules/lighthouse/node_modules/utf-8-validate": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", + "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, "node_modules/lighthouse/node_modules/ws": { "version": "7.5.13", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", diff --git a/package.json b/package.json index cc79fd1..1dd9a3b 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,8 @@ "test": "vitest run", "sync:bindings": "node scripts/sync-bindings.mjs", "check:bindings": "node scripts/sync-bindings.mjs --check", + "check:sdk": "node scripts/check-sdk-major.mjs", + "spec:diff": "node scripts/spec-diff.mjs", "e2e:testnet": "vitest run --config vitest.e2e.config.mts", "e2e:x402": "vitest run --config vitest.x402.config.mts", "check:x402": "node scripts/check-x402-versions.mjs", @@ -23,6 +25,8 @@ "@stellar/stellar-sdk": "^16.3.1", "@tanstack/react-query": "^5.100.10", "@tanstack/react-virtual": "3.14.21", + "@x402/core": "^2.26.0", + "@x402/stellar": "^2.26.0", "axios": "^1.16.1", "buffer": "^6.0.3", "class-variance-authority": "^0.7.1", diff --git a/scripts/__tests__/spec-diff.test.ts b/scripts/__tests__/spec-diff.test.ts new file mode 100644 index 0000000..bdb25d3 --- /dev/null +++ b/scripts/__tests__/spec-diff.test.ts @@ -0,0 +1,277 @@ +import { describe, expect, it } from "vitest"; +import { xdr } from "@stellar/stellar-sdk"; +import { normalize, readSpecEntries } from "../lib/spec.mjs"; +import { diffSpecs, scanReferences } from "../spec-diff.mjs"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +describe("Contract Spec Diff & Submodule Change Reporter (Issue #143)", () => { + it("detects added and removed functions", () => { + const fn1 = xdr.ScSpecEntry.scSpecEntryFunctionV0( + new xdr.ScSpecFunctionV0({ doc: "", name: "existing_fn", inputs: [], outputs: [] }), + ); + const fnRemoved = xdr.ScSpecEntry.scSpecEntryFunctionV0( + new xdr.ScSpecFunctionV0({ doc: "", name: "old_fn", inputs: [], outputs: [] }), + ); + const fnAdded = xdr.ScSpecEntry.scSpecEntryFunctionV0( + new xdr.ScSpecFunctionV0({ doc: "", name: "new_fn", inputs: [], outputs: [] }), + ); + + const oldNorm = normalize([fn1, fnRemoved]); + const newNorm = normalize([fn1, fnAdded]); + + const diff = diffSpecs("marketCore", oldNorm, newNorm); + + expect(diff).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: "fn-removed", contract: "marketCore", fn: "old_fn" }), + expect.objectContaining({ kind: "fn-added", contract: "marketCore", fn: "new_fn" }), + ]), + ); + }); + + it("detects argument rename and type change", () => { + const oldFn = xdr.ScSpecEntry.scSpecEntryFunctionV0( + new xdr.ScSpecFunctionV0({ + doc: "", + name: "buy", + inputs: [ + new xdr.ScSpecFunctionInputV0({ + doc: "", + name: "market_id", + type: xdr.ScSpecTypeDef.scSpecTypeU64(), + }), + ], + outputs: [], + }), + ); + + const newFn = xdr.ScSpecEntry.scSpecEntryFunctionV0( + new xdr.ScSpecFunctionV0({ + doc: "", + name: "buy", + inputs: [ + new xdr.ScSpecFunctionInputV0({ + doc: "", + name: "id", // renamed + type: xdr.ScSpecTypeDef.scSpecTypeU128(), // type changed + }), + ], + outputs: [], + }), + ); + + const diff = diffSpecs("marketCore", normalize([oldFn]), normalize([newFn])); + + expect(diff).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "fn-signature", + contract: "marketCore", + fn: "buy", + before: "(market_id: u64) -> void", + after: "(id: u128) -> void", + }), + ]), + ); + }); + + it("detects error added, removed, and renumbered", () => { + const oldErrorEnum = xdr.ScSpecEntry.scSpecEntryUdtErrorEnumV0( + new xdr.ScSpecUdtErrorEnumV0({ + doc: "", + lib: "", + name: "Errors", + cases: [ + new xdr.ScSpecUdtErrorEnumCaseV0({ doc: "", name: "Paused", value: 1 }), + new xdr.ScSpecUdtErrorEnumCaseV0({ doc: "", name: "SlippageExceeded", value: 2 }), + new xdr.ScSpecUdtErrorEnumCaseV0({ doc: "", name: "WillBeRemoved", value: 3 }), + ], + }), + ); + + const newErrorEnum = xdr.ScSpecEntry.scSpecEntryUdtErrorEnumV0( + new xdr.ScSpecUdtErrorEnumV0({ + doc: "", + lib: "", + name: "Errors", + cases: [ + new xdr.ScSpecUdtErrorEnumCaseV0({ doc: "", name: "Paused", value: 1 }), + new xdr.ScSpecUdtErrorEnumCaseV0({ doc: "", name: "SlippageExceeded", value: 5 }), // renumbered from 2 to 5 + new xdr.ScSpecUdtErrorEnumCaseV0({ doc: "", name: "NewError", value: 4 }), // added + ], + }), + ); + + const diff = diffSpecs("marketCore", normalize([oldErrorEnum]), normalize([newErrorEnum])); + + expect(diff).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "error-renumbered", + contract: "marketCore", + name: "SlippageExceeded", + from: 2, + to: 5, + }), + expect.objectContaining({ + kind: "error-added", + contract: "marketCore", + code: 4, + name: "NewError", + }), + expect.objectContaining({ + kind: "error-removed", + contract: "marketCore", + code: 3, + name: "WillBeRemoved", + }), + ]), + ); + }); + + it("detects union case added (Category gains Science)", () => { + const oldUnion = xdr.ScSpecEntry.scSpecEntryUdtUnionV0( + new xdr.ScSpecUdtUnionV0({ + doc: "", + lib: "", + name: "Category", + cases: [ + xdr.ScSpecUdtUnionCaseV0.scSpecUdtUnionCaseVoidV0( + new xdr.ScSpecUdtUnionCaseVoidV0({ doc: "", name: "Crypto" }), + ), + xdr.ScSpecUdtUnionCaseV0.scSpecUdtUnionCaseVoidV0( + new xdr.ScSpecUdtUnionCaseVoidV0({ doc: "", name: "Macro" }), + ), + ], + }), + ); + + const newUnion = xdr.ScSpecEntry.scSpecEntryUdtUnionV0( + new xdr.ScSpecUdtUnionV0({ + doc: "", + lib: "", + name: "Category", + cases: [ + xdr.ScSpecUdtUnionCaseV0.scSpecUdtUnionCaseVoidV0( + new xdr.ScSpecUdtUnionCaseVoidV0({ doc: "", name: "Crypto" }), + ), + xdr.ScSpecUdtUnionCaseV0.scSpecUdtUnionCaseVoidV0( + new xdr.ScSpecUdtUnionCaseVoidV0({ doc: "", name: "Macro" }), + ), + xdr.ScSpecUdtUnionCaseV0.scSpecUdtUnionCaseVoidV0( + new xdr.ScSpecUdtUnionCaseVoidV0({ doc: "", name: "Science" }), // added + ), + ], + }), + ); + + const diff = diffSpecs("marketCore", normalize([oldUnion]), normalize([newUnion])); + + expect(diff).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "type-changed", + contract: "marketCore", + type: "Category", + }), + ]), + ); + }); + + it("detects event topic moved to data", () => { + const oldEvent = xdr.ScSpecEntry.scSpecEntryEventV0( + new xdr.ScSpecEventV0({ + doc: "", + lib: "", + name: "Trade", + prefixTopics: [], + params: [ + new xdr.ScSpecEventParamV0({ + doc: "", + name: "market_id", + type: xdr.ScSpecTypeDef.scSpecTypeU64(), + location: xdr.ScSpecEventParamLocationV0.scSpecEventParamLocationTopicList(), + }), + ], + dataFormat: xdr.ScSpecEventDataFormat.scSpecEventDataFormatVec(), + }), + ); + + const newEvent = xdr.ScSpecEntry.scSpecEntryEventV0( + new xdr.ScSpecEventV0({ + doc: "", + lib: "", + name: "Trade", + prefixTopics: [], + params: [ + new xdr.ScSpecEventParamV0({ + doc: "", + name: "market_id", + type: xdr.ScSpecTypeDef.scSpecTypeU64(), + location: xdr.ScSpecEventParamLocationV0.scSpecEventParamLocationData(), // moved to data + }), + ], + dataFormat: xdr.ScSpecEventDataFormat.scSpecEventDataFormatVec(), + }), + ); + + const diff = diffSpecs("marketCore", normalize([oldEvent]), normalize([newEvent])); + + expect(diff).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "event-changed", + contract: "marketCore", + event: "Trade", + requiresIndexerReview: true, + }), + ]), + ); + }); + + it("doc-only change is not a signature change", () => { + const oldFn = xdr.ScSpecEntry.scSpecEntryFunctionV0( + new xdr.ScSpecFunctionV0({ + doc: "Old documentation description", + name: "get_market", + inputs: [], + outputs: [], + }), + ); + + const newFn = xdr.ScSpecEntry.scSpecEntryFunctionV0( + new xdr.ScSpecFunctionV0({ + doc: "New updated documentation description", + name: "get_market", + inputs: [], + outputs: [], + }), + ); + + const diff = diffSpecs("marketCore", normalize([oldFn]), normalize([newFn])); + + expect(diff.some((c) => c.kind === "fn-signature")).toBe(false); + expect(diff.some((c) => c.kind === "fn-doc")).toBe(true); + }); + + it("real bindings round-trip: diff(current, current) is empty", () => { + const content = readFileSync( + join(__dirname, "../../lib/stellar/generated/market-core.ts"), + "utf8", + ); + const entries = readSpecEntries(content); + const norm1 = normalize(entries); + const norm2 = normalize(entries); + + const diff = diffSpecs("marketCore", norm1, norm2); + expect(diff).toEqual([]); + }); + + it("reference scan identifies call sites of removed functions", () => { + const affected = [{ contract: "marketCore", fn: "non_existent_sweep_test_fn", factory: "marketCore(" }]; + const hits = scanReferences(affected); + // Since non_existent_sweep_test_fn doesn't exist in repo, hits should be empty + expect(hits).toEqual([]); + }); +}); diff --git a/scripts/check-sdk-major.mjs b/scripts/check-sdk-major.mjs new file mode 100644 index 0000000..5d36646 --- /dev/null +++ b/scripts/check-sdk-major.mjs @@ -0,0 +1,41 @@ +#!/usr/bin/env node +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { validateSdkMajor } from "../lib/stellar/sdk-major.ts"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); + +function readJsonSafe(filepath) { + if (!existsSync(filepath)) return null; + try { + return JSON.parse(readFileSync(filepath, "utf8")); + } catch (err) { + console.warn(`Failed to parse JSON at ${filepath}:`, err); + return null; + } +} + +const packageLock = readJsonSafe(join(ROOT, "package-lock.json")); +if (!packageLock) { + console.error("package-lock.json not found in repository root."); + process.exit(1); +} + +const bindingsPackage = readJsonSafe(join(ROOT, "lib", "stellar", "generated", "bindings-package.json")); +const kitPackage = readJsonSafe(join(ROOT, "node_modules", "@creit.tech", "stellar-wallets-kit", "package.json")); + +const result = validateSdkMajor({ packageLock, bindingsPackage, kitPackage }); + +if (!result.ok) { + console.error("❌ SDK Major Version & Deduplication Guard Failed:\n"); + for (const err of result.errors) { + console.error(`- ${err}`); + } + process.exit(1); +} else { + console.log( + `✅ Verified single @stellar/stellar-sdk installation and major alignment (major: ${result.appSdkMajor}, installed: ${result.installedSdkVersions.join(", ")}).`, + ); + process.exit(0); +} diff --git a/scripts/lib/files.mjs b/scripts/lib/files.mjs new file mode 100644 index 0000000..f071078 --- /dev/null +++ b/scripts/lib/files.mjs @@ -0,0 +1,17 @@ +// [source path under oracledesk-stellar/, destination file name] +export const FILES = [ + ["packages/bindings/market-core/src/index.ts", "market-core.ts"], + ["packages/bindings/treasury/src/index.ts", "treasury.ts"], + ["packages/bindings/reasoning-registry/src/index.ts", "reasoning-registry.ts"], + ["packages/bindings/resolver/src/index.ts", "resolver.ts"], + ["agents/core/fpmm.ts", "fpmm.ts"], + ["deployments/testnet.json", "deployments.testnet.json"], + ["packages/bindings/market-core/package.json", "bindings-package.json"], +]; + +export const CONTRACT_BINDINGS = [ + { contract: "marketCore", file: "market-core.ts", factory: "marketCore(" }, + { contract: "treasury", file: "treasury.ts", factory: "treasury(" }, + { contract: "resolver", file: "resolver.ts", factory: "resolver(" }, + { contract: "reasoningRegistry", file: "reasoning-registry.ts", factory: "reasoningRegistry(" }, +]; diff --git a/scripts/lib/spec.mjs b/scripts/lib/spec.mjs new file mode 100644 index 0000000..ad91dcf --- /dev/null +++ b/scripts/lib/spec.mjs @@ -0,0 +1,243 @@ +import { xdr } from "@stellar/stellar-sdk"; + +/** + * Extracts raw base64 spec strings from generated TypeScript source code using bracket matching, + * then decodes each into an xdr.ScSpecEntry. + */ +export function readSpecEntries(tsSource) { + const match = tsSource.match(/new\s+ContractSpec\s*\(\s*\[/); + if (!match) return []; + + const startIdx = match.index + match[0].length; + let depth = 1; + let endIdx = startIdx; + let inString = false; + let quoteChar = ""; + let escape = false; + + for (let i = startIdx; i < tsSource.length; i++) { + const ch = tsSource[i]; + if (inString) { + if (escape) { + escape = false; + } else if (ch === "\\") { + escape = true; + } else if (ch === quoteChar) { + inString = false; + } + } else { + if (ch === '"' || ch === "'" || ch === "`") { + inString = true; + quoteChar = ch; + } else if (ch === "[") { + depth++; + } else if (ch === "]") { + depth--; + if (depth === 0) { + endIdx = i; + break; + } + } + } + } + + const arrayBody = tsSource.slice(startIdx, endIdx); + const strRegex = /["']([A-Za-z0-9+/=]+)["']/g; + const entries = []; + let m; + while ((m = strRegex.exec(arrayBody)) !== null) { + const b64 = m[1].trim(); + if (b64.length > 0) { + entries.push(xdr.ScSpecEntry.fromXDR(b64, "base64")); + } + } + + return entries; +} + +/** + * Converts any ScSpecTypeDef into a clean string representation. + */ +export function typeToString(typeDef) { + if (!typeDef) return "void"; + const sw = typeDef.switch ? typeDef.switch().name : typeDef.arm(); + + switch (sw) { + case "scSpecTypeVal": + return "val"; + case "scSpecTypeBool": + return "bool"; + case "scSpecTypeVoid": + return "void"; + case "scSpecTypeError": + return "Error"; + case "scSpecTypeU32": + return "u32"; + case "scSpecTypeI32": + return "i32"; + case "scSpecTypeU64": + return "u64"; + case "scSpecTypeI64": + return "i64"; + case "scSpecTypeTimepoint": + return "Timepoint"; + case "scSpecTypeDuration": + return "Duration"; + case "scSpecTypeU128": + return "u128"; + case "scSpecTypeI128": + return "i128"; + case "scSpecTypeU256": + return "u256"; + case "scSpecTypeI256": + return "i256"; + case "scSpecTypeBytes": + return "bytes"; + case "scSpecTypeString": + return "string"; + case "scSpecTypeSymbol": + return "symbol"; + case "scSpecTypeAddress": + return "Address"; + case "scSpecTypeMuxedAddress": + return "MuxedAddress"; + case "scSpecTypeOption": { + const opt = typeDef.option(); + return `Option<${typeToString(opt.valueType())}>`; + } + case "scSpecTypeResult": { + const res = typeDef.result(); + return `Result<${typeToString(res.okType())}, ${typeToString(res.errorType())}>`; + } + case "scSpecTypeVec": { + const v = typeDef.vec(); + return `Vec<${typeToString(v.elementType())}>`; + } + case "scSpecTypeMap": { + const m = typeDef.map(); + return `Map<${typeToString(m.keyType())}, ${typeToString(m.valueType())}>`; + } + case "scSpecTypeTuple": { + const tup = typeDef.tuple(); + return `(${tup.valueTypes().map(typeToString).join(", ")})`; + } + case "bytesN": + case "scSpecTypeBytesN": { + const b = typeDef.bytesN(); + return `BytesN<${b.n()}>`; + } + case "udt": + case "scSpecTypeUdt": { + const u = typeDef.udt(); + return u.name().toString(); + } + default: + return String(sw); + } +} + +/** + * Normalizes ScSpecEntry items into indexed collections for deterministic diffing. + */ +export function normalize(entries) { + const functions = new Map(); + const types = new Map(); + const errors = new Map(); + const events = new Map(); + + for (const entry of entries) { + const arm = entry.arm(); + const val = entry.value(); + + switch (arm) { + case "functionV0": { + const fn = val; + const name = fn.name().toString(); + functions.set(name, { + name, + doc: fn.doc().toString(), + inputs: fn.inputs().map((i) => ({ + name: i.name().toString(), + type: typeToString(i.type()), + doc: i.doc().toString(), + })), + outputs: fn.outputs().map(typeToString), + }); + break; + } + case "udtStructV0": { + const st = val; + const name = st.name().toString(); + types.set(name, { + kind: "struct", + name, + doc: st.doc().toString(), + members: st.fields().map((f) => `${f.name().toString()}: ${typeToString(f.type())}`), + }); + break; + } + case "udtUnionV0": { + const un = val; + const name = un.name().toString(); + types.set(name, { + kind: "union", + name, + doc: un.doc().toString(), + members: un.cases().map((c) => { + const cArm = c.arm(); + const cVal = c.value(); + if (cArm === "voidV0") { + return cVal.name().toString(); + } + if (cArm === "tupleV0") { + const typesStr = cVal.type().map(typeToString).join(", "); + return `${cVal.name().toString()}(${typesStr})`; + } + return cArm; + }), + }); + break; + } + case "udtEnumV0": { + const en = val; + const name = en.name().toString(); + types.set(name, { + kind: "enum", + name, + doc: en.doc().toString(), + members: en.cases().map((cs) => `${cs.name().toString()} = ${cs.value()}`), + }); + break; + } + case "udtErrorEnumV0": { + const err = val; + const enumName = err.name().toString(); + const codeMap = new Map(); + for (const cs of err.cases()) { + codeMap.set(cs.value(), cs.name().toString()); + } + errors.set(enumName, codeMap); + break; + } + case "eventV0": { + const ev = val; + const name = ev.name().toString(); + events.set(name, { + name, + doc: ev.doc().toString(), + prefixTopics: ev.prefixTopics().map((t) => t.toString()), + params: ev.params().map((p) => ({ + name: p.name().toString(), + type: typeToString(p.type()), + location: p.location().name.includes("Topic") ? "topic" : "data", + })), + }); + break; + } + default: + break; + } + } + + return { functions, types, errors, events }; +} diff --git a/scripts/spec-diff.mjs b/scripts/spec-diff.mjs new file mode 100644 index 0000000..2f05453 --- /dev/null +++ b/scripts/spec-diff.mjs @@ -0,0 +1,411 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process"; +import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { dirname, join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; +import { CONTRACT_BINDINGS } from "./lib/files.mjs"; +import { normalize, readSpecEntries } from "./lib/spec.mjs"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); + +// Parse arguments +const args = process.argv.slice(2); +let baseRef = "origin/develop"; +let summaryFile = process.env.GITHUB_STEP_SUMMARY || null; + +for (let i = 0; i < args.length; i++) { + if (args[i] === "--base" && args[i + 1]) { + baseRef = args[++i]; + } else if (args[i] === "--summary" && args[i + 1]) { + summaryFile = args[++i]; + } +} + +// Read FRIENDLY and UNFRIENDLY_ALLOWLIST from lib/stellar/errors.ts +function loadErrorAllowlist() { + const errorsPath = join(ROOT, "lib", "stellar", "errors.ts"); + if (!existsSync(errorsPath)) return { allowed: new Set(), friendly: new Set() }; + const content = readFileSync(errorsPath, "utf8"); + + const friendlyNames = new Set(); + const allowlist = new Set(); + + const friendlyMatch = content.match(/export const FRIENDLY:\s*Record\s*=\s*{([\s\S]*?)};/); + if (friendlyMatch) { + const lines = friendlyMatch[1].split("\n"); + for (const line of lines) { + const m = line.match(/^\s*([A-Za-z0-9_]+):/); + if (m && m[1]) friendlyNames.add(m[1]); + } + } + + const allowMatch = content.match(/export const UNFRIENDLY_ALLOWLIST\s*=\s*\[([\s\S]*?)\]/); + if (allowMatch) { + const matches = allowMatch[1].matchAll(/["']([A-Za-z0-9_]+)["']/g); + for (const m of matches) { + allowlist.add(m[1]); + } + } + + const allowed = new Set([...friendlyNames, ...allowlist]); + return { allowed, friendly: friendlyNames, allowlist }; +} + +// Get file contents from git base ref +function getGitFile(ref, relativePath) { + try { + return execFileSync("git", ["show", `${ref}:${relativePath}`], { + cwd: ROOT, + encoding: "utf8", + stdio: ["pipe", "pipe", "ignore"], + }); + } catch { + // If ref not found, try fallback HEAD~1 + if (ref !== "HEAD~1") { + try { + return execFileSync("git", ["show", `HEAD~1:${relativePath}`], { + cwd: ROOT, + encoding: "utf8", + stdio: ["pipe", "pipe", "ignore"], + }); + } catch { + return null; + } + } + return null; + } +} + +// Compute diff between old and new normalized specs +export function diffSpecs(contract, oldNorm, newNorm) { + const changes = []; + + // 1. Functions + const allFnNames = new Set([...oldNorm.functions.keys(), ...newNorm.functions.keys()]); + for (const fnName of [...allFnNames].sort()) { + const oldFn = oldNorm.functions.get(fnName); + const newFn = newNorm.functions.get(fnName); + + if (!oldFn && newFn) { + changes.push({ kind: "fn-added", contract, fn: fnName }); + } else if (oldFn && !newFn) { + changes.push({ kind: "fn-removed", contract, fn: fnName }); + } else if (oldFn && newFn) { + const oldInputs = oldFn.inputs.map((i) => `${i.name}: ${i.type}`).join(", "); + const newInputs = newFn.inputs.map((i) => `${i.name}: ${i.type}`).join(", "); + const oldOutputs = oldFn.outputs.join(", "); + const newOutputs = newFn.outputs.join(", "); + + const oldSig = `(${oldInputs}) -> ${oldOutputs || "void"}`; + const newSig = `(${newInputs}) -> ${newOutputs || "void"}`; + + if (oldSig !== newSig) { + changes.push({ + kind: "fn-signature", + contract, + fn: fnName, + before: oldSig, + after: newSig, + }); + } else if (oldFn.doc !== newFn.doc) { + changes.push({ + kind: "fn-doc", + contract, + fn: fnName, + }); + } + } + } + + // 2. Types + const allTypeNames = new Set([...oldNorm.types.keys(), ...newNorm.types.keys()]); + for (const typeName of [...allTypeNames].sort()) { + const oldT = oldNorm.types.get(typeName); + const newT = newNorm.types.get(typeName); + + if (!oldT && newT) { + changes.push({ kind: "type-added", contract, type: typeName, kindType: newT.kind }); + } else if (oldT && !newT) { + changes.push({ kind: "type-removed", contract, type: typeName }); + } else if (oldT && newT) { + const oldMembers = (oldT.members || []).join("; "); + const newMembers = (newT.members || []).join("; "); + if (oldMembers !== newMembers) { + changes.push({ + kind: "type-changed", + contract, + type: typeName, + before: oldT.members, + after: newT.members, + }); + } + } + } + + // 3. Errors + const allEnumNames = new Set([...oldNorm.errors.keys(), ...newNorm.errors.keys()]); + for (const enumName of [...allEnumNames].sort()) { + const oldCodeMap = oldNorm.errors.get(enumName) || new Map(); + const newCodeMap = newNorm.errors.get(enumName) || new Map(); + + const oldNameMap = new Map([...oldCodeMap.entries()].map(([code, name]) => [name, code])); + const newNameMap = new Map([...newCodeMap.entries()].map(([code, name]) => [name, code])); + + // Check for renumbered errors + for (const [name, newCode] of newNameMap) { + const oldCode = oldNameMap.get(name); + if (oldCode !== undefined && oldCode !== newCode) { + changes.push({ + kind: "error-renumbered", + contract, + name, + from: oldCode, + to: newCode, + }); + } + } + + // Check added errors + for (const [code, name] of newCodeMap) { + if (!oldCodeMap.has(code) && !oldNameMap.has(name)) { + changes.push({ + kind: "error-added", + contract, + code, + name, + }); + } + } + + // Check removed errors + for (const [code, name] of oldCodeMap) { + if (!newCodeMap.has(code) && !newNameMap.has(name)) { + changes.push({ + kind: "error-removed", + contract, + code, + name, + }); + } + } + } + + // 4. Events + const allEvents = new Set([...oldNorm.events.keys(), ...newNorm.events.keys()]); + for (const eventName of [...allEvents].sort()) { + const oldEv = oldNorm.events.get(eventName); + const newEv = newNorm.events.get(eventName); + + if (!oldEv && newEv) { + changes.push({ kind: "event-added", contract, event: eventName }); + } else if (oldEv && !newEv) { + changes.push({ kind: "event-removed", contract, event: eventName }); + } else if (oldEv && newEv) { + const oldDesc = `${oldEv.prefixTopics.join(",")} | ${oldEv.params.map((p) => `${p.name}:${p.type}@${p.location}`).join(",")}`; + const newDesc = `${newEv.prefixTopics.join(",")} | ${newEv.params.map((p) => `${p.name}:${p.type}@${p.location}`).join(",")}`; + if (oldDesc !== newDesc) { + changes.push({ + kind: "event-changed", + contract, + event: eventName, + before: oldDesc, + after: newDesc, + requiresIndexerReview: eventName === "Trade" || eventName === "TracePublished", + }); + } + } + } + + return changes; +} + +// Find references in frontend source files to removed/changed methods +export function scanReferences(affectedFns) { + if (affectedFns.length === 0) return []; + const hits = []; + + const searchDirs = ["lib", "app", "components", "e2e"]; + const targetExts = new Set([".ts", ".tsx"]); + + function walk(dir) { + if (!existsSync(dir)) return; + const entries = readdirSync(dir); + for (const entry of entries) { + const full = join(dir, entry); + const stat = statSync(full); + if (stat.isDirectory()) { + // Exclude generated bindings and node_modules + if (full.includes("lib/stellar/generated") || full.includes("node_modules")) continue; + walk(full); + } else if (targetExts.has(entry.slice(entry.lastIndexOf(".")))) { + checkFile(full); + } + } + } + + function checkFile(filepath) { + const lines = readFileSync(filepath, "utf8").split("\n"); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const prevLine = i > 0 ? lines[i - 1] : ""; + + for (const { contract, fn, factory } of affectedFns) { + const fnPattern = `.${fn}(`; + if (line.includes(fnPattern)) { + // Check if scoped to contract factory on same or previous line + const context = `${prevLine} ${line}`; + if (!factory || context.includes(factory)) { + hits.push({ + file: relative(ROOT, filepath), + line: i + 1, + contract, + fn, + snippet: line.trim(), + }); + } + } + } + } + } + + for (const d of searchDirs) { + walk(join(ROOT, d)); + } + + return hits; +} + +// Main execution function +export async function runDiff(base = baseRef) { + const { allowed } = loadErrorAllowlist(); + const allChanges = []; + const uncoveredErrors = []; + const affectedFunctions = []; + + for (const item of CONTRACT_BINDINGS) { + const relDest = join("lib", "stellar", "generated", item.file).replace(/\\/g, "/"); + const localPath = join(ROOT, relDest); + if (!existsSync(localPath)) continue; + + const newContent = readFileSync(localPath, "utf8"); + const oldContent = getGitFile(base, relDest) || ""; + + const newEntries = readSpecEntries(newContent); + const oldEntries = oldContent ? readSpecEntries(oldContent) : []; + + const newNorm = normalize(newEntries); + const oldNorm = normalize(oldEntries); + + // Check error coverage in FRIENDLY + for (const [, codeMap] of newNorm.errors) { + for (const [code, name] of codeMap) { + if (!allowed.has(name)) { + uncoveredErrors.push({ contract: item.contract, code, name }); + } + } + } + + const changes = diffSpecs(item.contract, oldNorm, newNorm); + allChanges.push(...changes); + + for (const c of changes) { + if (c.kind === "fn-removed" || c.kind === "fn-signature") { + affectedFunctions.push({ contract: item.contract, fn: c.fn, factory: item.factory }); + } + } + } + + // Scan references to removed or modified functions + const referenceHits = scanReferences(affectedFunctions); + + // Check renumbered errors + const renumbered = allChanges.filter((c) => c.kind === "error-renumbered"); + + // Determine pass/fail + const failed = referenceHits.length > 0 || uncoveredErrors.length > 0 || renumbered.length > 0; + + // Build Markdown report + let report = `## Contract Interface Spec Diff Report\n\n`; + report += `Comparing local generated bindings against \`${base}\`:\n\n`; + + if (allChanges.length === 0) { + report += `> No contract interface changes detected across bindings.\n\n`; + } else { + report += `### Summary of Detected Changes (${allChanges.length})\n\n`; + report += `| Contract | Kind | Element | Details |\n`; + report += `|---|---|---|---|\n`; + + // Sort deterministically + allChanges.sort((a, b) => { + const c = a.contract.localeCompare(b.contract); + if (c !== 0) return c; + const k = a.kind.localeCompare(b.kind); + if (k !== 0) return k; + const elemA = a.fn || a.type || a.name || a.event || ""; + const elemB = b.fn || b.type || b.name || b.event || ""; + return elemA.localeCompare(elemB); + }); + + for (const c of allChanges) { + const elem = c.fn || c.type || c.name || c.event || ""; + let detail = ""; + if (c.kind === "fn-signature") detail = `\`${c.before}\` → \`${c.after}\``; + else if (c.kind === "fn-doc") detail = "Doc string updated"; + else if (c.kind === "error-renumbered") detail = `Renumbered from #${c.from} to #${c.to} ⚠️`; + else if (c.kind === "event-changed" && c.requiresIndexerReview) detail = `Changed (**requires backend indexer review**)`; + report += `| \`${c.contract}\` | \`${c.kind}\` | \`${elem}\` | ${detail} |\n`; + } + report += `\n`; + } + + if (uncoveredErrors.length > 0) { + report += `### ❌ Uncovered Contract Errors (${uncoveredErrors.length})\n\n`; + report += `The following contract errors have no friendly explanation in \`lib/stellar/errors.ts\`:\n\n`; + for (const u of uncoveredErrors) { + report += `- \`${u.contract}\` error #${u.code}: **${u.name}**\n`; + } + report += `\n`; + } + + if (referenceHits.length > 0) { + report += `### ❌ Breaking Call-Site References (${referenceHits.length})\n\n`; + report += `Removed or re-signatured contract methods are still called in frontend code:\n\n`; + for (const h of referenceHits) { + report += `- [${h.file}:${h.line}](file:///${join(ROOT, h.file).replace(/\\/g, "/")}) calls \`${h.contract}.${h.fn}()\`\n`; + } + report += `\n`; + } + + if (renumbered.length > 0) { + report += `### ❌ Renumbered Error Codes (${renumbered.length})\n\n`; + for (const r of renumbered) { + report += `- \`${r.contract}\` error **${r.name}** renumbered from #${r.from} to #${r.to}\n`; + } + report += `\n`; + } + + return { failed, report, allChanges, uncoveredErrors, referenceHits, renumbered }; +} + +// CLI entrypoint +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + runDiff(baseRef).then(({ failed, report }) => { + console.log(report); + + if (summaryFile) { + try { + writeFileSync(summaryFile, report, { flag: "a" }); + } catch (err) { + console.error(`Failed to write summary to ${summaryFile}:`, err); + } + } + + if (failed) { + console.error("\n❌ Contract interface diff checks failed."); + process.exit(1); + } else { + console.log("\n✅ Contract interface diff checks passed."); + process.exit(0); + } + }); +} diff --git a/scripts/sync-bindings.mjs b/scripts/sync-bindings.mjs index dc2ab16..52af870 100644 --- a/scripts/sync-bindings.mjs +++ b/scripts/sync-bindings.mjs @@ -19,15 +19,7 @@ const SUBMODULE = join(ROOT, "contracts"); const SOURCE = join(SUBMODULE, "oracledesk-stellar"); const DEST = join(ROOT, "lib", "stellar", "generated"); -// [source path under oracledesk-stellar/, destination file name] -const FILES = [ - ["packages/bindings/market-core/src/index.ts", "market-core.ts"], - ["packages/bindings/treasury/src/index.ts", "treasury.ts"], - ["packages/bindings/reasoning-registry/src/index.ts", "reasoning-registry.ts"], - ["packages/bindings/resolver/src/index.ts", "resolver.ts"], - ["agents/core/fpmm.ts", "fpmm.ts"], - ["deployments/testnet.json", "deployments.testnet.json"], -]; +import { FILES } from "./lib/files.mjs"; const check = process.argv.includes("--check"); diff --git a/vitest.config.mts b/vitest.config.mts index 8e7523a..c926227 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -13,7 +13,7 @@ export default defineConfig({ test: { name: "unit", environment: "node", - include: ["lib/**/*.test.ts", "components/**/*.test.ts"], + include: ["lib/**/*.test.ts", "components/**/*.test.ts", "scripts/**/*.test.ts"], typecheck: { enabled: true, include: ["lib/**/*.test-d.ts"], tsconfig: "./tsconfig.json" }, }, },