diff --git a/bun.lock b/bun.lock index d4251b6..04dc369 100644 --- a/bun.lock +++ b/bun.lock @@ -17,15 +17,57 @@ }, }, "examples/copy-trade": { - "name": "example-copy-trade", + "name": "copy-trade", "version": "0.1.0", "dependencies": { + "@coral-xyz/anchor": "^0.30.1", + "@magicblock-labs/gum-sdk": "^3.0.10", + "@solana/spl-token": "^0.4.14", + "@solana/wallet-adapter-base": "^0.9.27", + "@solana/wallet-adapter-phantom": "^0.9.29", + "@solana/wallet-adapter-react": "^0.15.39", + "@solana/wallet-adapter-solflare": "^0.6.28", "@solana/web3.js": "^1.98.2", "flash-v2": "workspace:*", + "next": "^15.5.19", + "react": "^19.2.0", + "react-dom": "^19.2.0", }, "devDependencies": { + "@tailwindcss/postcss": "^4.3.0", + "@types/bn.js": "^5.1.5", "@types/node": "^22.10.0", - "typescript": "^5.6.0", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "tailwindcss": "^4.3.0", + "typescript": "^5.9.0", + }, + }, + "examples/predict": { + "name": "predict", + "version": "0.1.0", + "dependencies": { + "@coral-xyz/anchor": "^0.30.1", + "@magicblock-labs/gum-sdk": "^3.0.10", + "@solana/spl-token": "^0.4.14", + "@solana/wallet-adapter-base": "^0.9.27", + "@solana/wallet-adapter-phantom": "^0.9.29", + "@solana/wallet-adapter-react": "^0.15.39", + "@solana/wallet-adapter-solflare": "^0.6.28", + "@solana/web3.js": "^1.98.2", + "flash-v2": "workspace:*", + "next": "^15.5.19", + "react": "^19.2.0", + "react-dom": "^19.2.0", + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.3.0", + "@types/bn.js": "^5.1.5", + "@types/node": "^22.10.0", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "tailwindcss": "^4.3.0", + "typescript": "^5.9.0", }, }, "examples/tap-trade": { @@ -506,6 +548,8 @@ "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "copy-trade": ["copy-trade@workspace:examples/copy-trade"], + "cross-fetch": ["cross-fetch@3.2.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], @@ -570,8 +614,6 @@ "eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], - "example-copy-trade": ["example-copy-trade@workspace:examples/copy-trade"], - "example-template": ["example-template@workspace:examples/_template"], "exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="], @@ -830,6 +872,8 @@ "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], + "predict": ["predict@workspace:examples/predict"], + "pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], "promise": ["promise@8.3.0", "", { "dependencies": { "asap": "~2.0.6" } }, "sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg=="], @@ -912,7 +956,7 @@ "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], - "superstruct": ["superstruct@2.0.2", "", {}, "sha512-uV+TFRZdXsqXTL2pRvujROjdZQ4RAlBUS5BTh9IGm+jTqQntYThciG/qu57Gs69yjnVUSqdxF9YLmSnpupBW9A=="], + "superstruct": ["superstruct@0.15.5", "", {}, "sha512-4AOeU+P5UuE/4nOUkmcQdW5y7i9ndt1cQd/3iUe+LTz3RxESf/W/5lg4B74HbDMMv8PHnPnGCQFH45kBcrQYoQ=="], "supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], @@ -958,7 +1002,7 @@ "utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="], - "uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], + "uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], "vlq": ["vlq@1.0.1", "", {}, "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w=="], @@ -992,8 +1036,6 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@coral-xyz/anchor/superstruct": ["superstruct@0.15.5", "", {}, "sha512-4AOeU+P5UuE/4nOUkmcQdW5y7i9ndt1cQd/3iUe+LTz3RxESf/W/5lg4B74HbDMMv8PHnPnGCQFH45kBcrQYoQ=="], - "@jest/types/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "@solana/accounts/@solana/codecs-core": ["@solana/codecs-core@6.9.0", "", { "dependencies": { "@solana/errors": "6.9.0" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-F2BmLecG/1nTtnjyD509NsEc254pxJKa2bpvotymv1lL1WfEn3zchcZ9SMIiLyL4G6J8b9F3OKIq2YSZho2AOQ=="], @@ -1140,12 +1182,12 @@ "@solana/wallet-adapter-base/eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], + "@solana/web3.js/superstruct": ["superstruct@2.0.2", "", {}, "sha512-uV+TFRZdXsqXTL2pRvujROjdZQ4RAlBUS5BTh9IGm+jTqQntYThciG/qu57Gs69yjnVUSqdxF9YLmSnpupBW9A=="], + "@solflare-wallet/sdk/bs58": ["bs58@5.0.0", "", { "dependencies": { "base-x": "^4.0.0" } }, "sha512-r+ihvQJvahgYT50JD05dyJNKlmmSlMoOGwn1lCcEzanPglg7TxYjioQUYehQ9mAR/+hOSd2jRc/Z2y5UxBymvQ=="], "@solflare-wallet/sdk/eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], - "@solflare-wallet/sdk/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg=="], @@ -1174,6 +1216,8 @@ "jayson/@types/node": ["@types/node@12.20.55", "", {}, "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ=="], + "jayson/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], + "jest-util/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "jest-util/ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="], diff --git a/examples/copy-trade/.env.example b/examples/copy-trade/.env.example index e03df22..1a1e9ec 100644 --- a/examples/copy-trade/.env.example +++ b/examples/copy-trade/.env.example @@ -1,5 +1,22 @@ -# copy-trade — LEADER is required; everything else optional (dry-run default) -LEADER= # wallet pubkey to mirror (any owner with live V2 positions) -MAX_FOLLOW_USD=100 # hard cap per mirrored trade -# RATIO=0.1 # override collateral-ratio sizing with a fixed ratio -# FOLLOWER_KEYPAIR= # + run with `-- --execute` to sign for real (MAINNET wallet!) +# copy-trade — copy to .env.local and fill in ONE thing: your RPC key. +# cp .env.example .env.local +# +# Flash Trade V2 runs on Solana MAINNET (real funds — start small). You connect a +# wallet and Enable One-Click Trading; mirrors sign with a session key, no popups. + +NEXT_PUBLIC_FLASH_NETWORK=mainnet + +# ── RPC ─────────────────────────────────────────────────────────────────────── +# Base-chain RPC (setup / deposit / withdraw) — browser-direct to a CORS-friendly +# RPC. A FREE key from helius.dev / triton.one / quicknode.com works. This URL +# ships in the browser bundle, so use a DOMAIN-RESTRICTED key for deploys. +NEXT_PUBLIC_BASE_RPC=https://mainnet.helius-rpc.com/?api-key=YOUR_KEY_HERE +# Ephemeral Rollup RPC (trading + the live leader stream) — serves CORS, direct. +NEXT_PUBLIC_ER_RPC=https://flash.magicblock.xyz + +# The leaderboard is fetched server-side from the public fstats V2 stats API via +# /api/leaders (it has no CORS). No key needed. Override only if it ever moves: +# FSTATS_BASE=https://fstats.io/v2/api/v1 + +# Override the hosted Flash V2 API (rarely needed). +# NEXT_PUBLIC_FLASH_API_BASE=https://flashapi.trade/v2 diff --git a/examples/copy-trade/app/api/leaders/route.ts b/examples/copy-trade/app/api/leaders/route.ts new file mode 100644 index 0000000..3f01960 --- /dev/null +++ b/examples/copy-trade/app/api/leaders/route.ts @@ -0,0 +1,39 @@ +// ───────────────────────────────────────────────────────────────────────────── +// app/api/leaders/route.ts — same-origin read-only proxy for the PUBLIC fstats +// V2/ER leaderboard. WHY IT EXISTS: fstats serves leader rankings (win rate, +// PnL) for the MagicBlock ER but does NOT send CORS headers, so a browser can't +// fetch it directly. This route fetches it server-side and hands the JSON back. +// It carries NO keys and reaches NO RPC — it only relays public stats. (Live +// LEADER POSITIONS come straight from flashapi.trade over the owner WS, which +// DOES serve CORS — see lib/copy-engine.ts; this is discovery only.) +// ───────────────────────────────────────────────────────────────────────────── + +export const runtime = "nodejs"; + +const FSTATS = "https://fstats.io/v2/api/v1"; + +export async function GET(): Promise { + try { + const [lbRes, openRes] = await Promise.all([ + fetch(`${FSTATS}/leaderboards/pnl`, { cache: "no-store" }), + fetch(`${FSTATS}/positions/open`, { cache: "no-store" }), + ]); + if (!lbRes.ok) throw new Error(`fstats leaderboard ${lbRes.status}`); + const lb = (await lbRes.json()) as { leaderboard?: unknown[] }; + // positions/open is best-effort — it only enriches rows with "live now" counts + let openByOwner: Record = {}; + if (openRes.ok) { + const open = (await openRes.json()) as { positions?: Array<{ owner: string }> }; + openByOwner = (open.positions ?? []).reduce>((acc, p) => { + acc[p.owner] = (acc[p.owner] ?? 0) + 1; + return acc; + }, {}); + } + return Response.json( + { leaders: lb.leaderboard ?? [], openByOwner }, + { headers: { "Cache-Control": "public, max-age=10" } }, + ); + } catch (e) { + return Response.json({ error: (e as Error).message }, { status: 502 }); + } +} diff --git a/examples/copy-trade/app/globals.css b/examples/copy-trade/app/globals.css new file mode 100644 index 0000000..d61cd0a --- /dev/null +++ b/examples/copy-trade/app/globals.css @@ -0,0 +1,197 @@ +/* ───────────────────────────────────────────────────────────────────────────── + app/globals.css — the LIQUID GLASS design language for copy-trade. + Deep OLED surface lit by a faint mesh of emerald/teal/rose glow orbs; every + surface is frosted glass: translucent fill + backdrop blur + a hairline white + edge + an inset specular highlight that reads as a beveled top lip. Major + containers use the Double-Bezel (an outer tray cradling an inner glass plate, + concentric squircle radii). Flash brand survives in the mint LONG / rose SHORT + and the gold leaderboard accent. Motion is heavy and springy (one easing). + THE HARD PART: blur only on fixed/non-scrolling glass; transform/opacity only. + Tailwind v4. + ───────────────────────────────────────────────────────────────────────── */ + +@import "tailwindcss"; + +@theme { + /* ── surface ─────────────────────────────────────────────────────────────── */ + --color-bg: #060807; /* near-OLED, faintly green-cold black */ + --color-bg2: #0a0d0c; /* lifted base behind glass */ + --color-ink: #eef2f0; + --color-dim: #9aa6a1; + --color-faint: #5b655f; + + /* ── semantics (Flash brand) ─────────────────────────────────────────────── */ + --color-long: #34d399; /* LONG · live · profit */ + --color-short: #f43f5e; /* SHORT · loss */ + --color-gold: #fbbf24; /* leaderboard rank · win-rate halo */ + --color-info: #5b9dff; /* leader / stream accents */ + --color-accent: #ffe600; /* Flash bolt — the logo mark ONLY */ + + /* ── glass material ──────────────────────────────────────────────────────── */ + --glass-fill: rgb(255 255 255 / 0.045); + --glass-fill-2: rgb(255 255 255 / 0.075); /* raised glass (chips, active) */ + --glass-edge: rgb(255 255 255 / 0.09); /* the hairline lip */ + --glass-edge-2: rgb(255 255 255 / 0.16); /* emphasis lip */ + --glass-hi: rgb(255 255 255 / 0.14); /* specular top highlight */ + --tray: rgb(255 255 255 / 0.03); /* double-bezel outer tray */ + --tray-edge: rgb(255 255 255 / 0.06); + + /* flat surface tokens — sheets, inputs, segmented controls (glass-flavored darks) */ + --color-panel: rgb(255 255 255 / 0.04); + --color-panel2: rgb(255 255 255 / 0.08); + --color-sheet: #0c100e; + --color-edge: rgb(255 255 255 / 0.10); + --color-edge2: rgb(255 255 255 / 0.17); + + --font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + --font-display: var(--font-grotesk), ui-sans-serif, system-ui, sans-serif; + --font-mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, "JetBrains Mono", Consolas, monospace; + + /* the one easing — springy, weighty (Apple-ish) */ + --ease: cubic-bezier(0.32, 0.72, 0, 1); +} + +html { background: var(--color-bg); color-scheme: dark; } + +/* The lit surface: OLED black under a fixed mesh of soft glow orbs. */ +body { + min-height: 100dvh; + background: + radial-gradient(58% 50% at 16% -4%, rgb(52 211 153 / 0.11), transparent 70%), + radial-gradient(46% 42% at 92% 8%, rgb(91 157 255 / 0.08), transparent 70%), + radial-gradient(64% 60% at 52% 116%, rgb(244 63 94 / 0.06), transparent 72%), + var(--color-bg); + background-attachment: fixed; + font-feature-settings: "tnum" 1; + overscroll-behavior: none; +} + +/* Fixed film-grain — kills banding on the glow gradients. pointer-events none. */ +body::before { + content: ""; + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + opacity: 0.025; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); +} + +::selection { background: rgb(52 211 153 / 0.22); } + +:where(button, a, [role="button"]):focus-visible { + outline: 1px solid var(--color-long); + outline-offset: 2px; +} +:where(input, textarea):focus-visible { outline: none; } + +/* ── LIQUID GLASS material ───────────────────────────────────────────────────── + .glass: a frosted plate. The inset top highlight is the bevel lip; the ambient + drop lifts it off the surface. Apply to NON-scrolling panels only (blur cost). */ +.glass { + background: var(--glass-fill); + -webkit-backdrop-filter: blur(22px) saturate(155%); + backdrop-filter: blur(22px) saturate(155%); + border: 1px solid var(--glass-edge); + box-shadow: + inset 0 1px 0 0 var(--glass-hi), + inset 0 0 0 1px rgb(255 255 255 / 0.02), + 0 18px 44px -16px rgb(0 0 0 / 0.66); +} +.glass-2 { + background: var(--glass-fill-2); + -webkit-backdrop-filter: blur(26px) saturate(160%); + backdrop-filter: blur(26px) saturate(160%); + border: 1px solid var(--glass-edge-2); + box-shadow: + inset 0 1px 0 0 var(--glass-hi), + 0 14px 34px -14px rgb(0 0 0 / 0.6); +} +/* a flat (non-blur) glass for rows inside scrolling lists — cheap, no repaint */ +.glass-flat { + background: rgb(255 255 255 / 0.035); + border: 1px solid rgb(255 255 255 / 0.06); + box-shadow: inset 0 1px 0 0 rgb(255 255 255 / 0.06); +} + +/* Specular sheen — a glancing top-left light across a glass plate. */ +.spec { position: relative; isolation: isolate; } +.spec::after { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + pointer-events: none; + background: + linear-gradient(177deg, rgb(255 255 255 / 0.10) 0%, transparent 26%), + radial-gradient(120% 80% at 0% 0%, rgb(255 255 255 / 0.06), transparent 42%); + mix-blend-mode: screen; +} + +/* Double-bezel: an outer tray cradling a glass plate, concentric radii. */ +.bezel { + padding: 5px; + border-radius: 26px; + background: var(--tray); + border: 1px solid var(--tray-edge); + box-shadow: inset 0 1px 0 0 rgb(255 255 255 / 0.05); +} +.bezel-r { border-radius: 21px; } /* the inner plate radius (26 − 5) */ + +/* tinted glow halos for semantic glass (long/short/gold leader) */ +.halo-long { box-shadow: inset 0 1px 0 0 var(--glass-hi), 0 0 0 1px rgb(52 211 153 / 0.22), 0 14px 40px -14px rgb(52 211 153 / 0.28); } +.halo-short { box-shadow: inset 0 1px 0 0 var(--glass-hi), 0 0 0 1px rgb(244 63 94 / 0.22), 0 14px 40px -14px rgb(244 63 94 / 0.28); } +.halo-gold { box-shadow: inset 0 1px 0 0 var(--glass-hi), 0 0 0 1px rgb(251 191 36 / 0.30), 0 14px 40px -14px rgb(251 191 36 / 0.26); } + +/* ── motion ──────────────────────────────────────────────────────────────── */ +.lift { transition: transform 0.5s var(--ease), box-shadow 0.5s var(--ease); will-change: transform; } +.lift:hover { transform: translateY(-2px); } +.press { transition: transform 0.18s var(--ease); } +.press:active { transform: scale(0.985); } + +/* Magnetic CTA — inner icon disc drifts diagonally on hover (button-in-button). */ +.mag .disc { transition: transform 0.45s var(--ease); } +.mag:hover .disc { transform: translate(2px, -1px) scale(1.06); } + +/* Bottom sheets slide on transform; backdrop fades on opacity. */ +.sheet-up { transition: transform 380ms var(--ease); will-change: transform; } +.backdrop-fade { transition: opacity 240ms ease; } + +/* Slot-reel digit columns (RollingNumber) — the signature numeric tick. */ +.reel-col { transition: transform 420ms var(--ease); will-change: transform; } + +/* Fill confirmation sweep — celebrates an on-chain mirror confirm (no spinners). */ +@keyframes fill-flash { + 0% { filter: brightness(1.4); box-shadow: 0 0 0 0 rgb(52 211 153 / 0); } + 30% { box-shadow: 0 0 22px 2px rgb(52 211 153 / 0.22); } + 100% { filter: brightness(1); box-shadow: 0 0 0 0 rgb(52 211 153 / 0); } +} +.fill-flash { animation: fill-flash 0.5s var(--ease); } + +/* Live breathing — the stream dot, "mirroring…" states. */ +@keyframes soft-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } } +.soft-pulse { animation: soft-pulse 1.7s ease-in-out infinite; } + +/* New rows (leader events, mirrored fills) slide + fade in on state change. */ +@keyframes row-in { from { opacity: 0; transform: translateY(-5px); } to { opacity: 1; transform: translateY(0); } } +.row-in { animation: row-in 0.4s var(--ease); } + +/* A glass plate entering (a leader picked, a sheet) — fade-up with a blur lift. */ +@keyframes glass-in { from { opacity: 0; transform: translateY(10px); filter: blur(6px); } to { opacity: 1; transform: translateY(0); filter: blur(0); } } +.glass-in { animation: glass-in 0.55s var(--ease); } + +/* Shimmer sweep for the live/auto-copy armed state (a moving specular band). */ +@keyframes shimmer { from { transform: translateX(-130%); } to { transform: translateX(130%); } } +.shimmer { position: relative; overflow: hidden; } +.shimmer::before { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(100deg, transparent 30%, rgb(255 255 255 / 0.10) 50%, transparent 70%); + animation: shimmer 2.6s var(--ease) infinite; +} + +@media (prefers-reduced-motion: reduce) { + .lift, .press, .mag .disc, .sheet-up, .reel-col, .fill-flash, .soft-pulse, + .row-in, .glass-in, .shimmer::before { animation: none !important; transition: none !important; } +} diff --git a/examples/copy-trade/app/icon.svg b/examples/copy-trade/app/icon.svg new file mode 100644 index 0000000..c8bd830 --- /dev/null +++ b/examples/copy-trade/app/icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/examples/copy-trade/app/layout.tsx b/examples/copy-trade/app/layout.tsx new file mode 100644 index 0000000..7a02418 --- /dev/null +++ b/examples/copy-trade/app/layout.tsx @@ -0,0 +1,36 @@ +// ───────────────────────────────────────────────────────────────────────────── +// app/layout.tsx — root shell for the Liquid Glass copy-trade app. Loads the +// Space Grotesk display face and frames everything on the lit OLED surface +// (the glow mesh + grain live in globals.css). All live logic is client-side. +// ───────────────────────────────────────────────────────────────────────────── + +import type { Metadata, Viewport } from "next"; +import { Space_Grotesk } from "next/font/google"; +import "./globals.css"; + +const grotesk = Space_Grotesk({ + subsets: ["latin"], + weight: ["600", "700"], + variable: "--font-grotesk", + display: "swap", +}); + +export const metadata: Metadata = { + title: "copy-trade — mirror the best, on the rollup", + description: + "Rank real Flash V2 leaders by win rate, follow one, and mirror their trades onto your own account with collateral-ratio sizing — manual or capped auto-copy, non-custodial. Liquid Glass UI on a MagicBlock Ephemeral Rollup.", +}; + +export const viewport: Viewport = { + themeColor: "#0b0d0c", +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ); +} diff --git a/examples/copy-trade/app/page.tsx b/examples/copy-trade/app/page.tsx new file mode 100644 index 0000000..9bf6460 --- /dev/null +++ b/examples/copy-trade/app/page.tsx @@ -0,0 +1,12 @@ +// ───────────────────────────────────────────────────────────────────────────── +// app/page.tsx — the single route. Hands straight off to the client app: +// everything (wallet, stream, taps) is browser state by design. +// THE HARD PART: none here — see components/app.tsx for the orchestration. +// GOTCHAS.md → (no API gotchas here) (../../GOTCHAS.md) +// ───────────────────────────────────────────────────────────────────────────── + +import App from "@/components/app"; + +export default function Page() { + return ; +} diff --git a/examples/copy-trade/components/app.tsx b/examples/copy-trade/components/app.tsx new file mode 100644 index 0000000..6d2057f --- /dev/null +++ b/examples/copy-trade/components/app.tsx @@ -0,0 +1,711 @@ +// ───────────────────────────────────────────────────────────────────────────── +// components/app.tsx — the copy-trade orchestrator, Liquid Glass. +// Flow: connect wallet → Enable One-Click Trading (account + session key) → +// pick a leader off the ranked board (or paste one) → their live book streams +// in → mirror their opens/closes onto YOUR account (manual one-tap by default, +// capped auto opt-in). The follower signs every mirror with their own session +// key; the app never holds keys and never moves funds outside an explicit trade. +// ───────────────────────────────────────────────────────────────────────────── + +"use client"; + +import { PhantomWalletAdapter } from "@solana/wallet-adapter-phantom"; +import { ConnectionProvider, WalletProvider, useAnchorWallet, useWallet } from "@solana/wallet-adapter-react"; +import { SolflareWalletAdapter } from "@solana/wallet-adapter-solflare"; +import { type PositionMetrics, type TradeType } from "flash-v2"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { flash, explorerTx } from "@/lib/flash"; +import { computePositionView, fmtAmount, fmtUsd, parseAmount, shortKey } from "@/lib/format"; +import { useBalances, useBasketBalance, useOwner, useUsdcMint } from "@/lib/hooks"; +import { loadSession, type LoadedSession } from "@/lib/session"; +import { makeSessionSigner } from "@/lib/signer"; +import { enableOneClickTrading, type EnableState } from "@/lib/enable"; +import { useLeaderboard, isLikelyPubkey, type LeaderRow } from "@/lib/leaders"; +import { useCopyEngine, type CopyConfig } from "@/lib/copy-engine"; +import FundsSheet from "@/components/funds-sheet"; + +// ── providers ──────────────────────────────────────────────────────────────── +export default function App() { + const wallets = useMemo(() => [new PhantomWalletAdapter(), new SolflareWalletAdapter()], []); + return ( + + + + + + ); +} + +// ── live prices (for honest mark-price PnL on both books) ────────────────────── +function usePrices(pollMs = 2000): Record { + const [prices, setPrices] = useState>({}); + useEffect(() => { + let dead = false; + const tick = async () => { + try { + const all = await flash.prices(); + if (dead) return; + const out: Record = {}; + for (const [sym, p] of Object.entries(all)) out[sym] = Number(p.priceUi); + setPrices(out); + } catch { /* keep last */ } + }; + void tick(); + const t = setInterval(tick, pollMs); + return () => { dead = true; clearInterval(t); }; + }, [pollMs]); + return prices; +} + +function AppInner() { + const { publicKey, connected } = useWallet(); + const walletCtx = useWallet(); + const anchorWallet = useAnchorWallet(); + const owner = publicKey?.toBase58() ?? null; + + const usdcMint = useUsdcMint(); + const { snapshot, loaded } = useOwner(owner); + const balances = useBalances(owner, usdcMint); + const { bal: basketBal, refresh: refreshBasket } = useBasketBalance(owner, snapshot?.basketPubkey ?? null, usdcMint); + const prices = usePrices(); + + const basketExists = Boolean(snapshot?.basketPubkey); + const inBasketUsd = basketBal?.inBasketUsd ?? 0; + // follower collateral the engine sizes against: deposited (free) + already at work + const marginInUse = Object.values(snapshot?.positionMetrics ?? {}).reduce((s, p) => s + (Number(p.collateralUsdUi) || 0), 0); + const followerCollateralUsd = inBasketUsd + marginInUse; + + // session signer (popup-free mirror signing) — present once Enabled + const [session, setSession] = useState(null); + useEffect(() => { setSession(owner ? loadSession(owner) : null); }, [owner]); + const signer = useMemo(() => { + if (!walletCtx.publicKey || !walletCtx.signTransaction || !walletCtx.signAllTransactions || !session) return null; + const sw = { publicKey: walletCtx.publicKey, signTransaction: walletCtx.signTransaction, signAllTransactions: walletCtx.signAllTransactions }; + return makeSessionSigner(sw, session, flash.network); + }, [walletCtx.publicKey, walletCtx.signTransaction, walletCtx.signAllTransactions, session]); + + // ── leader selection ──────────────────────────────────────────────────────── + const board = useLeaderboard(); + const [leader, setLeader] = useState(null); + const [paste, setPaste] = useState(""); + const leaderRow = useMemo(() => board.leaders.find((l) => l.owner === leader) ?? null, [board.leaders, leader]); + + // ── copy config ────────────────────────────────────────────────────────────── + const [cfg, setCfg] = useState({ mode: "manual", armed: false, budgetUsd: 50, maxPerTradeUsd: 15 }); + const setC = (p: Partial) => setCfg((c) => ({ ...c, ...p })); + const [fundsOpen, setFundsOpen] = useState(false); + + const engine = useCopyEngine({ leader, signer, followerCollateralUsd, config: cfg }); + + // ── navigation: board ⇄ a leader. An armed auto session KEEPS RUNNING when you + // return to the board; leaving only stops it if it isn't armed. One at a time. ─ + const [view, setView] = useState<"board" | "leader">("board"); + const running = cfg.armed && Boolean(leader); // 0 or 1 live auto sessions + const openLeader = useCallback((o: string) => { + if (cfg.armed && leader && o !== leader) return; // locked to the running leader — Stop to switch + setLeader(o); setView("leader"); setPaste(""); + }, [cfg.armed, leader]); + const goBoard = useCallback(() => { + setView("board"); + if (!cfg.armed) setLeader(null); // not armed ⇒ stop on leave (no background stream) + }, [cfg.armed]); + + // ── global Stop: disarm auto AND market-close every open copied position ────── + const [stopping, setStopping] = useState(false); + const stopAll = useCallback(async () => { + setCfg((c) => ({ ...c, armed: false })); + const positions = Object.values(snapshot?.positionMetrics ?? {}); + if (signer && positions.length > 0) { + setStopping(true); + let failed = 0; + // close every position independently — one failure must not abort the rest + // (this is the kill switch). Any that fail stay visibly open in the header. + for (const p of positions) { + try { + const built = await flash.closePosition({ + marketSymbol: p.marketSymbol, side: p.sideUi.toUpperCase() as TradeType, + inputUsdUi: "0", withdrawTokenSymbol: "USDC", owner: signer.owner, + ...signer.tradeFields, + }); + if (built.transactionBase64) await signer.sendTrade(built.transactionBase64); + } catch (e) { + failed++; + console.error(`close-all: ${p.marketSymbol} ${p.sideUi} did not close`, e); + } + } + void balances.refresh(); void refreshBasket(); + setStopping(false); + if (failed > 0) console.warn(`close-all: ${failed}/${positions.length} still open — they remain in your book`); + } + setView("board"); + setLeader(null); + }, [signer, snapshot, balances, refreshBasket]); + + // ── enable (account setup + session key) ───────────────────────────────────── + const [enabling, setEnabling] = useState(false); + const [enableState, setEnableState] = useState(null); + const [err, setErr] = useState(null); + const runEnable = useCallback(async () => { + if (enabling || !walletCtx.publicKey || !anchorWallet || !walletCtx.signTransaction) return; + setEnabling(true); setErr(null); + try { + const res = await enableOneClickTrading({ + wallet: { publicKey: walletCtx.publicKey, signTransaction: walletCtx.signTransaction, signAllTransactions: walletCtx.signAllTransactions }, + anchorWallet, snapshot, usdcMint, + balances: { sol: balances.sol, usdc: balances.usdc }, + onStep: setEnableState, onLog: () => {}, + }); + if (res.session) setSession(res.session); + void balances.refresh(); void refreshBasket(); + } catch (e) { setErr((e as Error).message); } + finally { setEnabling(false); } + }, [enabling, walletCtx, anchorWallet, snapshot, usdcMint, balances, refreshBasket]); + + const followerPositions = Object.values(snapshot?.positionMetrics ?? {}); + const ready = basketExists && Boolean(signer); + + return ( +
+
walletCtx.select?.(walletCtx.wallets[0]?.adapter.name ?? null)} + onBack={view === "leader" ? goBoard : undefined} + streamStatus={view === "leader" && leader ? engine.status : null} + running={running} + runningLeader={leader ? shortKey(leader) : null} + copiedCount={followerPositions.length} + onResume={() => setView("leader")} + /> + + {view === "leader" && leader ? ( +
+ + setFundsOpen(true)} + cfg={cfg} + setC={setC} + engine={engine} + followerPositions={followerPositions} + prices={prices} + /> +
+ ) : ( + setView("leader")} + running={running} + copiedCount={followerPositions.length} + onStopAll={stopAll} + stopping={stopping} + /> + )} +

+ Mainnet · real funds. You take the leader's risk — start small. +

+ + setFundsOpen(false)} + walletCtx={walletCtx.publicKey && walletCtx.signTransaction + ? { publicKey: walletCtx.publicKey, signTransaction: walletCtx.signTransaction, signAllTransactions: walletCtx.signAllTransactions } + : null} + usdcMint={usdcMint} + walletUsdc={balances.usdc} + walletSol={balances.sol} + inBasketUsd={basketBal?.inBasketUsd ?? null} + rollupAssets={basketBal?.assets ?? null} + onLog={() => {}} + onMoved={() => { void balances.refresh(); void refreshBasket(); }} + /> +
+ ); +} + +// ════════════════════════════════════════════════════════════════════════════ +// Header +// ════════════════════════════════════════════════════════════════════════════ +function Header({ connected, address, inBasketUsd, onConnect, onBack, streamStatus, running, runningLeader, copiedCount, onResume }: { + connected: boolean; address: string | null; inBasketUsd: number | null; onConnect: () => void; onBack?: () => void; streamStatus: string | null; + running: boolean; runningLeader: string | null; copiedCount: number; onResume: () => void; +}) { + return ( +
+
+ {onBack ? ( + + ) : ( + <> + + + +

Copy Trade

+ + )} + {streamStatus && ( + + + {streamStatus} + + )} + {running ? ( + + ) : copiedCount > 0 ? ( + + {copiedCount} open + + ) : null} +
+ {connected ? ( +
+ {inBasketUsd !== null && ( + {fmtUsd(inBasketUsd)} + )} + {address ? shortKey(address) : ""} +
+ ) : ( + + )} +
+ ); +} + +// ════════════════════════════════════════════════════════════════════════════ +// Discover — the leaderboard + paste-a-leader +// ════════════════════════════════════════════════════════════════════════════ +function Discover({ board, paste, setPaste, onFollow, prices, lockedTo, onResume, running, copiedCount, onStopAll, stopping }: { + board: ReturnType; paste: string; setPaste: (s: string) => void; onFollow: (o: string) => void; prices: Record; + lockedTo: string | null; onResume: () => void; running: boolean; copiedCount: number; onStopAll: () => void; stopping: boolean; +}) { + void prices; + // Pick one or more metric chips — multiple combine into a normalized blend + // (e.g. Top PnL + Most trades = strong on both). "In a trade" is independent. + const [picked, setPicked] = useState>(() => new Set(["pnl"])); + const [liveOnly, setLiveOnly] = useState(false); + const toggleMetric = (k: string) => setPicked((prev) => { + const n = new Set(prev); if (n.has(k)) n.delete(k); else n.add(k); + if (n.size === 0) n.add("pnl"); // never empty — fall back to Top PnL + return n; + }); + const metrics: { k: string; label: string }[] = [ + { k: "pnl", label: "Top PnL" }, { k: "win", label: "Win rate" }, { k: "vol", label: "Volume" }, { k: "trades", label: "Most trades" }, + ]; + const shown = useMemo(() => { + const base = liveOnly ? board.leaders.filter((l) => (board.openByOwner[l.owner] ?? 0) > 0) : board.leaders; + const val = (l: LeaderRow, k: string) => k === "win" ? l.win_rate : k === "vol" ? l.total_volume_usd : k === "trades" ? l.num_trades : l.net_pnl; + // normalize each picked metric to 0..1 across the board so they're comparable, then sum + const ranges = [...picked].map((k) => { + const vals = base.map((l) => val(l, k)); + const min = Math.min(...vals); const span = (Math.max(...vals) - min) || 1; + return { k, min, span }; + }); + const score = (l: LeaderRow) => ranges.reduce((s, r) => s + (val(l, r.k) - r.min) / r.span, 0); + return [...base].sort((a, b) => score(b) - score(a)).slice(0, 12); + }, [board.leaders, board.openByOwner, picked, liveOnly]); + + return ( +
+
+
+ live · ranked +

Copy a winner.

+

Pick a leader. Mirror their trades.

+
+ {(running || copiedCount > 0) && ( + + )} +
+ + {lockedTo && ( + + )} + +
+ +
+ + {/* metric chips — pick one or combine several — plus an independent "in a trade" */} +
+
+ {metrics.map((m) => { + const on = picked.has(m.k); + return ( + + ); + })} +
+ +
+ +
+ {board.loading && board.leaders.length === 0 && ( + Array.from({ length: 6 }).map((_, i) =>
) + )} + {board.error && board.leaders.length === 0 && ( +
Couldn't load the leaderboard: {board.error}
+ )} + {!board.loading && !board.error && shown.length === 0 && ( +
No leaders match those filters.
+ )} + {shown.map((l) => ( + 0} openCount={board.openByOwner[l.owner] ?? 0} onFollow={() => onFollow(l.owner)} locked={Boolean(lockedTo) && lockedTo !== l.owner} /> + ))} +
+
+ ); +} + +function LeaderCard({ l, live, openCount, onFollow, locked }: { l: LeaderRow; live: boolean; openCount: number; onFollow: () => void; locked?: boolean }) { + const win = l.win_rate; + return ( + + ); +} + +// ════════════════════════════════════════════════════════════════════════════ +// LeaderPanel — the followed leader's live book +// ════════════════════════════════════════════════════════════════════════════ +function LeaderPanel({ leader, row, positions, prices, status }: { + leader: string; row: LeaderRow | null; positions: PositionMetrics[]; prices: Record; status: string; +}) { + return ( +
+
+
+ {row ? `#${row.rank}` : "•"} +
+

{shortKey(leader)}

+

{row ? `${row.win_rate.toFixed(0)}% win · ${row.num_trades} trades` : "following"}

+
+
+
+ +
+ their trades + + {positions.length} open +
+ +
+ {positions.length === 0 && ( +
{status === "live" ? "No open trades right now." : "Connecting…"}
+ )} + {positions.map((p) => )} +
+
+ ); +} + +function PositionRow({ p, mark }: { p: PositionMetrics; mark: number | null }) { + const view = computePositionView(p, mark); + const long = p.sideUi.toUpperCase() === "LONG"; + return ( +
+
+ {long ? "LONG" : "SHORT"} +
+

{p.marketSymbol}

+

+ size {fmtUsd(p.sizeUsdUi)} · lev {Number(p.leverageUi || 0).toFixed(1)}× · entry {Number(p.entryPriceUi).toLocaleString()} +

+
+
+
+

pnl

+

= 0 ? "text-long" : "text-short"}`}>{view ? `${view.pnlUsd >= 0 ? "+" : ""}${fmtUsd(view.pnlUsd)}` : "—"}

+

{view ? `${view.pnlPct >= 0 ? "+" : ""}${view.pnlPct.toFixed(1)}%` : ""}

+
+
+ ); +} + +// ════════════════════════════════════════════════════════════════════════════ +// Console — your status, controls, pending mirrors, fills, your book +// ════════════════════════════════════════════════════════════════════════════ +function Console({ ready, connected, inBasketUsd, loaded, enabling, enableState, err, onEnable, onFunds, cfg, setC, engine, followerPositions, prices }: { + ready: boolean; connected: boolean; inBasketUsd: number; loaded: boolean; + enabling: boolean; enableState: EnableState | null; err: string | null; onEnable: () => void; onFunds: () => void; + cfg: CopyConfig; setC: (p: Partial) => void; engine: ReturnType; + followerPositions: PositionMetrics[]; prices: Record; +}) { + // gate: connect → enable → fund → copy + if (!connected) return ; + if (!loaded) return ; + if (!ready) return ( + + ); + if (inBasketUsd < 1) return ( + + ); + + return ( +
+ + + + +
+ ); +} + +function GateCard({ title, body, action, error, pulse }: { title: string; body: string; action?: { label: string; onClick: () => void; busy?: boolean }; error?: string | null; pulse?: boolean }) { + return ( +
+

{title}

+

{body}

+ {error &&

{error}

} + {action && ( + + )} +
+ ); +} + +function Controls({ cfg, setC, spent, inBasketUsd, onFunds }: { cfg: CopyConfig; setC: (p: Partial) => void; spent: number; inBasketUsd: number; onFunds: () => void }) { + const auto = cfg.mode === "auto"; + return ( +
+
+

How to copy

+
+ + +
+
+

+ {auto ? "Every trade they make copies automatically." : "We ask before each trade — you tap to confirm."} +

+ + {/* your money — tap to deposit / withdraw */} + + +
+ setC({ budgetUsd: v })} /> + setC({ maxPerTradeUsd: v })} /> +
+

Used {fmtUsd(spent)} of {fmtUsd(cfg.budgetUsd)}.

+ + {auto && ( + + )} + {auto && cfg.armed && ( +

Copying automatically. Keep this tab open.

+ )} +
+ ); +} + +function NumField({ label, hint, value, min = 0, onChange }: { label: string; hint?: string; value: number; min?: number; onChange: (v: number) => void }) { + const [text, setText] = useState(() => fmtAmount(value)); + const [editing, setEditing] = useState(false); + // Reflect the external value into the field only while NOT editing, so a clamp + // or reset elsewhere shows up — but a keystroke is never stomped mid-type. + useEffect(() => { if (!editing) setText(fmtAmount(value)); }, [value, editing]); + return ( + + ); +} + +function Pending({ engine }: { engine: ReturnType }) { + if (engine.pending.length === 0) return null; + return ( +
+ {engine.pending.map(({ event, sized }) => { + const opening = event.kind === "OPEN" || event.kind === "GROW"; + const long = event.side === "LONG"; + return ( +
+
+
+ {event.kind} + {event.side} {event.market} +
+ leader Δ{fmtUsd(event.deltaUsd)} +
+

+ {opening + ? `mirror ${fmtUsd(sized.collateralUsd ?? 0)} × ${event.leverage.toFixed(1)}× = ${fmtUsd(sized.sizeUsd)}` + : event.kind === "CLOSE" ? "close your matching position" : `trim ${fmtUsd(sized.sizeUsd)}`} + · ratio {sized.ratio.toFixed(2)} +

+
+ + +
+
+ ); + })} +
+ ); +} + +function Fills({ engine }: { engine: ReturnType }) { + if (engine.fills.length === 0) return null; + return ( +
+

activity

+
+ {engine.fills.map((f) => ( +
+
+ + + {f.event.kind} {f.event.side} {f.event.market} + {f.status === "skipped" && — {f.note}} + {f.status === "error" && — {f.note}} + +
+ {f.signature + ? {f.confirmMs}ms · {shortKey(f.signature)} + : {f.status}} +
+ ))} +
+
+ ); +} + +function YourBook({ positions, prices }: { positions: PositionMetrics[]; prices: Record }) { + return ( +
+
+ your copies + + {positions.length} open +
+ {positions.length === 0 + ?

Nothing copied yet.

+ :
{positions.map((p) => )}
} +
+ ); +} diff --git a/examples/copy-trade/components/funds-sheet.tsx b/examples/copy-trade/components/funds-sheet.tsx new file mode 100644 index 0000000..74d1672 --- /dev/null +++ b/examples/copy-trade/components/funds-sheet.tsx @@ -0,0 +1,347 @@ +// ───────────────────────────────────────────────────────────────────────────── +// components/funds-sheet.tsx — the EXPLICIT money controls: Deposit | Withdraw, +// per asset. THE POINT: amounts are typed by the user, every transfer is its +// own wallet approval, balances live in a labeled mini-table showing BOTH +// sides of EVERY asset — a nonzero rollup balance must never hide behind the +// selector (orphan prevention). Withdraw renders its two approvals as step-dot +// rows (request → execute) with a visible recovery path ("Execute again"). +// GOTCHAS.md → "Funds move on consent only" +// ───────────────────────────────────────────────────────────────────────────── + +"use client"; + +import { useState } from "react"; +import Sheet from "@/components/sheet"; +import { depositToken, executeWithdrawalToken, withdrawToken, type FundsStep } from "@/lib/funds"; +import type { EnableWalletCtx } from "@/lib/enable"; +import { explorerTx } from "@/lib/flash"; +import { fmtMs, parseAmount, shortKey } from "@/lib/format"; +import type { BasketAsset, LatencyEntry } from "@/lib/hooks"; + +type Tab = "deposit" | "withdraw"; + +/** Selectable transfer assets. USDC always; SOL arms once its mint is known + * (from the rollup-side assets array — the same source that proves a balance + * exists to withdraw). */ +type Asset = "USDC" | "SOL"; + +type DotStatus = "idle" | "active" | "done" | "error"; + +/** Same dot language as the enable sheet's per-step rows. */ +function StepDot({ status }: { status: DotStatus }) { + const cls = + status === "done" + ? "bg-long" + : status === "active" + ? "soft-pulse bg-long" + : status === "error" + ? "bg-short" + : "bg-edge"; + return ; +} + +export default function FundsSheet({ + open, + onClose, + walletCtx, + usdcMint, + walletUsdc, + walletSol, + inBasketUsd, + rollupAssets = null, + onLog, + onMoved, +}: { + open: boolean; + onClose: () => void; + /** null while disconnected — the sheet renders a connect hint. */ + walletCtx: EnableWalletCtx | null; + usdcMint: string | null; + walletUsdc: number | null; + /** Wallet SOL (base chain) — both sides of every asset stay visible. */ + walletSol: number | null; + inBasketUsd: number | null; + /** EVERY asset with rollup-side availability (useBasketBalance().assets) — + * rendered even when unselected so nothing orphans invisibly. */ + rollupAssets?: BasketAsset[] | null; + onLog: (e: Omit) => void; + /** Called after any confirmed transfer — refresh balances + snapshot. */ + onMoved: () => void; +}) { + const [tab, setTab] = useState("deposit"); + const [asset, setAsset] = useState("USDC"); + const [amount, setAmount] = useState(""); + const [busy, setBusy] = useState(false); + const [step, setStep] = useState(null); + const [execPending, setExecPending] = useState(false); + /** The mint/symbol mid-withdrawal — "Execute again" must never switch assets. */ + const [execAsset, setExecAsset] = useState<{ mint: string; symbol: string } | null>(null); + + // Rollup-side figures per asset. USDC has its own calibrated figure + // (inBasketUsd); everything else reads from the assets array. + const solAsset = rollupAssets?.find((a) => a.symbol.toUpperCase() === "SOL") ?? null; + const otherAssets = (rollupAssets ?? []).filter( + (a) => a.symbol.toUpperCase() !== "USDC" && a.symbol.toUpperCase() !== "SOL", + ); + const solLive = solAsset !== null; // mint known → SOL transfers are routable + const activeMint = asset === "USDC" ? usdcMint : (solAsset?.mint ?? null); + + const walletBal = asset === "USDC" ? walletUsdc : walletSol; + const rollupBal = asset === "USDC" ? inBasketUsd : (solAsset?.amountUi ?? 0); + const max = tab === "deposit" ? walletBal : rollupBal; + const dp = asset === "USDC" ? 2 : 4; // display/“max” precision per asset + const parsed = parseAmount(amount); + const valid = Number.isFinite(parsed) && parsed > 0 && (max === null || parsed <= max + 1e-9); + const locked = busy; // mid-approval — don't let the sheet close under the wallet popup + + const switchTab = (t: Tab) => { + if (busy) return; + setTab(t); + setAmount(""); + setStep(null); + }; + const switchAsset = (a: Asset) => { + if (busy || (a === "SOL" && !solLive)) return; + setAsset(a); + setAmount(""); + setStep(null); + }; + + const run = async () => { + if (!walletCtx || !activeMint || !valid || busy) return; + setBusy(true); + setExecPending(false); + setStep(null); + if (tab === "withdraw") setExecAsset({ mint: activeMint, symbol: asset }); + const args = { + wallet: walletCtx, + tokenMint: activeMint, + symbol: asset, + amount: String(parsed), + onStep: setStep, + onLog, + }; + const res = tab === "deposit" ? await depositToken(args) : await withdrawToken(args); + if (res.ok) { + setAmount(""); + onMoved(); + } else if ("executePending" in res && res.executePending) { + setExecPending(true); + } + setBusy(false); + }; + + const retryExecute = async () => { + const target = execAsset ?? (usdcMint ? { mint: usdcMint, symbol: "USDC" } : null); + if (!walletCtx || !target || busy) return; + setBusy(true); + const res = await executeWithdrawalToken({ + wallet: walletCtx, + tokenMint: target.mint, + symbol: target.symbol, + onStep: setStep, + onLog, + }); + if (res.ok) { + setExecPending(false); + onMoved(); + } + setBusy(false); + }; + + return ( + +

Funds

+

+ Your money moves only here — your amount, your approval, one transfer at a time. +

+ + {/* segmented tab */} +
+ {(["deposit", "withdraw"] as const).map((t) => ( + + ))} +
+ + {/* asset selector — explicit choice of WHAT moves. SOL arms once its + rollup-side mint is known (it appears in the assets array). */} +
+ asset + +
+
+ {(["USDC", "SOL"] as const).map((a) => { + const enabled = a === "USDC" || solLive; + return ( + + ); + })} +
+ + {/* balances as a mini-table — BOTH sides of EVERY asset stay visible + (a nonzero rollup balance must never hide behind the selector); the + row money moves FROM under the active tab carries the "from" tag */} +
+ {([ + ["wallet USDC (base chain)", walletUsdc === null ? "—" : `$${walletUsdc.toFixed(2)}`, tab === "deposit" && asset === "USDC"], + ["wallet SOL (base chain)", walletSol === null ? "—" : `${walletSol.toFixed(4)} SOL`, tab === "deposit" && asset === "SOL"], + ["in basket USDC (tradable)", inBasketUsd === null ? "—" : `$${inBasketUsd.toFixed(2)}`, tab === "withdraw" && asset === "USDC"], + ["in basket SOL", solAsset === null ? "—" : `${solAsset.amountUi.toFixed(4)} SOL`, tab === "withdraw" && asset === "SOL"], + ...otherAssets.map( + (a): [string, string, boolean] => [ + `in basket ${a.symbol}`, + `${a.amountUi.toFixed(Math.min(a.decimals, 4))} ${a.symbol}`, + false, + ], + ), + ] as Array<[string, string, boolean]>).map(([label, v, isSource], i) => ( +
0 ? "border-t border-edge" : ""}`} + > + + {label} + {isSource && · from} + + + {v} + +
+ ))} +
+ + {/* amount */} +
+ setAmount(e.target.value.replace(/[^0-9.,\s]/g, ""))} + disabled={busy} + className="h-11 rounded-[3px] border border-edge bg-panel px-3.5 font-mono text-sm tabular-nums text-ink outline-none transition-colors placeholder:text-faint focus:border-long/45 disabled:opacity-35" + /> + +
+ + {!walletCtx ? ( +

connect a wallet first (top right)

+ ) : ( + + )} + + {/* withdraw = two approvals — same step-dot rows as the enable sheet. + Statuses derive from the live FundsStep (request… labels = stage 1). */} + {tab === "withdraw" && ( +
    + {( + [ + ["request", "queues settlement off the rollup", "approval 1"], + ["execute", "funds land back in your wallet", "approval 2"], + ] as const + ).map(([name, note, tag], i) => { + const stage = step === null ? null : /request/i.test(step.label) ? 0 : 1; + const status: DotStatus = + stage === null || stage < i + ? "idle" + : stage > i + ? "done" + : step!.phase === "error" + ? "error" + : step!.phase === "done" + ? "done" + : "active"; + return ( +
  1. + + + {name} + + — {note} + {tag} +
  2. + ); + })} +
+ )} + + {/* live step status */} + {step && ( +
+

+ {step.label} + {step.note ? ` — ${step.note}` : ""} +

+ {step.signature && step.ms !== undefined && ( + + {fmtMs(step.ms)} · {shortKey(step.signature)} + + )} + {execPending && !busy && ( + + )} +
+ )} +
+ ); +} diff --git a/examples/copy-trade/components/rolling-number.tsx b/examples/copy-trade/components/rolling-number.tsx new file mode 100644 index 0000000..248c09f --- /dev/null +++ b/examples/copy-trade/components/rolling-number.tsx @@ -0,0 +1,73 @@ +// ───────────────────────────────────────────────────────────────────────────── +// components/rolling-number.tsx — bloxwap's signature slot-reel price: each +// digit is a vertical 0-9 column, translateY'd to the active digit with the +// app's spring-out easing. THE HARD PART: column keys must be stable while the +// string length is constant, or React remounts and the roll never animates. +// GOTCHAS.md → (pure UI; no API gotchas) (../../GOTCHAS.md) +// ───────────────────────────────────────────────────────────────────────────── + +"use client"; + +import { useEffect, useState } from "react"; + +const DIGITS = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]; + +function Reel({ digit }: { digit: number }) { + return ( + + + {DIGITS.map((d) => ( + + {d} + + ))} + + + ); +} + +/** + * Renders a pre-formatted numeric string (e.g. "$64.42") with rolling digits. + * Separators ($ . , −) are static layers. Monospace + tabular so columns + * never shift horizontally; a length change remounts (acceptable jump). + */ +export default function RollingNumber({ + value, + className = "", +}: { + value: string | null; + className?: string; +}) { + // HYDRATION GUARD: render plain text until after mount — the reels' inline + // translateY styles must never participate in hydration (React #418). + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + if (value === null || !mounted) { + return {value ?? "$—"}; + } + const chars = Array.from(value); + return ( + // items-start (NOT baseline): every child is an identical h-[1em] + // leading-[1em] box, so top-aligning the boxes puts separators ($ . ,) + // and reel digits on the exact same optical baseline. Baseline alignment + // breaks here because an overflow-hidden inline-block's flex baseline is + // its box bottom, not its glyph baseline — the $ drifts. + + {chars.map((ch, i) => + /[0-9]/.test(ch) ? ( + + ) : ( + + {ch} + + ), + )} + + ); +} diff --git a/examples/copy-trade/components/sheet.tsx b/examples/copy-trade/components/sheet.tsx new file mode 100644 index 0000000..90828b6 --- /dev/null +++ b/examples/copy-trade/components/sheet.tsx @@ -0,0 +1,64 @@ +// ───────────────────────────────────────────────────────────────────────────── +// components/sheet.tsx — the one bottom sheet: hairline notch, slide-up +// transform, backdrop tap-to-close, Escape, max-h-[80dvh]. THE HARD PART: +// content stays MOUNTED while hidden (translate-y-full + pointer-events-none) +// so live state inside (fee previews, step rows) never resets mid-flow. +// GOTCHAS.md → (pure UI; no API gotchas) (../../GOTCHAS.md) +// ───────────────────────────────────────────────────────────────────────────── + +"use client"; + +import { useEffect } from "react"; + +export default function Sheet({ + open, + onClose, + label, + locked = false, + children, +}: { + open: boolean; + onClose: () => void; + /** Accessible name for the dialog. */ + label: string; + /** True while mid-signing — the sheet must not be escapable then. */ + locked?: boolean; + children: React.ReactNode; +}) { + useEffect(() => { + if (!open || locked) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open, locked, onClose]); + + return ( +
+
+
+
+ +
+
+ {children} +
+
+
+ ); +} diff --git a/examples/copy-trade/lib/copy-engine.ts b/examples/copy-trade/lib/copy-engine.ts new file mode 100644 index 0000000..4801d0a --- /dev/null +++ b/examples/copy-trade/lib/copy-engine.ts @@ -0,0 +1,338 @@ +// ───────────────────────────────────────────────────────────────────────────── +// lib/copy-engine.ts — the copy loop, browser-side and non-custodial. +// +// THE HARD PARTS (all carried over from the CLI, now live in a hook): +// 1. There is NO "trades feed" — you DIFF consecutive `basket` frames of the +// leader's owner stream into events: OPEN / GROW / SHRINK / CLOSE. +// 2. You size by COLLATERAL RATIO (follower collateral ÷ leader collateral), +// never raw size — that is how a small follower copying a whale gets +// instantly liquidated. Then a hard per-trade cap and a budget ceiling. +// 3. The follower signs their OWN mirror txs with their OWN session key — the +// app never holds keys and never moves funds outside an explicit trade. +// 4. metrics frames re-price the SAME position every ~250ms; only `basket` +// frames are settlement truth, so we diff ONLY basket frames (GOTCHAS §9). +// 5. The $11-after-fees floor (GOTCHAS §16): a mirror whose collateral lands +// below the floor is SKIPPED and SAID SO — never silently up-sized. +// +// Default mode is MANUAL: every event becomes a one-tap "mirror this?" card. +// AUTO is opt-in, armed, and bounded by the budget + per-trade cap + kill switch. +// ───────────────────────────────────────────────────────────────────────────── + +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { + subscribeOwner, + RECOMMENDED_MIN_COLLATERAL_USD, + type BasketSnapshot, + type PositionMetrics, + type TradeType, +} from "flash-v2"; +import { flash } from "./flash"; +import type { ActiveSigner } from "./signer"; + +// ── events ───────────────────────────────────────────────────────────────── +export type MirrorKind = "OPEN" | "GROW" | "SHRINK" | "CLOSE"; + +export interface MirrorEvent { + id: string; + at: number; + kind: MirrorKind; + market: string; + side: TradeType; + /** leader size change, USD notional */ + deltaUsd: number; + /** leader's current leverage (OPEN/GROW) */ + leverage: number; + leaderCollateralUsd: number; +} + +/** A position is keyed market+side (V2 allows one long + one short per market). */ +const keyOf = (p: PositionMetrics): string => `${p.marketSymbol}:${p.sideUi.toUpperCase()}`; + +/** Diff two leader snapshots into mirror events. Pure — also unit-testable. */ +export function diffPositions(prev: BasketSnapshot | undefined, next: BasketSnapshot): Omit[] { + const out: Omit[] = []; + const before = new Map(Object.values(prev?.positionMetrics ?? {}).map((p) => [keyOf(p), p])); + const after = new Map(Object.values(next.positionMetrics ?? {}).map((p) => [keyOf(p), p])); + + for (const [k, now] of after) { + const was = before.get(k); + const side = now.sideUi.toUpperCase() as TradeType; + const sizeNow = Number(now.sizeUsdUi); + const lev = Number.parseFloat(now.leverageUi) || 1; + const col = Number(now.collateralUsdUi); + if (!was) { + out.push({ kind: "OPEN", market: now.marketSymbol, side, deltaUsd: sizeNow, leverage: lev, leaderCollateralUsd: col }); + } else { + const delta = sizeNow - Number(was.sizeUsdUi); + if (Math.abs(delta) > 0.01) { + out.push({ + kind: delta > 0 ? "GROW" : "SHRINK", + market: now.marketSymbol, side, deltaUsd: Math.abs(delta), leverage: lev, leaderCollateralUsd: col, + }); + } + } + } + for (const [k, was] of before) { + if (!after.has(k)) { + out.push({ + kind: "CLOSE", market: was.marketSymbol, side: was.sideUi.toUpperCase() as TradeType, + deltaUsd: Number(was.sizeUsdUi), leverage: 1, leaderCollateralUsd: Number(was.collateralUsdUi), + }); + } + } + return out; +} + +// ── sizing ─────────────────────────────────────────────────────────────────── +export interface CopyConfig { + /** manual = surface a card per event; auto = execute when armed + in budget. */ + mode: "manual" | "auto"; + /** auto is only live while this is true (the arm toggle). */ + armed: boolean; + /** total USD the copier may ever deploy this session (budget ceiling). */ + budgetUsd: number; + /** hard cap on any single mirror's notional. */ + maxPerTradeUsd: number; + /** optional fixed ratio override; otherwise collateral ratio is used. */ + ratioOverride?: number; +} + +export interface SizedMirror { + /** follower collateral to post (USDC), null if this is a close. */ + collateralUsd: number | null; + /** mirror notional USD (size). */ + sizeUsd: number; + /** "0" full close, a USD string for partial, or null for opens. */ + closeUsdUi: string | null; + ratio: number; + /** present when the mirror can't be placed honestly — show, don't execute. */ + skip?: string; +} + +/** Size one event. Identical math for the preview card and the executed trade. */ +export function sizeMirror( + e: Pick, + cfg: CopyConfig, + followerCollateralUsd: number, + spentUsd: number, +): SizedMirror { + const ratio = + cfg.ratioOverride ?? + (e.leaderCollateralUsd > 0 && followerCollateralUsd > 0 ? followerCollateralUsd / e.leaderCollateralUsd : 0); + + if (e.kind === "CLOSE") { + return { collateralUsd: null, sizeUsd: e.deltaUsd * ratio, closeUsdUi: "0", ratio }; + } + if (e.kind === "SHRINK") { + const usd = Math.min(e.deltaUsd * ratio, cfg.maxPerTradeUsd); + return { collateralUsd: null, sizeUsd: usd, closeUsdUi: usd.toFixed(2), ratio }; + } + + // OPEN / GROW + const budgetLeft = Math.max(0, cfg.budgetUsd - spentUsd); + const sizeUsd = Math.min(e.deltaUsd * ratio, cfg.maxPerTradeUsd, budgetLeft * e.leverage); + const collateralUsd = sizeUsd / (e.leverage || 1); + // ratio hits 0 for two very different reasons — say which. Don't blame the + // follower's wallet when it's the leader's position that can't be sized. + if (cfg.ratioOverride == null && ratio <= 0) { + if (followerCollateralUsd <= 0) return { collateralUsd, sizeUsd, closeUsdUi: null, ratio, skip: "deposit USDC to copy" }; + // leader collateral ≈ 0 ⇒ near-infinite leverage; a proportional mirror is + // undefined (you can't take a fraction of zero), so skip and say so. + return { collateralUsd, sizeUsd, closeUsdUi: null, ratio, skip: "leader has ~0 collateral (∞ leverage) — can't size" }; + } + if (ratio <= 0) return { collateralUsd, sizeUsd, closeUsdUi: null, ratio, skip: "can't size this trade" }; + if (budgetLeft <= 0) return { collateralUsd, sizeUsd, closeUsdUi: null, ratio, skip: "budget used up" }; + // FLOORING collateral while keeping leader leverage would inflate the mirror + // (a $4 copy becoming $44) — too small to mirror honestly, so skip + say so. + if (collateralUsd < RECOMMENDED_MIN_COLLATERAL_USD) { + return { collateralUsd, sizeUsd, closeUsdUi: null, ratio, skip: `too small to copy (min $${RECOMMENDED_MIN_COLLATERAL_USD})` }; + } + return { collateralUsd, sizeUsd, closeUsdUi: null, ratio }; +} + +// ── live fills ──────────────────────────────────────────────────────────────── +export interface MirrorFill { + id: string; + at: number; + event: MirrorEvent; + sized: SizedMirror; + status: "executing" | "done" | "skipped" | "error"; + signature?: string; + confirmMs?: number; + note?: string; +} + +export type EngineStatus = "idle" | "connecting" | "live" | "reconnecting" | "polling" | "stopped"; + +export interface CopyEngineState { + status: EngineStatus; + /** the leader's current live positions (rendered as their open book). */ + leaderPositions: PositionMetrics[]; + /** newest first — every event we surfaced (pending manual cards + history). */ + pending: { event: MirrorEvent; sized: SizedMirror }[]; + fills: MirrorFill[]; + spentUsd: number; +} + +let _seq = 0; +const uid = () => `${++_seq}`; + +/** + * The copy engine. Streams `leader`, diffs basket frames, and either queues a + * one-tap mirror (manual) or executes it with the follower's session signer + * (auto, armed, in budget). `kill()` stops the stream and clears the queue. + */ +export function useCopyEngine(args: { + leader: string | null; + signer: ActiveSigner | null; + followerCollateralUsd: number; + config: CopyConfig; +}): CopyEngineState & { + confirm: (eventId: string) => void; + dismiss: (eventId: string) => void; +} { + const { leader, signer, followerCollateralUsd, config } = args; + const [state, setState] = useState({ + status: "idle", + leaderPositions: [], + pending: [], + fills: [], + spentUsd: 0, + }); + + // refs so the stream callback always sees the latest config/signer/collateral + const cfgRef = useRef(config); + cfgRef.current = config; + const signerRef = useRef(signer); + signerRef.current = signer; + const colRef = useRef(followerCollateralUsd); + colRef.current = followerCollateralUsd; + const spentRef = useRef(0); + + // ── execute one sized mirror against the follower's account ──────────────── + const execute = useCallback(async (event: MirrorEvent, sized: SizedMirror) => { + const sg = signerRef.current; + const fillId = uid(); + if (!sg) return; + const startFill: MirrorFill = { id: fillId, at: Date.now(), event, sized, status: "executing" }; + setState((s) => ({ ...s, fills: [startFill, ...s.fills].slice(0, 60) })); + const patch = (p: Partial) => + setState((s) => ({ ...s, fills: s.fills.map((f) => (f.id === fillId ? { ...f, ...p } : f)) })); + try { + if (event.kind === "OPEN" || event.kind === "GROW") { + const built = await flash.openPosition({ + inputTokenSymbol: "USDC", outputTokenSymbol: event.market, + inputAmountUi: (sized.collateralUsd ?? 0).toFixed(2), leverage: event.leverage, + tradeType: event.side, orderType: "MARKET", owner: sg.owner, + slippagePercentage: "0.8", // wider than the leader — you fill a beat later + ...sg.tradeFields, + }); + if (!built.transactionBase64) throw new Error("no transaction from open-position"); + const { signature, confirmMs } = await sg.sendTrade(built.transactionBase64); + spentRef.current += sized.collateralUsd ?? 0; + setState((s) => ({ ...s, spentUsd: spentRef.current })); + patch({ status: "done", signature, confirmMs }); + } else { + const built = await flash.closePosition({ + marketSymbol: event.market, side: event.side, + inputUsdUi: sized.closeUsdUi ?? "0", withdrawTokenSymbol: "USDC", owner: sg.owner, + ...sg.tradeFields, + }); + if (!built.transactionBase64) throw new Error("no transaction from close-position"); + const { signature, confirmMs } = await sg.sendTrade(built.transactionBase64); + patch({ status: "done", signature, confirmMs }); + } + } catch (e) { + patch({ status: "error", note: (e as Error).message }); + } + }, []); + + // ── route an event: skip · auto-execute · or queue for manual confirm ────── + const routeEvent = useCallback((raw: Omit) => { + const event: MirrorEvent = { ...raw, id: uid(), at: Date.now() }; + const sized = sizeMirror(event, cfgRef.current, colRef.current, spentRef.current); + if (sized.skip && event.kind !== "CLOSE" && event.kind !== "SHRINK") { + const skipFill: MirrorFill = { id: uid(), at: Date.now(), event, sized, status: "skipped", note: sized.skip }; + setState((s) => ({ ...s, fills: [skipFill, ...s.fills].slice(0, 60) })); + return; + } + const cfg = cfgRef.current; + if (cfg.mode === "auto" && cfg.armed && signerRef.current) { + void execute(event, sized); + } else { + setState((s) => ({ ...s, pending: [{ event, sized }, ...s.pending].slice(0, 24) })); + } + }, [execute]); + + // ── confirm / dismiss a queued manual mirror ─────────────────────────────── + const confirm = useCallback((eventId: string) => { + setState((s) => { + const hit = s.pending.find((p) => p.event.id === eventId); + if (hit) void execute(hit.event, hit.sized); + return { ...s, pending: s.pending.filter((p) => p.event.id !== eventId) }; + }); + }, [execute]); + const dismiss = useCallback((eventId: string) => { + setState((s) => ({ ...s, pending: s.pending.filter((p) => p.event.id !== eventId) })); + }, []); + + // ── the leader stream: diff basket frames, coalescing re-entrant frames ──── + useEffect(() => { + spentRef.current = 0; + setState({ status: leader ? "connecting" : "idle", leaderPositions: [], pending: [], fills: [], spentUsd: 0 }); + if (!leader) return; + + let prev: BasketSnapshot | undefined; + let baselined = false; + let processing = false; + let queued: BasketSnapshot | undefined; + let dead = false; + + const onBasket = (snap: BasketSnapshot) => { + if (dead) return; + setState((s) => ({ ...s, leaderPositions: Object.values(snap.positionMetrics ?? {}) })); + if (processing) { queued = snap; return; } + processing = true; + let next: BasketSnapshot | undefined = snap; + while (next) { + if (!baselined) { + // Baseline: adopt the leader's CURRENT book without copying it — you + // don't want to buy a position they're already mid-way through. Copy + // only their NEXT move from here. + prev = structuredClone(next); + baselined = true; + } else { + for (const raw of diffPositions(prev, next)) routeEvent(raw); + prev = structuredClone(next); + } + next = queued; + queued = undefined; + } + processing = false; + }; + + const stream = subscribeOwner({ + owner: leader, + network: flash.network, + onUpdate: (snap, source) => { + if (source === "metrics") { + // refresh the rendered book (live PnL) but never diff metrics frames + if (!dead) setState((s) => ({ ...s, leaderPositions: Object.values(snap.positionMetrics ?? {}) })); + return; + } + onBasket(snap); + }, + onStatus: (st) => { + if (dead) return; + const map: Record = { open: "live", connecting: "connecting", reconnecting: "reconnecting", polling: "polling", closed: "stopped" }; + setState((s) => ({ ...s, status: map[st] ?? s.status })); + }, + }); + + return () => { dead = true; stream.close(); }; + }, [leader, routeEvent]); + + return { ...state, confirm, dismiss }; +} diff --git a/examples/copy-trade/lib/enable.ts b/examples/copy-trade/lib/enable.ts new file mode 100644 index 0000000..e1c7726 --- /dev/null +++ b/examples/copy-trade/lib/enable.ts @@ -0,0 +1,339 @@ +// ───────────────────────────────────────────────────────────────────────────── +// lib/enable.ts — "Enable One-Click Trading": session + basket + ledger + +// delegate behind ONE wallet approval sheet (signAllTransactions). +// CONSENT RULE: NO USER FUNDS MOVE HERE. Enable is account setup only — the +// sole transfer is the disclosed 0.01 SOL rent top-up to the session key +// (recoverable on revoke). Deposits/withdrawals are EXPLICIT, amount-chosen, +// separately-approved actions in lib/funds.ts. Never bundle a deposit into a +// convenience flow in a production app. +// THE HARD PART: build EVERYTHING first (API txs arrive partially signed with +// their own blockhashes — never touch them), sign once, then submit in strict +// lifecycle order on the BASE chain. +// GOTCHAS.md → "The lifecycle has a strict order" +// ───────────────────────────────────────────────────────────────────────────── + +import type { PublicKey, Transaction, VersionedTransaction } from "@solana/web3.js"; +import { decodeTransaction, type BasketSnapshot } from "flash-v2"; +import { baseConnection, flash } from "./flash"; +import type { LatencyEntry } from "./hooks"; +import { + buildSessionTransaction, + loadSession, + persistSession, + type BuiltSessionTransaction, + type LoadedSession, + type SessionWallet, +} from "./session"; + +// ── public shapes ───────────────────────────────────────────────────────────── + +export type EnableStepId = "fund" | "session" | "basket" | "ledger" | "delegate"; +export type EnableStepStatus = "idle" | "active" | "done" | "skipped" | "error"; + +export interface EnableStepRow { + id: EnableStepId; + label: string; + status: EnableStepStatus; + note?: string; + ms?: number; + signature?: string; +} + +export interface EnableState { + phase: "precheck" | "building" | "signing" | "submitting" | "done" | "stopped"; + /** Live text for the action-zone pill ("approve in wallet…", "init-basket…"). */ + headline: string; + steps: EnableStepRow[]; + /** Set when the wallet can't pay setup rent. */ + fundingHint: string | null; + /** Fresh basket has no funds yet — surface the EXPLICIT Deposit affordance. + * (Funds never move during Enable; see lib/funds.ts.) */ + needsUsdc: boolean; + /** Fatal step error (already phrased for humans; calming when upstream). */ + error: string | null; + /** True when `error` is a known transient upstream-deploy situation. */ + upstream: boolean; +} + +export interface EnableResult { + ok: boolean; + /** Minted (and persisted) even when a later step failed — partial progress counts. */ + session: LoadedSession | null; + needsUsdc: boolean; +} + +/** The narrow slice of wallet-adapter's useWallet() that enable needs. */ +export interface EnableWalletCtx { + publicKey: PublicKey; + signTransaction: (tx: T) => Promise; + signAllTransactions?: ((txs: T[]) => Promise) | undefined; +} + +// ── error classification (per-step) ────────────────────────────────────────── + +const RE_ALREADY = /already in use|already exists|custom program error: 0x0\b/i; +const RE_UPSTREAM = /Access violation in heap|ProgramFailedToComplete/i; +const RE_REJECTED = /reject|declin|cancel/i; +const RE_STALE = /blockhash not found|block height exceeded|expired/i; +const RE_NO_SIGNALL = /signAllTransactions|not.{0,4}supported/i; + +export const UPSTREAM_MESSAGE = + "known upstream issue: the magic-trade program is temporarily unavailable (Flash may be redeploying) — your setup is fine; retry after the fix."; + +export function errText(e: unknown): string { + const msg = e instanceof Error ? e.message : String(e); + const logs = (e as { logs?: unknown })?.logs; + return Array.isArray(logs) ? `${msg} ${logs.join(" ")}` : msg; +} + +/** Shared per-tx error classification — also used by the explicit funds flows. */ +export function classifyTxError(e: unknown): { + message: string; + upstream: boolean; + stale: boolean; + rejected: boolean; +} { + const raw = errText(e); + if (RE_UPSTREAM.test(raw)) return { message: UPSTREAM_MESSAGE, upstream: true, stale: false, rejected: false }; + if (RE_REJECTED.test(raw)) + return { message: "approval declined in the wallet — nothing was sent", upstream: false, stale: false, rejected: true }; + if (RE_STALE.test(raw)) + return { message: "the approval took too long and the transaction expired — try again", upstream: false, stale: true, rejected: false }; + return { message: raw, upstream: false, stale: false, rejected: false }; +} + +// ── confirmation (the confirmAirdrop pattern: poll signature statuses) ─────── + +export async function submitAndConfirm( + tx: Transaction | VersionedTransaction, + timeoutMs = 45_000, +): Promise<{ signature: string; ms: number }> { + const raw = tx.serialize(); + const started = Date.now(); + const signature = await baseConnection.sendRawTransaction(raw, { maxRetries: 3 }); + for (;;) { + try { + const status = (await baseConnection.getSignatureStatuses([signature])).value[0]; + if (status) { + if (status.err) throw new Error(`on-chain error (${signature}): ${JSON.stringify(status.err)}`); + const level = status.confirmationStatus; + if (level === "confirmed" || level === "finalized") { + return { signature, ms: Date.now() - started }; + } + } + } catch (e) { + // A real on-chain error must surface; transient 429s while polling must not. + if (e instanceof Error && e.message.startsWith("on-chain error")) throw e; + } + if (Date.now() - started > timeoutMs) throw new Error(`confirmation timeout (${signature})`); + await new Promise((r) => setTimeout(r, 400)); + } +} + +// ── the flow ────────────────────────────────────────────────────────────────── + +export async function enableOneClickTrading(args: { + wallet: EnableWalletCtx; + anchorWallet: SessionWallet; + snapshot: BasketSnapshot | null; + usdcMint: string | null; + balances: { sol: number | null; usdc: number | null }; + onStep: (state: EnableState) => void; + onLog: (e: Omit) => void; +}): Promise { + const { wallet, anchorWallet, snapshot, usdcMint, balances, onStep, onLog } = args; + const owner = wallet.publicKey.toBase58(); + + // 1 ── what does this account still need? + const basketExists = Boolean(snapshot?.basketPubkey); + const freshSession = loadSession(owner); + const needs = { + session: !freshSession, + basket: !basketExists, + ledger: !basketExists, // basket unknown → ledger unknown → init it (idempotent-ish) + delegate: !basketExists, // a fresh basket must be handed to the MagicBlock validator + // NO deposit here — funds move only via the explicit Deposit flow (lib/funds.ts). + }; + + const state: EnableState = { + phase: "precheck", + headline: "checking wallet…", + steps: [], + fundingHint: null, + needsUsdc: false, + error: null, + upstream: false, + }; + const rows = new Map(); + const emit = () => { + state.steps = [...rows.values()]; + onStep({ ...state, steps: state.steps.map((s) => ({ ...s })) }); + }; + const row = (id: EnableStepId, label: string, patch: Partial) => { + rows.set(id, { ...(rows.get(id) ?? { id, label, status: "idle" }), label, ...patch }); + emit(); + }; + const stop = (error: string, upstream = false): EnableResult => { + state.phase = "stopped"; + state.error = error; + state.upstream = upstream; + state.headline = upstream ? "program unavailable upstream" : "setup stopped"; + emit(); + return { ok: false, session: mintedSession, needsUsdc: state.needsUsdc }; + }; + let mintedSession: LoadedSession | null = freshSession; + /** Freshly built session pieces (set in step 3, persisted in step 5 once its tx confirms). */ + let sessionParts: BuiltSessionTransaction | null = null; + + // 2 ── funding pre-check: compute the real SOL needed at runtime — mostly + // recoverable account rent (basket + deposit-ledger + session-token), + // plus the recoverable 0.01 SOL session top-up and a small fee buffer. + // No hardcoded gate: getMinimumBalanceForRentExemption is the source of truth. + const sol = balances.sol ?? 0; + emit(); + let rentLamports = 0; + if (needs.basket) rentLamports += await baseConnection.getMinimumBalanceForRentExemption(256); + if (needs.ledger) rentLamports += await baseConnection.getMinimumBalanceForRentExemption(852); + if (needs.session) rentLamports += await baseConnection.getMinimumBalanceForRentExemption(144); + const requiredSol = rentLamports / 1e9 + (needs.session ? 0.01 : 0) + 0.002; // top-up + fee buffer + if (sol < requiredSol) { + const need = requiredSol.toFixed(4); + state.fundingHint = + `this wallet needs ~${need} SOL for setup — mostly recoverable account rent plus a ` + + `0.01 SOL session top-up (recoverable on revoke), not fees. Fund ${owner} and tap Enable again.`; + return stop(`wallet needs ~${need} SOL for setup (recoverable rent + top-up)`); + } + // 3 ── BUILD everything first (no signatures yet). + state.phase = "building"; + state.headline = "preparing transactions…"; + emit(); + + type Planned = { id: EnableStepId; label: string; action: string; tx: Transaction | VersionedTransaction }; + const plan: Planned[] = []; + try { + if (needs.session) { + row("session", "create session key", { status: "active", note: "building…" }); + const built = await buildSessionTransaction(anchorWallet, baseConnection, { + }); + plan.push({ id: "session", label: "create session key", action: "create-session", tx: built.tx }); + // Persisted only after its tx confirms (step 5 below). + sessionParts = built; + row("session", "create session key", { status: "active", note: "ready to sign" }); + } else { + row("session", "create session key", { status: "skipped", note: "session still valid" }); + } + + const builders: Array<{ id: EnableStepId; label: string; action: string; build: () => Promise<{ transactionBase64: string }> }> = []; + if (needs.basket) builders.push({ id: "basket", label: "init-basket", action: "init-basket", build: () => flash.initBasket({ owner }) }); + else row("basket", "init-basket", { status: "skipped", note: "basket already on-chain" }); + if (needs.ledger) builders.push({ id: "ledger", label: "init-deposit-ledger", action: "init-deposit-ledger", build: () => flash.initDepositLedger({ owner }) }); + else row("ledger", "init-deposit-ledger", { status: "skipped", note: "already set up" }); + if (needs.delegate) builders.push({ id: "delegate", label: "delegate-basket", action: "delegate-basket", build: () => flash.delegateBasket({ payer: owner, owner }) }); + else row("delegate", "delegate-basket", { status: "skipped", note: "already delegated" }); + + // CONSENT RULE: no deposit in this batch — a fresh basket starts EMPTY and + // the user funds it via the explicit Deposit sheet (their amount, their + // separate approval). needsUsdc just surfaces that affordance. + if (!basketExists) state.needsUsdc = true; + + for (const b of builders) { + row(b.id, b.label, { status: "active", note: "building…" }); + const built = await b.build(); + // NEVER touch the blockhash — these arrive partially signed server-side. + plan.push({ id: b.id, label: b.label, action: b.action, tx: decodeTransaction(built.transactionBase64) }); + row(b.id, b.label, { status: "active", note: "ready to sign" }); + } + } catch (e) { + const raw = errText(e); + return stop(RE_UPSTREAM.test(raw) ? UPSTREAM_MESSAGE : raw, RE_UPSTREAM.test(raw)); + } + + if (plan.length === 0) { + state.phase = "done"; + state.headline = "already set up"; + emit(); + return { ok: true, session: mintedSession, needsUsdc: state.needsUsdc }; + } + + // 4 ── ONE approval for the whole bundle (fallback: sequential popups). + state.phase = "signing"; + state.headline = "approve in wallet…"; + for (const p of plan) row(p.id, p.label, { status: "active", note: "waiting for your approval…" }); + let signed: (Transaction | VersionedTransaction)[]; + try { + const toSign = plan.map((p) => p.tx); + const signAll = wallet.signAllTransactions; + if (typeof signAll === "function") { + try { + signed = await signAll(toSign); + } catch (e) { + const raw = errText(e); + if (RE_REJECTED.test(raw)) throw e; + if (!RE_NO_SIGNALL.test(raw)) throw e; + signed = await signSequentially(); + } + } else { + signed = await signSequentially(); + } + async function signSequentially(): Promise<(Transaction | VersionedTransaction)[]> { + const out: (Transaction | VersionedTransaction)[] = []; + for (let i = 0; i < plan.length; i++) { + const p = plan[i]; + if (!p) continue; + state.headline = `approve in wallet (${i + 1}/${plan.length})…`; + row(p.id, p.label, { status: "active", note: `approve in wallet (${i + 1}/${plan.length})…` }); + out.push(await wallet.signTransaction(p.tx)); + } + return out; + } + } catch (e) { + const raw = errText(e); + const friendly = RE_REJECTED.test(raw) ? "approval declined in the wallet — nothing was sent" : raw; + for (const p of plan) row(p.id, p.label, { status: "error", note: friendly }); + return stop(friendly); + } + + // 5 ── submit sequentially with confirmation, strict lifecycle order. + state.phase = "submitting"; + for (const p of plan) row(p.id, p.label, { status: "active", note: "queued…" }); + for (let i = 0; i < plan.length; i++) { + const p = plan[i]; + const tx = signed[i]; + if (!p || !tx) continue; + state.headline = `${p.action}…`; + row(p.id, p.label, { status: "active", note: "submitting to the base chain…" }); + try { + const { signature, ms } = await submitAndConfirm(tx); + row(p.id, p.label, { status: "done", note: undefined, ms, signature }); + onLog({ action: p.action, chain: "base", ms, signature }); + if (p.id === "session" && sessionParts) { + mintedSession = persistSession({ + sessionSigner: sessionParts.sessionSigner, + sessionToken: sessionParts.sessionToken, + authority: owner, + validUntil: sessionParts.validUntil, + }); + } + } catch (e) { + const raw = errText(e); + if (RE_ALREADY.test(raw)) { + row(p.id, p.label, { status: "done", note: "already set up" }); + continue; + } + if (RE_UPSTREAM.test(raw)) { + row(p.id, p.label, { status: "error", note: UPSTREAM_MESSAGE }); + return stop(UPSTREAM_MESSAGE, true); + } + const friendly = RE_STALE.test(raw) + ? "the approval took too long and the transaction expired — tap Enable again to rebuild" + : raw; + row(p.id, p.label, { status: "error", note: friendly }); + return stop(friendly); + } + } + + state.phase = "done"; + state.headline = "one-click trading enabled"; + emit(); + return { ok: true, session: mintedSession, needsUsdc: state.needsUsdc }; +} diff --git a/examples/copy-trade/lib/flash.ts b/examples/copy-trade/lib/flash.ts new file mode 100644 index 0000000..e2922bc --- /dev/null +++ b/examples/copy-trade/lib/flash.ts @@ -0,0 +1,48 @@ +// ───────────────────────────────────────────────────────────────────────────── +// lib/flash.ts — one FlashV2Client + one base-chain Connection for the app. +// THE HARD PART: V2 spans TWO chains — trading txs go to network.erRpc, setup +// txs to network.baseRpc. Flash V2 is MAINNET — real funds. +// GOTCHAS.md → "Two chains, one flow" (../../GOTCHAS.md) +// ───────────────────────────────────────────────────────────────────────────── + +import { Connection } from "@solana/web3.js"; +import { FlashV2Client, type NetworkConfig } from "flash-v2"; + +/** NEXT_PUBLIC_ env overrides, applied only when actually set (a key whose + * value is `undefined` would otherwise stomp the preset in the spread). */ +function overridesFromEnv(): Partial { + const cfg: Partial = {}; + if (process.env.NEXT_PUBLIC_FLASH_API_BASE) cfg.apiBase = process.env.NEXT_PUBLIC_FLASH_API_BASE; + if (process.env.NEXT_PUBLIC_ER_RPC) cfg.erRpc = process.env.NEXT_PUBLIC_ER_RPC; + if (process.env.NEXT_PUBLIC_BASE_RPC) cfg.baseRpc = process.env.NEXT_PUBLIC_BASE_RPC; + return cfg; +} + +/** The one client. Reads, quotes, and every transaction builder. */ +export const flash = new FlashV2Client(overridesFromEnv()); + + +/** Base-chain connection — airdrops, balances, setup-tx submission. + * disableRetryOnRateLimit: the public RPC rate-limits; one attempt, one + * clean failure — the app owns retry UX. */ +export const baseConnection = new Connection(flash.network.baseRpc, { + commitment: "confirmed", + disableRetryOnRateLimit: true, +}); + +/** All tradeable markets (first is the default). */ +export const MARKETS = ["SOL", "BTC", "ETH"]; +/** Kept for backward compat — equals MARKETS[0]. */ +export const MARKET = MARKETS[0] as string; +export const COLLATERAL = "USDC"; + +/** Price poll cadence — 1s keeps the chart line + slot-reel digits alive. */ +export const PRICE_POLL_MS = 1000; + +/** Static, clearly-labeled comparison baseline for the latency HUD. */ +export const SOLANA_L1_TYPICAL_MS = 400; + +/** Explorer link for a signature — cluster param matches the active network. */ +export function explorerTx(signature: string): string { + return `https://explorer.solana.com/tx/${signature}`; +} diff --git a/examples/copy-trade/lib/format.ts b/examples/copy-trade/lib/format.ts new file mode 100644 index 0000000..478888e --- /dev/null +++ b/examples/copy-trade/lib/format.ts @@ -0,0 +1,155 @@ +// ───────────────────────────────────────────────────────────────────────────── +// lib/format.ts — display formatting for keys, prices, USD, PnL, and latency. +// THE HARD PART: the API speaks UI DECIMAL STRINGS (and leverage can be the +// literal string "Infinity") — every formatter here tolerates that without +// NaN leaking into the UI. GOTCHAS.md → "Responses are V1-shaped" (../../GOTCHAS.md) +// ───────────────────────────────────────────────────────────────────────────── + +/** "FMTg…xtvj" — middle-truncate a pubkey or signature. */ +export function shortKey(key: string, n = 4): string { + if (key.length <= n * 2 + 1) return key; + return `${key.slice(0, n)}…${key.slice(-n)}`; +} + +/** Parse an API decimal string defensively. Returns null on junk/Infinity. */ +export function num(v: string | number | null | undefined): number | null { + if (v === null || v === undefined) return null; + const n = typeof v === "number" ? v : Number(v); + return Number.isFinite(n) ? n : null; +} + +/** + * Parse a money string the way people ACTUALLY type it, across locales: + * "1,234.50" (US) "1.234,50" (EU) "12,5" (EU decimal) "1 234" ".5" "" + * Rule: keep digits + separators, then the LAST '.' or ',' is the decimal point + * and every other separator is grouping (dropped). Empty / garbage → 0. Never NaN. + */ +export function parseAmount(raw: string): number { + const s = (raw ?? "").replace(/[^\d.,]/g, ""); + if (!s) return 0; + const lastSep = Math.max(s.lastIndexOf("."), s.lastIndexOf(",")); + if (lastSep === -1) return Number(s) || 0; + const intDigits = s.slice(0, lastSep).replace(/[.,]/g, ""); + const fracDigits = s.slice(lastSep + 1).replace(/[.,]/g, ""); + const n = Number(`${intDigits || "0"}.${fracDigits || "0"}`); + return Number.isFinite(n) ? n : 0; +} + +/** Format a number for an EDITABLE money field: up to `dp` decimals, no trailing + * zeros, no grouping (grouping is ambiguous to re-edit). 0 stays "0". */ +export function fmtAmount(n: number, dp = 6): string { + if (!Number.isFinite(n)) return "0"; + const r = Math.round(n * 10 ** dp) / 10 ** dp; + return String(r); +} + +/** USDC has 6 decimals on-chain — tiny but REAL values must never display as + * $0.00 (a $5 position's fee is ~$0.004). Adaptive precision: more digits as + * the magnitude shrinks, never inventing precision the chain doesn't have. */ +function usdDigits(abs: number, base: number): number { + if (abs === 0) return base; + if (abs < 0.01) return 4; + if (abs < 1) return 3; + return base; +} + +/** "$151.23" / "−$0.0042" — USD with sign folded in, 6-dec-aware. */ +export function fmtUsd(v: string | number | null | undefined, digits = 2): string { + const n = num(v); + if (n === null) return "—"; + const abs = Math.abs(n); + const d = usdDigits(abs, digits); + const sign = n < 0 ? "−" : ""; + return `${sign}$${abs.toLocaleString("en-US", { + minimumFractionDigits: d, + maximumFractionDigits: d, + })}`; +} + +/** Signed USD for PnL: "+$0.42" / "−$0.0042" (6-dec-aware for small books). */ +export function fmtPnlUsd(v: string | number | null | undefined): string { + const n = num(v); + if (n === null) return "—"; + const abs = Math.abs(n); + return `${n >= 0 ? "+" : "−"}$${abs.toFixed(usdDigits(abs, 2))}`; +} + +/** "+3.81%" / "−1.20%". */ +export function fmtPct(v: string | number | null | undefined, digits = 2): string { + const n = num(v); + if (n === null) return "—"; + return `${n >= 0 ? "+" : "−"}${Math.abs(n).toFixed(digits)}%`; +} + +/** Plain number with fixed digits, em-dash on junk. */ +export function fmtNum(v: string | number | null | undefined, digits = 2): string { + const n = num(v); + return n === null ? "—" : n.toLocaleString("en-US", { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }); +} + +/** "5.0×" — leverage; tolerates the API's literal "Infinity". */ +export function fmtLeverage(v: string | number | null | undefined): string { + const n = num(v); + return n === null ? "∞×" : `${n.toFixed(1)}×`; +} + +/** "38 ms" / "1.24 s". */ +export function fmtMs(ms: number): string { + return ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(2)} s`; +} + +/** Median of a number list (null when empty). */ +export function median(values: number[]): number | null { + if (values.length === 0) return null; + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + const lo = sorted[mid - 1]; + const hi = sorted[mid]; + if (sorted.length % 2 === 0 && lo !== undefined && hi !== undefined) { + return Math.round((lo + hi) / 2); + } + return hi ?? null; +} + +// ── Flash-parity position math (client-side, like Flash's own UI) ─────────── +// Flash's UI shows mark-price PnL incl. fees, NOT the indexer's spread-through +// pnlWithFeeUsdUi (which can read wildly different on the same position). +// Compute what the product computes: +// pricePnl = (mark − entry)/entry × size × dir +// pnl = pricePnl − (exitFee + borrowFee) [raw 6-dec ÷ 1e6] +// pct = pnl / collateral × 100 +// lev = size / collateral +// liq ≈ entry × (1 ∓ collateral/size × 0.92) [≈ maintenance buffer] +export interface PositionView { + pnlUsd: number; + pnlPct: number; + leverage: number; + liqUi: number | null; +} + +export function computePositionView( + p: { + sideUi: string; + sizeUsdUi: string; + collateralUsdUi: string; + entryPriceUi: string; + exitFeeUsd: string; + borrowFeeUsd: string; + }, + markUi: number | null, +): PositionView | null { + const size = num(p.sizeUsdUi); + const coll = num(p.collateralUsdUi); + const entry = num(p.entryPriceUi); + if (size === null || coll === null || entry === null || entry <= 0 || markUi === null) return null; + const dir = p.sideUi.toUpperCase() === "LONG" ? 1 : -1; + const fees = ((num(p.exitFeeUsd) ?? 0) + (num(p.borrowFeeUsd) ?? 0)) / 1e6; + const pnlUsd = ((markUi - entry) / entry) * size * dir - fees; + const pnlPct = coll > 0 ? (pnlUsd / coll) * 100 : 0; + const leverage = coll > 0 ? size / coll : 0; + const liqUi = size > 0 ? entry * (1 - dir * (coll / size) * 0.92) : null; + return { pnlUsd, pnlPct, leverage, liqUi }; +} diff --git a/examples/copy-trade/lib/funds.ts b/examples/copy-trade/lib/funds.ts new file mode 100644 index 0000000..3cb4cbd --- /dev/null +++ b/examples/copy-trade/lib/funds.ts @@ -0,0 +1,218 @@ +// ───────────────────────────────────────────────────────────────────────────── +// lib/funds.ts — EXPLICIT deposit & withdraw. THE PRODUCTION RULE THIS FILE +// EXISTS TO TEACH: user funds move ONLY on a user-initiated action with a +// user-chosen amount and its own wallet approval — never bundled into a +// convenience flow like "Enable". Withdraw is the API's two-step dance: +// request-withdrawal → execute-withdrawal (both signed by the OWNER wallet, +// both on the BASE chain; execute can be re-run alone to recover). +// GOTCHAS.md → "Funds move on consent only" · "Two chains, one flow" +// ───────────────────────────────────────────────────────────────────────────── + +import { decodeTransaction } from "flash-v2"; +import { baseConnection, flash } from "./flash"; +import { classifyTxError, submitAndConfirm, type EnableWalletCtx } from "./enable"; +import type { LatencyEntry } from "./hooks"; + +export interface FundsStep { + /** "building" → "approve" → "submitting" → "done" | "error" */ + phase: "building" | "approve" | "submitting" | "done" | "error"; + label: string; + note?: string; + signature?: string; + ms?: number; +} + +type OnStep = (step: FundsStep) => void; +type OnLog = (e: Omit) => void; + +async function signSubmit( + wallet: EnableWalletCtx, + txBase64: string, + label: string, + onStep: OnStep, + onLog: OnLog, +): Promise<{ signature: string; ms: number }> { + onStep({ phase: "approve", label, note: "approve in your wallet…" }); + // NEVER touch the blockhash — builder txs arrive partially signed server-side. + const signed = await wallet.signTransaction(decodeTransaction(txBase64)); + onStep({ phase: "submitting", label, note: "submitting to the base chain…" }); + const { signature, ms } = await submitAndConfirm(signed); + onLog({ action: label, chain: "base", ms, signature }); + return { signature, ms }; +} + +/** + * Deposit ANY pool token into the basket — the user typed `amount`, the user + * approves the single transfer. Tradable once the deposit confirms on the ER. + */ +export async function depositToken(args: { + wallet: EnableWalletCtx; + tokenMint: string; + symbol: string; + amount: string; // UI units, user-chosen + onStep: OnStep; + onLog: OnLog; +}): Promise<{ ok: boolean; error?: string }> { + const { wallet, tokenMint, symbol, amount, onStep, onLog } = args; + const owner = wallet.publicKey.toBase58(); + const label = `deposit ${amount} ${symbol}`; + try { + onStep({ phase: "building", label, note: "building the transaction…" }); + const built = await flash.depositDirect({ owner, tokenMint, amount }); + const { signature, ms } = await signSubmit(wallet, built.transactionBase64, label, onStep, onLog); + onStep({ phase: "done", label, note: "deposited — tradable once it confirms on the rollup", signature, ms }); + return { ok: true }; + } catch (e) { + const c = classifyTxError(e); + onStep({ phase: "error", label, note: c.message }); + return { ok: false, error: c.message }; + } +} + +/** USDC compat wrapper (existing callers). */ +export async function depositUsdc(args: { + wallet: EnableWalletCtx; + usdcMint: string; + amount: string; + onStep: OnStep; + onLog: OnLog; +}): Promise<{ ok: boolean; error?: string }> { + return depositToken({ wallet: args.wallet, tokenMint: args.usdcMint, symbol: "USDC", amount: args.amount, onStep: args.onStep, onLog: args.onLog }); +} + +// The receipt the execute step consumes is written when the rollup's validator +// CROSSES the settlement to base chain (~30-90s after request). Until then the +// program answers 0xbc4 AccountNotInitialized on `settlement_receipt` — a +// timing state, never an error worth showing raw logs for. +const RE_SETTLEMENT_PENDING = /settlement_receipt|AccountNotInitialized|0xbc4|3012/i; + +/** + * Withdraw USDC back to the wallet. Two explicit steps, two approvals: + * 1) request-withdrawal (queues settlement out of the rollup) + * 2) execute-withdrawal — AUTO-RETRIED while the rollup crosses the + * settlement receipt to base (~30-90s), with a countdown the sheet shows. + */ +export async function withdrawToken(args: { + wallet: EnableWalletCtx; + tokenMint: string; + symbol: string; + amount: string; // UI units, user-chosen + onStep: OnStep; + onLog: OnLog; +}): Promise<{ ok: boolean; error?: string; executePending?: boolean }> { + const { wallet, tokenMint, symbol, amount, onStep, onLog } = args; + const owner = wallet.publicKey.toBase58(); + try { + onStep({ phase: "building", label: `request withdrawal of ${amount} ${symbol}`, note: "building…" }); + const req = await flash.requestWithdrawal({ owner, tokenMint, amount }); + await signSubmit(wallet, req.transactionBase64, `request-withdrawal ${amount} ${symbol}`, onStep, onLog); + } catch (e) { + const c = classifyTxError(e); + onStep({ phase: "error", label: "request withdrawal", note: c.message }); + return { ok: false, error: c.message }; + } + // Settlement crossing: poll UNSIGNED simulations (zero popups) until the + // receipt exists on base, then ask for the ONE execute signature. + const ready = await waitForSettlementReceipt({ owner, tokenMint, onStep, maxSeconds: 120 }); + if (!ready) { + onStep({ + phase: "error", + label: "execute withdrawal", + note: "settlement is taking longer than usual — your funds are queued safely; tap \"Execute again\" in a minute.", + }); + return { ok: false, error: "settlement still crossing", executePending: true }; + } + return executeWithdrawalToken({ wallet, tokenMint, symbol, onStep, onLog }); +} + +/** USDC compat wrapper (existing callers). */ +export async function withdrawUsdc(args: { + wallet: EnableWalletCtx; + usdcMint: string; + amount: string; + onStep: OnStep; + onLog: OnLog; +}): Promise<{ ok: boolean; error?: string; executePending?: boolean }> { + return withdrawToken({ wallet: args.wallet, tokenMint: args.usdcMint, symbol: "USDC", amount: args.amount, onStep: args.onStep, onLog: args.onLog }); +} + +/** + * Poll an UNSIGNED simulation of execute-withdrawal until the settlement + * receipt exists (no wallet popups while waiting). True = ready to sign. + */ +async function waitForSettlementReceipt(args: { + owner: string; + tokenMint: string; + onStep: OnStep; + maxSeconds: number; +}): Promise { + const { owner, tokenMint, onStep, maxSeconds } = args; + const started = Date.now(); + for (;;) { + const waited = Math.round((Date.now() - started) / 1000); + if (waited > maxSeconds) return false; + try { + const exec = await flash.executeWithdrawal({ owner, tokenMint }); + const tx = decodeTransaction(exec.transactionBase64); + const sim = await baseConnection.simulateTransaction(tx, { + sigVerify: false, + replaceRecentBlockhash: true, + }); + const err = sim.value.err ? JSON.stringify(sim.value.err) + (sim.value.logs ?? []).join(" ") : ""; + if (!sim.value.err) return true; // receipt landed — execute will pass + if (!RE_SETTLEMENT_PENDING.test(err)) return true; // different state — let the real attempt surface it + } catch { /* builder/RPC hiccup — keep polling */ } + onStep({ + phase: "submitting", + label: "settlement crossing from the rollup", + note: `the rollup writes the receipt to base chain (~30–90s) — waiting ${waited}s, no action needed`, + }); + await new Promise((r) => setTimeout(r, 8000)); + } +} + +/** Step 2 alone — also the recovery path when settlement needed more time. */ +export async function executeWithdrawalToken(args: { + wallet: EnableWalletCtx; + tokenMint: string; + symbol: string; + onStep: OnStep; + onLog: OnLog; + attempt?: number; +}): Promise<{ ok: boolean; error?: string; executePending?: boolean }> { + const { wallet, tokenMint, onStep, onLog, attempt } = args; + const owner = wallet.publicKey.toBase58(); + const tag = attempt ? ` (attempt ${attempt}/4)` : ""; + try { + onStep({ phase: "building", label: "execute withdrawal", note: `building…${tag}` }); + const exec = await flash.executeWithdrawal({ owner, tokenMint }); + const { signature, ms } = await signSubmit(wallet, exec.transactionBase64, "execute-withdrawal", onStep, onLog); + onStep({ phase: "done", label: "execute withdrawal", note: "funds are back in your wallet", signature, ms }); + return { ok: true }; + } catch (e) { + const raw = e instanceof Error ? e.message : String(e); + if (RE_SETTLEMENT_PENDING.test(raw)) { + // Timing, not failure — the caller's retry loop (or the button) handles it. + onStep({ + phase: "submitting", + label: "settlement crossing from the rollup", + note: `receipt not on base chain yet${tag} — retrying shortly`, + }); + return { ok: false, error: "settlement pending", executePending: true }; + } + const c = classifyTxError(e); + onStep({ phase: "error", label: "execute withdrawal", note: c.message }); + return { ok: false, error: c.message, executePending: c.rejected ? false : true }; + } +} + +/** USDC compat wrapper (existing callers). */ +export async function executeWithdrawalStep(args: { + wallet: EnableWalletCtx; + usdcMint: string; + onStep: OnStep; + onLog: OnLog; + attempt?: number; +}): Promise<{ ok: boolean; error?: string; executePending?: boolean }> { + return executeWithdrawalToken({ wallet: args.wallet, tokenMint: args.usdcMint, symbol: "USDC", onStep: args.onStep, onLog: args.onLog, ...(args.attempt !== undefined ? { attempt: args.attempt } : {}) }); +} diff --git a/examples/copy-trade/lib/hooks.ts b/examples/copy-trade/lib/hooks.ts new file mode 100644 index 0000000..1ab0f9e --- /dev/null +++ b/examples/copy-trade/lib/hooks.ts @@ -0,0 +1,474 @@ +// ───────────────────────────────────────────────────────────────────────────── +// lib/hooks.ts — live data: price poll, owner stream, balances, latency log. +// THE HARD PART: the owner WS sends TWO frame types (basket vs metrics) that +// flash-v2 merges for us; the HUD numbers are REAL confirmMs values only — +// nothing here invents latency. GOTCHAS.md → "The WS sends two frame types" +// · "WS connection limits are real" (../../GOTCHAS.md) +// ───────────────────────────────────────────────────────────────────────────── + +"use client"; + +import { PublicKey } from "@solana/web3.js"; +import { + subscribeOwner, + type BasketSnapshot, + type PositionMetrics, + type PriceInfo, +} from "flash-v2"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { baseConnection, flash } from "./flash"; +import { MAGIC_TRADE_PROGRAM } from "./session"; + +/** The magic-trade program on the ACTIVE network (ledger reads filter on it). */ +const MAGIC_TRADE_PROGRAM_ID = MAGIC_TRADE_PROGRAM.toBase58(); + +// ── price ticker ───────────────────────────────────────────────────────────── + +export function usePrice(symbol: string, intervalMs = 1000): { + price: PriceInfo | null; + drift: "up" | "down" | "flat"; +} { + const [price, setPrice] = useState(null); + const [drift, setDrift] = useState<"up" | "down" | "flat">("flat"); + const last = useRef(null); + + useEffect(() => { + // Market switch: drop the previous symbol's price immediately so the + // history buffer never receives a stale cross-market tick. + setPrice(null); + setDrift("flat"); + last.current = null; + let dead = false; + const tick = async () => { + try { + const p = await flash.price(symbol); + if (dead) return; + if (last.current !== null && p.priceUi !== last.current) { + setDrift(p.priceUi > last.current ? "up" : "down"); + } + last.current = p.priceUi; + setPrice(p); + } catch { + /* keep last price; next tick retries */ + } + }; + void tick(); + const timer = setInterval(tick, intervalMs); + return () => { dead = true; clearInterval(timer); }; + }, [symbol, intervalMs]); + + return { price, drift }; +} + +/** + * The tradeable market list, LIVE from Flash's own config (`GET /v2/tokens` — + * CDN-driven and hot-reloaded server-side): every non-stable token in the + * active pool is a market. New listings appear here automatically. + * Returns null until loaded (callers keep a static fallback meanwhile). + */ +export function useMarkets(): string[] | null { + const [markets, setMarkets] = useState(null); + useEffect(() => { + let dead = false; + flash.tokens() + .then((tokens) => { + if (dead) return; + const symbols = tokens.filter((t) => !t.isStable).map((t) => t.symbol); + if (symbols.length > 0) setMarkets(symbols); + }) + .catch(() => { /* fallback list keeps the selector usable */ }); + return () => { dead = true; }; + }, []); + return markets; +} + +/** Per-market leverage limits + trade spreads, LIVE from Flash's custody config. */ +export interface MarketLimits { + /** Lowest initial leverage the program accepts (UI floor, ≥1.1 for sanity). */ + minLeverage: number; + /** Highest initial leverage the program accepts for this market. */ + maxLeverage: number; + /** Exit spread the engine prices longs through (0.10 = 10%). THE number + * that makes a fresh fill read −10% "realizable now" — show it BEFORE + * the tap, and name it in the PnL breakdown. FX pairs are 0%. */ + spreadLongPct: number; + spreadShortPct: number; +} + +// custody.pricing values are fixed-point with 4 decimals (110000 = 11x — +// Flash's tiers carry a ×1.1 buffer over the advertised 10x/50x/100x/500x). +const LEVERAGE_SCALE = 10_000; + +/** + * Live leverage bounds for a market symbol: token config → custody (by mint) + * → pricing.minInitialLeverage/maxInitialLeverage. Cached per session; null + * until loaded (callers keep a conservative fallback meanwhile). Same + * philosophy as useMarkets: Flash changes limits → the UI follows, no code. + */ +const limitsCache = new Map(); +let custodyPricing: Map | null = null; + +export function useMarketLimits(marketSymbol: string): MarketLimits | null { + const [limits, setLimits] = useState(limitsCache.get(marketSymbol) ?? null); + useEffect(() => { + const cached = limitsCache.get(marketSymbol); + setLimits(cached ?? null); + if (cached) return; + let dead = false; + void (async () => { + try { + if (!custodyPricing || custodyPricing.size === 0) { + const res = await fetch(`${flash.network.apiBase}/raw/custodies`); + const json = (await res.json()) as + | Array<{ account?: { tokenMint?: string; pricing?: { minInitialLeverage?: number; maxInitialLeverage?: number; tradeSpreadLong?: number; tradeSpreadShort?: number } } }> + | { custodies?: unknown }; + const arr = Array.isArray(json) ? json : []; + const map = new Map(); + for (const c of arr) { + const a = c.account; + // the custody's token field is `tokenMint` (NOT `mint`) + if (a?.tokenMint && a.pricing?.maxInitialLeverage) { + map.set(a.tokenMint, { + min: (a.pricing.minInitialLeverage ?? LEVERAGE_SCALE) / LEVERAGE_SCALE, + max: a.pricing.maxInitialLeverage / LEVERAGE_SCALE, + // tradeSpread is in bps×1 (1000 = 10%): ÷1e4 → fraction + spreadL: (a.pricing.tradeSpreadLong ?? 0) / 10_000, + spreadS: (a.pricing.tradeSpreadShort ?? 0) / 10_000, + }); + } + } + // only cache a USEFUL result — an empty map must not wedge "loading…" + if (map.size > 0) custodyPricing = map; + } + if (!custodyPricing) return; + const tokens = await flash.tokens(); + if (dead) return; + const mint = tokens.find((t) => t.symbol.toUpperCase() === marketSymbol.toUpperCase())?.mintKey; + const p = mint ? custodyPricing.get(mint) : undefined; + if (p) { + const out: MarketLimits = { + minLeverage: Math.max(1.1, p.min), + maxLeverage: p.max, + spreadLongPct: p.spreadL, + spreadShortPct: p.spreadS, + }; + limitsCache.set(marketSymbol, out); + setLimits(out); + } + } catch { /* fallback stays; next mount retries */ } + })(); + return () => { dead = true; }; + }, [marketSymbol]); + return limits; +} + +// ── owner snapshot stream ──────────────────────────────────────────────────── + +export type StreamStatus = "connecting" | "open" | "reconnecting" | "polling" | "closed"; + +export function useOwner(owner: string | null): { + snapshot: BasketSnapshot | null; + /** true once we have heard ANYTHING for this owner (fetch or frame). */ + loaded: boolean; + status: StreamStatus; + refresh: () => Promise; +} { + const [snapshot, setSnapshot] = useState(null); + const [loaded, setLoaded] = useState(false); + const [status, setStatus] = useState("connecting"); + + useEffect(() => { + setSnapshot(null); + setLoaded(false); + if (!owner) { setStatus("closed"); return; } + setStatus("connecting"); + let dead = false; + + // Spec move: fetch client.owner(pubkey) on load so the setup wizard can + // decide instantly; the stream takes over from the first frame onward. + flash.owner(owner) + .then((snap) => { + if (dead) return; + setSnapshot((prev) => prev ?? snap); + setLoaded(true); + }) + .catch(() => { /* stream below retries via poll fallback */ }); + + const stream = subscribeOwner({ + owner, + network: flash.network, + onUpdate: (snap) => { + if (dead) return; + setSnapshot(snap); + setLoaded(true); + }, + onStatus: (s) => { if (!dead) setStatus(s); }, + }); + return () => { dead = true; stream.close(); }; + }, [owner]); + + const refresh = useCallback(async () => { + if (!owner) return; + try { + const snap = await flash.owner(owner); + setSnapshot(snap); + setLoaded(true); + } catch { /* non-fatal */ } + }, [owner]); + + return { snapshot, loaded, status, refresh }; +} + +/** All live positions for one market symbol out of a snapshot. */ +export function positionsFor(snapshot: BasketSnapshot | null, marketSymbol: string): PositionMetrics[] { + if (!snapshot) return []; + return Object.values(snapshot.positionMetrics ?? {}).filter( + (p) => p.marketSymbol?.toUpperCase() === marketSymbol.toUpperCase(), + ); +} + +// ── wallet balances (base chain) ───────────────────────────────────────────── + +export function useBalances(owner: string | null, usdcMint: string | null): { + sol: number | null; + usdc: number | null; + refresh: () => Promise; +} { + const [sol, setSol] = useState(null); + const [usdc, setUsdc] = useState(null); + + const refresh = useCallback(async () => { + if (!owner) { setSol(null); setUsdc(null); return; } + try { + const lamports = await baseConnection.getBalance(new PublicKey(owner)); + setSol(lamports / 1e9); + } catch { /* keep last */ } + if (usdcMint) { + try { + const res = await baseConnection.getParsedTokenAccountsByOwner( + new PublicKey(owner), + { mint: new PublicKey(usdcMint) }, + ); + const total = res.value.reduce((sum, acc) => { + const ui = acc.account.data.parsed?.info?.tokenAmount?.uiAmount as number | null | undefined; + return sum + (typeof ui === "number" ? ui : 0); + }, 0); + setUsdc(total); + } catch { /* keep last */ } + } + }, [owner, usdcMint]); + + useEffect(() => { + void refresh(); + // Background safety-net poll; balance-changing actions refresh explicitly + // for instant updates. A keyed RPC handles this cadence comfortably. + const timer = setInterval(() => { void refresh(); }, 6_000); + return () => clearInterval(timer); + }, [refresh]); + + return { sol, usdc, refresh }; +} + +// ── deposited balance: the USER DEPOSIT LEDGER, read like Flash's own infra ── + +export interface BasketAsset { + mint: string; + symbol: string; + decimals: number; + /** AVAILABLE on the rollup side (ledger − debits + pending), native units. */ + amountUi: number; +} + +export interface BasketBalance { + /** USDC available — the tradable "in basket" figure (compat alias of assets). */ + inBasketUsd: number; + /** EVERY asset with rollup-side availability — nothing orphans invisibly. */ + assets: BasketAsset[]; + /** Where the ledger was found ("er" once delegated, "base" before). */ + source: "er" | "base"; +} + +// Anchor discriminator for `UserDepositLedger` = sha256("account:UserDepositLedger")[0..8] +// (hex 3363adbe15330782). Precomputed so the browser needs no crypto/bs58 deps. +const LEDGER_DISC_B58 = "9bYPoR9mRKo"; +// Zero-copy layout (validated against 60 live mainnet ledgers, all 852 bytes): +// 8 disc | 1 bump | 7 pad | 32 owner @16 | u32 count @48 | 20 × {32 mint, u64 amount} @52. +const LEDGER_OWNER_OFFSET = 16; +const LEDGER_COUNT_OFFSET = 48; +const LEDGER_ENTRIES_OFFSET = 52; +const LEDGER_ENTRY_SIZE = 40; + +/** All ledger entries (mint → raw u64 amount), or null when no ledger exists. */ +async function fetchLedgerEntries(rpcUrl: string, owner: string): Promise | null> { + const res = await fetch(rpcUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", id: 1, method: "getProgramAccounts", + params: [MAGIC_TRADE_PROGRAM_ID, { + encoding: "base64", + filters: [ + { memcmp: { offset: 0, bytes: LEDGER_DISC_B58 } }, + { memcmp: { offset: LEDGER_OWNER_OFFSET, bytes: owner } }, + ], + }], + }), + }); + const json = (await res.json()) as { result?: Array<{ account: { data: [string, string] } }> }; + const acct = json.result?.[0]; + if (!acct) return null; + const buf = Uint8Array.from(atob(acct.account.data[0]), (c) => c.charCodeAt(0)); + const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength); + const count = view.getUint32(LEDGER_COUNT_OFFSET, true); + const out = new Map(); + for (let i = 0; i < Math.min(count, 20); i++) { + const off = LEDGER_ENTRIES_OFFSET + i * LEDGER_ENTRY_SIZE; + const mint = new PublicKey(buf.subarray(off, off + 32)).toBase58(); + const amount = Number(view.getBigUint64(off + 32, true)); + out.set(mint, (out.get(mint) ?? 0) + amount); + } + return out; +} + + + +/** + * The owner's AVAILABLE USDC (GOTCHAS §20): + * + * available = ledger.deposits − basket.debits + basket.pendingCredits + * + * V2 is double-entry: the ledger records CUMULATIVE deposits (withdrawals do + * NOT decrement it), the basket's debits/pendingCredits record all internal + * churn, and only the three together net to the balance. A fully-withdrawn + * account nets exactly $0.00; while funded, the same math gives the true + * positive balance. All three figures MUST come from the same coherent + * source — the ER-fed store (mixing chains on a half-delegated account breaks + * the invariant; clamp at 0). + */ +export function useBasketBalance( + owner: string | null, + basketPubkey: string | null, + usdcMint: string | null, +): { bal: BasketBalance | null; refresh: () => Promise } { + const [bal, setBal] = useState(null); + const tokenMeta = useRef | null>(null); + // Monotonic request id: a newer read (poll OR an explicit post-trade refresh) + // supersedes an in-flight older one, so stale data never lands. + const reqId = useRef(0); + + const refresh = useCallback(async () => { + if (!owner || !usdcMint) { setBal(null); return; } + const id = ++reqId.current; + try { + // 1) cumulative deposits — ALL mints from the ledger (ER → base fallback) + let source: "er" | "base" = "er"; + let ledger = await fetchLedgerEntries(flash.network.erRpc, owner); + if (ledger === null) { + ledger = await fetchLedgerEntries(flash.network.baseRpc, owner); + source = "base"; + } + if (id !== reqId.current) return; + if (ledger === null) { setBal({ inBasketUsd: 0, assets: [], source: "base" }); return; } + // 2) the basket's counter-entries (server-decoded from the ER store) + const debits = new Map(); + const pending = new Map(); + if (basketPubkey) { + const raw = await flash.rawBasket(basketPubkey); + if (id !== reqId.current) return; + const acct = raw.account as { + debits?: Array<{ mint: string; amount: number | string }>; + pendingCredits?: Array<{ mint: string; amount: number | string }>; + }; + for (const r of acct.debits ?? []) debits.set(r.mint, (debits.get(r.mint) ?? 0) + Number(r.amount)); + for (const r of acct.pendingCredits ?? []) pending.set(r.mint, (pending.get(r.mint) ?? 0) + Number(r.amount)); + } + // 3) symbols/decimals from Flash's live token config (cached across reads) + if (!tokenMeta.current) { + try { + tokenMeta.current = new Map( + (await flash.tokens()).map((t) => [t.mintKey, { symbol: t.symbol, decimals: t.decimals }]), + ); + } catch { tokenMeta.current = new Map(); } + } + if (id !== reqId.current) return; + // 4) available per mint = ledger − debits + pending (clamped), native units + const mints = new Set([...ledger.keys(), ...debits.keys(), ...pending.keys()]); + const assets: BasketAsset[] = []; + for (const mint of mints) { + const meta = tokenMeta.current.get(mint) ?? { symbol: mint.slice(0, 4) + "…", decimals: mint === usdcMint ? 6 : 9 }; + const rawAvail = (ledger.get(mint) ?? 0) - (debits.get(mint) ?? 0) + (pending.get(mint) ?? 0); + const amountUi = Math.max(0, rawAvail) / 10 ** meta.decimals; + if (amountUi > 0) assets.push({ mint, symbol: meta.symbol, decimals: meta.decimals, amountUi }); + } + assets.sort((a, b) => (a.mint === usdcMint ? -1 : b.mint === usdcMint ? 1 : a.symbol.localeCompare(b.symbol))); + const usdc = assets.find((a) => a.mint === usdcMint)?.amountUi ?? 0; + setBal({ inBasketUsd: usdc, assets, source }); + } catch { /* keep last good read; next read retries */ } + }, [owner, basketPubkey, usdcMint]); + + useEffect(() => { + setBal(null); // clear on owner/basket change — don't show another owner's balance + reqId.current++; // invalidate any in-flight read + void refresh(); + const timer = setInterval(() => { void refresh(); }, 5_000); + return () => clearInterval(timer); + }, [refresh]); + + return { bal, refresh }; +} + +/** Resolve the USDC mint once via /tokens (deposits need the MINT, not "USDC"). */ +export function useUsdcMint(): string | null { + const [mint, setMint] = useState(null); + useEffect(() => { + let dead = false; + flash.tokens() + .then((tokens) => { + if (dead) return; + const usdc = tokens.find((t) => t.symbol.toUpperCase() === "USDC"); + setMint(usdc?.mintKey ?? null); + }) + .catch(() => { /* tokens() retried implicitly when wizard runs */ }); + return () => { dead = true; }; + }, []); + return mint; +} + +// ── latency log (the HUD's data) ───────────────────────────────────────────── + +export interface LatencyEntry { + id: string; + /** What was tapped, e.g. "LONG 5×", "FLATTEN", "init-basket". */ + action: string; + /** Which chain confirmed it — "er" rows are the headline numbers. */ + chain: "er" | "base"; + /** REAL wall-clock submit→confirmed milliseconds (flash-v2 confirmMs). */ + ms: number; + /** Measured send round-trip (≈ one wire trip) — splits network vs rollup. */ + sendMs?: number; + signature: string; + at: number; + /** Trade facts captured at action time (history rows render these). + * pnlUi = the WITH-FEE figure at the moment of close (≈ realized). */ + trade?: { + market: string; + side: "LONG" | "SHORT"; + entryUi: number | null; + collateralUi: number | null; + pnlUi: number | null; + }; +} + +export function useLatencyLog(): { + entries: LatencyEntry[]; + add: (entry: Omit) => void; +} { + const [entries, setEntries] = useState([]); + const add = useCallback((entry: Omit) => { + const id = typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : `${Date.now()}-${Math.random()}`; + setEntries((prev) => [{ ...entry, id, at: Date.now() }, ...prev].slice(0, 50)); + }, []); + return { entries, add }; +} diff --git a/examples/copy-trade/lib/leaders.ts b/examples/copy-trade/lib/leaders.ts new file mode 100644 index 0000000..fd03772 --- /dev/null +++ b/examples/copy-trade/lib/leaders.ts @@ -0,0 +1,76 @@ +// ───────────────────────────────────────────────────────────────────────────── +// lib/leaders.ts — leader DISCOVERY off the fstats V2/ER leaderboard (via the +// same-origin /api/leaders route). This is the "who do I copy?" half: ranked +// traders with real win rate, PnL, and volume on the Ephemeral Rollup. The +// "mirror their trades" half lives in lib/copy-engine.ts (the live owner WS). +// ───────────────────────────────────────────────────────────────────────────── + +"use client"; + +import { useEffect, useRef, useState } from "react"; + +/** One row of the fstats V2/ER PnL leaderboard. */ +export interface LeaderRow { + owner: string; + rank: number; + win_rate: number; // 0–100 + wins: number; + losses: number; + num_trades: number; + net_pnl: number; + gross_pnl: number; + total_volume_usd: number; +} + +export interface LeaderboardState { + leaders: LeaderRow[]; + /** owner → number of positions open RIGHT NOW (so the UI can flag live leaders). */ + openByOwner: Record; + loading: boolean; + error: string | null; +} + +/** Poll the ranked leaderboard. Discovery data tolerates a slow cadence. */ +export function useLeaderboard(pollMs = 20_000): LeaderboardState { + const [state, setState] = useState({ + leaders: [], + openByOwner: {}, + loading: true, + error: null, + }); + const dead = useRef(false); + + useEffect(() => { + dead.current = false; + const load = async () => { + try { + const res = await fetch("/api/leaders"); + const j = (await res.json()) as Partial & { error?: string }; + if (dead.current) return; + if (!res.ok || j.error) throw new Error(j.error ?? `leaderboard ${res.status}`); + setState({ + leaders: j.leaders ?? [], + openByOwner: j.openByOwner ?? {}, + loading: false, + error: null, + }); + } catch (e) { + if (dead.current) return; + setState((s) => ({ ...s, loading: false, error: (e as Error).message })); + } + }; + void load(); + const t = setInterval(load, pollMs); + return () => { + dead.current = true; + clearInterval(t); + }; + }, [pollMs]); + + return state; +} + +/** Validate a pasted leader pubkey (base58, 32–44 chars) before streaming it. */ +export function isLikelyPubkey(s: string): boolean { + return /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(s.trim()); +} diff --git a/examples/copy-trade/lib/session.ts b/examples/copy-trade/lib/session.ts new file mode 100644 index 0000000..63a8964 --- /dev/null +++ b/examples/copy-trade/lib/session.ts @@ -0,0 +1,268 @@ +// ───────────────────────────────────────────────────────────────────────────── +// lib/session.ts — REAL session keys via @magicblock-labs/gum-sdk (v3). +// THE HARD PART: there is NO server endpoint for sessions — the CLIENT mints a +// SessionTokenV2 PDA (seeds ["session_token_v2", target, signer, authority] — +// note the _v2!). buildSessionTransaction lets lib/enable.ts bundle it into +// the ONE Enable One-Click Trading approval; trades then auto-sign. +// SESSION-KEYS.md has the full story. GOTCHAS.md → "Session keys" entry. +// ───────────────────────────────────────────────────────────────────────────── + +import { BN } from "@coral-xyz/anchor"; +import { SessionTokenManager } from "@magicblock-labs/gum-sdk"; +import { + Connection, + Keypair, + PublicKey, + type Transaction, + type VersionedTransaction, +} from "@solana/web3.js"; + +/** Gum session-keys program. */ +export const SESSION_KEYS_PROGRAM = new PublicKey("KeyspM2ssCJbqUhQ4k7sveSiY4WjnYsrXkC8oDbwde5"); + +/** + * magic-trade (Flash V2 on MagicBlock) program — the session target. + * Mainnet: FTv2RxXarPfNta45HTTMVaGvjzsGg27FXJ3hEKWBhrzV + * (verified by decoding a mainnet init-basket transaction from /v2/transaction-builder) + */ +/** magic-trade program (mainnet) — decoded from live builder txs. */ +export const MAGIC_TRADE_PROGRAM = new PublicKey("FTv2RxXarPfNta45HTTMVaGvjzsGg27FXJ3hEKWBhrzV"); + + +/** localStorage key for the persisted session (signer secret + token PDA). */ +export const SESSION_STORAGE_KEY = "tap-trade-session"; + +/** Minimal wallet shape we need (matches wallet-adapter's AnchorWallet). */ +export interface SessionWallet { + publicKey: PublicKey; + signTransaction(tx: T): Promise; + signAllTransactions(txs: T[]): Promise; +} + +export interface LoadedSession { + /** Ephemeral signer — auto-signs every trade, no popup. */ + keypair: Keypair; + /** SessionTokenV2 PDA (base58) — passed as `sessionToken` to the API. */ + token: string; + /** The real wallet that authorized the session (base58). */ + authority: string; + /** Unix seconds — the program rejects the session after this. */ + validUntil: number; +} + +interface StoredSession { + secretKey: number[]; + token: string; + authority: string; + validUntil: number; +} + +/** + * Derive the SessionTokenV2 PDA. Live-verified on-chain: 77 session tokens + * targeting magic-trade all match seeds + * ["session_token_v2", target_program, session_signer, authority]. + */ +export function deriveSessionToken( + authority: PublicKey, + sessionSigner: PublicKey +): PublicKey { + const [pda] = PublicKey.findProgramAddressSync( + [ + new TextEncoder().encode("session_token_v2"), + MAGIC_TRADE_PROGRAM.toBytes(), + sessionSigner.toBytes(), + authority.toBytes(), + ], + SESSION_KEYS_PROGRAM, + ); + return pda; +} + +/** Load the persisted session if it exists, belongs to `authority`, and has + * ≥60s of validity left. Anything else is cleared. Browser-only. */ +export function loadSession(authority?: string): LoadedSession | null { + if (typeof window === "undefined") return null; + const raw = window.localStorage.getItem(SESSION_STORAGE_KEY); + if (!raw) return null; + try { + const stored = JSON.parse(raw) as StoredSession; + const fresh = stored.validUntil > Date.now() / 1000 + 60; + const mine = !authority || stored.authority === authority; + if (!fresh || !mine) { + if (!fresh) clearSession(); + return null; + } + return { + keypair: Keypair.fromSecretKey(Uint8Array.from(stored.secretKey)), + token: stored.token, + authority: stored.authority, + validUntil: stored.validUntil, + }; + } catch { + clearSession(); + return null; + } +} + +export function clearSession(): void { + if (typeof window !== "undefined") window.localStorage.removeItem(SESSION_STORAGE_KEY); +} + +/** Poll a signature to confirmation (same approach as flash-v2/sign.ts). */ +async function confirmSignature(connection: Connection, signature: string, timeoutMs = 60_000): Promise { + const started = Date.now(); + for (;;) { + const status = (await connection.getSignatureStatuses([signature])).value[0]; + if (status) { + if (status.err) throw new Error(`on-chain error (${signature}): ${JSON.stringify(status.err)}`); + const level = status.confirmationStatus; + if (level === "confirmed" || level === "finalized") return; + } + if (Date.now() - started > timeoutMs) throw new Error(`confirmation timeout (${signature})`); + await new Promise((r) => setTimeout(r, 400)); + } +} + +/** Everything `enableOneClickTrading` needs to bundle the session into ONE + * signAllTransactions sheet: the tx (blockhash set, ephemeral key already + * partial-signed — the wallet only adds its own signature) plus the pieces + * to persist once it confirms. */ +export interface BuiltSessionTransaction { + tx: Transaction; + sessionSigner: Keypair; + /** SessionTokenV2 PDA (base58). */ + sessionToken: string; + validUntil: number; +} + +/** + * Build (don't sign-with-the-wallet, don't send) a `create_session_v2` tx: + * generate an ephemeral keypair, derive the SessionTokenV2 PDA, set blockhash + * + fee payer, and partial-sign with the ephemeral key. `topUpSol` moves SOL + * from the wallet to the session signer so trades pay their own fees. + */ +export async function buildSessionTransaction( + wallet: SessionWallet, + connection: Connection, + opts: { validHours?: number; topUpSol?: number } = {}, +): Promise { + const { validHours = 24, topUpSol = 0.01 } = opts; + const sessionSigner = Keypair.generate(); + const validUntil = Math.floor(Date.now() / 1000) + Math.floor(validHours * 3600); + const sessionToken = deriveSessionToken(wallet.publicKey, sessionSigner.publicKey); + const targetProgram = MAGIC_TRADE_PROGRAM; + + const manager = new SessionTokenManager(wallet, connection); + const tx: Transaction = await manager.program.methods + .createSessionV2(true, new BN(validUntil), new BN(Math.round(topUpSol * 1e9))) + .accountsPartial({ + sessionToken, + sessionSigner: sessionSigner.publicKey, + feePayer: wallet.publicKey, + authority: wallet.publicKey, + targetProgram, + }) + .transaction(); + + tx.feePayer = wallet.publicKey; + tx.recentBlockhash = (await connection.getLatestBlockhash("confirmed")).blockhash; + tx.partialSign(sessionSigner); // ephemeral key co-signs; wallet signs later + return { tx, sessionSigner, sessionToken: sessionToken.toBase58(), validUntil }; +} + +/** Persist a confirmed session to localStorage and return the live shape. */ +export function persistSession(args: { + sessionSigner: Keypair; + sessionToken: string; + authority: string; + validUntil: number; +}): LoadedSession { + const stored: StoredSession = { + secretKey: Array.from(args.sessionSigner.secretKey), + token: args.sessionToken, + authority: args.authority, + validUntil: args.validUntil, + }; + window.localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(stored)); + return { + keypair: args.sessionSigner, + token: stored.token, + authority: stored.authority, + validUntil: stored.validUntil, + }; +} + +/** + * Compat wrapper (build → ONE wallet popup → send → confirm → persist). + * `enableOneClickTrading` (lib/enable.ts) uses buildSessionTransaction + * directly so the session rides the same signAllTransactions sheet as the + * basket/ledger/delegate/deposit txs. + */ +export async function createSession(opts: { + wallet: SessionWallet; + connection: Connection; + validHours?: number; + topUpSol?: number; +}): Promise<{ session: LoadedSession; signature: string; confirmMs: number }> { + const { wallet, connection, validHours, topUpSol } = opts; + const built = await buildSessionTransaction(wallet, connection, { validHours, topUpSol }); + const signed = await wallet.signTransaction(built.tx); // the ONE popup + const started = Date.now(); // measure submit→confirmed only + const signature = await connection.sendRawTransaction(signed.serialize(), { maxRetries: 3 }); + await confirmSignature(connection, signature); + const confirmMs = Date.now() - started; + const session = persistSession({ + sessionSigner: built.sessionSigner, + sessionToken: built.sessionToken, + authority: wallet.publicKey.toBase58(), + validUntil: built.validUntil, + }); + return { session, signature, confirmMs }; +} + +/** + * Revoke the session on-chain (rent + leftover top-up refund to the wallet). + * `revoke_session_v2` needs NO wallet signature — the session signer itself + * pays the fee, so even ending a session is popup-free. Always clears local + * storage, even if the on-chain revoke fails (e.g. already expired + closed). + */ +export async function revokeSession( + session: LoadedSession, + connection: Connection, +): Promise<{ signature: string; confirmMs: number } | null> { + try { + const authority = new PublicKey(session.authority); + // Provider wallet here is only a type requirement — nothing is signed by it. + const manager = new SessionTokenManager( + { + publicKey: session.keypair.publicKey, + signTransaction: async (t: T) => t, + signAllTransactions: async (t: T[]) => t, + } as SessionWallet, + connection, + ); + const tx: Transaction = await manager.program.methods + .revokeSessionV2() + .accountsPartial({ + sessionToken: new PublicKey(session.token), + // Must match tx.feePayer below — the SESSION key pays for its own + // revocation (popup-free); a mismatch fails the signer constraint. + feePayer: session.keypair.publicKey, + authority, + }) + .transaction(); + tx.feePayer = session.keypair.publicKey; + tx.recentBlockhash = (await connection.getLatestBlockhash("confirmed")).blockhash; + tx.sign(session.keypair); + const started = Date.now(); + const signature = await connection.sendRawTransaction(tx.serialize(), { maxRetries: 3 }); + await confirmSignature(connection, signature); + clearSession(); + return { signature, confirmMs: Date.now() - started }; + // ONLY a confirmed revoke clears storage — wiping on failure would orphan + // a LIVE key with no way to retry the revoke (audit find). + } catch { + return null; // revoke failed — session stays stored so you can retry; + // worst case the token expires on its own at validUntil + } +} diff --git a/examples/copy-trade/lib/signer.ts b/examples/copy-trade/lib/signer.ts new file mode 100644 index 0000000..84a53a5 --- /dev/null +++ b/examples/copy-trade/lib/signer.ts @@ -0,0 +1,40 @@ +// ───────────────────────────────────────────────────────────────────────────── +// lib/signer.ts — the trading signer: the SESSION keypair signs every tap. +// THE HARD PART: routing — trading txs sign+submit to network.erRpc and NEVER +// touch the blockhash (the API pre-signed it). Setup txs are handled by +// lib/enable.ts on the base chain behind ONE wallet approval. +// GOTCHAS.md → "Two chains, one flow" (../../GOTCHAS.md) +// ───────────────────────────────────────────────────────────────────────────── + +import { signAndSend, type NetworkConfig, type SendResult } from "flash-v2"; +import type { LoadedSession, SessionWallet } from "./session"; + +/** Everything a tap needs to know about "who signs and where it goes". */ +export interface ActiveSigner { + /** Basket owner (base58) — the connected wallet. */ + owner: string; + /** Sign + submit a TRADING tx → Ephemeral Rollup. Popup-free. */ + sendTrade(transactionBase64: string): Promise; + /** Extra request fields so the API builds for the session signer. */ + tradeFields: { signer: string; sessionToken: string }; +} + +/** + * One-Click Trading signer: the wallet approved setup ONCE (lib/enable.ts); + * every TRADE auto-signs with the ephemeral session key — `signer` + + * `sessionToken` ride along in the request so the API builds for it. + */ +export function makeSessionSigner( + wallet: SessionWallet, + session: LoadedSession, + network: NetworkConfig, +): ActiveSigner { + return { + owner: wallet.publicKey.toBase58(), + sendTrade: (tx64) => signAndSend(network.erRpc, tx64, session.keypair, { skipPreflight: true }), + tradeFields: { + signer: session.keypair.publicKey.toBase58(), + sessionToken: session.token, + }, + }; +} diff --git a/examples/copy-trade/next-env.d.ts b/examples/copy-trade/next-env.d.ts new file mode 100644 index 0000000..830fb59 --- /dev/null +++ b/examples/copy-trade/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/examples/copy-trade/next.config.ts b/examples/copy-trade/next.config.ts new file mode 100644 index 0000000..6804dcf --- /dev/null +++ b/examples/copy-trade/next.config.ts @@ -0,0 +1,30 @@ +// ───────────────────────────────────────────────────────────────────────────── +// next.config.ts — Next.js wiring for a workspace that ships raw TypeScript. +// THE HARD PART: flash-v2's entry point IS src/index.ts (no build step), so it +// must be transpiled by the app; anchor/gum-sdk pull Node-ish deps that need +// browser fallbacks. GOTCHAS.md → "Two chains, one flow" (../../GOTCHAS.md) +// ───────────────────────────────────────────────────────────────────────────── + +import path from "node:path"; +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + reactStrictMode: true, + // The monorepo root (bun workspaces) — keeps Next from guessing wrong. + outputFileTracingRoot: path.join(__dirname, "../.."), + // flash-v2 is consumed straight from packages/flash-v2/src/*.ts — compile it. + transpilePackages: ["flash-v2"], + // Browsers auto-request /favicon.ico; we only ship app/icon.svg — redirect + // instead of 404ing the console on every load. + async redirects() { + return [{ source: "/favicon.ico", destination: "/icon.svg", permanent: true }]; + }, + webpack: (config) => { + // @coral-xyz/anchor (via @magicblock-labs/gum-sdk) probes Node built-ins; + // none are needed in the browser paths this app uses. + config.resolve.fallback = { ...config.resolve.fallback, fs: false, os: false, path: false }; + return config; + }, +}; + +export default nextConfig; diff --git a/examples/copy-trade/package.json b/examples/copy-trade/package.json index 0c14d5c..cdd467d 100644 --- a/examples/copy-trade/package.json +++ b/examples/copy-trade/package.json @@ -1,19 +1,36 @@ { - "name": "example-copy-trade", + "name": "copy-trade", "private": true, "version": "0.1.0", - "type": "module", + "description": "Copy trading on Flash V2 × MagicBlock: rank real leaders by win rate, follow one, and mirror their opens/closes onto your own account with collateral-ratio sizing — manual-confirm or capped auto-copy, non-custodial via session keys. Liquid Glass UI.", "scripts": { - "dev": "bun run src/index.ts", + "dev": "next dev", + "build": "next build", + "start": "next start", "typecheck": "tsc --noEmit" }, "dependencies": { + "@coral-xyz/anchor": "^0.30.1", + "@magicblock-labs/gum-sdk": "^3.0.10", + "@solana/spl-token": "^0.4.14", + "@solana/wallet-adapter-base": "^0.9.27", + "@solana/wallet-adapter-phantom": "^0.9.29", + "@solana/wallet-adapter-react": "^0.15.39", + "@solana/wallet-adapter-solflare": "^0.6.28", "@solana/web3.js": "^1.98.2", - "flash-v2": "workspace:*" + "flash-v2": "workspace:*", + "next": "^15.5.19", + "react": "^19.2.0", + "react-dom": "^19.2.0" }, "devDependencies": { + "@tailwindcss/postcss": "^4.3.0", + "@types/bn.js": "^5.1.5", "@types/node": "^22.10.0", - "typescript": "^5.6.0" + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "tailwindcss": "^4.3.0", + "typescript": "^5.9.0" }, "repository": { "type": "git", diff --git a/examples/copy-trade/postcss.config.mjs b/examples/copy-trade/postcss.config.mjs new file mode 100644 index 0000000..5e461c6 --- /dev/null +++ b/examples/copy-trade/postcss.config.mjs @@ -0,0 +1,14 @@ +// ───────────────────────────────────────────────────────────────────────────── +// postcss.config.mjs — Tailwind v4 enters through PostCSS; no tailwind.config. +// THE HARD PART: nothing — v4 reads design tokens from @theme in globals.css, +// so the entire look lives in one CSS file instead of a JS config. +// GOTCHAS.md → (no API gotchas here) (../../GOTCHAS.md) +// ───────────────────────────────────────────────────────────────────────────── + +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/examples/copy-trade/src/index.ts b/examples/copy-trade/src/index.ts deleted file mode 100644 index 64bb28c..0000000 --- a/examples/copy-trade/src/index.ts +++ /dev/null @@ -1,220 +0,0 @@ -// ───────────────────────────────────────────────────────────────────────────── -// copy-trade — mirror a leader wallet's positions, non-custodially. -// THE HARD PARTS: there is no "trades feed" — you DIFF consecutive owner -// snapshots into events; you size by COLLATERAL RATIO (never copy raw size, -// that's how followers get over-leveraged); and the follower signs their own -// transactions — nobody ever holds their keys. Dry-run by default. -// GOTCHAS.md → §9 WS frames · §16 the $11 rule · §4 the 97% close threshold -// ───────────────────────────────────────────────────────────────────────────── -// -// LEADER= bun run dev # dry-run: print mirrored intents -// LEADER= FOLLOWER_KEYPAIR=~/.config/solana/mainnet.json \ -// bun run dev -- --execute # mainnet: follower signs + submits -// -// Architecture (one screen): -// subscribeOwner(LEADER) ──▶ diff(prev, next) ──▶ events: OPEN / GROW / SHRINK / CLOSE -// events × (follower_collateral / leader_collateral) ──▶ trade-builder calls -// dry-run prints them · --execute signs with the FOLLOWER's key → ER RPC - -import { Keypair } from "@solana/web3.js"; -import { readFileSync } from "node:fs"; -import { - FlashV2Client, - subscribeOwner, - signAndSend, - RECOMMENDED_MIN_COLLATERAL_USD, - type BasketSnapshot, - type PositionMetrics, - type TradeType, -} from "flash-v2"; - -// ── Config ─────────────────────────────────────────────────────────────────── -const LEADER = process.env.LEADER ?? ""; -const EXECUTE = process.argv.includes("--execute"); -const MAX_FOLLOW_USD = Number(process.env.MAX_FOLLOW_USD ?? "100"); // hard cap per mirror -const RATIO_OVERRIDE = process.env.RATIO ? Number(process.env.RATIO) : undefined; - -if (!LEADER) { - console.log("copy-trade needs LEADER=."); - console.log("Tip: any wallet with live V2 positions works. Dry-run prints intents only."); - process.exit(1); -} - -const flash = new FlashV2Client(); - -const followerKeypair = (() => { - if (!EXECUTE) return undefined; - const p = process.env.FOLLOWER_KEYPAIR; - if (!p) throw new Error("--execute needs FOLLOWER_KEYPAIR (THROWAWAY wallet — real funds, keep it small)"); - return Keypair.fromSecretKey(Uint8Array.from(JSON.parse(readFileSync(p, "utf8")))); -})(); -const follower = followerKeypair?.publicKey.toBase58(); - -// ── 1. Diffing: snapshots → events ─────────────────────────────────────────── -// A position is keyed by market+side (V2 allows one long + one short per market). -type PosKey = string; -const keyOf = (p: PositionMetrics): PosKey => `${p.marketSymbol}:${p.sideUi.toUpperCase()}`; - -interface MirrorEvent { - kind: "OPEN" | "GROW" | "SHRINK" | "CLOSE"; - market: string; - side: TradeType; - deltaUsd: number; // size change, USD notional - leverage: number; // leader's current leverage (for OPEN/GROW) - leaderCollateralUsd: number; -} - -function diff(prev: BasketSnapshot | undefined, next: BasketSnapshot): MirrorEvent[] { - const events: MirrorEvent[] = []; - const before = new Map( - Object.values(prev?.positionMetrics ?? {}).map((p) => [keyOf(p), p]), - ); - const after = new Map( - Object.values(next.positionMetrics).map((p) => [keyOf(p), p]), - ); - - for (const [k, now] of after) { - const was = before.get(k); - const side = now.sideUi.toUpperCase() as TradeType; - const sizeNow = Number(now.sizeUsdUi); - const lev = Number.parseFloat(now.leverageUi) || 1; - const col = Number(now.collateralUsdUi); - if (!was) { - events.push({ kind: "OPEN", market: now.marketSymbol, side, deltaUsd: sizeNow, leverage: lev, leaderCollateralUsd: col }); - } else { - const deltaUsd = sizeNow - Number(was.sizeUsdUi); - if (Math.abs(deltaUsd) > 0.01) { - events.push({ - kind: deltaUsd > 0 ? "GROW" : "SHRINK", - market: now.marketSymbol, side, deltaUsd: Math.abs(deltaUsd), leverage: lev, leaderCollateralUsd: col, - }); - } - } - } - for (const [k, was] of before) { - if (!after.has(k)) { - events.push({ - kind: "CLOSE", market: was.marketSymbol, - side: was.sideUi.toUpperCase() as TradeType, - deltaUsd: Number(was.sizeUsdUi), leverage: 1, leaderCollateralUsd: Number(was.collateralUsdUi), - }); - } - } - return events; -} - -// ── 2. Sizing: collateral ratio, never raw size — ONE function, both modes ─── -async function followerCollateralUsd(): Promise { - if (!follower) return 0; - const snap = await flash.owner(follower); - // Free collateral approximation for the demo: sum of position collateral. - // A production copier would track its deposit-ledger balance too. - return Object.values(snap.positionMetrics).reduce((s, p) => s + Number(p.collateralUsdUi), 0); -} - -/** Identical sizing for dry-run and execute. Dry-run without a follower wallet - * assumes ratio 1 and SAYS SO — the printed intent must match what execute - * would do, or the dry-run is a lie. */ -function sizeFor(e: MirrorEvent, followerColUsd: number | undefined): { usd: number; label: string } { - const ratio = - RATIO_OVERRIDE ?? - (followerColUsd === undefined - ? 1 // dry-run with no follower configured — labeled as assumed below - : e.leaderCollateralUsd > 0 && followerColUsd > 0 - ? followerColUsd / e.leaderCollateralUsd - : 0); // unknown leader collateral or broke follower → mirror nothing - const usd = Math.min(e.deltaUsd * ratio, MAX_FOLLOW_USD); // hard cap — never YOLO a whale's size - const label = - RATIO_OVERRIDE !== undefined ? `ratio ${RATIO_OVERRIDE} (RATIO env)` : - followerColUsd === undefined ? "ratio 1 (ASSUMED — set FOLLOWER_KEYPAIR for real sizing)" : - `ratio ${ratio.toFixed(3)} (collateral ${followerColUsd.toFixed(2)}/${e.leaderCollateralUsd.toFixed(2)})`; - return { usd, label }; -} - -// ── 3. Replay: build → (dry-print | follower signs → ER) ───────────────────── -async function replay(e: MirrorEvent) { - const followerCol = EXECUTE ? await followerCollateralUsd() : undefined; - const { usd, label } = sizeFor(e, followerCol); - const stamp = new Date().toISOString().slice(11, 19); - - if (e.kind === "OPEN" || e.kind === "GROW") { - // GOTCHAS §16: collateral below ~$11 can't take TP/SL — but FLOORING the - // collateral while keeping leader leverage would INFLATE the position - // (e.g. a $5 mirror becoming $55). Too small to mirror honestly → skip. - const collateral = usd / e.leverage; - if (collateral < RECOMMENDED_MIN_COLLATERAL_USD) { - console.log(`[${stamp}] SKIP ${e.kind} ${e.side} ${e.market} — mirror too small ($${collateral.toFixed(2)} collateral < $${RECOMMENDED_MIN_COLLATERAL_USD} floor; raise MAX_FOLLOW_USD/RATIO or accept skipping)`); - return; - } - console.log(`[${stamp}] ${e.kind} ${e.side} ${e.market} — leader Δ$${e.deltaUsd.toFixed(2)} → mirror $${usd.toFixed(2)} (${collateral.toFixed(2)} USDC × ${e.leverage}x) · ${label}`); - if (!EXECUTE || !followerKeypair || !follower) return console.log(" dry-run: not signed (set FOLLOWER_KEYPAIR + --execute)"); - const built = await flash.openPosition({ - inputTokenSymbol: "USDC", outputTokenSymbol: e.market, - inputAmountUi: collateral.toFixed(2), leverage: e.leverage, - tradeType: e.side, orderType: "MARKET", owner: follower, slippagePercentage: "0.8", // wider than leader — you fill later - }); - if (built.transactionBase64) { - const { signature, confirmMs } = await signAndSend(flash.network.erRpc, built.transactionBase64, followerKeypair); - console.log(` ⛓ mirrored in ${confirmMs}ms → ${signature}`); - } - } else { - // SHRINK/CLOSE: close proportionally. CLOSE sends "0" = full (GOTCHAS §4/§18). - const closeUsd = e.kind === "CLOSE" ? "0" : usd.toFixed(2); - console.log(`[${stamp}] ${e.kind} ${e.side} ${e.market} — leader Δ$${e.deltaUsd.toFixed(2)} → close ${closeUsd === "0" ? "ALL" : "$" + closeUsd}`); - if (!EXECUTE || !followerKeypair || !follower) return console.log(" dry-run: not signed"); - const built = await flash.closePosition({ - marketSymbol: e.market, side: e.side, inputUsdUi: closeUsd, - withdrawTokenSymbol: "USDC", owner: follower, - }); - if (built.transactionBase64) { - const { signature, confirmMs } = await signAndSend(flash.network.erRpc, built.transactionBase64, followerKeypair); - console.log(` ⛓ mirrored in ${confirmMs}ms → ${signature}`); - } - } -} - -// ── Main: stream the leader, diff every basket frame ───────────────────────── -console.log(`copy-trade on ${flash.network.name} — leader ${LEADER}`); -console.log(EXECUTE ? `EXECUTE mode — follower ${follower} signs its own txs (mainnet)` : "DRY-RUN — printing mirror intents only"); -console.log("waiting for leader activity (diffing basket frames)…\n"); - -let prev: BasketSnapshot | undefined; - -// RE-ENTRANCY: onUpdate is NOT awaited by the stream — frames can arrive while -// a replay is mid-flight, and two concurrent replay loops would double-mirror. -// We COALESCE instead of dropping: remember only the LATEST basket frame and -// diff against it when the current loop finishes. Intermediate states collapse -// into net deltas, which is exactly what a mirror wants. -let processing = false; -let queued: BasketSnapshot | undefined; - -async function onBasketFrame(snap: BasketSnapshot) { - if (processing) { queued = snap; return; } - processing = true; - try { - let next: BasketSnapshot | undefined = snap; - while (next) { - const events = diff(prev, next); - prev = structuredClone(next); - for (const e of events) { - try { await replay(e); } catch (err) { console.log(`! mirror failed: ${(err as Error).message}`); } - } - next = queued; - queued = undefined; - } - } finally { - processing = false; - } -} - -subscribeOwner({ - owner: LEADER, - network: flash.network, - onUpdate: (snap, source) => { - // Only `basket` frames are settlement truth (GOTCHAS §8-9); `metrics` - // frames re-price the SAME position every second and would spam GROW/SHRINK. - if (source === "metrics") return; - void onBasketFrame(snap); - }, - onStatus: (s) => console.log(`(stream: ${s})`), -}); diff --git a/examples/copy-trade/tsconfig.json b/examples/copy-trade/tsconfig.json index 553828d..792d382 100644 --- a/examples/copy-trade/tsconfig.json +++ b/examples/copy-trade/tsconfig.json @@ -1,13 +1,23 @@ { "compilerOptions": { "target": "ES2022", - "lib": ["ES2022", "DOM"], - "module": "ESNext", - "moduleResolution": "Bundler", - "strict": true, + "lib": ["dom", "dom.iterable", "ES2022"], + "allowJs": true, "skipLibCheck": true, + "strict": true, + "noUncheckedIndexedAccess": true, "noEmit": true, - "allowImportingTsExtensions": true + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "allowImportingTsExtensions": true, + "plugins": [{ "name": "next" }], + "paths": { "@/*": ["./*"] } }, - "include": ["src/**/*.ts"] + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] } diff --git a/examples/predict/.env.example b/examples/predict/.env.example new file mode 100644 index 0000000..d32a3aa --- /dev/null +++ b/examples/predict/.env.example @@ -0,0 +1,17 @@ +# predict — copy to .env.local and fill in ONE thing: your RPC key. +# cp .env.example .env.local +# +# Updown runs on Solana MAINNET (real funds — every round is a real position). + +NEXT_PUBLIC_FLASH_NETWORK=mainnet + +# ── RPC ─────────────────────────────────────────────────────────────────────── +# Base-chain RPC (setup / deposit / withdraw). Use your own CORS-friendly RPC — +# a FREE key from helius.dev / triton.one / quicknode.com. This URL ships in the +# browser bundle, so use a DOMAIN-RESTRICTED key (or proxy it server-side). +NEXT_PUBLIC_BASE_RPC=https://mainnet.helius-rpc.com/?api-key=YOUR_KEY_HERE +# Ephemeral Rollup RPC (trading) — serves CORS, called directly from the browser. +NEXT_PUBLIC_ER_RPC=https://flash.magicblock.xyz + +# Override the hosted Flash V2 API (rarely needed). +# NEXT_PUBLIC_FLASH_API_BASE=https://flashapi.trade/v2 diff --git a/examples/predict/DESIGN.md b/examples/predict/DESIGN.md new file mode 100644 index 0000000..1d6f928 --- /dev/null +++ b/examples/predict/DESIGN.md @@ -0,0 +1,48 @@ +# Updown — design direction + +**Vibe: Void Instrument, v2 — frosted.** A dark room with one light source that knows how you're doing; the instruments are sheets of glass catching that light. Not a casino, not a terminal — a kitchen timer with money on it. + +## Glass recipe (v2) + +One static ambient orb (mood-tinted teal/coral via `body[data-mood]`) gives the frost something to refract — never two (they fight). Surfaces: `.glass` = white 5% fill, `blur(12px) saturate(180%) brightness(1.08)`, 1px white/10 border, inset top rim-light `0 1px 0 white/12`, drop `0 8px 32px black/36`, radius 20px. `.glass-strong` (inputs, picker, locked-numbers) = white 7% + blur 16. **Live digits always sit on a `.film`** (~32% dark plate) so numbers never fight the frost. Blur only on static surfaces — history rows stay solid. Solid teal/coral commit buttons keep an accent glow (`.cta-glow-*`); everything else is quiet glass. No-`backdrop-filter` fallback: solid `#0d0f1a/92`. + +## Plain-words rule (v2.1) + +Zero trading vocabulary anywhere a user reads, and **no payoff chart at all** — a chart is still something to decode. "What can happen" is three one-line rows on film plates (▲ Moves your way 1% → +$0.55 · ▼ Moves against you 1% → −$0.55 · ✕ Falls 18% — round ends → −$11 max). Reading beats decoding; nothing can overlap. Timeframes say "pays ×5/×3.3/×2"; the locked card says "You get in at / Round ends early at / Backing your call". The first-run disclosure remains the one place mechanics are named honestly. + +## Tokens + +| Token | Value | Rule | +|---|---|---| +| `bg` | `#07080F` | indigo-tinted void — never pure black, never the sibling's `#0b0d0c` green-black | +| `panel / panel2 / sheet` | `#0D0F1A / #131628 / #0B0D17` | flat layers, 1px `edge #1E2338` hairlines | +| `ink / dim / faint` | `#EFF1FF / #9AA1C4 / #5A6184` | type ladder | +| `up` | `#3EE6C1` | mint-teal — UP, wins. Always paired with ▲ or words (never color-only) | +| `down` | `#FF5C87` | coral-rose — DOWN, losses. Always paired with ▼ or words | +| `warn` | `#FFC857` | amber — disclosure & warnings ONLY. Never decorative | + +Anti-patterns enforced: no AI purple/pink gradients, no glassmorphism-everywhere, no traffic-light green/red, no Inter. + +## Type + +- **Unbounded** (500/700/900) — display only: wordmark, UP/DOWN buttons, result verdicts ("CALLED IT"). +- **Sora** (400–700) — all UI copy. +- **JetBrains Mono** — every number, `tnum` everywhere a digit lives. Numbers never reflow. + +## Motion (meaning only) + +Curve: `cubic-bezier(0.32, 0.72, 0, 1)` — mass, no bounce. Press = scale 0.98. +Only four things move: the countdown bar (1s linear depletion; urgency pulse + coral in the final fifth), the price tick (brightness flash, direction-tinted), the settle card (`settle-pop`), and sheets (transform-only slide). `prefers-reduced-motion` kills all of it. + +## Signature moves + +1. **The ambient mood radial** — a fixed background glow tinted by state (`body[data-mood]`): teal when you're calling UP / winning, coral on DOWN / a loss. The room reacts; the UI stays still. +2. **Double-bezel cards** — every major card is machined: outer shell (`.bezel`, white/3% + hairline + 1.6rem radius) holding an inner core with concentric radius and inset highlight. +3. **The payoff line IS the screen** — an SVG of your actual dollars vs the move, with break-even (teal dot) and knockout (coral wall) marked. It kills the odds mental model on sight. +4. **Locked-numbers card** — commit is two-step; the confirm card restates *your* numbers ("These don't drift") in mono before anything signs. +5. **The welcome-back settle card** — a returning user's expired round settles in front of them: stake shown intact while "settling…", then the verdict in Unbounded with a receipt link. Acknowledgment gates the next round. +6. **Verb, not noun** — the word "predict" appears as something you *do*; the product never claims to *be* a prediction market (the disclosure negates it explicitly, in amber). + +## Layout + +Mobile-first 390px single column (ticket → live rounds → history). Desktop ≥1024px: asymmetric 12-col bento — ticket col-span-5, rounds column col-span-7 offset down 4rem so the grid breathes diagonally. diff --git a/examples/predict/README.md b/examples/predict/README.md new file mode 100644 index 0000000..d7c9a17 --- /dev/null +++ b/examples/predict/README.md @@ -0,0 +1,66 @@ +# predict — Updown: pick up or down. + +**Your stake is the most you can lose.** Timed UP/DOWN price rounds (5m / 15m / 1h) where every round is a *real* Flash Trade V2 perp position on Solana mainnet — opened with your session key in ~30–50 ms, settled by a full close when the clock runs out. + +## What you'll build + +A single-screen price-call game: pick a market (live from `/v2/tokens`), pick UP or DOWN, pick a timeframe, stake USDC. The ticket shows your numbers *before* you sign — payoff per 1% move, break-even, knockout level, max loss (= your stake) — locks them at commit, and settles the round from your browser at expiry. Wins land in your basket; history keeps the receipts. + +## What's tricky here + +- **It is not a prediction market** — no fixed odds, no shared pot. It's a capped-loss leveraged position wearing a round-timer. The app says this out loud (first-run disclosure) instead of letting users keep the Polymarket mental model. +- **The browser is the settlement engine.** Settlement triggers on *observed* state (owner stream + a shared 1 s clock), never a bare `setTimeout`. Three guards prevent double-closes: a `settling` status, an in-flight set, and a retry backoff. +- **Chain is the source of truth; localStorage is a cache.** Rounds reconcile against live positions by `(market, side)` — positions carry no timestamp. Positions opened elsewhere surface for *adoption*; rounds whose position vanished get marked *settled elsewhere*, never silently dropped. +- **Close the tab mid-round and nothing is lost** — the position stays on-chain. Come back and the welcome-back card settles it in front of you; acknowledgment gates the next round. +- **Your numbers can't drift.** PancakeSwap Prediction's biggest trust failure is a payout multiplier that changes after you enter. Here the entry, fees, and knockout shown on the confirm card come from the same signed response the round is built from. +- **Displayed PnL is Flash-parity client math** (`computePositionView`) — the indexer's `pnlWithFeeUsdUi` is never rendered (GOTCHAS §20). + +## How it's meant to work + +1. **Connect** (Phantom / Solflare) — mainnet warning up front. +2. **Enable one-tap rounds** — one wallet approval: session key + basket + ledger + delegate. *No funds move*; the disclosed 0.01 SOL rent top-up goes to your own session key and comes back on revoke. +3. **Deposit** — explicit, typed amount, its own approval (GOTCHAS §17 consent rule). +4. **Call it** — UP or DOWN; review the locked numbers; confirm. The position opens on the Ephemeral Rollup via your session key — no popup. +5. **Watch** — countdown bar, live mark-price PnL, settle-early any time. +6. **Settle** — at expiry the app full-closes the position (`inputUsdUi: "0"` — the 97% partial-close trap never applies). Result card shows won/lost with the on-chain receipt. +7. **Withdraw** — two approvals by design: `request-withdrawal` → `execute-withdrawal`, with unsigned-simulation polling in between (no popup burning). + +Timeframe → payout profiles: **5m → ×5, 15m → ×3.3, 1h → ×2**, clamped to the market's live caps. Break-even move is multiplier-independent (fees scale with size); early-end distance ≈ 92%/multiplier (−18% / −28% / −46%) — deliberately far away on every round length. + +## Endpoints used + +| Endpoint | Why | +|---|---| +| `GET /v2/tokens` | live market list (never hardcoded) | +| `GET /v2/prices/{symbol}` | ticket price + live PnL mark | +| `POST open-position` (no `owner`) | the free quote at the review gate | +| `POST open-position` | the round itself (session-signed → ER RPC) | +| `POST close-position` (`"0"`) | settlement — explicit FULL close | +| `GET /v2/owner/{owner}/ws` | live positions (one shared `subscribeOwner`) | +| `init-basket` / `init-deposit-ledger` / `delegate-basket` / `deposit-direct` | Enable + deposit (base chain) | +| `request-withdrawal` / `execute-withdrawal` | two-phase exit (base chain) | + +## Run it + +```bash +cp examples/predict/.env.example examples/predict/.env.local # add your RPC key +bun install +bun run predict # from the repo root (or: bun run --cwd examples/predict dev) +``` + +Open http://localhost:3000. You can browse markets, prices, and quotes with **no wallet at all** — connect + enable only when you want a real round. `bun run --cwd examples/predict typecheck` and `bun run --cwd examples/predict build` must stay green. + +## Money flows (the consent rule) + +Enable never moves funds. Deposits and withdrawals are explicit: user-typed amount, labeled source → destination (wallet vs basket), dedicated approval. Balances are computed with the deposit-ledger formula from the ER (`deposits − debits + pendingCredits`) and always labeled by where they live. Session revoke returns the rent top-up. + +## Production checklist (for operators) + +This is an open-source example. Before running it as a *service* for other people: + +- **Jurisdictions are your problem.** Comparable products geo-fence: PancakeSwap Prediction hard-blocks restricted regions (HTTP 451), dYdX/Hyperliquid block US/UK/Canada IPs and prohibit VPN circumvention in their ToU. Several countries classify prediction-style products as gambling (Singapore, Indonesia, Spain, others). Get advice, geofence accordingly, and put a Restricted-Persons clause in your terms. +- **Adopt the loss-disclosure banner.** ESMA/FCA require "X% of retail accounts lose money" for CFD-like retail products; this app ships a voluntary version in the footer. Keep it. +- **Protect your RPC key.** `NEXT_PUBLIC_BASE_RPC` ships in the browser bundle — use a domain-restricted key, or better, a server-side proxy (e.g. Helius's one-click worker). Add a fallback provider; map 429s to calm copy (already done in `lib/copy.ts`). +- **Add error monitoring** (Sentry + source maps) and uptime checks on `https://flashapi.trade/v2/health`. +- **Keeper settlement.** Client-side settlement is honest but depends on the user returning. A production service should run a keeper that closes expired rounds server-side — that requires a delegated signing design (users' session keys must never leave their browser; consider per-round TP/SL brackets via `place-tp-sl` as a chain-side backstop instead). +- **Known future work:** touch-mode rounds (TP/SL bracket = near-binary payoff, `validateTriggerPrice` + the $11 floor already enforced), shared leaderboards (needs an indexer), Mobile Wallet Adapter flows for Android/iOS. diff --git a/examples/predict/SPEC-PREDICT-V2.md b/examples/predict/SPEC-PREDICT-V2.md new file mode 100644 index 0000000..15aa192 --- /dev/null +++ b/examples/predict/SPEC-PREDICT-V2.md @@ -0,0 +1,142 @@ +# predict v2 — a real prediction market on Flash V2 perps + +Status: **in progress.** Phase 1 (the odds engine, `lib/markets.ts`) is built + typechecked. This spec locks the direction and the remaining phases. + +## The decision (confirmed) + +Transform "Updown" (a capped-loss UP/DOWN timer) into a **real-feeling prediction market**: cents = probability, buy YES/NO, "to win $X", settle to win/lose. Market types to build: **strike thresholds** ("TOKEN above $X by T") and **multi-outcome buckets** ("where does it land?"). (Timed up/down and all-token-classes were *not* selected for the first build.) + +This is the **Drift BET / Kalshi model adapted to Flash's capped-loss perps**: on Drift, "a prediction market is a perp market priced $0–1, YES = long, NO = short." We do the same, but every position is a *capped-loss* perp (you can never lose more than your stake — the liquidation is the floor). + +## The honest line (non-negotiable, keep the trust) + +The current app is *proud* it's "not a prediction market." v2 adopts the prediction-market framing but stays honest about the one thing it cannot be without a custom on-chain program: + +- **Odds are formula-set** from a real leveraged position — they do **not** aggregate other traders' beliefs and do **not** move from order flow. +- **Payout comes from Flash's LP**, not a shared pot of losers' stakes. +- **What is real:** the underlying price, both barriers, and *you can never lose more than your stake.* + +Surface this in a first-run disclosure + a persistent "how odds are set" link (inherit `mechanics-disclosure.tsx`). "Trade, not bet" / "formula-set odds on a real position" — Kalshi's lexical discipline is the trust signal. + +## The algo (built — `lib/markets.ts`) + +A prediction = a step payoff `{−stake, +payout}` + a number `q` read as probability. A perp = a clipped line. Clamp both ends → a one-touch binary: + +- **Lower clamp** = the native knockout (liquidation) → lose exactly your stake `C`. +- **Upper clamp** = a **bundled take-profit at the strike** (`OpenPositionRequest.takeProfit`) → win a fixed payout `P`. + +``` +R (payout multiple) = L·(t − fee) # profit per $1 staked on YES +q (YES prob, 0..1) = 1 / (1 + R) # the cents when ×100 +to-win = stake / q # $1 per share, shares = stake/q +``` + +`L` = leverage (set by the knockout distance, `koDist = 0.92/L`), `t` = favorable move to the strike, `C` = stake, `fee` = round-trip rate. **Symmetric barriers give q≈0.52 at any leverage; asymmetry spans the ladder** (far TP + near knockout = long shot). Probabilities clamped to **3¢–97¢** (Drift's 0.05/0.95 convention). + +Engine surface (all pure, typechecked): +- `priceMarket({entry, strike, knockoutPrice, direction, timeframe})` → leverage + `prob` + `payoutMult`. +- `marketForTargetProb(...)` → solve the construction for a target YES price. +- `strikeLadder(...)` → a row of YES markets at 85¢/70¢/55¢/40¢/25¢ (the odds ladder). +- `bucketMarket({edges})` → multi-outcome buckets, normalized to sum ~1. +- `openParamsFor(market, stake)` → the `flash.openPosition` params (capped-loss perp + bundled TP). +- `cents` / `toWinUsd` / `profitUsd` / `questionFor` — the cents=probability lexicon. + +**Settlement:** open with the TP bundled (YES win → +P). Liquidation = NO (lose stake). Between-barrier at expiry → full close at mark (`inputUsdUi:"0"`, the existing engine) and resolve by which side is ahead. Reuse the existing triple double-close guard, welcome-back settle, and `(market, side)` reconciliation in `lib/rounds.ts` / `components/app.tsx`. + +## The UX (Kalshi/Polymarket-grade, on the existing dark glass) + +Screens: +1. **Discover** — category tabs (Crypto / Stocks / FX / Commodities, from `/v2/tokens`) + search + a **market-card grid**. Card = question · big YES/NO ¢ · a YES/NO split bar · countdown · sparkline · volume-ish. (The single most important component — make it instantly scannable + clickable.) +2. **Market detail** — the question, a **probability-over-time line chart** (reuse `usePriceHistory`, plot implied prob), the strike ladder / bucket outcomes as rows, and the **buy ticket**. +3. **Buy ticket** — YES/NO toggle, amount, **avg price (¢), shares, "To win $X", max loss = your stake**, the locked-numbers confirm ("these don't drift"). Honest, never manipulative. +4. **Portfolio** — open positions as shares with live value (mark→implied prob), realized history, a local leaderboard/stats (inherit `stats-strip` / `history`). + +Reuse wholesale: `enable-sheet`, `funds-sheet`, `wallet-bar`, `settle-card`, `clock`, `stream`, `session`/`signer`, `flash`, `payoff` constants, `token-icon`. + +## Design tokens (from the premium research) + +Keep the existing "Void Instrument / frosted glass" base (`globals.css`, `DESIGN.md`) but tighten to the premium discipline the research confirmed: + +- **Cents = probability everywhere** (Polymarket/Kalshi/Drift/Myriad all do this — it's what makes it read "real"). +- **Semantic color, never decoration; never color-alone** — pair YES/NO + up/down with ▲/▼ + words. Kalshi's confirmed split: YES=blue `#265cff` / NO=purple `#aa00ff`; up/green `#0ac285` / down/red `#d91667`; dark bg `#141414`. Manifold: YES=teal `#14B8A6` / NO=scarlet `#F75836`. **Our existing up=`#3EE6C1` / down=`#FF5C87` already works — keep it, just apply it semantically to YES/NO.** +- **Restraint = premium** (Linear/Stripe/Coinbase/Refactoring-UI, all verified): one accent on near-black (`#07080F`, not pure black), **8px grid**, ~9 neutral shades, 2–3 font weights, **hairlines not boxes**, **tabular numerals** on every digit, desaturated accents on dark (no neon vibration), whitespace as luxury. Motion only where it means something (countdown, price tick, settle). +- Type ladder unchanged: Unbounded (display) · Sora (UI) · JetBrains Mono (`tnum`, all numbers). + +## Build phases (sequential; subagents rate-limited until Jun 15 9am ET) + +1. ✅ **Odds engine** — `lib/markets.ts` (pure, typechecked). +2. **Market model + state** — extend `lib/rounds.ts` for share/market rounds; a `useMarkets`-style hook that builds ladders/buckets from live `/v2/tokens` + `/v2/prices`; the `openParamsFor` → confirm/lock flow. +3. **Discover + market card** — the category/search grid + the card component (the highest-leverage UI). +4. **Market detail + buy ticket** — prob chart + ladder/bucket rows + the YES/NO ticket with "to win $X" / max-loss. +5. **Portfolio + settlement** — shares with live value, history, the welcome-back settle adapted to win/lose framing. +6. **Disclosure + polish** — the honest "how odds are set" disclosure; the premium pass; `typecheck` + `build` green; adversarial review. + +## v2.1 REDESIGN — Path A, spread-aware (supersedes the engine math above) + +The 3-reviewer money-path audit (2026-06-15) found the v2 engine **funds-loss-grade broken**: it modeled cost as a 0.16% fee and **ignored the real 5–10% Flash trade spread**, so the bundled take-profit landed *inside* the spread and fired as a ~100% loss on SOL (live `pnlPercentage: −100`), leverage was never clamped (SOL opened at 100× already-liquidated), and nothing was reconciled to the signed fill. Decision: **keep the true fixed-odds binary (Path A), corrected for the spread.** `$11` floor is correct here — a bundled TP needs `> $10` collateral after fees (`guards.ts`: `MIN_COLLATERAL_USD_AFTER_FEES=10`, `RECOMMENDED_MIN_COLLATERAL_USD=11`). (A bare open's floor is ~$5, but our win = a bundled TP, so $11 stands.) + +### The corrected construction — the spread is paid on BOTH legs (EMPIRICALLY corrected 2026-06-15) + +A LONG fills at `entryFill = oracle·(1+s)`; a SHORT at `oracle·(1−s)`, where `s = tradeSpread` (live, per market, from `useMarketLimits` — SOL≈0.10, BTC/ETH≈0.05). + +**Implementation discovery (live probe, read-only quote):** the v2.1 math above was STILL incomplete — it only charged the spread on ENTRY. The live API charges it AGAIN on exit: a take-profit triggering at price `K` *exits* at `K·(1∓s)` (a LONG sells back at the bid). Proof: a $90 SOL TP returned `exitPriceUi=$81` (=90×0.9) and `profitUsdUi≈$0` — break-even, not the 59¢ winner the entry-only math claimed. **A take-profit must clear TWO spreads to win.** The corrected, live-matched construction: + +``` +entryFill = oracle·(1 ± s) # + ABOVE/LONG, − BELOW/SHORT (pay the spread on entry) +exitFill = strike·(1 ∓ s) # the TP EXITS through the spread too (pay it again) +t = exitFill/entryFill − 1 (LONG) # the NET move that drives PnL (∓ mirrored for SHORT) +strike = entryFill·(1 ± t)/(1 ∓ s) # ⇒ gross the strike UP by 1/(1−s) so net move = t +knockout = entryFill·(1 ∓ 0.92/L) # liquidation (the loss); builder's liq is degenerate +R = L·(t − fee) q = 1/(1+R) # q now matches the venue's real payout +to-win = stake/q max-loss = stake +``` + +Break-even (LONG) is `strike = oracle·(1+s)/(1−s)` ≈ ×1.222 on SOL — so a genuine 58¢ SOL headline strike sits at ≈ `$100` when the oracle is `$74` (oracle×1.36), and the bet truly needs a big move. **Verified live end-to-end:** across the SOL and BTC headline + full ladder, the engine's shown to-win matches the API's `takeProfitQuote` to within 3%. The commit-gate reconcile (#3) is the backstop — it reads the venue's own `profitUsdUi` and blocks if the real payout diverges from what was shown. + +### The new hard constraint: the knockout must sit BEYOND the spread + +At high L, `koDist = 0.92/L` can be smaller than `s` → the position opens already past liquidation (CRIT-2). So clamp: + +``` +L_max = min( custody.maxLeverage , 0.92 / (s · KO_MARGIN) ) # KO_MARGIN ≈ 1.5 +``` + +SOL (s=0.10) ⇒ `L ≤ 0.92/0.15 ≈ 6×`. High-spread markets are naturally low-leverage (correct). The ladder must only offer odds achievable within `L_max` at a believable strike; deeper long-shots simply aren't available on high-spread tokens (show them honestly or omit). + +### The fix list (every CRITICAL/HIGH from the audit), in order + +1. **Engine spread-aware** (`lib/markets.ts`): `priceMarket`/`marketForTargetProb`/`strikeLadder`/`bucketMarket` take `oracle` + `spread` + `maxLeverage`; compute from `fillEntry`; clamp `L` by the constraint above; the `Market` carries `oracle` (display) and `entry`=`fillEntry`. **Fixes CRIT-1, CRIT-2, HIGH-4.** +2. **Wire live spread + caps**: card/detail call `useMarketLimits(token)` → pass `tradeSpread` + `maxLeverage` into the engine. No market is built before limits load (gate the ticket). +3. **Reconcile to the signed fill** (two-step review→confirm): quote the REAL fill, render the venue's own numbers, and **block the commit** if (a) the strike fails `validateTriggerPrice` vs the real entry, (b) the API returns no `takeProfitQuote`, (c) `takeProfitQuote.profitUsdUi ≤ 0` (TP inside the round-trip spread → fires as a loss), or (d) the live payout falls below 60% of the shown odds (stale-price / model-drift divergence guard). Re-quote + re-check at confirm (two-step like `app.tsx doReview→doConfirm`). The venue's `profitUsdUi` is ground truth — never trust local math over it. **Fixes CRIT-3, MEDIUM (stale price).** +4. **Validate the TP**: call `guards.validateTriggerPrice({side, kind:"tp", price:strike, markPrice:fillEntry})` before sending; refuse on `!ok` (avoids on-chain `6057`). +5. **Settlement + expiry** (port `lib/rounds.ts`): record a `Round{…expiresAt}` on a confirmed send; run the reconcile/settle watcher; close at expiry by mark (`inputUsdUi:"0"`) for the between-barrier case. Render a **/markets-specific** disclosure (don't inherit Updown's). **Fixes HIGH-5.** +6. **Gates + errors**: `canBet` floor → `MIN_STAKE` (11); add `stake > balance` block; double-submit guard (in-flight set keyed by owner|market|side, re-check live balance); route `place()`/`enable()` errors through `calmError`; surface the real `enable` step error. **Fixes C3, H1, H2, M1.** +7. **Buckets** — RESOLVED by REMOVAL (pending a proper rebuild; needs user OK to make permanent). Investigation finding: a single capped-loss perp + one take-profit can only express a ONE-TOUCH "reaches X", never a bounded "lands between X and Y" (that needs a call-spread = two positions). The honest relabel ("chance it reaches this zone", de-normalized) was implemented but DEGENERATES: with edges inside the 5–10% spread every near-edge sits below the entry fill, so every zone saturates to the 97¢ ceiling — and it duplicates the strike ladder, which already gives honest reach-odds. So the degenerate widget was removed from the rendered detail; `bucketMarket` stays in the engine (corrected, reach semantics) for a future **call-spread** or **vol-aware** rebuild. **HIGH-6 closed by removal; rebuild is a flagged scope item.** +8. **Slippage**: set `slippagePercentage` ≥ the spread (or omit and let the spread price it) — `"1"` is below a 5–10% spread. **Fixes MEDIUM-7.** +9. Guard degenerate inputs (`oracle ≤ 0`, `t ≤ s+fee`). **Fixes LOW-8.** + +**Re-run the 3-agent adversarial review before flipping the "don't trade real funds" banner.** + +## v2.1 REVIEW OUTCOME (2026-06-15) — ②–⑧ built + reviewed + remediated + +All of ②–⑧ (plus ①, ⑨) implemented and verified (typecheck + production build green; the odds engine verified against the **live mainnet API** to within 3% across SOL + BTC headline + full ladder). Two bugs the original audit missed were found en route: the **two-leg spread** (a TP at K exits at `K·(1−s)` — strike now grossed up by `1/(1−s)`, live-confirmed) and **buckets are inexpressible** on a single-TP perp (removed; the dead builder deleted). + +The mandatory 3-agent adversarial review (code-reviewer + silent-failure-hunter + money-math auditor) then ran, a 4th agent verified the fixes. Outcome — all fixed + re-verified (commits `6a648b2`, `55197e5`, F7 guard): + +- **C1** (CRITICAL): `reconcileBet` now calls `checkCollateralForTriggers(stake, entryFee)` — blocks a stake that drops ≤ $10 after fees (bundled TP would silently fail to place). Runs on both the review + owner quotes. +- **C2** (CRITICAL): mount-sweep demotes a persisted `"settling"` round → `"active"` so a tab-close mid-settle can't strand a position. +- **Settlement scoring** (CRITICAL, display/stats only): `resolveVanished` RETURNS on a failed price fetch (never brands a winner a `−stake` loss); retry-safe; rounds go `active → settled` only (no resultless `closed-elsewhere`). +- **H1**: synchronous `useRef` lock on `doConfirm` (the async `phase` guard couldn't stop a double-tap double-open). +- **H2**: `settleRound` skips the close if the position is already gone (no timeout double-close). +- **F2** (HIGH latent): deleted dead `bucketMarket` + `openParamsFor` (undefended paths that rendered guaranteed-loss strikes at 97¢). +- **F7** (the one architecture finding): `/` (Updown) and `/markets` share ONE on-chain position per market+side. The auto-settle watcher now **refuses to FULL-close a position whose on-chain size ≫ this bet's recorded size** (a blend tell-tale); the user's explicit manual `settle` is allowed through. `settleRound(round, auto)`. +- Plus: `oracle ≤ 0 → PROB_FLOOR` (was 97¢ ceiling); cross-owner refs cleared on wallet switch; custody fetch checks `res.ok`. + +### Residuals (no funds at risk; documented, gate the banner) +- **F7 guard is a heuristic** — a SMALL blend (this bet + a tiny other) under the 1.5× threshold could still be auto-closed; sub-account namespacing is the complete fix. A blended bet past expiry shows `settling…` until manually settled or the blend clears. +- **Vanish-scoring is a mark-vs-strike inference** — the exact realized win/lose is the on-chain receipt; a sharp reversal between vanish and poll can still misread the card (the `−stake`-on-null case is fixed). +- **Banner stays UP.** Before flipping it: re-verify the F7 guard + the residuals are acceptable, and decide whether the two example apps may share a wallet (the cleanest fix is per-app sub-accounts). + +## Verification bar (every phase) + +`bun run --cwd examples/predict typecheck` and `build` must stay green. Mainnet, real funds — no shortcuts. Adversarial review before anything is called done. diff --git a/examples/predict/app/globals.css b/examples/predict/app/globals.css new file mode 100644 index 0000000..bfc5177 --- /dev/null +++ b/examples/predict/app/globals.css @@ -0,0 +1,291 @@ +/* ───────────────────────────────────────────────────────────────────────────── + app/globals.css — the design language, v2: minimalist glassmorphism over an + indigo void. ONE ambient mood orb gives the frost something to refract + (council: two orbs fight). Frost recipe (researched): white 5–7% fill, + blur 12px + saturate 180% + brightness 1.08, white/10 border, inset top + rim-light; live digits always sit on a darker ~30% film for legibility. + THE HARD PART: restraint — glass on a handful of static surfaces only, + never scrolling rows; motion only where it means something. TW v4. + ───────────────────────────────────────────────────────────────────────── */ + +@import "tailwindcss"; + +@theme { + --color-bg: #07080f; /* indigo-tinted void — never pure #000 */ + --color-panel: #0d0f1a; /* solid fallback surfaces (history rows) */ + --color-panel2: #131628; + --color-sheet: #0b0d17; + --color-edge: #1e2338; + --color-edge2: #2a3050; + --color-ink: #eff1ff; + --color-dim: #9aa1c4; + --color-faint: #5a6184; + --color-up: #3ee6c1; /* mint-teal — UP, wins */ + --color-up-deep: #052019; + --color-down: #ff5c87; /* coral-rose — DOWN, losses */ + --color-down-deep: #2a0612; + --color-warn: #ffc857; /* amber — disclosure & warnings ONLY */ + + --font-sans: var(--font-sora), ui-sans-serif, system-ui, sans-serif; + --font-display: var(--font-unbounded), var(--font-sora), ui-sans-serif, sans-serif; + --font-mono: var(--font-jbmono), ui-monospace, "SF Mono", Menlo, monospace; + + --ease-mass: cubic-bezier(0.32, 0.72, 0, 1); +} + +html { + background: var(--color-bg); + color-scheme: dark; +} + +body { + font-feature-settings: "tnum" 1; + overscroll-behavior: none; +} + +::selection { + background: rgb(62 230 193 / 0.25); +} + +:where(button, a, [role="button"]):focus-visible { + outline: 1.5px solid var(--color-up); + outline-offset: 2px; +} +:where(input, textarea):focus-visible { + outline: none; +} + +/* ── ONE ambient orb — the room's light. Static (no animation: digits stay + legible), mood-tinted via body[data-mood]. The glass refracts this. */ +.ambient { + position: fixed; + inset: 0; + pointer-events: none; + z-index: 0; + background: + radial-gradient(55% 42% at 28% 8%, var(--mood, rgb(86 96 160 / 0.34)) 0%, transparent 70%), + radial-gradient(120% 100% at 50% 120%, rgb(10 12 24 / 0.9) 0%, transparent 60%); + transition: opacity 1.2s var(--ease-mass); +} +body[data-mood="up"] .ambient { + --mood: rgb(62 230 193 / 0.2); +} +body[data-mood="down"] .ambient { + --mood: rgb(255 92 135 / 0.17); +} + +/* ── The glass system ────────────────────────────────────────────────────── + .glass — standard frosted card (static surfaces only) + .glass-strong — elevated frost for inputs / the picker / focal moments + .film — the darker plate that sits BEHIND live digits (legibility) + Fallback: agents without backdrop-filter get a solid panel. */ +.glass, +.glass-strong { + border: 1px solid rgb(255 255 255 / 0.1); + border-radius: 1.25rem; + background: rgb(255 255 255 / 0.05); + box-shadow: + inset 0 1px 0 rgb(255 255 255 / 0.12), + 0 8px 32px rgb(0 0 0 / 0.36); +} +.glass-strong { + background: rgb(255 255 255 / 0.07); + border-color: rgb(255 255 255 / 0.14); +} +@supports (backdrop-filter: blur(1px)) { + .glass { + backdrop-filter: blur(12px) saturate(180%) brightness(1.08); + } + .glass-strong { + backdrop-filter: blur(16px) saturate(180%) brightness(1.1); + } +} +@supports not (backdrop-filter: blur(1px)) { + .glass, + .glass-strong { + background: rgb(13 15 26 / 0.92); + } +} +.film { + background: rgb(7 8 15 / 0.32); + border-radius: 0.875rem; +} + +/* ── Press physics: every tappable scales down 2% on press. */ +.press { + transition: transform 180ms var(--ease-mass), filter 180ms var(--ease-mass), opacity 180ms var(--ease-mass), + background-color 180ms var(--ease-mass), border-color 180ms var(--ease-mass); +} +.press:active { + transform: scale(0.98); +} + +/* Accent glow behind the solid primary commit button (style guidance). */ +.cta-glow-up { + box-shadow: 0 0 28px rgb(62 230 193 / 0.28); +} +.cta-glow-down { + box-shadow: 0 0 28px rgb(255 92 135 / 0.24); +} + +/* ── Countdown — depleting bar; urgency in the final fifth. */ +.cd-bar { + transform-origin: left center; + transition: transform 1s linear, background-color 600ms var(--ease-mass); +} +@keyframes urgency-pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.55; + } +} +.cd-urgent { + animation: urgency-pulse 1s ease-in-out infinite; +} + +/* ── Settle reveal. */ +@keyframes settle-pop { + 0% { + opacity: 0; + transform: translateY(14px) scale(0.97); + } + 100% { + opacity: 1; + transform: translateY(0) scale(1); + } +} +.settle-pop { + animation: settle-pop 0.6s var(--ease-mass); +} + +/* ── Sheets mount only while open (closed markup cannot exist, so it cannot + ever render unstyled). Enter 420ms, exit 260ms (~62%), transform-only. */ +@keyframes sheet-in { + from { + transform: translateY(100%); + } + to { + transform: translateY(0); + } +} +@keyframes sheet-out { + from { + transform: translateY(0); + } + to { + transform: translateY(100%); + } +} +@keyframes backdrop-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} +@keyframes backdrop-out { + from { + opacity: 1; + } + to { + opacity: 0; + } +} +.sheet-in { + animation: sheet-in 420ms var(--ease-mass); + will-change: transform; +} +.sheet-out { + animation: sheet-out 260ms var(--ease-mass) forwards; + will-change: transform; +} +.backdrop-in { + animation: backdrop-in 260ms ease; +} +.backdrop-out { + animation: backdrop-out 200ms ease forwards; +} + +@keyframes row-in { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} +.row-in { + animation: row-in 0.35s ease-out; +} + +@keyframes soft-pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.35; + } +} +.soft-pulse { + animation: soft-pulse 1.6s ease-in-out infinite; +} + +/* ── Stake slider — glass track, solid ink thumb (fat-finger friendly). */ +.stake-range { + -webkit-appearance: none; + appearance: none; + height: 6px; + border-radius: 999px; + background: rgb(255 255 255 / 0.08); + border: 1px solid rgb(255 255 255 / 0.06); + outline: none; +} +.stake-range::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 22px; + height: 22px; + border-radius: 999px; + border: none; + background: var(--color-ink); + box-shadow: 0 2px 10px rgb(0 0 0 / 0.5); + cursor: pointer; +} +.stake-range::-moz-range-thumb { + width: 22px; + height: 22px; + border-radius: 999px; + border: none; + background: var(--color-ink); + box-shadow: 0 2px 10px rgb(0 0 0 / 0.5); + cursor: pointer; +} +.stake-range:disabled { + opacity: 0.35; +} + +@media (prefers-reduced-motion: reduce) { + .cd-urgent, + .settle-pop, + .row-in, + .soft-pulse, + .sheet-in, + .backdrop-in { + animation: none; + } + .sheet-out, + .backdrop-out { + animation-duration: 1ms; + } + .press, + .cd-bar { + transition: none; + } +} diff --git a/examples/predict/app/icon.svg b/examples/predict/app/icon.svg new file mode 100644 index 0000000..641b3cc --- /dev/null +++ b/examples/predict/app/icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/examples/predict/app/layout.tsx b/examples/predict/app/layout.tsx new file mode 100644 index 0000000..df5812a --- /dev/null +++ b/examples/predict/app/layout.tsx @@ -0,0 +1,38 @@ +// ───────────────────────────────────────────────────────────────────────────── +// app/layout.tsx — root shell: void canvas, three faces loaded once here — +// Unbounded (display moments), Sora (UI copy), JetBrains Mono (every number, +// tabular). THE HARD PART: numbers must never reflow — the mono face + +// "tnum" on (globals.css) keep timers and prices width-stable. +// GOTCHAS.md → (no API gotchas here) (../../GOTCHAS.md) +// ───────────────────────────────────────────────────────────────────────────── + +import type { Metadata, Viewport } from "next"; +import { JetBrains_Mono, Sora, Unbounded } from "next/font/google"; +import "./globals.css"; + +const unbounded = Unbounded({ subsets: ["latin"], weight: ["500", "700", "900"], variable: "--font-unbounded" }); +const sora = Sora({ subsets: ["latin"], weight: ["400", "500", "600", "700"], variable: "--font-sora" }); +const jbMono = JetBrains_Mono({ subsets: ["latin"], weight: ["400", "500", "700"], variable: "--font-jbmono" }); + +export const metadata: Metadata = { + title: "predict — what happens next?", + description: + "Pick a side. Cents are the odds. Your stake is the most you can lose. Markets backed by real Flash Trade V2 positions on Solana mainnet.", +}; + +export const viewport: Viewport = { + themeColor: "#07080f", + width: "device-width", + initialScale: 1, +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + +
+ {children} + + + ); +} diff --git a/examples/predict/app/markets/page.tsx b/examples/predict/app/markets/page.tsx new file mode 100644 index 0000000..cbd9767 --- /dev/null +++ b/examples/predict/app/markets/page.tsx @@ -0,0 +1,7 @@ +// app/markets/page.tsx — the prediction market moved to the root route (/). +// Redirect any old /markets links there so there is one canonical home. +import { redirect } from "next/navigation"; + +export default function MarketsPage() { + redirect("/"); +} diff --git a/examples/predict/app/page.tsx b/examples/predict/app/page.tsx new file mode 100644 index 0000000..19d605d --- /dev/null +++ b/examples/predict/app/page.tsx @@ -0,0 +1,11 @@ +// ───────────────────────────────────────────────────────────────────────────── +// app/page.tsx — THE app: the prediction market (formerly at /markets). All +// state is browser state by design: wallet, session key, rounds cache. Connect +// any installed wallet; browsing is wallet-free. +// ───────────────────────────────────────────────────────────────────────────── + +import { MarketsApp } from "@/components/markets-app"; + +export default function Page() { + return ; +} diff --git a/examples/predict/components/bets.tsx b/examples/predict/components/bets.tsx new file mode 100644 index 0000000..e8a86c1 --- /dev/null +++ b/examples/predict/components/bets.tsx @@ -0,0 +1,144 @@ +// ───────────────────────────────────────────────────────────────────────────── +// components/bets.tsx — "your bets". Each round is a real Flash V2 position plus +// the question + the clock. ACTIVE bets show live value (Flash-parity mark math, +// never the indexer pnl) + a countdown to the deadline + a manual settle; SETTLED +// bets show WON/LOST. The position is the truth — the round adds the framing. The +// settlement engine (useMarketRounds) auto-closes at the deadline; "settle" is the +// manual override. Marks come from one polled /v2/prices. +// ───────────────────────────────────────────────────────────────────────────── + +"use client"; + +import { useEffect, useState } from "react"; +import type { BasketSnapshot, PriceInfo } from "flash-v2"; +import { computePositionView } from "@/lib/format"; +import { flash } from "@/lib/flash"; +import { positionFor, type Round } from "@/lib/rounds"; +import { TokenIcon } from "./token-icon"; + +const fmtUsd = (n: number) => `${n < 0 ? "−" : ""}$${Math.abs(n).toFixed(2)}`; +const fmtStrike = (n: number) => (n >= 1000 ? n.toLocaleString(undefined, { maximumFractionDigits: 0 }) : n.toFixed(n >= 1 ? 2 : 4)); + +function countdown(ms: number): string { + if (ms <= 0) return "settling…"; + const s = Math.floor(ms / 1000); + const m = Math.floor(s / 60); + const h = Math.floor(m / 60); + if (h > 0) return `${h}h ${m % 60}m`; + if (m > 0) return `${m}m ${s % 60}s`; + return `${s}s`; +} + +export function Bets({ + rounds, + snapshot, + now, + onSettleNow, +}: { + rounds: Round[]; + snapshot: BasketSnapshot | null; + now: number; + onSettleNow: (r: Round) => void; +}) { + const [prices, setPrices] = useState>({}); + useEffect(() => { + let alive = true; + let timer: ReturnType | undefined; + const tick = async () => { + try { + const p = await flash.prices(); + if (alive) setPrices(p); + } catch { + /* keep last marks */ + } + if (alive) timer = setTimeout(() => void tick(), 3000); + }; + void tick(); + return () => { + alive = false; + if (timer) clearTimeout(timer); + }; + }, []); + + const active = rounds.filter((r) => r.status === "active" || r.status === "settling"); + const settled = rounds.filter((r) => r.result).slice(0, 8); + if (active.length === 0 && settled.length === 0) return null; + + return ( +
+
+ your bets + + {active.length} live +
+ +
+ {active.map((r) => { + const long = r.side === "LONG"; + const pos = snapshot ? positionFor(snapshot, r.market, r.side) : undefined; + const mark = prices[r.market]?.priceUi ?? null; + const view = pos ? computePositionView(pos, mark) : null; + const pnl = view?.pnlUsd ?? null; + const remaining = r.expiresAt != null ? r.expiresAt - now : 0; + return ( +
+
+ +
+

+ {r.market}{" "} + + {long ? "▲ above" : "below ▼"} {r.strike != null ? fmtStrike(r.strike) : ""} + +

+

+ {r.status === "settling" ? "settling…" : `⏱ ${countdown(remaining)} · stake ${fmtUsd(r.stakeUsd)}`} +

+
+
+
+ {pnl !== null && ( + = 0 ? "text-up" : "text-down"}`}> + {pnl >= 0 ? "+" : ""} + {fmtUsd(pnl)} + + )} + +
+
+ ); + })} + + {settled.map((r) => { + const won = r.result?.won ?? false; + return ( +
+
+ +

+ {r.market} {r.side === "LONG" ? "above" : "below"}{" "} + {r.strike != null ? fmtStrike(r.strike) : ""} +

+
+
+ + {won ? "WON" : "LOST"} + + + {(r.result?.pnlUsd ?? 0) >= 0 ? "+" : ""} + {fmtUsd(r.result?.pnlUsd ?? 0)} + +
+
+ ); + })} +
+
+ ); +} diff --git a/examples/predict/components/discover.tsx b/examples/predict/components/discover.tsx new file mode 100644 index 0000000..0f0904c --- /dev/null +++ b/examples/predict/components/discover.tsx @@ -0,0 +1,149 @@ +// ───────────────────────────────────────────────────────────────────────────── +// components/discover.tsx — the market browser: category tabs + search + +// timeframe + a grid of MarketCards. Browsing needs NO wallet (markets are +// public: /v2/tokens + /v2/prices). Tapping a card hands (token, price, tf) up +// to open the detail + ticket (phase 4). +// ───────────────────────────────────────────────────────────────────────────── + +"use client"; + +import { useMemo, useState } from "react"; +import { TIMEFRAMES, type TimeframeId } from "@/lib/payoff"; +import { usePredictMarkets } from "@/lib/use-markets"; +import { useAllMarketLimits } from "@/lib/hooks"; +import type { Round } from "@/lib/rounds"; +import type { ActiveSigner } from "@/lib/signer"; +import { MarketCard } from "./market-card"; +import { MarketDetail } from "./market-detail"; + +export function Discover({ + signer, + canBet, + availableUsd, + onNeedWallet, + onPlaced, + onOpen, +}: { + signer: ActiveSigner | null; + canBet: boolean; + /** USDC available to bet (gates stake ≤ balance in the ticket). */ + availableUsd: number | null; + onNeedWallet?: () => void; + onPlaced?: (round: Round) => void; + onOpen?: (token: string, price: number, tf: TimeframeId) => void; +}) { + const { rows, categories, loading, error } = usePredictMarkets(); + const limitsBySymbol = useAllMarketLimits(useMemo(() => rows.map((r) => r.token), [rows])); + const [cat, setCat] = useState("All"); + const [tf, setTf] = useState("1h"); + const [query, setQuery] = useState(""); + const [selected, setSelected] = useState<{ token: string; price: number; tf: TimeframeId } | null>(null); + + const shown = useMemo( + () => + rows.filter( + (r) => + (cat === "All" || r.category === cat) && + (query === "" || r.token.toLowerCase().includes(query.toLowerCase())), + ), + [rows, cat, query], + ); + const pills = categories.length > 0 ? ["All", ...categories] : []; + + return ( +
+
+ + live · on-chain + +

+ What happens next? +

+

Pick a side. Cents are the odds. Your stake is the most you can lose.

+
+ +
+ +
+ {TIMEFRAMES.map((t) => ( + + ))} +
+
+ + {pills.length > 0 && ( +
+ {pills.map((p) => ( + + ))} +
+ )} + +
+ {loading && rows.length === 0 && + Array.from({ length: 6 }).map((_, i) =>
)} + {error && rows.length === 0 && ( +
Couldn't load markets: {error}
+ )} + {!loading && !error && shown.length === 0 && ( +
No markets match.
+ )} + {shown.map((r) => ( + { + setSelected({ token: r.token, price: r.price, tf }); + onOpen?.(r.token, r.price, tf); + }} + /> + ))} +
+ +

+ Mainnet · real funds. Odds are set by a formula on a real leveraged position — not a shared-pot market with + discovered prices. You can never lose more than your stake. +

+ + {selected && ( + setSelected(null)} + onNeedWallet={onNeedWallet} + onPlaced={onPlaced} + /> + )} +
+ ); +} diff --git a/examples/predict/components/funds-sheet.tsx b/examples/predict/components/funds-sheet.tsx new file mode 100644 index 0000000..b2ebfcf --- /dev/null +++ b/examples/predict/components/funds-sheet.tsx @@ -0,0 +1,131 @@ +// ───────────────────────────────────────────────────────────────────────────── +// components/funds-sheet.tsx — explicit deposit & withdraw. User types the +// amount, sees where money lives, approves that ONE transfer. +// THE HARD PART: withdraw is two phases — request queues settlement off the +// rollup (~30–90s), execute completes it. The wait is polled with UNSIGNED +// simulations (lib/funds.ts), so retries never burn wallet popups. +// GOTCHAS.md → "Withdrawals settle in two phases" · §17 (../../GOTCHAS.md) +// ───────────────────────────────────────────────────────────────────────────── +"use client"; + +import { useState } from "react"; +import type { FundsStep } from "@/lib/funds"; +import { fmtUsd } from "@/lib/format"; +import { useSheetMount } from "@/lib/use-sheet-mount"; + +interface Props { + open: boolean; + busy: boolean; + walletUsdc: number | null; + inBasketUsd: number | null; + step: FundsStep | null; + /** A requested withdrawal is waiting for its execute leg. */ + executePending: boolean; + onDeposit: (amount: string) => void; + onWithdraw: (amount: string) => void; + onExecuteWithdraw: () => void; + onClose: () => void; +} + +export function FundsSheet({ open, busy, walletUsdc, inBasketUsd, step, executePending, onDeposit, onWithdraw, onExecuteWithdraw, onClose }: Props) { + const [mode, setMode] = useState<"deposit" | "withdraw">("deposit"); + const [amount, setAmount] = useState(""); + + const { mounted, closing } = useSheetMount(open); + + const max = mode === "deposit" ? walletUsdc : inBasketUsd; + const amt = Number(amount); + const valid = Number.isFinite(amt) && amt > 0 && (max === null || amt <= max + 1e-9); + + if (!mounted) return null; + return ( +
+
+
+
+
+
+ {(["deposit", "withdraw"] as const).map((m) => ( + + ))} +
+ +
+ wallet {walletUsdc === null ? "…" : fmtUsd(walletUsdc)} + {mode === "deposit" ? "→" : "←"} + basket {inBasketUsd === null ? "…" : fmtUsd(inBasketUsd)} +
+ +
+ $ + setAmount(e.target.value.replace(/[^0-9.]/g, ""))} + disabled={busy} + aria-label={`Amount to ${mode} in USDC`} + className="w-full bg-transparent font-mono text-xl text-ink placeholder:text-faint focus:outline-none" + /> + +
+ +

+ {mode === "deposit" + ? "USDC moves from your wallet into your basket. One wallet approval." + : "Two approvals by design: request queues settlement off the rollup (~30–90s), execute completes it to your wallet."} +

+ + {executePending && ( + + )} + + {step && ( +
+ + {step.label} + {step.note ? {step.note} : null} +
+ )} + +
+ + +
+
+
+
+
+ ); +} diff --git a/examples/predict/components/market-card.tsx b/examples/predict/components/market-card.tsx new file mode 100644 index 0000000..aae783e --- /dev/null +++ b/examples/predict/components/market-card.tsx @@ -0,0 +1,108 @@ +// ───────────────────────────────────────────────────────────────────────────── +// components/market-card.tsx — the single most important component (Polymarket's +// market card is the click): token + live price + a plain-words question + the +// YES/NO odds as a split bar. Cents ARE the odds. Semantic colour + ▲/▼ always +// (never colour-alone). The headline is a believable "climb above" call, priced +// LIVE through this market's real trade spread + leverage caps by the engine — +// so each card's odds FLOAT (no two read alike). Until the live limits load the +// card shows a skeleton; it NEVER quotes spread-blind odds. Tapping opens the +// full ladder + ticket. +// ───────────────────────────────────────────────────────────────────────────── + +"use client"; + +import { useMemo } from "react"; +import { cents, headlineMarket } from "@/lib/markets"; +import type { MarketLimits } from "@/lib/hooks"; +import { timeframe, type TimeframeId } from "@/lib/payoff"; +import { fmtPrice } from "@/lib/copy"; +import { TokenIcon } from "./token-icon"; + +function fmtStrike(n: number): string { + return n >= 1000 ? n.toLocaleString(undefined, { maximumFractionDigits: 0 }) : n.toFixed(n >= 1 ? 2 : 4); +} + +export function MarketCard({ + token, + price, + timeframe: tf, + limits, + onOpen, +}: { + token: string; + price: number; + timeframe: TimeframeId; + /** live spread + caps for this market; null until loaded (→ skeleton). */ + limits: MarketLimits | null; + onOpen?: () => void; +}) { + const m = useMemo( + () => + limits + ? headlineMarket({ + token, + oracle: price, + spread: limits.spreadLongPct, + maxLeverage: limits.maxLeverage, + minLeverage: limits.minLeverage, + timeframe: tf, + }) + : null, + [token, price, tf, limits], + ); + + return ( + + ); +} + +function Odds({ yes }: { yes: number }) { + const no = 100 - yes; + return ( +
+
+ ▲ YES {yes}¢ + NO {no}¢ ▼ +
+
+
+
+
+ ); +} diff --git a/examples/predict/components/market-detail.tsx b/examples/predict/components/market-detail.tsx new file mode 100644 index 0000000..151cb77 --- /dev/null +++ b/examples/predict/components/market-detail.tsx @@ -0,0 +1,427 @@ +// ───────────────────────────────────────────────────────────────────────────── +// components/market-detail.tsx — tap a card → this overlay. The full prediction +// surface for one token+timeframe: +// • Above / Below direction toggle (each side is a real, separate bet — odds +// don't sum to 100¢; the gap is the honest house edge, like a book's vig). +// • The STRIKE LADDER: a row per strike, each a YES bet at its own odds. +// • MULTI-OUTCOME BUCKETS: "where does it land?". +// • The BUY TICKET: stake → to-win · profit · max-loss (= stake), locked math. +// Every number is priced LIVE through this market's real trade spread + leverage +// caps (useMarketLimits) by lib/markets.ts — measured from the SIGNED FILL, never +// the oracle, so a take-profit can't land inside the spread. Nothing is shown +// until the limits load (no spread-blind odds, ever). +// ───────────────────────────────────────────────────────────────────────────── + +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { assertNoErr, checkCollateralForTriggers, validateTriggerPrice, type OpenPositionResponse } from "flash-v2"; +import { + cents, + profitUsd, + slippageForSpread, + strikeLadder, + toWinUsd, + type Direction, + type PricedMarket, +} from "@/lib/markets"; +import { useMarketLimits } from "@/lib/hooks"; +import { lockedQuoteFrom, timeframe, type TimeframeId } from "@/lib/payoff"; +import { newRoundId, type Round } from "@/lib/rounds"; +import { calmError, fmtPrice } from "@/lib/copy"; +import { flash } from "@/lib/flash"; +import type { ActiveSigner } from "@/lib/signer"; +import { TokenIcon } from "./token-icon"; + +const MIN_STAKE = 11; // the $11-after-fees floor (RECOMMENDED_MIN_COLLATERAL_USD) + +const fmtUsd = (n: number) => `$${n.toFixed(2)}`; +const fmtStrike = (n: number) => (n >= 1000 ? n.toLocaleString(undefined, { maximumFractionDigits: 0 }) : n.toFixed(n >= 1 ? 2 : 4)); + +/** The bet reconciled to the SIGNED response — the authoritative numbers the user + * actually signs, never the pre-trade estimate. `ok:false` blocks the commit. */ +interface ReconciledBet { + ok: boolean; + reason?: string; + /** real fill entry from the venue. */ + entry: number; + /** price the take-profit actually fires at. */ + tpExit: number; + /** profit the API computes for the TP ON THIS FILL (the spread guard). */ + profitUsd: number; + toWinUsd: number; + maxLossUsd: number; + entryFeeUsd: number; + liq: number; +} + +/** Reconcile an open-position response against the market we meant to back, and + * decide whether it can honestly be signed. THREE gates, all from the real fill: + * (a) the strike is a valid TP trigger vs the real entry (else on-chain 6057) — fix #4 + * (b) the venue actually priced a take-profit + * (c) THE SPREAD GUARD: the TP nets a profit > 0. profit ≤ 0 means the strike + * landed inside the spread and would fire as a ~100% LOSS — the exact bug + * the v2.1 redesign exists to kill. The user can never sign through it. */ +function reconcileBet(res: OpenPositionResponse, m: PricedMarket, stakeUsd: number): ReconciledBet { + const side = m.construction.side; + const entry = Number(res.newEntryPrice) || 0; + const liq = Number(res.newLiquidationPrice) || 0; + const entryFeeUsd = Number(res.entryFee) || 0; + const tp = res.takeProfitQuote; + // The venue's OWN profit-at-TP on the real fill — ground truth. The spread is + // paid on BOTH legs, so this already nets the exit spread (a TP inside the + // round-trip spread reports ≤ 0). Never trust local math over this number. + const realProfitUsd = tp ? Number(tp.profitUsdUi) || 0 : 0; + const tpExit = tp ? Number(tp.exitPriceUi) || 0 : 0; + const base = { entry, tpExit, profitUsd: realProfitUsd, toWinUsd: stakeUsd + Math.max(0, realProfitUsd), maxLossUsd: stakeUsd, entryFeeUsd, liq }; + + const valid = validateTriggerPrice({ side, kind: "tp", price: m.construction.takeProfitPrice, markPrice: entry }); + if (!valid.ok) return { ...base, ok: false, reason: "This strike isn't beyond the live fill, so the bet can't pay out. Pick a farther strike." }; + // On-chain rule: TP/SL/limit need > $10 collateral AFTER entry fees. A stake + // whose fee drops it to ≤ $10 opens a perp whose take-profit — the entire win + // boundary — silently fails to place. Never sign that (the fee is in hand here). + const collat = checkCollateralForTriggers(stakeUsd, entryFeeUsd); + if (!collat.ok) return { ...base, ok: false, reason: "Stake too small once fees apply — raise it a little so the take-profit can be placed." }; + if (!tp) return { ...base, ok: false, reason: "Couldn't price the take-profit on this fill. Try again in a moment." }; + // THE SPREAD GUARD: on the real round-trip the TP must net a profit, else the + // move is inside the (two-leg) spread and would fire as a loss. + if (realProfitUsd <= 0) return { ...base, ok: false, reason: "On the live fill this bet can't win right now — the move is inside the spread. Try a farther strike or another market." }; + // DIVERGENCE GUARD: the live payout must be close to the odds we displayed. + // A big shortfall (stale browse price / model drift) blocks rather than let the + // user sign a bet materially worse than advertised. + const shownProfit = profitUsd(stakeUsd, m.prob); + if (shownProfit > 0 && realProfitUsd < shownProfit * 0.6) { + return { ...base, ok: false, reason: `The live payout dropped to about ${fmtUsd(base.toWinUsd)} (we showed ~${fmtUsd(stakeUsd + shownProfit)}). Re-review or pick another strike.` }; + } + return { ...base, ok: true }; +} + +export function MarketDetail({ + token, + price, + timeframe: tf, + signer, + canBet, + availableUsd, + onClose, + onNeedWallet, + onPlaced, +}: { + token: string; + price: number; + timeframe: TimeframeId; + signer: ActiveSigner | null; + canBet: boolean; + /** USDC available to bet; null until known. Gates stake ≤ balance. */ + availableUsd: number | null; + onClose: () => void; + onNeedWallet?: () => void; + onPlaced?: (round: Round) => void; +}) { + const limits = useMarketLimits(token); + const [dir, setDir] = useState("ABOVE"); + // Side-appropriate spread: a LONG (Above) enters through the long spread, a + // SHORT (Below) through the short spread. The engine measures from that fill. + const spread = limits ? (dir === "ABOVE" ? limits.spreadLongPct : limits.spreadShortPct) : 0; + + const ladder = useMemo( + () => + limits + ? strikeLadder({ + token, oracle: price, spread, maxLeverage: limits.maxLeverage, + minLeverage: limits.minLeverage, direction: dir, timeframe: tf, + }) + : [], + [token, price, spread, dir, tf, limits], + ); + const [picked, setPicked] = useState(null); + const active = picked && picked.direction === dir ? picked : ladder[Math.floor(ladder.length / 2)] ?? null; + const [stake, setStake] = useState(String(MIN_STAKE)); + + const stakeUsd = Math.max(0, Number(stake.replace(/[^0-9.]/g, "")) || 0); + const tooSmall = stakeUsd > 0 && stakeUsd < MIN_STAKE; + const tooBig = availableUsd != null && stakeUsd > availableUsd + 1e-9; + const yesCents = active ? cents(active.prob) : 0; + + type Phase = "idle" | "quoting" | "review" | "signing"; + const [phase, setPhase] = useState("idle"); + const [review, setReview] = useState(null); + const [ticketError, setTicketError] = useState(null); + const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null); + // Synchronous re-entrancy lock: React state (phase) updates async, so a fast + // double-tap could pass the `phase` guard twice and fire TWO real opens. + const sending = useRef(false); + + // Any change to the bet (strike, side, stake) invalidates a pending review — + // you can only sign numbers you just reviewed. + useEffect(() => { + setPhase((p) => (p === "quoting" || p === "signing" ? p : "idle")); + setReview(null); + }, [active, stakeUsd]); + + // The openPosition request — IDENTICAL for the review quote and the signed + // commit, so the numbers you review are the numbers you sign. Slippage clears + // the live spread; the take-profit IS the YES win (= the strike). + const reqFor = useCallback( + (m: PricedMarket) => ({ + inputTokenSymbol: "USDC", + outputTokenSymbol: m.token, + inputAmountUi: stakeUsd.toFixed(2), + leverage: Number(m.construction.leverage.toFixed(4)), + tradeType: m.construction.side, + slippagePercentage: slippageForSpread(m.spread), + takeProfit: m.construction.takeProfitPrice.toFixed(4), + }), + [stakeUsd], + ); + + // STEP 1 — review: quote the REAL fill (no owner = no tx), reconcile, and BLOCK + // if the take-profit can't pay out on this fill. The user never reaches a sign + // prompt for a bet that's a loss by construction. + const doReview = useCallback(async () => { + // Guard: never quote from an in-flight phase or on an invalid/over-balance stake. + if (!active || phase === "quoting" || phase === "signing") return; + if (stakeUsd <= 0 || stakeUsd < MIN_STAKE || (availableUsd != null && stakeUsd > availableUsd + 1e-9)) return; + setTicketError(null); + setResult(null); + setPhase("quoting"); + try { + const res = await flash.openPosition(reqFor(active)); + assertNoErr("open-position quote", res); + setReview(reconcileBet(res, active, stakeUsd)); + setPhase("review"); + } catch (e) { + setTicketError(calmError(e)); + setPhase("idle"); + } + }, [active, reqFor, stakeUsd, phase, availableUsd]); + + // STEP 2 — confirm: re-quote WITH owner (the price may have moved since review), + // re-run the same reconciliation, and only sign + send if it STILL passes. A + // re-quote that went bad bumps back to the review with the new reason. + const doConfirm = useCallback(async () => { + if (!active || !signer || phase === "signing" || sending.current) return; // double-submit guard + sending.current = true; + setPhase("signing"); + setTicketError(null); + try { + const res = await flash.openPosition({ ...reqFor(active), owner: signer.owner, ...signer.tradeFields }); + assertNoErr("open-position", res); + const recon = reconcileBet(res, active, stakeUsd); + if (!recon.ok) { + setReview(recon); + setPhase("review"); + return; + } + if (!res.transactionBase64) throw new Error(res.err ?? "no transaction returned"); + const sent = await signer.sendTrade(res.transactionBase64); + // Record the bet so the settlement engine can run its clock + resolve it. + const lev = active.construction.leverage; + const round: Round = { + id: newRoundId(), + market: active.token, + side: active.construction.side, + stakeUsd, + leverage: lev, + timeframe: tf, + placedAt: Date.now(), + expiresAt: Date.now() + timeframe(tf).ms, + quote: lockedQuoteFrom(res, stakeUsd, lev, active.construction.side), + status: "active", + strike: active.construction.takeProfitPrice, + winProfitUsd: recon.profitUsd, + }; + setResult({ ok: true, msg: `Bet placed · ${sent.signature.slice(0, 8)}…` }); + setReview(null); + setPhase("idle"); + onPlaced?.(round); + } catch (e) { + setTicketError(calmError(e)); + setPhase("review"); + } finally { + sending.current = false; + } + }, [active, signer, reqFor, stakeUsd, onPlaced, phase]); + + return ( +
+ +
+ + {/* direction toggle — each side is its own real bet */} +
+ + +
+ + {!limits ? ( +
+

pricing this market…

+ {Array.from({ length: 3 }).map((_, i) =>
)} +
+ ) : ( + <> + {/* the strike ladder — pick your odds by picking a strike */} +
+

{dir === "ABOVE" ? "climbs above" : "drops below"} — pick a strike

+ {ladder.map((m) => { + const on = active === m; + const c = cents(m.prob); + return ( + + ); + })} +
+ + {/* the buy ticket */} + {active && ( +
+
+

+ {token} {dir === "ABOVE" ? "above" : "below"} {fmtStrike(active.strike)} +

+ {yesCents}¢ +
+ + +

+ min ${MIN_STAKE} (a bet bundles a take-profit) + {availableUsd != null ? ` · you have $${availableUsd.toFixed(2)}` : ""} +

+ + {/* the honest numbers — locked from the same math the trade uses */} +
+ + + +
+ + {(phase === "review" || phase === "signing") && review ? ( +
+ {/* RECONCILED to the signed fill — the numbers you actually sign */} +
+ + + + = 0 ? "+" : "−"}${fmtUsd(Math.abs(review.profitUsd))}`} tone={review.profitUsd > 0 ? "up" : "down"} /> + + +
+ {!review.ok &&

{review.reason}

} + {ticketError &&

{ticketError}

} +
+ + +
+
+ ) : ( + <> + + {result && ( +

{result.msg}

+ )} + {ticketError &&

{ticketError}

} + + )} +

+ Odds are formula-set on a real capped-loss position. Wins if {token} reaches the strike before the deadline; + otherwise it closes at the deadline price. You can never lose more than your stake. +

+
+ )} + + )} +
+
+ ); +} + +function Stat({ label, value, tone }: { label: string; value: string; tone: "up" | "down" }) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +function ReconRow({ k, v, tone }: { k: string; v: string; tone?: "up" | "down" }) { + return ( +
+ {k} + {v} +
+ ); +} diff --git a/examples/predict/components/markets-app.tsx b/examples/predict/components/markets-app.tsx new file mode 100644 index 0000000..9331706 --- /dev/null +++ b/examples/predict/components/markets-app.tsx @@ -0,0 +1,264 @@ +// ───────────────────────────────────────────────────────────────────────────── +// components/markets-app.tsx — THE app shell, at the root route /. Standalone: +// providers → owner stream → session → signer → enable → bet → settle, plus its +// OWN deposit/withdraw (FundsSheet) and an ANY-wallet picker (no wallet is +// hardcoded). Browsing is wallet-free; the wallet is asked for only at "back +// this". (The old Updown app + the cross-app shared-position hazard are gone.) +// +// ⚠️ Real-funds path on Solana mainnet. The disclosure stays until the F7 guard + +// scoring residuals are signed off (SPEC-PREDICT-V2 → v2.1 REVIEW OUTCOME). +// ───────────────────────────────────────────────────────────────────────────── + +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { ConnectionProvider, WalletProvider, useAnchorWallet, useWallet } from "@solana/wallet-adapter-react"; +import { PhantomWalletAdapter } from "@solana/wallet-adapter-phantom"; +import { SolflareWalletAdapter } from "@solana/wallet-adapter-solflare"; +import { enableOneClickTrading, type EnableState, type EnableWalletCtx } from "@/lib/enable"; +import { depositUsdc, executeWithdrawalStep, withdrawUsdc, type FundsStep } from "@/lib/funds"; +import { flash } from "@/lib/flash"; +import { calmError } from "@/lib/copy"; +import { useBalances, useBasketBalance, useUsdcMint } from "@/lib/hooks"; +import { loadSession, type LoadedSession } from "@/lib/session"; +import { makeSessionSigner } from "@/lib/signer"; +import { StreamProvider, useStream } from "@/lib/stream"; +import { useMarketRounds } from "@/lib/use-market-rounds"; +import { Bets } from "./bets"; +import { Discover } from "./discover"; +import { FundsSheet } from "./funds-sheet"; +import { WalletMenu } from "./wallet-menu"; +import { WalletSheet } from "./wallet-sheet"; + +export function MarketsApp() { + const wallets = useMemo(() => [new PhantomWalletAdapter(), new SolflareWalletAdapter()], []); + return ( + + + + + + ); +} + +function Shell() { + const { publicKey } = useWallet(); + const owner = publicKey ? publicKey.toBase58() : null; + return ( + + + + ); +} + +function Inner({ owner }: { owner: string | null }) { + const walletCtx = useWallet(); + const anchorWallet = useAnchorWallet(); + const { snapshot, refresh: refreshSnapshot } = useStream(); + const usdcMint = useUsdcMint(); + const balances = useBalances(owner, usdcMint); + const basket = useBasketBalance(owner, snapshot?.basketPubkey ?? null, usdcMint); + const inBasketUsd = basket.bal?.inBasketUsd ?? null; + + const [session, setSession] = useState(null); + useEffect(() => { + setSession(owner ? loadSession(owner) : null); + }, [owner]); + const signer = useMemo( + // Authority guard (matches tap-trade app.tsx): during an A→B wallet switch the + // `session` state lags `anchorWallet` by one render — never build a signer that + // pairs wallet B with wallet A's session token. + () => + session && anchorWallet && session.authority === anchorWallet.publicKey.toBase58() + ? makeSessionSigner(anchorWallet, session, flash.network) + : null, + [session, anchorWallet], + ); + + // Settlement engine: records each bet, auto-closes it at its deadline, and + // resolves win/lose. The "by " promise is only honest with this. + const { rounds, now, addRound, settleNow } = useMarketRounds(owner, snapshot, signer); + + // "canBet" = wallet connected + one-tap enabled. Whether THIS stake is funded + // (≥ MIN_STAKE and ≤ available) is gated per-bet in the ticket against + // `availableUsd`, so an enabled-but-underfunded user gets an "add funds" + // message on the ticket rather than a dead-end "enable" CTA. + const basketExists = Boolean(snapshot?.basketPubkey); + const canBet = basketExists && Boolean(signer); + + const [enabling, setEnabling] = useState(false); + const [note, setNote] = useState(null); + const [pickerOpen, setPickerOpen] = useState(false); + + // Open the wallet sheet — the user chooses ANY installed wallet (no wallet is + // hardcoded; selection + the connect ref-dance happen in WalletSheet). + const connect = useCallback(() => setPickerOpen(true), []); + + // ── funds: deposit/withdraw USDC ↔ basket (ported so /markets is standalone). + // Withdraw is two-phase by design (request → execute, ~30–90s). + const [fundsOpen, setFundsOpen] = useState(false); + const [fundsStep, setFundsStep] = useState(null); + const [executePending, setExecutePending] = useState(false); + const [fundsBusy, setFundsBusy] = useState(false); + + const fundsWallet = useMemo(() => { + if (!walletCtx.publicKey || !walletCtx.signTransaction) return null; + return { + publicKey: walletCtx.publicKey, + signTransaction: walletCtx.signTransaction, + signAllTransactions: walletCtx.signAllTransactions, + }; + }, [walletCtx.publicKey, walletCtx.signTransaction, walletCtx.signAllTransactions]); + + const doDeposit = useCallback(async (amount: string) => { + if (!fundsWallet || !usdcMint) return; + setFundsBusy(true); + try { + const r = await depositUsdc({ wallet: fundsWallet, usdcMint, amount, onStep: setFundsStep, onLog: () => {} }); + if (!r.ok && r.error) setNote(r.error); + await Promise.all([balances.refresh(), basket.refresh()]); + } finally { + setFundsBusy(false); + } + }, [fundsWallet, usdcMint, balances, basket]); + + const doWithdraw = useCallback(async (amount: string) => { + if (!fundsWallet || !usdcMint) return; + setFundsBusy(true); + try { + const r = await withdrawUsdc({ wallet: fundsWallet, usdcMint, amount, onStep: setFundsStep, onLog: () => {} }); + setExecutePending(Boolean(r.executePending)); + if (!r.ok && r.error) setNote(r.error); + await Promise.all([balances.refresh(), basket.refresh()]); + } finally { + setFundsBusy(false); + } + }, [fundsWallet, usdcMint, balances, basket]); + + const doExecuteWithdraw = useCallback(async () => { + if (!fundsWallet || !usdcMint) return; + setFundsBusy(true); + try { + const r = await executeWithdrawalStep({ wallet: fundsWallet, usdcMint, onStep: setFundsStep, onLog: () => {} }); + setExecutePending(Boolean(r.executePending)); + if (!r.ok && r.error) setNote(r.error); + await Promise.all([balances.refresh(), basket.refresh()]); + } finally { + setFundsBusy(false); + } + }, [fundsWallet, usdcMint, balances, basket]); + + const enable = useCallback(async () => { + if (!walletCtx.publicKey || !walletCtx.signTransaction || !anchorWallet) { + connect(); + return; + } + setEnabling(true); + setNote(null); + try { + const ctx: EnableWalletCtx = { + publicKey: walletCtx.publicKey, + signTransaction: walletCtx.signTransaction, + signAllTransactions: walletCtx.signAllTransactions, + }; + // Capture the live step state so a failure can name the REAL step error + // (already human-phrased upstream) instead of a generic "check your wallet". + // A container defeats closure-assignment narrowing. + const step: { last: EnableState | null } = { last: null }; + const result = await enableOneClickTrading({ + wallet: ctx, + anchorWallet, + snapshot, + usdcMint, + balances: { sol: balances.sol, usdc: balances.usdc }, + onStep: (s) => { step.last = s; }, + onLog: () => {}, + }); + if (result.session) setSession(result.session); + if (!result.ok) { + setNote( + result.needsUsdc + ? "Account ready — tap your balance above to deposit USDC, then bet." + : step.last?.error ?? "Couldn't finish enabling — check your wallet and try again.", + ); + } else if (result.needsUsdc) { + setNote("Tap your balance above to deposit USDC and start betting."); + } + // Pull the fresh owner snapshot so the NEW basket lands immediately — this + // is what flips basketExists → true so the user gets PAST "Enable one-tap" + // (the missing step that left it stuck). Matches tap-trade's doEnable. + await refreshSnapshot(); + void balances.refresh(); + void basket.refresh(); + } catch (e) { + setNote(calmError(e)); + } finally { + setEnabling(false); + } + }, [walletCtx, anchorWallet, snapshot, usdcMint, balances, basket, connect, refreshSnapshot]); + + const onNeedWallet = useCallback(() => { + if (!owner) connect(); + else void enable(); + }, [owner, connect, enable]); + + return ( +
+
+
+ P + predict +
+ {owner ? ( +
+ {!canBet && ( + + )} + setFundsOpen(true)} + /> +
+ ) : ( + + )} +
+ {note &&

{note}

} + {owner && } + { + addRound(round); + void refreshSnapshot(); // surface the new position immediately (Bets + reconcile) + void basket.refresh(); + }} + /> + setPickerOpen(false)} /> + { setFundsOpen(false); setFundsStep(null); }} + /> +
+ ); +} diff --git a/examples/predict/components/token-icon.tsx b/examples/predict/components/token-icon.tsx new file mode 100644 index 0000000..edfa100 --- /dev/null +++ b/examples/predict/components/token-icon.tsx @@ -0,0 +1,34 @@ +// ───────────────────────────────────────────────────────────────────────────── +// components/token-icon.tsx — one token glyph, shared by ticket and picker. +// THE HARD PART: the vendored icon set mixes .png and .svg — try png, fall +// back to svg, fall back to a lettered dot. No network beyond /public. +// GOTCHAS.md → (no API gotchas here) (../../GOTCHAS.md) +// ───────────────────────────────────────────────────────────────────────────── +"use client"; + +import { useEffect, useState } from "react"; + +export function TokenIcon({ symbol, size = 20 }: { symbol: string; size?: number }) { + const [ext, setExt] = useState<"png" | "svg" | "none">("png"); + useEffect(() => setExt("png"), [symbol]); + if (ext === "none") + return ( + + {symbol.slice(0, 1)} + + ); + return ( + // eslint-disable-next-line @next/next/no-img-element + setExt(ext === "png" ? "svg" : "none")} + /> + ); +} diff --git a/examples/predict/components/wallet-menu.tsx b/examples/predict/components/wallet-menu.tsx new file mode 100644 index 0000000..1f7b9d0 --- /dev/null +++ b/examples/predict/components/wallet-menu.tsx @@ -0,0 +1,101 @@ +// ───────────────────────────────────────────────────────────────────────────── +// components/wallet-menu.tsx — the connected wallet segment + dropdown, matching +// the other apps (tap-trade's top-bar wallet menu): balance · deposit/withdraw · +// copy address · disconnect. Predict glass styling. +// ───────────────────────────────────────────────────────────────────────────── + +"use client"; + +import { useWallet } from "@solana/wallet-adapter-react"; +import { useState } from "react"; +import { shortKey } from "@/lib/format"; + +function Chevron({ open }: { open: boolean }) { + return ( + + + + ); +} + +export function WalletMenu({ + owner, + walletUsdc, + inBasketUsd, + onOpenFunds, +}: { + owner: string; + walletUsdc: number | null; + inBasketUsd: number | null; + onOpenFunds: () => void; +}) { + const { disconnect } = useWallet(); + const [open, setOpen] = useState(false); + const [copied, setCopied] = useState(false); + + const copy = async () => { + try { + await navigator.clipboard.writeText(owner); + setCopied(true); + setTimeout(() => setCopied(false), 1200); + } catch { + /* clipboard denied — the menu stays */ + } + }; + const usd = (n: number | null) => (n === null ? "—" : `$${n.toFixed(2)}`); + + return ( +
+ + + {open && ( + <> + + + +
+ + )} +
+ ); +} diff --git a/examples/predict/components/wallet-sheet.tsx b/examples/predict/components/wallet-sheet.tsx new file mode 100644 index 0000000..0f98600 --- /dev/null +++ b/examples/predict/components/wallet-sheet.tsx @@ -0,0 +1,111 @@ +// ───────────────────────────────────────────────────────────────────────────── +// components/wallet-sheet.tsx — connect ANY wallet, the SAME flow the other apps +// use (tap-trade's WalletSheet). THE HARD PART: wallet-adapter's select() is +// async state — selecting an adapter then connecting must wait for the selection +// to land (the "ref dance"): a `wantConnect` ref + an effect that fires connect() +// once `wallet` is the chosen adapter. Not-installed wallets open their install +// page; a hydration guard keeps readyState SSR-stable (React #418). Predict glass. +// ───────────────────────────────────────────────────────────────────────────── + +"use client"; + +import { WalletReadyState } from "@solana/wallet-adapter-base"; +import { useWallet } from "@solana/wallet-adapter-react"; +import { useEffect, useRef, useState } from "react"; +import { useSheetMount } from "@/lib/use-sheet-mount"; + +export function WalletSheet({ open, onClose }: { open: boolean; onClose: () => void }) { + const { wallets, wallet, select, connect, connecting, connected } = useWallet(); + const { mounted, closing } = useSheetMount(open); + const [error, setError] = useState(null); + const wantConnect = useRef(false); + + // HYDRATION GUARD: wallets/readyState are empty/"not detected" during SSR but + // populate client-side. Render the list only after mount so the server/client + // markup matches (React #418 otherwise). + const [hydrated, setHydrated] = useState(false); + useEffect(() => setHydrated(true), []); + + // select() is async state — finish the connect once the adapter is selected. + useEffect(() => { + if (wantConnect.current && wallet && !connected && !connecting) { + wantConnect.current = false; + connect().catch((e: unknown) => setError(e instanceof Error ? e.message : String(e))); + } + }, [wallet, connected, connecting, connect]); + + // Close on a successful connect. + useEffect(() => { + if (connected && open) onClose(); + }, [connected, open, onClose]); + + if (!mounted) return null; + return ( +
+
+
+
+
+
+

Connect a wallet

+ +
+

+ Mainnet — real funds. Your wallet owns the account; after Enable, taps auto-sign via a session key. +

+ +
+ {hydrated && + wallets.map((w) => { + const installed = + w.readyState === WalletReadyState.Installed || w.readyState === WalletReadyState.Loadable; + const isThis = wallet?.adapter.name === w.adapter.name; + return ( + + ); + })} + {hydrated && wallets.length === 0 && ( +

+ No Solana wallet detected. Install Phantom, Solflare, or Backpack, then try again. +

+ )} +
+ + {error &&

{error}

} +
+
+
+
+ ); +} diff --git a/examples/predict/lib/copy.ts b/examples/predict/lib/copy.ts new file mode 100644 index 0000000..4f90507 --- /dev/null +++ b/examples/predict/lib/copy.ts @@ -0,0 +1,73 @@ +// ───────────────────────────────────────────────────────────────────────────── +// lib/copy.ts — every user-facing sentence that carries risk, in one place. +// THE HARD PART: honesty without jargon. The disclosure NEGATES the +// prediction-market mental model (fixed odds) and states what this is: a +// short-term leveraged position with loss capped at your stake. Failure +// modes map to calm copy, never raw logs. GOTCHAS.md → "Withdrawals settle +// in two phases" · "err arrives inside HTTP 200" (../../GOTCHAS.md) +// ───────────────────────────────────────────────────────────────────────────── + +export const APP_NAME = "Updown"; +export const TAGLINE = "Pick up or down. Your stake is the most you can lose."; + +export const DISCLOSURE = { + title: "How Updown actually works", + paragraphs: [ + "This isn't a Polymarket-style prediction market — there are no fixed odds and no shared pot. When you call a direction, you open a real, short-term leveraged position on Flash Trade, sized by your stake.", + "Your payoff is linear: for every 1% the price moves your way, you win your stake × leverage × 1%. Move against you and you lose the same way — but never more than your stake. If the price falls past your knockout level before time runs out, the round ends early and your stake is gone.", + "Your numbers lock when you commit. The entry price, fees, and knockout level on the confirm screen are the ones you settle with — they don't drift afterward.", + "Settlement happens from this browser with your session key. If you close the tab mid-round, the position simply stays open on-chain — come back any time and settle it. Nothing expires, nothing is lost.", + ], + checkbox: "I understand each round is a real leveraged position on Solana mainnet, with real money.", +} as const; + +export const CONNECT_WARNING = "Mainnet. Real funds — every round is a real position. Start small."; + +export const ENABLE_EXPLAINER = { + title: "Enable one-tap rounds", + body: "This sets up your trading account — it never moves your money. The only transfer is a 0.01 SOL top-up to your own session key for rent, returned when you revoke.", + steps: ["Create session key (lives in this browser)", "Create basket + deposit ledger", "Delegate to the rollup"], +} as const; + +/** Calm-copy mapping for everything that can go wrong. Raw details stay in + * the console for debugging; the user sees the human line. */ +export function calmError(e: unknown): string { + const msg = e instanceof Error ? e.message : String(e); + if (/429|rate.?limit/i.test(msg)) return "Your RPC is rate-limiting. Add a free RPC key in .env.local and reload."; + if (/blockhash|expired|block height/i.test(msg)) return "That transaction went stale. Nothing was sent — try again."; + if (/reject|declin|cancel/i.test(msg)) return "You declined in the wallet. Nothing was sent."; + if (/settlement_receipt|AccountNotInitialized|0xbc4/i.test(msg)) + return "Your withdrawal is crossing from the rollup — usually 30–90 seconds. We'll keep checking."; + if (/Position is empty/i.test(msg)) return "That position is already closed — refreshing your rounds."; + if (/insufficient|not enough/i.test(msg)) return "Not enough balance for that. Lower the amount or deposit first."; + if (/session|signer/i.test(msg)) return "Your session key needs a refresh — re-enable one-tap rounds to continue."; + if (/fetch|network|ECONN|timeout/i.test(msg)) return "Network hiccup talking to the API. Check your connection and try again."; + return "Something went wrong on the venue side. Your funds are safe — try again in a moment."; +} + +export function fmtCountdown(ms: number): string { + const total = Math.max(0, Math.ceil(ms / 1000)); + const m = Math.floor(total / 60); + const s = total % 60; + if (m >= 60) { + const h = Math.floor(m / 60); + return `${h}:${String(m % 60).padStart(2, "0")}:${String(s).padStart(2, "0")}`; + } + return `${m}:${String(s).padStart(2, "0")}`; +} + +export function fmtDuration(ms: number): string { + const m = Math.round(ms / 60_000); + if (m < 60) return `${m} min`; + return `${m / 60} hour${m === 60 ? "" : "s"}`; +} + +export const explorerTxUrl = (signature: string) => `https://solscan.io/tx/${signature}`; + +/** One price formatter for every surface — kills the scattered `>=1000` + * digit branches. "main" = focal numbers; "compact" = grid/axis hints. */ +export function fmtPrice(v: number | null | undefined, mode: "main" | "compact" = "main"): string { + if (v === null || v === undefined || !Number.isFinite(v)) return "—"; + const digits = mode === "main" ? (v >= 1000 ? 2 : 4) : v >= 1000 ? 0 : 2; + return `$${v.toLocaleString("en-US", { minimumFractionDigits: digits, maximumFractionDigits: digits })}`; +} diff --git a/examples/predict/lib/enable.ts b/examples/predict/lib/enable.ts new file mode 100644 index 0000000..e1c7726 --- /dev/null +++ b/examples/predict/lib/enable.ts @@ -0,0 +1,339 @@ +// ───────────────────────────────────────────────────────────────────────────── +// lib/enable.ts — "Enable One-Click Trading": session + basket + ledger + +// delegate behind ONE wallet approval sheet (signAllTransactions). +// CONSENT RULE: NO USER FUNDS MOVE HERE. Enable is account setup only — the +// sole transfer is the disclosed 0.01 SOL rent top-up to the session key +// (recoverable on revoke). Deposits/withdrawals are EXPLICIT, amount-chosen, +// separately-approved actions in lib/funds.ts. Never bundle a deposit into a +// convenience flow in a production app. +// THE HARD PART: build EVERYTHING first (API txs arrive partially signed with +// their own blockhashes — never touch them), sign once, then submit in strict +// lifecycle order on the BASE chain. +// GOTCHAS.md → "The lifecycle has a strict order" +// ───────────────────────────────────────────────────────────────────────────── + +import type { PublicKey, Transaction, VersionedTransaction } from "@solana/web3.js"; +import { decodeTransaction, type BasketSnapshot } from "flash-v2"; +import { baseConnection, flash } from "./flash"; +import type { LatencyEntry } from "./hooks"; +import { + buildSessionTransaction, + loadSession, + persistSession, + type BuiltSessionTransaction, + type LoadedSession, + type SessionWallet, +} from "./session"; + +// ── public shapes ───────────────────────────────────────────────────────────── + +export type EnableStepId = "fund" | "session" | "basket" | "ledger" | "delegate"; +export type EnableStepStatus = "idle" | "active" | "done" | "skipped" | "error"; + +export interface EnableStepRow { + id: EnableStepId; + label: string; + status: EnableStepStatus; + note?: string; + ms?: number; + signature?: string; +} + +export interface EnableState { + phase: "precheck" | "building" | "signing" | "submitting" | "done" | "stopped"; + /** Live text for the action-zone pill ("approve in wallet…", "init-basket…"). */ + headline: string; + steps: EnableStepRow[]; + /** Set when the wallet can't pay setup rent. */ + fundingHint: string | null; + /** Fresh basket has no funds yet — surface the EXPLICIT Deposit affordance. + * (Funds never move during Enable; see lib/funds.ts.) */ + needsUsdc: boolean; + /** Fatal step error (already phrased for humans; calming when upstream). */ + error: string | null; + /** True when `error` is a known transient upstream-deploy situation. */ + upstream: boolean; +} + +export interface EnableResult { + ok: boolean; + /** Minted (and persisted) even when a later step failed — partial progress counts. */ + session: LoadedSession | null; + needsUsdc: boolean; +} + +/** The narrow slice of wallet-adapter's useWallet() that enable needs. */ +export interface EnableWalletCtx { + publicKey: PublicKey; + signTransaction: (tx: T) => Promise; + signAllTransactions?: ((txs: T[]) => Promise) | undefined; +} + +// ── error classification (per-step) ────────────────────────────────────────── + +const RE_ALREADY = /already in use|already exists|custom program error: 0x0\b/i; +const RE_UPSTREAM = /Access violation in heap|ProgramFailedToComplete/i; +const RE_REJECTED = /reject|declin|cancel/i; +const RE_STALE = /blockhash not found|block height exceeded|expired/i; +const RE_NO_SIGNALL = /signAllTransactions|not.{0,4}supported/i; + +export const UPSTREAM_MESSAGE = + "known upstream issue: the magic-trade program is temporarily unavailable (Flash may be redeploying) — your setup is fine; retry after the fix."; + +export function errText(e: unknown): string { + const msg = e instanceof Error ? e.message : String(e); + const logs = (e as { logs?: unknown })?.logs; + return Array.isArray(logs) ? `${msg} ${logs.join(" ")}` : msg; +} + +/** Shared per-tx error classification — also used by the explicit funds flows. */ +export function classifyTxError(e: unknown): { + message: string; + upstream: boolean; + stale: boolean; + rejected: boolean; +} { + const raw = errText(e); + if (RE_UPSTREAM.test(raw)) return { message: UPSTREAM_MESSAGE, upstream: true, stale: false, rejected: false }; + if (RE_REJECTED.test(raw)) + return { message: "approval declined in the wallet — nothing was sent", upstream: false, stale: false, rejected: true }; + if (RE_STALE.test(raw)) + return { message: "the approval took too long and the transaction expired — try again", upstream: false, stale: true, rejected: false }; + return { message: raw, upstream: false, stale: false, rejected: false }; +} + +// ── confirmation (the confirmAirdrop pattern: poll signature statuses) ─────── + +export async function submitAndConfirm( + tx: Transaction | VersionedTransaction, + timeoutMs = 45_000, +): Promise<{ signature: string; ms: number }> { + const raw = tx.serialize(); + const started = Date.now(); + const signature = await baseConnection.sendRawTransaction(raw, { maxRetries: 3 }); + for (;;) { + try { + const status = (await baseConnection.getSignatureStatuses([signature])).value[0]; + if (status) { + if (status.err) throw new Error(`on-chain error (${signature}): ${JSON.stringify(status.err)}`); + const level = status.confirmationStatus; + if (level === "confirmed" || level === "finalized") { + return { signature, ms: Date.now() - started }; + } + } + } catch (e) { + // A real on-chain error must surface; transient 429s while polling must not. + if (e instanceof Error && e.message.startsWith("on-chain error")) throw e; + } + if (Date.now() - started > timeoutMs) throw new Error(`confirmation timeout (${signature})`); + await new Promise((r) => setTimeout(r, 400)); + } +} + +// ── the flow ────────────────────────────────────────────────────────────────── + +export async function enableOneClickTrading(args: { + wallet: EnableWalletCtx; + anchorWallet: SessionWallet; + snapshot: BasketSnapshot | null; + usdcMint: string | null; + balances: { sol: number | null; usdc: number | null }; + onStep: (state: EnableState) => void; + onLog: (e: Omit) => void; +}): Promise { + const { wallet, anchorWallet, snapshot, usdcMint, balances, onStep, onLog } = args; + const owner = wallet.publicKey.toBase58(); + + // 1 ── what does this account still need? + const basketExists = Boolean(snapshot?.basketPubkey); + const freshSession = loadSession(owner); + const needs = { + session: !freshSession, + basket: !basketExists, + ledger: !basketExists, // basket unknown → ledger unknown → init it (idempotent-ish) + delegate: !basketExists, // a fresh basket must be handed to the MagicBlock validator + // NO deposit here — funds move only via the explicit Deposit flow (lib/funds.ts). + }; + + const state: EnableState = { + phase: "precheck", + headline: "checking wallet…", + steps: [], + fundingHint: null, + needsUsdc: false, + error: null, + upstream: false, + }; + const rows = new Map(); + const emit = () => { + state.steps = [...rows.values()]; + onStep({ ...state, steps: state.steps.map((s) => ({ ...s })) }); + }; + const row = (id: EnableStepId, label: string, patch: Partial) => { + rows.set(id, { ...(rows.get(id) ?? { id, label, status: "idle" }), label, ...patch }); + emit(); + }; + const stop = (error: string, upstream = false): EnableResult => { + state.phase = "stopped"; + state.error = error; + state.upstream = upstream; + state.headline = upstream ? "program unavailable upstream" : "setup stopped"; + emit(); + return { ok: false, session: mintedSession, needsUsdc: state.needsUsdc }; + }; + let mintedSession: LoadedSession | null = freshSession; + /** Freshly built session pieces (set in step 3, persisted in step 5 once its tx confirms). */ + let sessionParts: BuiltSessionTransaction | null = null; + + // 2 ── funding pre-check: compute the real SOL needed at runtime — mostly + // recoverable account rent (basket + deposit-ledger + session-token), + // plus the recoverable 0.01 SOL session top-up and a small fee buffer. + // No hardcoded gate: getMinimumBalanceForRentExemption is the source of truth. + const sol = balances.sol ?? 0; + emit(); + let rentLamports = 0; + if (needs.basket) rentLamports += await baseConnection.getMinimumBalanceForRentExemption(256); + if (needs.ledger) rentLamports += await baseConnection.getMinimumBalanceForRentExemption(852); + if (needs.session) rentLamports += await baseConnection.getMinimumBalanceForRentExemption(144); + const requiredSol = rentLamports / 1e9 + (needs.session ? 0.01 : 0) + 0.002; // top-up + fee buffer + if (sol < requiredSol) { + const need = requiredSol.toFixed(4); + state.fundingHint = + `this wallet needs ~${need} SOL for setup — mostly recoverable account rent plus a ` + + `0.01 SOL session top-up (recoverable on revoke), not fees. Fund ${owner} and tap Enable again.`; + return stop(`wallet needs ~${need} SOL for setup (recoverable rent + top-up)`); + } + // 3 ── BUILD everything first (no signatures yet). + state.phase = "building"; + state.headline = "preparing transactions…"; + emit(); + + type Planned = { id: EnableStepId; label: string; action: string; tx: Transaction | VersionedTransaction }; + const plan: Planned[] = []; + try { + if (needs.session) { + row("session", "create session key", { status: "active", note: "building…" }); + const built = await buildSessionTransaction(anchorWallet, baseConnection, { + }); + plan.push({ id: "session", label: "create session key", action: "create-session", tx: built.tx }); + // Persisted only after its tx confirms (step 5 below). + sessionParts = built; + row("session", "create session key", { status: "active", note: "ready to sign" }); + } else { + row("session", "create session key", { status: "skipped", note: "session still valid" }); + } + + const builders: Array<{ id: EnableStepId; label: string; action: string; build: () => Promise<{ transactionBase64: string }> }> = []; + if (needs.basket) builders.push({ id: "basket", label: "init-basket", action: "init-basket", build: () => flash.initBasket({ owner }) }); + else row("basket", "init-basket", { status: "skipped", note: "basket already on-chain" }); + if (needs.ledger) builders.push({ id: "ledger", label: "init-deposit-ledger", action: "init-deposit-ledger", build: () => flash.initDepositLedger({ owner }) }); + else row("ledger", "init-deposit-ledger", { status: "skipped", note: "already set up" }); + if (needs.delegate) builders.push({ id: "delegate", label: "delegate-basket", action: "delegate-basket", build: () => flash.delegateBasket({ payer: owner, owner }) }); + else row("delegate", "delegate-basket", { status: "skipped", note: "already delegated" }); + + // CONSENT RULE: no deposit in this batch — a fresh basket starts EMPTY and + // the user funds it via the explicit Deposit sheet (their amount, their + // separate approval). needsUsdc just surfaces that affordance. + if (!basketExists) state.needsUsdc = true; + + for (const b of builders) { + row(b.id, b.label, { status: "active", note: "building…" }); + const built = await b.build(); + // NEVER touch the blockhash — these arrive partially signed server-side. + plan.push({ id: b.id, label: b.label, action: b.action, tx: decodeTransaction(built.transactionBase64) }); + row(b.id, b.label, { status: "active", note: "ready to sign" }); + } + } catch (e) { + const raw = errText(e); + return stop(RE_UPSTREAM.test(raw) ? UPSTREAM_MESSAGE : raw, RE_UPSTREAM.test(raw)); + } + + if (plan.length === 0) { + state.phase = "done"; + state.headline = "already set up"; + emit(); + return { ok: true, session: mintedSession, needsUsdc: state.needsUsdc }; + } + + // 4 ── ONE approval for the whole bundle (fallback: sequential popups). + state.phase = "signing"; + state.headline = "approve in wallet…"; + for (const p of plan) row(p.id, p.label, { status: "active", note: "waiting for your approval…" }); + let signed: (Transaction | VersionedTransaction)[]; + try { + const toSign = plan.map((p) => p.tx); + const signAll = wallet.signAllTransactions; + if (typeof signAll === "function") { + try { + signed = await signAll(toSign); + } catch (e) { + const raw = errText(e); + if (RE_REJECTED.test(raw)) throw e; + if (!RE_NO_SIGNALL.test(raw)) throw e; + signed = await signSequentially(); + } + } else { + signed = await signSequentially(); + } + async function signSequentially(): Promise<(Transaction | VersionedTransaction)[]> { + const out: (Transaction | VersionedTransaction)[] = []; + for (let i = 0; i < plan.length; i++) { + const p = plan[i]; + if (!p) continue; + state.headline = `approve in wallet (${i + 1}/${plan.length})…`; + row(p.id, p.label, { status: "active", note: `approve in wallet (${i + 1}/${plan.length})…` }); + out.push(await wallet.signTransaction(p.tx)); + } + return out; + } + } catch (e) { + const raw = errText(e); + const friendly = RE_REJECTED.test(raw) ? "approval declined in the wallet — nothing was sent" : raw; + for (const p of plan) row(p.id, p.label, { status: "error", note: friendly }); + return stop(friendly); + } + + // 5 ── submit sequentially with confirmation, strict lifecycle order. + state.phase = "submitting"; + for (const p of plan) row(p.id, p.label, { status: "active", note: "queued…" }); + for (let i = 0; i < plan.length; i++) { + const p = plan[i]; + const tx = signed[i]; + if (!p || !tx) continue; + state.headline = `${p.action}…`; + row(p.id, p.label, { status: "active", note: "submitting to the base chain…" }); + try { + const { signature, ms } = await submitAndConfirm(tx); + row(p.id, p.label, { status: "done", note: undefined, ms, signature }); + onLog({ action: p.action, chain: "base", ms, signature }); + if (p.id === "session" && sessionParts) { + mintedSession = persistSession({ + sessionSigner: sessionParts.sessionSigner, + sessionToken: sessionParts.sessionToken, + authority: owner, + validUntil: sessionParts.validUntil, + }); + } + } catch (e) { + const raw = errText(e); + if (RE_ALREADY.test(raw)) { + row(p.id, p.label, { status: "done", note: "already set up" }); + continue; + } + if (RE_UPSTREAM.test(raw)) { + row(p.id, p.label, { status: "error", note: UPSTREAM_MESSAGE }); + return stop(UPSTREAM_MESSAGE, true); + } + const friendly = RE_STALE.test(raw) + ? "the approval took too long and the transaction expired — tap Enable again to rebuild" + : raw; + row(p.id, p.label, { status: "error", note: friendly }); + return stop(friendly); + } + } + + state.phase = "done"; + state.headline = "one-click trading enabled"; + emit(); + return { ok: true, session: mintedSession, needsUsdc: state.needsUsdc }; +} diff --git a/examples/predict/lib/flash.ts b/examples/predict/lib/flash.ts new file mode 100644 index 0000000..e2922bc --- /dev/null +++ b/examples/predict/lib/flash.ts @@ -0,0 +1,48 @@ +// ───────────────────────────────────────────────────────────────────────────── +// lib/flash.ts — one FlashV2Client + one base-chain Connection for the app. +// THE HARD PART: V2 spans TWO chains — trading txs go to network.erRpc, setup +// txs to network.baseRpc. Flash V2 is MAINNET — real funds. +// GOTCHAS.md → "Two chains, one flow" (../../GOTCHAS.md) +// ───────────────────────────────────────────────────────────────────────────── + +import { Connection } from "@solana/web3.js"; +import { FlashV2Client, type NetworkConfig } from "flash-v2"; + +/** NEXT_PUBLIC_ env overrides, applied only when actually set (a key whose + * value is `undefined` would otherwise stomp the preset in the spread). */ +function overridesFromEnv(): Partial { + const cfg: Partial = {}; + if (process.env.NEXT_PUBLIC_FLASH_API_BASE) cfg.apiBase = process.env.NEXT_PUBLIC_FLASH_API_BASE; + if (process.env.NEXT_PUBLIC_ER_RPC) cfg.erRpc = process.env.NEXT_PUBLIC_ER_RPC; + if (process.env.NEXT_PUBLIC_BASE_RPC) cfg.baseRpc = process.env.NEXT_PUBLIC_BASE_RPC; + return cfg; +} + +/** The one client. Reads, quotes, and every transaction builder. */ +export const flash = new FlashV2Client(overridesFromEnv()); + + +/** Base-chain connection — airdrops, balances, setup-tx submission. + * disableRetryOnRateLimit: the public RPC rate-limits; one attempt, one + * clean failure — the app owns retry UX. */ +export const baseConnection = new Connection(flash.network.baseRpc, { + commitment: "confirmed", + disableRetryOnRateLimit: true, +}); + +/** All tradeable markets (first is the default). */ +export const MARKETS = ["SOL", "BTC", "ETH"]; +/** Kept for backward compat — equals MARKETS[0]. */ +export const MARKET = MARKETS[0] as string; +export const COLLATERAL = "USDC"; + +/** Price poll cadence — 1s keeps the chart line + slot-reel digits alive. */ +export const PRICE_POLL_MS = 1000; + +/** Static, clearly-labeled comparison baseline for the latency HUD. */ +export const SOLANA_L1_TYPICAL_MS = 400; + +/** Explorer link for a signature — cluster param matches the active network. */ +export function explorerTx(signature: string): string { + return `https://explorer.solana.com/tx/${signature}`; +} diff --git a/examples/predict/lib/format.ts b/examples/predict/lib/format.ts new file mode 100644 index 0000000..ef221ac --- /dev/null +++ b/examples/predict/lib/format.ts @@ -0,0 +1,130 @@ +// ───────────────────────────────────────────────────────────────────────────── +// lib/format.ts — display formatting for keys, prices, USD, PnL, and latency. +// THE HARD PART: the API speaks UI DECIMAL STRINGS (and leverage can be the +// literal string "Infinity") — every formatter here tolerates that without +// NaN leaking into the UI. GOTCHAS.md → "Responses are V1-shaped" (../../GOTCHAS.md) +// ───────────────────────────────────────────────────────────────────────────── + +/** "FMTg…xtvj" — middle-truncate a pubkey or signature. */ +export function shortKey(key: string, n = 4): string { + if (key.length <= n * 2 + 1) return key; + return `${key.slice(0, n)}…${key.slice(-n)}`; +} + +/** Parse an API decimal string defensively. Returns null on junk/Infinity. */ +export function num(v: string | number | null | undefined): number | null { + if (v === null || v === undefined) return null; + const n = typeof v === "number" ? v : Number(v); + return Number.isFinite(n) ? n : null; +} + +/** USDC has 6 decimals on-chain — tiny but REAL values must never display as + * $0.00 (a $5 position's fee is ~$0.004). Adaptive precision: more digits as + * the magnitude shrinks, never inventing precision the chain doesn't have. */ +function usdDigits(abs: number, base: number): number { + if (abs === 0) return base; + if (abs < 0.01) return 4; + if (abs < 1) return 3; + return base; +} + +/** "$151.23" / "−$0.0042" — USD with sign folded in, 6-dec-aware. */ +export function fmtUsd(v: string | number | null | undefined, digits = 2): string { + const n = num(v); + if (n === null) return "—"; + const abs = Math.abs(n); + const d = usdDigits(abs, digits); + const sign = n < 0 ? "−" : ""; + return `${sign}$${abs.toLocaleString("en-US", { + minimumFractionDigits: d, + maximumFractionDigits: d, + })}`; +} + +/** Signed USD for PnL: "+$0.42" / "−$0.0042" (6-dec-aware for small books). */ +export function fmtPnlUsd(v: string | number | null | undefined): string { + const n = num(v); + if (n === null) return "—"; + const abs = Math.abs(n); + return `${n >= 0 ? "+" : "−"}$${abs.toFixed(usdDigits(abs, 2))}`; +} + +/** "+3.81%" / "−1.20%". */ +export function fmtPct(v: string | number | null | undefined, digits = 2): string { + const n = num(v); + if (n === null) return "—"; + return `${n >= 0 ? "+" : "−"}${Math.abs(n).toFixed(digits)}%`; +} + +/** Plain number with fixed digits, em-dash on junk. */ +export function fmtNum(v: string | number | null | undefined, digits = 2): string { + const n = num(v); + return n === null ? "—" : n.toLocaleString("en-US", { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }); +} + +/** "5.0×" — leverage; tolerates the API's literal "Infinity". */ +export function fmtLeverage(v: string | number | null | undefined): string { + const n = num(v); + return n === null ? "∞×" : `${n.toFixed(1)}×`; +} + +/** "38 ms" / "1.24 s". */ +export function fmtMs(ms: number): string { + return ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(2)} s`; +} + +/** Median of a number list (null when empty). */ +export function median(values: number[]): number | null { + if (values.length === 0) return null; + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + const lo = sorted[mid - 1]; + const hi = sorted[mid]; + if (sorted.length % 2 === 0 && lo !== undefined && hi !== undefined) { + return Math.round((lo + hi) / 2); + } + return hi ?? null; +} + +// ── Flash-parity position math (client-side, like Flash's own UI) ─────────── +// Flash's UI shows mark-price PnL incl. fees, NOT the indexer's spread-through +// pnlWithFeeUsdUi (which can read wildly different on the same position). +// Compute what the product computes: +// pricePnl = (mark − entry)/entry × size × dir +// pnl = pricePnl − (exitFee + borrowFee) [raw 6-dec ÷ 1e6] +// pct = pnl / collateral × 100 +// lev = size / collateral +// liq ≈ entry × (1 ∓ collateral/size × 0.92) [≈ maintenance buffer] +export interface PositionView { + pnlUsd: number; + pnlPct: number; + leverage: number; + liqUi: number | null; +} + +export function computePositionView( + p: { + sideUi: string; + sizeUsdUi: string; + collateralUsdUi: string; + entryPriceUi: string; + exitFeeUsd: string; + borrowFeeUsd: string; + }, + markUi: number | null, +): PositionView | null { + const size = num(p.sizeUsdUi); + const coll = num(p.collateralUsdUi); + const entry = num(p.entryPriceUi); + if (size === null || coll === null || entry === null || entry <= 0 || markUi === null) return null; + const dir = p.sideUi.toUpperCase() === "LONG" ? 1 : -1; + const fees = ((num(p.exitFeeUsd) ?? 0) + (num(p.borrowFeeUsd) ?? 0)) / 1e6; + const pnlUsd = ((markUi - entry) / entry) * size * dir - fees; + const pnlPct = coll > 0 ? (pnlUsd / coll) * 100 : 0; + const leverage = coll > 0 ? size / coll : 0; + const liqUi = size > 0 ? entry * (1 - dir * (coll / size) * 0.92) : null; + return { pnlUsd, pnlPct, leverage, liqUi }; +} diff --git a/examples/predict/lib/funds.ts b/examples/predict/lib/funds.ts new file mode 100644 index 0000000..3cb4cbd --- /dev/null +++ b/examples/predict/lib/funds.ts @@ -0,0 +1,218 @@ +// ───────────────────────────────────────────────────────────────────────────── +// lib/funds.ts — EXPLICIT deposit & withdraw. THE PRODUCTION RULE THIS FILE +// EXISTS TO TEACH: user funds move ONLY on a user-initiated action with a +// user-chosen amount and its own wallet approval — never bundled into a +// convenience flow like "Enable". Withdraw is the API's two-step dance: +// request-withdrawal → execute-withdrawal (both signed by the OWNER wallet, +// both on the BASE chain; execute can be re-run alone to recover). +// GOTCHAS.md → "Funds move on consent only" · "Two chains, one flow" +// ───────────────────────────────────────────────────────────────────────────── + +import { decodeTransaction } from "flash-v2"; +import { baseConnection, flash } from "./flash"; +import { classifyTxError, submitAndConfirm, type EnableWalletCtx } from "./enable"; +import type { LatencyEntry } from "./hooks"; + +export interface FundsStep { + /** "building" → "approve" → "submitting" → "done" | "error" */ + phase: "building" | "approve" | "submitting" | "done" | "error"; + label: string; + note?: string; + signature?: string; + ms?: number; +} + +type OnStep = (step: FundsStep) => void; +type OnLog = (e: Omit) => void; + +async function signSubmit( + wallet: EnableWalletCtx, + txBase64: string, + label: string, + onStep: OnStep, + onLog: OnLog, +): Promise<{ signature: string; ms: number }> { + onStep({ phase: "approve", label, note: "approve in your wallet…" }); + // NEVER touch the blockhash — builder txs arrive partially signed server-side. + const signed = await wallet.signTransaction(decodeTransaction(txBase64)); + onStep({ phase: "submitting", label, note: "submitting to the base chain…" }); + const { signature, ms } = await submitAndConfirm(signed); + onLog({ action: label, chain: "base", ms, signature }); + return { signature, ms }; +} + +/** + * Deposit ANY pool token into the basket — the user typed `amount`, the user + * approves the single transfer. Tradable once the deposit confirms on the ER. + */ +export async function depositToken(args: { + wallet: EnableWalletCtx; + tokenMint: string; + symbol: string; + amount: string; // UI units, user-chosen + onStep: OnStep; + onLog: OnLog; +}): Promise<{ ok: boolean; error?: string }> { + const { wallet, tokenMint, symbol, amount, onStep, onLog } = args; + const owner = wallet.publicKey.toBase58(); + const label = `deposit ${amount} ${symbol}`; + try { + onStep({ phase: "building", label, note: "building the transaction…" }); + const built = await flash.depositDirect({ owner, tokenMint, amount }); + const { signature, ms } = await signSubmit(wallet, built.transactionBase64, label, onStep, onLog); + onStep({ phase: "done", label, note: "deposited — tradable once it confirms on the rollup", signature, ms }); + return { ok: true }; + } catch (e) { + const c = classifyTxError(e); + onStep({ phase: "error", label, note: c.message }); + return { ok: false, error: c.message }; + } +} + +/** USDC compat wrapper (existing callers). */ +export async function depositUsdc(args: { + wallet: EnableWalletCtx; + usdcMint: string; + amount: string; + onStep: OnStep; + onLog: OnLog; +}): Promise<{ ok: boolean; error?: string }> { + return depositToken({ wallet: args.wallet, tokenMint: args.usdcMint, symbol: "USDC", amount: args.amount, onStep: args.onStep, onLog: args.onLog }); +} + +// The receipt the execute step consumes is written when the rollup's validator +// CROSSES the settlement to base chain (~30-90s after request). Until then the +// program answers 0xbc4 AccountNotInitialized on `settlement_receipt` — a +// timing state, never an error worth showing raw logs for. +const RE_SETTLEMENT_PENDING = /settlement_receipt|AccountNotInitialized|0xbc4|3012/i; + +/** + * Withdraw USDC back to the wallet. Two explicit steps, two approvals: + * 1) request-withdrawal (queues settlement out of the rollup) + * 2) execute-withdrawal — AUTO-RETRIED while the rollup crosses the + * settlement receipt to base (~30-90s), with a countdown the sheet shows. + */ +export async function withdrawToken(args: { + wallet: EnableWalletCtx; + tokenMint: string; + symbol: string; + amount: string; // UI units, user-chosen + onStep: OnStep; + onLog: OnLog; +}): Promise<{ ok: boolean; error?: string; executePending?: boolean }> { + const { wallet, tokenMint, symbol, amount, onStep, onLog } = args; + const owner = wallet.publicKey.toBase58(); + try { + onStep({ phase: "building", label: `request withdrawal of ${amount} ${symbol}`, note: "building…" }); + const req = await flash.requestWithdrawal({ owner, tokenMint, amount }); + await signSubmit(wallet, req.transactionBase64, `request-withdrawal ${amount} ${symbol}`, onStep, onLog); + } catch (e) { + const c = classifyTxError(e); + onStep({ phase: "error", label: "request withdrawal", note: c.message }); + return { ok: false, error: c.message }; + } + // Settlement crossing: poll UNSIGNED simulations (zero popups) until the + // receipt exists on base, then ask for the ONE execute signature. + const ready = await waitForSettlementReceipt({ owner, tokenMint, onStep, maxSeconds: 120 }); + if (!ready) { + onStep({ + phase: "error", + label: "execute withdrawal", + note: "settlement is taking longer than usual — your funds are queued safely; tap \"Execute again\" in a minute.", + }); + return { ok: false, error: "settlement still crossing", executePending: true }; + } + return executeWithdrawalToken({ wallet, tokenMint, symbol, onStep, onLog }); +} + +/** USDC compat wrapper (existing callers). */ +export async function withdrawUsdc(args: { + wallet: EnableWalletCtx; + usdcMint: string; + amount: string; + onStep: OnStep; + onLog: OnLog; +}): Promise<{ ok: boolean; error?: string; executePending?: boolean }> { + return withdrawToken({ wallet: args.wallet, tokenMint: args.usdcMint, symbol: "USDC", amount: args.amount, onStep: args.onStep, onLog: args.onLog }); +} + +/** + * Poll an UNSIGNED simulation of execute-withdrawal until the settlement + * receipt exists (no wallet popups while waiting). True = ready to sign. + */ +async function waitForSettlementReceipt(args: { + owner: string; + tokenMint: string; + onStep: OnStep; + maxSeconds: number; +}): Promise { + const { owner, tokenMint, onStep, maxSeconds } = args; + const started = Date.now(); + for (;;) { + const waited = Math.round((Date.now() - started) / 1000); + if (waited > maxSeconds) return false; + try { + const exec = await flash.executeWithdrawal({ owner, tokenMint }); + const tx = decodeTransaction(exec.transactionBase64); + const sim = await baseConnection.simulateTransaction(tx, { + sigVerify: false, + replaceRecentBlockhash: true, + }); + const err = sim.value.err ? JSON.stringify(sim.value.err) + (sim.value.logs ?? []).join(" ") : ""; + if (!sim.value.err) return true; // receipt landed — execute will pass + if (!RE_SETTLEMENT_PENDING.test(err)) return true; // different state — let the real attempt surface it + } catch { /* builder/RPC hiccup — keep polling */ } + onStep({ + phase: "submitting", + label: "settlement crossing from the rollup", + note: `the rollup writes the receipt to base chain (~30–90s) — waiting ${waited}s, no action needed`, + }); + await new Promise((r) => setTimeout(r, 8000)); + } +} + +/** Step 2 alone — also the recovery path when settlement needed more time. */ +export async function executeWithdrawalToken(args: { + wallet: EnableWalletCtx; + tokenMint: string; + symbol: string; + onStep: OnStep; + onLog: OnLog; + attempt?: number; +}): Promise<{ ok: boolean; error?: string; executePending?: boolean }> { + const { wallet, tokenMint, onStep, onLog, attempt } = args; + const owner = wallet.publicKey.toBase58(); + const tag = attempt ? ` (attempt ${attempt}/4)` : ""; + try { + onStep({ phase: "building", label: "execute withdrawal", note: `building…${tag}` }); + const exec = await flash.executeWithdrawal({ owner, tokenMint }); + const { signature, ms } = await signSubmit(wallet, exec.transactionBase64, "execute-withdrawal", onStep, onLog); + onStep({ phase: "done", label: "execute withdrawal", note: "funds are back in your wallet", signature, ms }); + return { ok: true }; + } catch (e) { + const raw = e instanceof Error ? e.message : String(e); + if (RE_SETTLEMENT_PENDING.test(raw)) { + // Timing, not failure — the caller's retry loop (or the button) handles it. + onStep({ + phase: "submitting", + label: "settlement crossing from the rollup", + note: `receipt not on base chain yet${tag} — retrying shortly`, + }); + return { ok: false, error: "settlement pending", executePending: true }; + } + const c = classifyTxError(e); + onStep({ phase: "error", label: "execute withdrawal", note: c.message }); + return { ok: false, error: c.message, executePending: c.rejected ? false : true }; + } +} + +/** USDC compat wrapper (existing callers). */ +export async function executeWithdrawalStep(args: { + wallet: EnableWalletCtx; + usdcMint: string; + onStep: OnStep; + onLog: OnLog; + attempt?: number; +}): Promise<{ ok: boolean; error?: string; executePending?: boolean }> { + return executeWithdrawalToken({ wallet: args.wallet, tokenMint: args.usdcMint, symbol: "USDC", onStep: args.onStep, onLog: args.onLog, ...(args.attempt !== undefined ? { attempt: args.attempt } : {}) }); +} diff --git a/examples/predict/lib/hooks.ts b/examples/predict/lib/hooks.ts new file mode 100644 index 0000000..2293fd8 --- /dev/null +++ b/examples/predict/lib/hooks.ts @@ -0,0 +1,542 @@ +// ───────────────────────────────────────────────────────────────────────────── +// lib/hooks.ts — live data: price poll, owner stream, balances, latency log. +// THE HARD PART: the owner WS sends TWO frame types (basket vs metrics) that +// flash-v2 merges for us; the HUD numbers are REAL confirmMs values only — +// nothing here invents latency. GOTCHAS.md → "The WS sends two frame types" +// · "WS connection limits are real" (../../GOTCHAS.md) +// ───────────────────────────────────────────────────────────────────────────── + +"use client"; + +import { PublicKey } from "@solana/web3.js"; +import { + subscribeOwner, + type BasketSnapshot, + type PositionMetrics, + type PriceInfo, + type TokenInfo, +} from "flash-v2"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { baseConnection, flash } from "./flash"; +import { MAGIC_TRADE_PROGRAM } from "./session"; + +/** The magic-trade program on the ACTIVE network (ledger reads filter on it). */ +const MAGIC_TRADE_PROGRAM_ID = MAGIC_TRADE_PROGRAM.toBase58(); + +// ── price ticker ───────────────────────────────────────────────────────────── + +export function usePrice(symbol: string, intervalMs = 1000): { + price: PriceInfo | null; + drift: "up" | "down" | "flat"; +} { + const [price, setPrice] = useState(null); + const [drift, setDrift] = useState<"up" | "down" | "flat">("flat"); + const last = useRef(null); + + useEffect(() => { + // Market switch: drop the previous symbol's price immediately so the + // history buffer never receives a stale cross-market tick. + setPrice(null); + setDrift("flat"); + last.current = null; + let dead = false; + const tick = async () => { + try { + const p = await flash.price(symbol); + if (dead) return; + if (last.current !== null && p.priceUi !== last.current) { + setDrift(p.priceUi > last.current ? "up" : "down"); + } + last.current = p.priceUi; + setPrice(p); + } catch { + /* keep last price; next tick retries */ + } + }; + void tick(); + const timer = setInterval(tick, intervalMs); + return () => { dead = true; clearInterval(timer); }; + }, [symbol, intervalMs]); + + return { price, drift }; +} + +/** + * The tradeable market list, LIVE from Flash's own config (`GET /v2/tokens` — + * CDN-driven and hot-reloaded server-side): every non-stable token in the + * active pool is a market. New listings appear here automatically. + * Returns null until loaded (callers keep a static fallback meanwhile). + */ +export function useMarkets(): string[] | null { + const [markets, setMarkets] = useState(null); + useEffect(() => { + let dead = false; + flash.tokens() + .then((tokens) => { + if (dead) return; + const symbols = tokens.filter((t) => !t.isStable).map((t) => t.symbol); + if (symbols.length > 0) setMarkets(symbols); + }) + .catch(() => { /* fallback list keeps the selector usable */ }); + return () => { dead = true; }; + }, []); + return markets; +} + +/** Per-market leverage limits + trade spreads, LIVE from Flash's custody config. */ +export interface MarketLimits { + /** Lowest initial leverage the program accepts (UI floor, ≥1.1 for sanity). */ + minLeverage: number; + /** Highest initial leverage the program accepts for this market. */ + maxLeverage: number; + /** Exit spread the engine prices longs through (0.10 = 10%). THE number + * that makes a fresh fill read −10% "realizable now" — show it BEFORE + * the tap, and name it in the PnL breakdown. FX pairs are 0%. */ + spreadLongPct: number; + spreadShortPct: number; +} + +// custody.pricing values are fixed-point with 4 decimals (110000 = 11x — +// Flash's tiers carry a ×1.1 buffer over the advertised 10x/50x/100x/500x). +const LEVERAGE_SCALE = 10_000; + +type CustodyPricing = { min: number; max: number; spreadL: number; spreadS: number }; + +// ── one parse path, shared by the single + bulk hooks (the spread is a +// fund-critical number — it must be derived in exactly ONE place so the grid and +// the detail can never disagree). Custody + tokens are each fetched ONCE and +// deduped via in-flight promises, so N simultaneous cards trigger one of each. +const limitsCache = new Map(); +let custodyPricing: Map | null = null; +let custodyInFlight: Promise | null> | null = null; +let tokensPromise: Promise | null = null; + +async function loadCustodyPricing(): Promise | null> { + if (custodyPricing && custodyPricing.size > 0) return custodyPricing; + if (custodyInFlight) return custodyInFlight; + custodyInFlight = (async () => { + try { + const res = await fetch(`${flash.network.apiBase}/raw/custodies`); + // Don't parse a non-OK response (error page / 5xx) into a partial map that + // then sticks for the session — bail so the next call retries (review H4). + if (!res.ok) return custodyPricing; + const json = (await res.json()) as + | Array<{ account?: { tokenMint?: string; pricing?: { minInitialLeverage?: number; maxInitialLeverage?: number; tradeSpreadLong?: number; tradeSpreadShort?: number } } }> + | { custodies?: unknown }; + const arr = Array.isArray(json) ? json : []; + const map = new Map(); + for (const c of arr) { + const a = c.account; + // the custody's token field is `tokenMint` (NOT `mint`) + if (a?.tokenMint && a.pricing?.maxInitialLeverage) { + map.set(a.tokenMint, { + min: (a.pricing.minInitialLeverage ?? LEVERAGE_SCALE) / LEVERAGE_SCALE, + max: a.pricing.maxInitialLeverage / LEVERAGE_SCALE, + // tradeSpread is in bps×1 (1000 = 10%): ÷1e4 → fraction + spreadL: (a.pricing.tradeSpreadLong ?? 0) / 10_000, + spreadS: (a.pricing.tradeSpreadShort ?? 0) / 10_000, + }); + } + } + // only cache a USEFUL result — an empty map must not wedge "loading…" + if (map.size > 0) custodyPricing = map; + return custodyPricing; + } catch { + return custodyPricing; // fallback stays null; next call retries + } finally { + custodyInFlight = null; + } + })(); + return custodyInFlight; +} + +function loadTokens(): Promise { + return (tokensPromise ??= flash.tokens().catch((e) => ((tokensPromise = null), Promise.reject(e)))); +} + +/** token config → custody (by mint) → leverage bounds + per-side spreads. The + * SINGLE place a symbol becomes MarketLimits. Returns null when the custody + * isn't found (callers keep a conservative fallback / loading state meanwhile). */ +function buildLimits(symbol: string, pricing: Map, tokens: TokenInfo[]): MarketLimits | null { + const mint = tokens.find((t) => t.symbol.toUpperCase() === symbol.toUpperCase())?.mintKey; + const p = mint ? pricing.get(mint) : undefined; + if (!p) return null; + return { minLeverage: Math.max(1.1, p.min), maxLeverage: p.max, spreadLongPct: p.spreadL, spreadShortPct: p.spreadS }; +} + +/** + * Live leverage bounds + spreads for ONE market symbol. Cached per session; null + * until loaded. Same philosophy as useMarkets: Flash changes limits → the UI + * follows, no code. + */ +export function useMarketLimits(marketSymbol: string): MarketLimits | null { + const [limits, setLimits] = useState(limitsCache.get(marketSymbol) ?? null); + useEffect(() => { + const cached = limitsCache.get(marketSymbol); + setLimits(cached ?? null); + if (cached) return; + let dead = false; + void (async () => { + try { + const pricing = await loadCustodyPricing(); + if (dead || !pricing) return; + const tokens = await loadTokens(); + if (dead) return; + const out = buildLimits(marketSymbol, pricing, tokens); + if (out) { + limitsCache.set(marketSymbol, out); + setLimits(out); + } + } catch { /* fallback stays; next mount retries */ } + })(); + return () => { dead = true; }; + }, [marketSymbol]); + return limits; +} + +/** + * Live limits for MANY symbols at once (the Discover grid). Shares the deduped + * custody + tokens fetches and the one `buildLimits` path with useMarketLimits, + * so a card and the detail it opens always price through the identical spread. + * Returns a Map keyed by symbol; absent until a symbol's custody resolves. + */ +export function useAllMarketLimits(symbols: string[]): Map { + const key = symbols.join(","); + const seed = () => { + const m = new Map(); + for (const s of symbols) { const c = limitsCache.get(s); if (c) m.set(s, c); } + return m; + }; + const [map, setMap] = useState>(seed); + useEffect(() => { + let dead = false; + void (async () => { + try { + const pricing = await loadCustodyPricing(); + if (dead || !pricing) return; + const tokens = await loadTokens(); + if (dead) return; + const next = new Map(); + for (const s of symbols) { + const out = limitsCache.get(s) ?? buildLimits(s, pricing, tokens); + if (out) { limitsCache.set(s, out); next.set(s, out); } + } + if (next.size > 0) setMap(next); + } catch { /* keep last good map; next change retries */ } + })(); + return () => { dead = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [key]); + return map; +} + +// ── owner snapshot stream ──────────────────────────────────────────────────── + +export type StreamStatus = "connecting" | "open" | "reconnecting" | "polling" | "closed"; + +export function useOwner(owner: string | null): { + snapshot: BasketSnapshot | null; + /** true once we have heard ANYTHING for this owner (fetch or frame). */ + loaded: boolean; + status: StreamStatus; + refresh: () => Promise; +} { + const [snapshot, setSnapshot] = useState(null); + const [loaded, setLoaded] = useState(false); + const [status, setStatus] = useState("connecting"); + + useEffect(() => { + setSnapshot(null); + setLoaded(false); + if (!owner) { setStatus("closed"); return; } + setStatus("connecting"); + let dead = false; + + // Spec move: fetch client.owner(pubkey) on load so the setup wizard can + // decide instantly; the stream takes over from the first frame onward. + flash.owner(owner) + .then((snap) => { + if (dead) return; + setSnapshot((prev) => prev ?? snap); + setLoaded(true); + }) + .catch(() => { /* stream below retries via poll fallback */ }); + + const stream = subscribeOwner({ + owner, + network: flash.network, + onUpdate: (snap) => { + if (dead) return; + setSnapshot(snap); + setLoaded(true); + }, + onStatus: (s) => { if (!dead) setStatus(s); }, + }); + return () => { dead = true; stream.close(); }; + }, [owner]); + + const refresh = useCallback(async () => { + if (!owner) return; + try { + const snap = await flash.owner(owner); + setSnapshot(snap); + setLoaded(true); + } catch { /* non-fatal */ } + }, [owner]); + + return { snapshot, loaded, status, refresh }; +} + +/** All live positions for one market symbol out of a snapshot. */ +export function positionsFor(snapshot: BasketSnapshot | null, marketSymbol: string): PositionMetrics[] { + if (!snapshot) return []; + return Object.values(snapshot.positionMetrics ?? {}).filter( + (p) => p.marketSymbol?.toUpperCase() === marketSymbol.toUpperCase(), + ); +} + +// ── wallet balances (base chain) ───────────────────────────────────────────── + +export function useBalances(owner: string | null, usdcMint: string | null): { + sol: number | null; + usdc: number | null; + refresh: () => Promise; +} { + const [sol, setSol] = useState(null); + const [usdc, setUsdc] = useState(null); + + const refresh = useCallback(async () => { + if (!owner) { setSol(null); setUsdc(null); return; } + try { + const lamports = await baseConnection.getBalance(new PublicKey(owner)); + setSol(lamports / 1e9); + } catch { /* keep last */ } + if (usdcMint) { + try { + const res = await baseConnection.getParsedTokenAccountsByOwner( + new PublicKey(owner), + { mint: new PublicKey(usdcMint) }, + ); + const total = res.value.reduce((sum, acc) => { + const ui = acc.account.data.parsed?.info?.tokenAmount?.uiAmount as number | null | undefined; + return sum + (typeof ui === "number" ? ui : 0); + }, 0); + setUsdc(total); + } catch { /* keep last */ } + } + }, [owner, usdcMint]); + + useEffect(() => { + void refresh(); + // Background safety-net poll; balance-changing actions refresh explicitly + // for instant updates. A keyed RPC handles this cadence comfortably. + const timer = setInterval(() => { void refresh(); }, 6_000); + return () => clearInterval(timer); + }, [refresh]); + + return { sol, usdc, refresh }; +} + +// ── deposited balance: the USER DEPOSIT LEDGER, read like Flash's own infra ── + +export interface BasketAsset { + mint: string; + symbol: string; + decimals: number; + /** AVAILABLE on the rollup side (ledger − debits + pending), native units. */ + amountUi: number; +} + +export interface BasketBalance { + /** USDC available — the tradable "in basket" figure (compat alias of assets). */ + inBasketUsd: number; + /** EVERY asset with rollup-side availability — nothing orphans invisibly. */ + assets: BasketAsset[]; + /** Where the ledger was found ("er" once delegated, "base" before). */ + source: "er" | "base"; +} + +// Anchor discriminator for `UserDepositLedger` = sha256("account:UserDepositLedger")[0..8] +// (hex 3363adbe15330782). Precomputed so the browser needs no crypto/bs58 deps. +const LEDGER_DISC_B58 = "9bYPoR9mRKo"; +// Zero-copy layout (validated against 60 live mainnet ledgers, all 852 bytes): +// 8 disc | 1 bump | 7 pad | 32 owner @16 | u32 count @48 | 20 × {32 mint, u64 amount} @52. +const LEDGER_OWNER_OFFSET = 16; +const LEDGER_COUNT_OFFSET = 48; +const LEDGER_ENTRIES_OFFSET = 52; +const LEDGER_ENTRY_SIZE = 40; + +/** All ledger entries (mint → raw u64 amount), or null when no ledger exists. */ +async function fetchLedgerEntries(rpcUrl: string, owner: string): Promise | null> { + const res = await fetch(rpcUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", id: 1, method: "getProgramAccounts", + params: [MAGIC_TRADE_PROGRAM_ID, { + encoding: "base64", + filters: [ + { memcmp: { offset: 0, bytes: LEDGER_DISC_B58 } }, + { memcmp: { offset: LEDGER_OWNER_OFFSET, bytes: owner } }, + ], + }], + }), + }); + const json = (await res.json()) as { result?: Array<{ account: { data: [string, string] } }> }; + const acct = json.result?.[0]; + if (!acct) return null; + const buf = Uint8Array.from(atob(acct.account.data[0]), (c) => c.charCodeAt(0)); + const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength); + const count = view.getUint32(LEDGER_COUNT_OFFSET, true); + const out = new Map(); + for (let i = 0; i < Math.min(count, 20); i++) { + const off = LEDGER_ENTRIES_OFFSET + i * LEDGER_ENTRY_SIZE; + const mint = new PublicKey(buf.subarray(off, off + 32)).toBase58(); + const amount = Number(view.getBigUint64(off + 32, true)); + out.set(mint, (out.get(mint) ?? 0) + amount); + } + return out; +} + + + +/** + * The owner's AVAILABLE USDC (GOTCHAS §20): + * + * available = ledger.deposits − basket.debits + basket.pendingCredits + * + * V2 is double-entry: the ledger records CUMULATIVE deposits (withdrawals do + * NOT decrement it), the basket's debits/pendingCredits record all internal + * churn, and only the three together net to the balance. A fully-withdrawn + * account nets exactly $0.00; while funded, the same math gives the true + * positive balance. All three figures MUST come from the same coherent + * source — the ER-fed store (mixing chains on a half-delegated account breaks + * the invariant; clamp at 0). + */ +export function useBasketBalance( + owner: string | null, + basketPubkey: string | null, + usdcMint: string | null, +): { bal: BasketBalance | null; refresh: () => Promise } { + const [bal, setBal] = useState(null); + const tokenMeta = useRef | null>(null); + // Monotonic request id: a newer read (poll OR an explicit post-trade refresh) + // supersedes an in-flight older one, so stale data never lands. + const reqId = useRef(0); + + const refresh = useCallback(async () => { + if (!owner || !usdcMint) { setBal(null); return; } + const id = ++reqId.current; + try { + // 1) cumulative deposits — ALL mints from the ledger (ER → base fallback) + let source: "er" | "base" = "er"; + let ledger = await fetchLedgerEntries(flash.network.erRpc, owner); + if (ledger === null) { + ledger = await fetchLedgerEntries(flash.network.baseRpc, owner); + source = "base"; + } + if (id !== reqId.current) return; + if (ledger === null) { setBal({ inBasketUsd: 0, assets: [], source: "base" }); return; } + // 2) the basket's counter-entries (server-decoded from the ER store) + const debits = new Map(); + const pending = new Map(); + if (basketPubkey) { + const raw = await flash.rawBasket(basketPubkey); + if (id !== reqId.current) return; + const acct = raw.account as { + debits?: Array<{ mint: string; amount: number | string }>; + pendingCredits?: Array<{ mint: string; amount: number | string }>; + }; + for (const r of acct.debits ?? []) debits.set(r.mint, (debits.get(r.mint) ?? 0) + Number(r.amount)); + for (const r of acct.pendingCredits ?? []) pending.set(r.mint, (pending.get(r.mint) ?? 0) + Number(r.amount)); + } + // 3) symbols/decimals from Flash's live token config (cached across reads) + if (!tokenMeta.current) { + try { + tokenMeta.current = new Map( + (await flash.tokens()).map((t) => [t.mintKey, { symbol: t.symbol, decimals: t.decimals }]), + ); + } catch { tokenMeta.current = new Map(); } + } + if (id !== reqId.current) return; + // 4) available per mint = ledger − debits + pending (clamped), native units + const mints = new Set([...ledger.keys(), ...debits.keys(), ...pending.keys()]); + const assets: BasketAsset[] = []; + for (const mint of mints) { + const meta = tokenMeta.current.get(mint) ?? { symbol: mint.slice(0, 4) + "…", decimals: mint === usdcMint ? 6 : 9 }; + const rawAvail = (ledger.get(mint) ?? 0) - (debits.get(mint) ?? 0) + (pending.get(mint) ?? 0); + const amountUi = Math.max(0, rawAvail) / 10 ** meta.decimals; + if (amountUi > 0) assets.push({ mint, symbol: meta.symbol, decimals: meta.decimals, amountUi }); + } + assets.sort((a, b) => (a.mint === usdcMint ? -1 : b.mint === usdcMint ? 1 : a.symbol.localeCompare(b.symbol))); + const usdc = assets.find((a) => a.mint === usdcMint)?.amountUi ?? 0; + setBal({ inBasketUsd: usdc, assets, source }); + } catch { /* keep last good read; next read retries */ } + }, [owner, basketPubkey, usdcMint]); + + useEffect(() => { + setBal(null); // clear on owner/basket change — don't show another owner's balance + reqId.current++; // invalidate any in-flight read + void refresh(); + const timer = setInterval(() => { void refresh(); }, 5_000); + return () => clearInterval(timer); + }, [refresh]); + + return { bal, refresh }; +} + +/** Resolve the USDC mint once via /tokens (deposits need the MINT, not "USDC"). */ +export function useUsdcMint(): string | null { + const [mint, setMint] = useState(null); + useEffect(() => { + let dead = false; + flash.tokens() + .then((tokens) => { + if (dead) return; + const usdc = tokens.find((t) => t.symbol.toUpperCase() === "USDC"); + setMint(usdc?.mintKey ?? null); + }) + .catch(() => { /* tokens() retried implicitly when wizard runs */ }); + return () => { dead = true; }; + }, []); + return mint; +} + +// ── latency log (the HUD's data) ───────────────────────────────────────────── + +export interface LatencyEntry { + id: string; + /** What was tapped, e.g. "LONG 5×", "FLATTEN", "init-basket". */ + action: string; + /** Which chain confirmed it — "er" rows are the headline numbers. */ + chain: "er" | "base"; + /** REAL wall-clock submit→confirmed milliseconds (flash-v2 confirmMs). */ + ms: number; + /** Measured send round-trip (≈ one wire trip) — splits network vs rollup. */ + sendMs?: number; + signature: string; + at: number; + /** Trade facts captured at action time (history rows render these). + * pnlUi = the WITH-FEE figure at the moment of close (≈ realized). */ + trade?: { + market: string; + side: "LONG" | "SHORT"; + entryUi: number | null; + collateralUi: number | null; + pnlUi: number | null; + }; +} + +export function useLatencyLog(): { + entries: LatencyEntry[]; + add: (entry: Omit) => void; +} { + const [entries, setEntries] = useState([]); + const add = useCallback((entry: Omit) => { + const id = typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : `${Date.now()}-${Math.random()}`; + setEntries((prev) => [{ ...entry, id, at: Date.now() }, ...prev].slice(0, 50)); + }, []); + return { entries, add }; +} diff --git a/examples/predict/lib/markets.ts b/examples/predict/lib/markets.ts new file mode 100644 index 0000000..e460dc7 --- /dev/null +++ b/examples/predict/lib/markets.ts @@ -0,0 +1,357 @@ +// ───────────────────────────────────────────────────────────────────────────── +// lib/markets.ts — the PREDICTION-MARKET engine (v2.1, SPREAD-AWARE). +// +// Turns a price threshold into a YES/NO market priced in CENTS (= implied +// probability), constructed underneath as a capped-loss Flash V2 perp with a +// bundled TAKE-PROFIT at the strike (the YES win) and the liquidation KNOCKOUT +// (the NO / lose). One open-position call carries the TP (OpenPositionRequest +// `takeProfit`, flash-v2 types.ts:32), so a "buy YES" is a single signed trade. +// +// THE HONEST MODEL (say it out loud; never pretend otherwise): +// • Odds are FORMULA-SET from a real leveraged position, NOT discovered by +// other traders' order flow. q does not aggregate anyone's belief. +// • The payout comes from Flash's LP pool, NOT a shared pot of losers' stakes. +// • What IS real: the underlying price, both barriers, and the iron rule — +// you can NEVER lose more than your stake (the knockout = −stake, native). +// +// ── THE CORRECTED MATH — measure EVERYTHING from the FILL, not the oracle ───── +// A money-path audit (SPEC-PREDICT-V2 §v2.1) found the original engine modeled +// cost as a 0.16% fee and IGNORED Flash's real 5–10% trade spread. A LONG fills +// at oracle·(1+s), so a take-profit computed off the oracle lands INSIDE the +// spread and fires as a ~100% LOSS. The fix: the fill is the origin. +// +// fillEntry = oracle·(1 ± s) // + for ABOVE/LONG, − for BELOW/SHORT +// strike = fillEntry·(1 ± t) // the TP, set BEYOND the fill (the win) +// knockout = fillEntry·(1 ∓ 0.92/L) // liquidation (the loss) +// R = L·(t − fee) q = 1/(1 + R) // t is the move FROM THE FILL; q honest +// to-win = stake/q max-loss = stake +// +// L = leverage, t = favorable move to the strike, fee = round-trip fee rate. +// Symmetric barriers give q≈0.52 for ANY leverage; ASYMMETRY spans the ladder +// (far TP + near knockout = long shot). Because the strike sits a full spread +// ABOVE the oracle, high-spread tokens (SOL ≈ 10%) are honestly long-shot-only. +// +// ── THE HARD CONSTRAINT — the knockout must clear the spread ────────────────── +// At high L, koDist = 0.92/L can be SMALLER than s → the position opens already +// past liquidation. So leverage is capped: L ≤ 0.92/(s·KO_MARGIN). On SOL +// (s=0.10) that is ≈ 6×. High-spread markets are naturally low-leverage. This +// invariant is enforced at the single chokepoint `priceMarket`, so NO market — +// however constructed — can be quoted with a knockout inside the spread. +// ───────────────────────────────────────────────────────────────────────────── + +import { EST_ROUNDTRIP_FEE_RATE, LIQ_FACTOR, timeframe, type TimeframeId } from "./payoff"; +import type { TradeType } from "flash-v2"; + +/** YES = the price ends ABOVE the strike (LONG) or BELOW it (SHORT). */ +export type Direction = "ABOVE" | "BELOW"; + +/** Drift-style probability band: never quote a 0¢ or 100¢ "certainty". */ +export const PROB_FLOOR = 0.03; +export const PROB_CEIL = 0.97; + +/** The knockout must sit at least this multiple of the spread beyond the fill, + * so a fresh position is never already past liquidation. */ +export const KO_MARGIN = 1.5; + +/** Nominal favorable move (%) per timeframe — the move-scale that sets leverage + * for a target. While leverage is below the cap, rungs differ by leverage (near + * vs far knockout); once leverage pins at the spread cap (high-spread tokens), + * rungs differ by STRIKE instead, so the ladder still spans. */ +const FAR_MOVE_PCT: Record = { "5m": 1.2, "15m": 2.5, "1h": 5 }; + +/** Generous absolute ceiling on the strike move from the fill, per timeframe — + * bounds pathological far strikes only; large enough to never squash the default + * ladder. A deep long-shot that needs more move than this isn't offered (its q + * floats up to the deepest the timeframe honestly supports). */ +const STRIKE_MOVE_CEIL: Record = { "5m": 0.15, "15m": 0.35, "1h": 0.75 }; + +/** The default odds ladder — the YES prices we offer per token+timeframe. */ +const DEFAULT_TARGETS = [0.85, 0.7, 0.55, 0.4, 0.25] as const; + +/** The probability a card's single headline question targets (then rounded). */ +const HEADLINE_TARGET = 0.58; + +export interface MarketConstruction { + /** perp side expressing YES (ABOVE→LONG, BELOW→SHORT). */ + side: TradeType; + /** target leverage — already clamped to the spread constraint + custody caps. */ + leverage: number; + /** take-profit price = the strike (the YES win boundary). */ + takeProfitPrice: number; + /** knockout / liquidation price (the NO, lose-your-stake boundary). */ + knockoutPrice: number; +} + +export interface Market { + token: string; + direction: Direction; + /** the threshold price the question asks about (= the take-profit). */ + strike: number; + /** the live ORACLE price (what the card shows; the question is framed off it). */ + oracle: number; + /** the SIGNED-FILL entry the odds are actually measured from (oracle·(1±s)). */ + entry: number; + /** the trade spread this market was priced through (side-appropriate fraction). */ + spread: number; + timeframe: TimeframeId; + /** implied probability of YES, clamped to [PROB_FLOOR, PROB_CEIL]. */ + prob: number; + /** payout multiple R: profit per $1 of stake on YES (to-win = stake·(1+R)). */ + payoutMult: number; +} + +export interface PricedMarket extends Market { + construction: MarketConstruction; +} + +const clampProb = (q: number) => Math.min(PROB_CEIL, Math.max(PROB_FLOOR, q)); + +// ── display helpers (cents = probability, the prediction-market lexicon) ─────── + +/** "42¢" — a YES/NO price as whole cents. */ +export function cents(prob: number): number { + return Math.round(clampProb(prob) * 100); +} + +/** Total you receive per $1 staked on a winning YES (cost + profit). */ +export function toWinPerDollar(prob: number): number { + return 1 / clampProb(prob); +} + +/** Total returned on a winning bet of `stakeUsd` (the "to win $X"). */ +export function toWinUsd(stakeUsd: number, prob: number): number { + return stakeUsd * toWinPerDollar(prob); +} + +/** Profit (not total) on a winning bet — to-win minus the stake. */ +export function profitUsd(stakeUsd: number, prob: number): number { + return toWinUsd(stakeUsd, prob) - stakeUsd; +} + +// ── the spread-aware primitives ─────────────────────────────────────────────── + +/** The signed-fill entry price — the ORIGIN every barrier is measured from. + * A LONG buys at the ask (oracle·(1+s)); a SHORT sells at the bid (oracle·(1−s)). */ +export function fillEntryFrom(oracle: number, spread: number, direction: Direction): number { + const s = Math.max(0, spread); + return direction === "ABOVE" ? oracle * (1 + s) : oracle * (1 - s); +} + +/** The price a position REALIZES when its take-profit triggers at `strike`. The + * exit crosses the spread the OTHER way — a LONG sells at the bid (strike·(1−s)), + * a SHORT buys at the ask (strike·(1+s)) — so the spread is paid AGAIN on exit. + * This is why a take-profit must clear TWO spreads to actually win (verified live: + * a $90 SOL TP exits at $81 and nets ≈ $0). PnL is driven by entryFill→exitFill. */ +export function exitFillFrom(strike: number, spread: number, direction: Direction): number { + const s = Math.max(0, spread); + return direction === "ABOVE" ? strike * (1 - s) : strike * (1 + s); +} + +/** The highest leverage whose knockout (0.92/L from the fill) still clears the + * spread, intersected with the custody cap. Spread-free markets (FX) are capped + * only by custody. This is THE constraint that stops an already-liquidated open. */ +export function maxLeverageForSpread(spread: number, custodyMaxLeverage: number): number { + const s = Math.max(0, spread); + const spreadCap = s > 0 ? LIQ_FACTOR / (s * KO_MARGIN) : Infinity; + const custody = custodyMaxLeverage > 0 ? custodyMaxLeverage : Infinity; + return Math.max(1, Math.min(custody, spreadCap)); +} + +// ── the core: oracle + spread + leverage + strike → odds, payout, both barriers ── + +/** Price one market from live facts. THE CHOKEPOINT: leverage is clamped here so + * the knockout always clears the spread (CRIT-2), regardless of caller. The odds + * fall out of the construction. Pure — never throws, never returns NaN. */ +export function priceMarket(opts: { + token: string; + direction: Direction; + /** live oracle price (> 0; a non-positive oracle yields an unpriceable q=floor). */ + oracle: number; + /** side-appropriate trade spread as a fraction (SOL≈0.10, BTC/ETH≈0.05, FX 0). */ + spread: number; + /** desired leverage; clamped down so koDist ≥ spread·KO_MARGIN. */ + leverage: number; + /** the strike / take-profit price the question asks about. */ + strike: number; + timeframe: TimeframeId; + feesRate?: number; +}): PricedMarket { + const { token, direction, timeframe: tf } = opts; + const oracle = opts.oracle; + const spread = Math.max(0, opts.spread); + const feesRate = opts.feesRate ?? EST_ROUNDTRIP_FEE_RATE; + const side: TradeType = direction === "ABOVE" ? "LONG" : "SHORT"; + + // Spread invariant enforced HERE: no quoted market can have a knockout inside + // the spread, even if a caller passes an unclamped leverage. + const lev = clampLeverage(opts.leverage, spread, Infinity); + const entryFill = oracle > 0 ? fillEntryFrom(oracle, spread, direction) : 0; + + // The TP triggers at the strike but EXITS through the spread, so PnL is driven + // by the entry-fill → exit-fill move — the spread is paid on BOTH legs. t is + // that NET favorable move; koDist is the adverse move to liquidation. + const exitFill = exitFillFrom(opts.strike, spread, direction); + const t = entryFill > 0 + ? (direction === "ABOVE" ? exitFill / entryFill - 1 : 1 - exitFill / entryFill) + : 0; + const koDist = LIQ_FACTOR / lev; + const knockoutPrice = direction === "ABOVE" ? entryFill * (1 - koDist) : entryFill * (1 + koDist); + + // R = L·t − fees/C = L·(t − fee) (fees scale with size). t ≤ fee ⇒ R floors at 0. + const payoutMult = Math.max(0, lev * (t - feesRate)); + // A non-positive / non-finite oracle is UNPRICEABLE → floor (3¢ "no"), never the + // 97¢ ceiling a zero entry would otherwise paint (a broken feed must not read as + // a near-certain YES). Live cards also gate on loaded limits + a positive price. + const prob = oracle > 0 ? clampProb(1 / (1 + payoutMult)) : PROB_FLOOR; + + return { + token, direction, strike: opts.strike, oracle, entry: entryFill, spread, timeframe: tf, prob, payoutMult, + construction: { side, leverage: lev, takeProfitPrice: opts.strike, knockoutPrice }, + }; +} + +/** Clamp a desired leverage into [floor, spread∧custody cap]. */ +function clampLeverage(desired: number, spread: number, custodyMaxLeverage: number, floor = 1): number { + const cap = maxLeverageForSpread(spread, custodyMaxLeverage); + const lo = Math.max(1, floor); + const d = Number.isFinite(desired) && desired > 0 ? desired : lo; + return Math.max(lo, Math.min(cap, d)); +} + +/** Solve the construction for a TARGET probability — pick the leverage that lands + * the strike within the timeframe's believable move, clamp it to the spread + + * custody caps, then derive both price levels from the FILL. This is how each + * ladder rung is generated. On high-spread tokens a deep long-shot target may be + * unreachable within the leverage cap; q then floats to the deepest the token + * honestly allows (the strike is held at the believable-move boundary). */ +export function marketForTargetProb(opts: { + token: string; + direction: Direction; + oracle: number; + spread: number; + maxLeverage: number; + minLeverage?: number; + timeframe: TimeframeId; + targetProb: number; + feesRate?: number; +}): PricedMarket { + const { token, direction, oracle, spread, timeframe: tf, targetProb } = opts; + const feesRate = opts.feesRate ?? EST_ROUNDTRIP_FEE_RATE; + const floor = Math.max(1.1, opts.minLeverage ?? 1.1); + + const q = clampProb(targetProb); + const R = (1 - q) / q; // payout multiple needed for this YES price + const nominal = FAR_MOVE_PCT[tf] / 100; // move-scale that sets leverage + const span = Math.max(nominal - feesRate, 1e-4); + // Target the leverage that hits R at the nominal move, clamped to [floor, cap]. + // When the cap bites (high-spread tokens), leverage pins and the strike extends. + const leverage = clampLeverage(R / span, spread, opts.maxLeverage, floor); + // Strike move follows from the (possibly pinned) leverage; bounded only by the + // pathology ceiling. Pinned leverage ⇒ deeper rungs ⇒ farther strikes (the + // ladder spans by STRIKE). If the ceiling bites, q floats up (honest). + const t = Math.min(R / leverage + feesRate, STRIKE_MOVE_CEIL[tf]); + + const entryFill = oracle > 0 ? fillEntryFrom(oracle, spread, direction) : 0; + const sClamp = Math.min(0.99, Math.max(0, spread)); + // Place the strike so that AFTER the exit spread the NET move is t: + // LONG exit = strike·(1−s) = entryFill·(1+t) ⇒ strike = entryFill·(1+t)/(1−s) + // SHORT exit = strike·(1+s) = entryFill·(1−t) ⇒ strike = entryFill·(1−t)/(1+s) + const strike = direction === "ABOVE" + ? (entryFill * (1 + t)) / (1 - sClamp) + : (entryFill * (1 - t)) / (1 + sClamp); + // Re-price from the realized strike + clamped leverage so prob is exact. + return priceMarket({ token, direction, oracle, spread, leverage, strike, timeframe: tf, feesRate }); +} + +/** A token+timeframe+direction → the odds ladder (a row of YES markets). Rungs + * that collapse to the same strike under the leverage cap (high-spread tokens) + * are de-duplicated, so the ladder never shows two identical bets. */ +export function strikeLadder(opts: { + token: string; + oracle: number; + spread: number; + maxLeverage: number; + minLeverage?: number; + direction: Direction; + timeframe: TimeframeId; + targets?: readonly number[]; +}): PricedMarket[] { + const targets = opts.targets ?? DEFAULT_TARGETS; + const out: PricedMarket[] = []; + const seen = new Set(); + for (const targetProb of targets) { + const m = marketForTargetProb({ + token: opts.token, direction: opts.direction, oracle: opts.oracle, spread: opts.spread, + maxLeverage: opts.maxLeverage, minLeverage: opts.minLeverage, timeframe: opts.timeframe, targetProb, + }); + const key = m.strike.toPrecision(6); + if (seen.has(key)) continue; + seen.add(key); + out.push(m); + } + return out; +} + +// ── the card headline ───────────────────────────────────────────────────────── + +/** Round a target strike to a clean, human number CLOSE to it (≈2 significant + * figures) for a card headline — close enough that the re-priced odds stay near + * the target, clean enough to read as a real question ("Will SOL hit $100?"). + * Coarse "next nice number up" rounding overshot a 58¢ target to a 30¢ long-shot. */ +function roundStrikeNice(price: number): number { + if (!(price > 0)) return 0; + return Number(price.toPrecision(price >= 1 ? 2 : 3)); +} + +/** The single "climb above" headline a card shows. Targets a believable headline + * probability through the corrected (two-spread) solver, then rounds the strike + * to a human number and RE-PRICES so the cents stay honest for the rounded + * strike. Each token's own spread + caps make the cents FLOAT — no two alike. */ +export function headlineMarket(opts: { + token: string; + oracle: number; + spread: number; + maxLeverage: number; + minLeverage?: number; + timeframe: TimeframeId; + direction?: Direction; +}): PricedMarket { + const direction = opts.direction ?? "ABOVE"; + const target = marketForTargetProb({ + token: opts.token, direction, oracle: opts.oracle, spread: opts.spread, + maxLeverage: opts.maxLeverage, minLeverage: opts.minLeverage, + timeframe: opts.timeframe, targetProb: HEADLINE_TARGET, + }); + const strike = direction === "ABOVE" ? roundStrikeNice(target.strike) : target.strike; + return priceMarket({ + token: opts.token, direction, oracle: opts.oracle, spread: opts.spread, + leverage: target.construction.leverage, strike, timeframe: opts.timeframe, + }); +} + +// NOTE: multi-outcome "buckets" were REMOVED (SPEC §v2.1 fix #7). A single +// capped-loss perp + one take-profit can only express a ONE-TOUCH "reaches X", +// never a bounded "lands between X and Y" (that needs a call-spread = two +// positions), and the honest one-touch relabel degenerated to 97¢ inside the +// spread. The strike ladder already gives honest reach-odds. The old bucketMarket +// / openParamsFor builders were deleted too — they were undefended exported paths +// that would render a guaranteed-loss strike as 97¢ if re-wired. A future +// call-spread rebuild is a flagged, user-approved scope item. + +// ── the bridge to the trade builder ─────────────────────────────────────────── + +/** Slippage tolerance that clears the trade spread with headroom. A hardcoded + * "1" is far below a 5–10% spread and rejects every fill (SPEC §v2.1 fix #8). */ +export function slippageForSpread(spread: number): string { + const pct = Math.max(1, Math.ceil((Math.max(0, spread) * 1.5) * 100)); + return String(pct); +} + +/** Human one-liner for a market question (plain words, no trading vocabulary). + * Framed off the ORACLE — the price a person sees — not the internal fill. */ +export function questionFor(m: Market): string { + const tf = timeframe(m.timeframe).label; + const f = (n: number) => (n >= 1000 ? n.toLocaleString(undefined, { maximumFractionDigits: 0 }) : n.toFixed(2)); + const dir = m.direction === "ABOVE" ? "above" : "below"; + return `${m.token} ${dir} ${f(m.strike)} in ${tf}?`; +} diff --git a/examples/predict/lib/payoff.ts b/examples/predict/lib/payoff.ts new file mode 100644 index 0000000..40a120a --- /dev/null +++ b/examples/predict/lib/payoff.ts @@ -0,0 +1,142 @@ +// ───────────────────────────────────────────────────────────────────────────── +// lib/payoff.ts — the honest math behind a round: pure functions, no React. +// THE HARD PART: the live payoff line is LOCAL math (never quote-per-tick — +// rate limits + jitter); ONE real quote at the commit gate overrides every +// estimate and becomes the round's locked numbers. Fees scale with size, so +// break-even move % is leverage-INDEPENDENT; liquidation distance ≈ 92%/L. +// GOTCHAS.md → "Three numbers that surprise you on your first real fill" +// ───────────────────────────────────────────────────────────────────────────── + +/** Conservative entry+exit fee estimate (fraction of SIZE) until a real quote lands. */ +export const EST_ROUNDTRIP_FEE_RATE = 0.0016; // 0.08% open + 0.08% close + +/** The protocol's liquidation buffer factor (GOTCHAS §20 display formula). */ +export const LIQ_FACTOR = 0.92; + +/** Timeframes a round can run. Leverage profiles are conservative-moderate: + * break-even move is leverage-independent, and liquidation distance (≈92%/L) + * is hardest to reach in short windows — so short rounds can carry more + * leverage without raising knockout risk. Clamped to live market caps. */ +export const TIMEFRAMES = [ + { id: "5m", label: "5 min", ms: 5 * 60_000, leverage: 5 }, + { id: "15m", label: "15 min", ms: 15 * 60_000, leverage: 3.3 }, + { id: "1h", label: "1 hour", ms: 60 * 60_000, leverage: 2 }, +] as const; + +export type TimeframeId = (typeof TIMEFRAMES)[number]["id"]; + +export function timeframe(id: TimeframeId) { + const tf = TIMEFRAMES.find((t) => t.id === id); + if (!tf) throw new Error(`unknown timeframe: ${id}`); + return tf; +} + +/** Dollars of PnL per 1% move in the chosen direction. */ +export function pnlPerPct(stakeUsd: number, leverage: number): number { + return (stakeUsd * leverage) / 100; +} + +/** Move (in %) the price must travel your way before fees are covered. + * feesUsd defaults to the local estimate when no quote exists yet. */ +export function breakEvenMovePct(stakeUsd: number, leverage: number, feesUsd?: number): number { + const size = stakeUsd * leverage; + const fees = feesUsd ?? size * EST_ROUNDTRIP_FEE_RATE; + if (size <= 0) return 0; + return (fees / size) * 100; +} + +/** Adverse move (in %) that knocks the round out early (liquidation). */ +export function knockoutMovePct(leverage: number): number { + if (leverage <= 0) return 100; + return (LIQ_FACTOR / leverage) * 100; +} + +/** Liquidation price from entry — the GOTCHAS §20 approximation. */ +export function liqPriceApprox(entry: number, leverage: number, side: "LONG" | "SHORT"): number { + const dist = LIQ_FACTOR / leverage; + return side === "LONG" ? entry * (1 - dist) : entry * (1 + dist); +} + +/** Hourly borrow drag in USD for a given size (marginFeePercentage is %/hr). */ +export function borrowDragUsd(sizeUsd: number, marginFeePctHourly: number, hours: number): number { + return sizeUsd * (marginFeePctHourly / 100) * hours; +} + +/** Everything the ticket renders live, from local math alone. */ +export interface PayoffFacts { + sizeUsd: number; + perPctUsd: number; + breakEvenPct: number; + knockoutPct: number; + estFeesUsd: number; +} + +export function payoffFacts(stakeUsd: number, leverage: number): PayoffFacts { + const sizeUsd = stakeUsd * leverage; + return { + sizeUsd, + perPctUsd: pnlPerPct(stakeUsd, leverage), + breakEvenPct: breakEvenMovePct(stakeUsd, leverage), + knockoutPct: knockoutMovePct(leverage), + estFeesUsd: sizeUsd * EST_ROUNDTRIP_FEE_RATE, + }; +} + +/** The numbers locked at commit — taken from the SIGNED open's response, never + * from a stale preview. PancakeSwap's #1 trust failure is a displayed payout + * that drifts before settle; ours cannot, because these lock here. */ +export interface LockedQuote { + entryPrice: number; + liqPrice: number; + entryFeeUsd: number; + /** hourly borrow %, straight from the API */ + marginFeePctHourly: number; + perPctUsd: number; + breakEvenPct: number; + /** EFFECTIVE size from the venue's fill math (GOTCHAS §21) — not stake×lev. */ + sizeUsd: number; +} + +/** Build the locked quote from an open-position response — the ONE place the + * API's strings become the round's numbers (review and confirm both use it). */ +export function lockedQuoteFrom( + res: { + newEntryPrice: string; + newLiquidationPrice: string; + entryFee: string; + marginFeePercentage: string; + outputAmountUi: string; + }, + stakeUsd: number, + leverage: number, + side: "LONG" | "SHORT", +): LockedQuote { + const entryFeeUsd = Number(res.entryFee) || 0; + const entryPrice = Number(res.newEntryPrice) || 0; + // The venue's actual fill: output tokens × entry — the spread reshapes it. + const filled = (Number(res.outputAmountUi) || 0) * entryPrice; + const sizeUsd = filled > 0 ? filled : stakeUsd * leverage; + // Knockout is OURS to compute (GOTCHAS §20): the builder's + // newLiquidationPrice is spread-valued and degenerates (it can land on the + // wrong side of entry). Effective leverage = filled size / stake. + const effLev = stakeUsd > 0 ? sizeUsd / stakeUsd : leverage; + return { + entryPrice, + liqPrice: liqPriceApprox(entryPrice, effLev, side), + entryFeeUsd, + marginFeePctHourly: Number(res.marginFeePercentage) || 0, + perPctUsd: sizeUsd / 100, + breakEvenPct: sizeUsd > 0 ? ((entryFeeUsd * 2) / sizeUsd) * 100 : 0, + sizeUsd, + }; +} + +/** PnL in USD at a given move % (for drawing the payoff line). */ +export function pnlAtMovePct(stakeUsd: number, leverage: number, movePct: number, feesUsd?: number): number { + const size = stakeUsd * leverage; + const fees = feesUsd ?? size * EST_ROUNDTRIP_FEE_RATE; + const gross = (size * movePct) / 100; + const net = gross - fees; + // Loss is capped at stake: the position liquidates before losing more. + return Math.max(net, -stakeUsd); +} diff --git a/examples/predict/lib/rounds.ts b/examples/predict/lib/rounds.ts new file mode 100644 index 0000000..14b1db0 --- /dev/null +++ b/examples/predict/lib/rounds.ts @@ -0,0 +1,230 @@ +// ───────────────────────────────────────────────────────────────────────────── +// lib/rounds.ts — rounds are a CACHE; chain positions are the truth. +// THE HARD PART: reconciliation. Positions carry NO timestamp, so the join +// key is (marketSymbol, sideUi) — valid because one position per market+side +// exists at a time. Settlement triggers on OBSERVED state (snapshot + clock), +// never a bare setTimeout; "settling" status + an in-flight set guard against +// double-close. GOTCHAS.md → "The 97% full-close threshold" (we always send +// "0" = explicit FULL close) (../../GOTCHAS.md) +// ───────────────────────────────────────────────────────────────────────────── + +import type { BasketSnapshot, PositionMetrics } from "flash-v2"; +import type { TimeframeId } from "./payoff"; +import type { LockedQuote } from "./payoff"; + +export const SCHEMA_VERSION = 1; + +/** New rounds are invisible to reconciliation for this long — the position + * needs a beat to appear in the owner stream after the ER confirm. */ +export const PROPAGATION_GRACE_MS = 5_000; + +export type Side = "LONG" | "SHORT"; + +export type RoundStatus = + | "active" // position open, clock running (or adopted: no clock) + | "settling" // close dispatched, awaiting confirm + | "settled" // we closed it; result recorded + | "closed-elsewhere"; // position vanished without our close (other device / liquidated) + +export interface RoundResult { + pnlUsd: number; + won: boolean; + settledAt: number; + /** ER tx signature of the close — the round's receipt. */ + signature?: string; +} + +export interface Round { + id: string; + market: string; // marketSymbol, e.g. "SOL" + side: Side; + stakeUsd: number; + leverage: number; + timeframe: TimeframeId | null; // null = adopted position, settle-now only + placedAt: number; + expiresAt: number | null; // null = adopted, no clock + quote: LockedQuote; + status: RoundStatus; + result?: RoundResult; + /** The user saw and dismissed the result card (ack gates new rounds). */ + acked?: boolean; + /** Prediction-market: the strike / threshold the question asks about (the TP). */ + strike?: number; + /** Prediction-market: reconciled profit if the bet WINS — used to score a + * position that closed via its take-profit before we observed it. */ + winProfitUsd?: number; +} + +interface StoredRounds { + v: number; + rounds: Round[]; +} + +// Per-app store namespace: the Updown app and the /markets prediction app keep +// SEPARATE round histories (a shared key would cross-contaminate reconciliation). +const roundsKey = (owner: string, prefix = "predict-rounds") => `${prefix}-${owner}`; + +export function loadRounds(owner: string, prefix?: string): Round[] { + if (typeof window === "undefined") return []; + try { + const raw = window.localStorage.getItem(roundsKey(owner, prefix)); + if (!raw) return []; + const parsed = JSON.parse(raw) as StoredRounds; + if (parsed.v !== SCHEMA_VERSION || !Array.isArray(parsed.rounds)) { + window.localStorage.removeItem(roundsKey(owner, prefix)); + return []; + } + return parsed.rounds; + } catch { + return []; + } +} + +export function saveRounds(owner: string, rounds: Round[], prefix?: string): void { + if (typeof window === "undefined") return; + const stored: StoredRounds = { v: SCHEMA_VERSION, rounds }; + window.localStorage.setItem(roundsKey(owner, prefix), JSON.stringify(stored)); +} + +export function newRoundId(): string { + return `r_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; +} + +/** A live position with no matching round — opened elsewhere (or a lost + * cache). Surfaced for adoption: settle-now only, no invented clock. */ +export interface Orphan { + market: string; + side: Side; + metrics: PositionMetrics; +} + +export interface ReconcileResult { + /** Expired, position still on chain → dispatch full close. */ + toSettle: Round[]; + /** Round says active, chain says gone → mark closed-elsewhere. */ + closedElsewhere: Round[]; + /** Chain position with no round → offer adoption. */ + orphans: Orphan[]; +} + +const sideOf = (p: PositionMetrics): Side => (p.sideUi.toUpperCase() === "LONG" ? "LONG" : "SHORT"); + +/** The round↔chain join: one position per market+side exists at a time. */ +export function positionFor(snapshot: BasketSnapshot, market: string, side: Side): PositionMetrics | undefined { + return Object.values(snapshot.positionMetrics).find( + (p) => p.marketSymbol === market && sideOf(p) === side && Number(p.sizeUsdUi) > 0, + ); +} + +/** Pure classification — no I/O, no status writes. Rounds inside the + * propagation grace window are left untouched in BOTH directions. */ +export function reconcile(rounds: Round[], snapshot: BasketSnapshot | null, nowMs: number): ReconcileResult { + const result: ReconcileResult = { toSettle: [], closedElsewhere: [], orphans: [] }; + if (!snapshot) return result; + + const active = rounds.filter((r) => r.status === "active"); + + for (const round of active) { + if (nowMs - round.placedAt < PROPAGATION_GRACE_MS) continue; + const pos = positionFor(snapshot, round.market, round.side); + if (!pos) { + result.closedElsewhere.push(round); + } else if (round.expiresAt !== null && nowMs >= round.expiresAt) { + result.toSettle.push(round); + } + } + + const claimed = new Set( + rounds + .filter((r) => r.status === "active" || r.status === "settling") + .map((r) => `${r.market}|${r.side}`), + ); + for (const p of Object.values(snapshot.positionMetrics)) { + if (Number(p.sizeUsdUi) <= 0) continue; + const key = `${p.marketSymbol}|${sideOf(p)}`; + if (!claimed.has(key)) result.orphans.push({ market: p.marketSymbol, side: sideOf(p), metrics: p }); + } + + return result; +} + +/** Status transitions, applied immutably. */ +export function withStatus(rounds: Round[], id: string, status: RoundStatus): Round[] { + return rounds.map((r) => (r.id === id ? { ...r, status } : r)); +} + +export function withResult(rounds: Round[], id: string, result: RoundResult): Round[] { + return rounds.map((r) => (r.id === id ? { ...r, status: "settled" as const, result } : r)); +} + +export function withAck(rounds: Round[], id: string): Round[] { + return rounds.map((r) => (r.id === id ? { ...r, acked: true } : r)); +} + +/** Results waiting to be seen — drives the SettleCard queue, oldest first. */ +export function pendingAcks(rounds: Round[]): Round[] { + return rounds + .filter((r) => (r.status === "settled" || r.status === "closed-elsewhere") && !r.acked) + .sort((a, b) => (a.result?.settledAt ?? a.placedAt) - (b.result?.settledAt ?? b.placedAt)); +} + +/** Adopt an orphan as a clockless round so it can be watched and settled. */ +export function adoptOrphan(orphan: Orphan, nowMs: number): Round { + const collateral = Number(orphan.metrics.collateralUsdUi) || 0; + const size = Number(orphan.metrics.sizeUsdUi) || 0; + const entry = Number(orphan.metrics.entryPriceUi) || 0; + const leverage = collateral > 0 ? size / collateral : 1; + return { + id: newRoundId(), + market: orphan.market, + side: orphan.side, + stakeUsd: collateral, + leverage, + timeframe: null, + placedAt: nowMs, + expiresAt: null, + quote: { + entryPrice: entry, + liqPrice: Number(orphan.metrics.liquidationPriceUi) || 0, + entryFeeUsd: 0, + marginFeePctHourly: 0, + perPctUsd: size / 100, + breakEvenPct: 0, + sizeUsd: size, + }, + status: "active", + }; +} + +export interface RoundStats { + played: number; + wins: number; + losses: number; + winRatePct: number; + totalPnlUsd: number; + streak: number; // positive = current win streak, negative = loss streak +} + +export function roundStats(rounds: Round[]): RoundStats { + const settled = rounds + .filter((r) => r.status === "settled" && r.result) + .sort((a, b) => (a.result?.settledAt ?? 0) - (b.result?.settledAt ?? 0)); + const wins = settled.filter((r) => r.result?.won).length; + const losses = settled.length - wins; + let streak = 0; + for (let i = settled.length - 1; i >= 0; i--) { + const won = settled[i]?.result?.won ?? false; + if (streak === 0) streak = won ? 1 : -1; + else if (won && streak > 0) streak++; + else if (!won && streak < 0) streak--; + else break; + } + return { + played: settled.length, + wins, + losses, + winRatePct: settled.length ? (wins / settled.length) * 100 : 0, + totalPnlUsd: settled.reduce((s, r) => s + (r.result?.pnlUsd ?? 0), 0), + streak, + }; +} diff --git a/examples/predict/lib/session.ts b/examples/predict/lib/session.ts new file mode 100644 index 0000000..ab2fa31 --- /dev/null +++ b/examples/predict/lib/session.ts @@ -0,0 +1,268 @@ +// ───────────────────────────────────────────────────────────────────────────── +// lib/session.ts — REAL session keys via @magicblock-labs/gum-sdk (v3). +// THE HARD PART: there is NO server endpoint for sessions — the CLIENT mints a +// SessionTokenV2 PDA (seeds ["session_token_v2", target, signer, authority] — +// note the _v2!). buildSessionTransaction lets lib/enable.ts bundle it into +// the ONE Enable One-Click Trading approval; trades then auto-sign. +// SESSION-KEYS.md has the full story. GOTCHAS.md → "Session keys" entry. +// ───────────────────────────────────────────────────────────────────────────── + +import { BN } from "@coral-xyz/anchor"; +import { SessionTokenManager } from "@magicblock-labs/gum-sdk"; +import { + Connection, + Keypair, + PublicKey, + type Transaction, + type VersionedTransaction, +} from "@solana/web3.js"; + +/** Gum session-keys program. */ +export const SESSION_KEYS_PROGRAM = new PublicKey("KeyspM2ssCJbqUhQ4k7sveSiY4WjnYsrXkC8oDbwde5"); + +/** + * magic-trade (Flash V2 on MagicBlock) program — the session target. + * Mainnet: FTv2RxXarPfNta45HTTMVaGvjzsGg27FXJ3hEKWBhrzV + * (verified by decoding a mainnet init-basket transaction from /v2/transaction-builder) + */ +/** magic-trade program (mainnet) — decoded from live builder txs. */ +export const MAGIC_TRADE_PROGRAM = new PublicKey("FTv2RxXarPfNta45HTTMVaGvjzsGg27FXJ3hEKWBhrzV"); + + +/** localStorage key for the persisted session (signer secret + token PDA). */ +export const SESSION_STORAGE_KEY = "predict-session"; + +/** Minimal wallet shape we need (matches wallet-adapter's AnchorWallet). */ +export interface SessionWallet { + publicKey: PublicKey; + signTransaction(tx: T): Promise; + signAllTransactions(txs: T[]): Promise; +} + +export interface LoadedSession { + /** Ephemeral signer — auto-signs every trade, no popup. */ + keypair: Keypair; + /** SessionTokenV2 PDA (base58) — passed as `sessionToken` to the API. */ + token: string; + /** The real wallet that authorized the session (base58). */ + authority: string; + /** Unix seconds — the program rejects the session after this. */ + validUntil: number; +} + +interface StoredSession { + secretKey: number[]; + token: string; + authority: string; + validUntil: number; +} + +/** + * Derive the SessionTokenV2 PDA. Live-verified on-chain: 77 session tokens + * targeting magic-trade all match seeds + * ["session_token_v2", target_program, session_signer, authority]. + */ +export function deriveSessionToken( + authority: PublicKey, + sessionSigner: PublicKey +): PublicKey { + const [pda] = PublicKey.findProgramAddressSync( + [ + new TextEncoder().encode("session_token_v2"), + MAGIC_TRADE_PROGRAM.toBytes(), + sessionSigner.toBytes(), + authority.toBytes(), + ], + SESSION_KEYS_PROGRAM, + ); + return pda; +} + +/** Load the persisted session if it exists, belongs to `authority`, and has + * ≥60s of validity left. Anything else is cleared. Browser-only. */ +export function loadSession(authority?: string): LoadedSession | null { + if (typeof window === "undefined") return null; + const raw = window.localStorage.getItem(SESSION_STORAGE_KEY); + if (!raw) return null; + try { + const stored = JSON.parse(raw) as StoredSession; + const fresh = stored.validUntil > Date.now() / 1000 + 60; + const mine = !authority || stored.authority === authority; + if (!fresh || !mine) { + if (!fresh) clearSession(); + return null; + } + return { + keypair: Keypair.fromSecretKey(Uint8Array.from(stored.secretKey)), + token: stored.token, + authority: stored.authority, + validUntil: stored.validUntil, + }; + } catch { + clearSession(); + return null; + } +} + +export function clearSession(): void { + if (typeof window !== "undefined") window.localStorage.removeItem(SESSION_STORAGE_KEY); +} + +/** Poll a signature to confirmation (same approach as flash-v2/sign.ts). */ +async function confirmSignature(connection: Connection, signature: string, timeoutMs = 60_000): Promise { + const started = Date.now(); + for (;;) { + const status = (await connection.getSignatureStatuses([signature])).value[0]; + if (status) { + if (status.err) throw new Error(`on-chain error (${signature}): ${JSON.stringify(status.err)}`); + const level = status.confirmationStatus; + if (level === "confirmed" || level === "finalized") return; + } + if (Date.now() - started > timeoutMs) throw new Error(`confirmation timeout (${signature})`); + await new Promise((r) => setTimeout(r, 400)); + } +} + +/** Everything `enableOneClickTrading` needs to bundle the session into ONE + * signAllTransactions sheet: the tx (blockhash set, ephemeral key already + * partial-signed — the wallet only adds its own signature) plus the pieces + * to persist once it confirms. */ +export interface BuiltSessionTransaction { + tx: Transaction; + sessionSigner: Keypair; + /** SessionTokenV2 PDA (base58). */ + sessionToken: string; + validUntil: number; +} + +/** + * Build (don't sign-with-the-wallet, don't send) a `create_session_v2` tx: + * generate an ephemeral keypair, derive the SessionTokenV2 PDA, set blockhash + * + fee payer, and partial-sign with the ephemeral key. `topUpSol` moves SOL + * from the wallet to the session signer so trades pay their own fees. + */ +export async function buildSessionTransaction( + wallet: SessionWallet, + connection: Connection, + opts: { validHours?: number; topUpSol?: number } = {}, +): Promise { + const { validHours = 24, topUpSol = 0.01 } = opts; + const sessionSigner = Keypair.generate(); + const validUntil = Math.floor(Date.now() / 1000) + Math.floor(validHours * 3600); + const sessionToken = deriveSessionToken(wallet.publicKey, sessionSigner.publicKey); + const targetProgram = MAGIC_TRADE_PROGRAM; + + const manager = new SessionTokenManager(wallet, connection); + const tx: Transaction = await manager.program.methods + .createSessionV2(true, new BN(validUntil), new BN(Math.round(topUpSol * 1e9))) + .accountsPartial({ + sessionToken, + sessionSigner: sessionSigner.publicKey, + feePayer: wallet.publicKey, + authority: wallet.publicKey, + targetProgram, + }) + .transaction(); + + tx.feePayer = wallet.publicKey; + tx.recentBlockhash = (await connection.getLatestBlockhash("confirmed")).blockhash; + tx.partialSign(sessionSigner); // ephemeral key co-signs; wallet signs later + return { tx, sessionSigner, sessionToken: sessionToken.toBase58(), validUntil }; +} + +/** Persist a confirmed session to localStorage and return the live shape. */ +export function persistSession(args: { + sessionSigner: Keypair; + sessionToken: string; + authority: string; + validUntil: number; +}): LoadedSession { + const stored: StoredSession = { + secretKey: Array.from(args.sessionSigner.secretKey), + token: args.sessionToken, + authority: args.authority, + validUntil: args.validUntil, + }; + window.localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(stored)); + return { + keypair: args.sessionSigner, + token: stored.token, + authority: stored.authority, + validUntil: stored.validUntil, + }; +} + +/** + * Compat wrapper (build → ONE wallet popup → send → confirm → persist). + * `enableOneClickTrading` (lib/enable.ts) uses buildSessionTransaction + * directly so the session rides the same signAllTransactions sheet as the + * basket/ledger/delegate/deposit txs. + */ +export async function createSession(opts: { + wallet: SessionWallet; + connection: Connection; + validHours?: number; + topUpSol?: number; +}): Promise<{ session: LoadedSession; signature: string; confirmMs: number }> { + const { wallet, connection, validHours, topUpSol } = opts; + const built = await buildSessionTransaction(wallet, connection, { validHours, topUpSol }); + const signed = await wallet.signTransaction(built.tx); // the ONE popup + const started = Date.now(); // measure submit→confirmed only + const signature = await connection.sendRawTransaction(signed.serialize(), { maxRetries: 3 }); + await confirmSignature(connection, signature); + const confirmMs = Date.now() - started; + const session = persistSession({ + sessionSigner: built.sessionSigner, + sessionToken: built.sessionToken, + authority: wallet.publicKey.toBase58(), + validUntil: built.validUntil, + }); + return { session, signature, confirmMs }; +} + +/** + * Revoke the session on-chain (rent + leftover top-up refund to the wallet). + * `revoke_session_v2` needs NO wallet signature — the session signer itself + * pays the fee, so even ending a session is popup-free. Always clears local + * storage, even if the on-chain revoke fails (e.g. already expired + closed). + */ +export async function revokeSession( + session: LoadedSession, + connection: Connection, +): Promise<{ signature: string; confirmMs: number } | null> { + try { + const authority = new PublicKey(session.authority); + // Provider wallet here is only a type requirement — nothing is signed by it. + const manager = new SessionTokenManager( + { + publicKey: session.keypair.publicKey, + signTransaction: async (t: T) => t, + signAllTransactions: async (t: T[]) => t, + } as SessionWallet, + connection, + ); + const tx: Transaction = await manager.program.methods + .revokeSessionV2() + .accountsPartial({ + sessionToken: new PublicKey(session.token), + // Must match tx.feePayer below — the SESSION key pays for its own + // revocation (popup-free); a mismatch fails the signer constraint. + feePayer: session.keypair.publicKey, + authority, + }) + .transaction(); + tx.feePayer = session.keypair.publicKey; + tx.recentBlockhash = (await connection.getLatestBlockhash("confirmed")).blockhash; + tx.sign(session.keypair); + const started = Date.now(); + const signature = await connection.sendRawTransaction(tx.serialize(), { maxRetries: 3 }); + await confirmSignature(connection, signature); + clearSession(); + return { signature, confirmMs: Date.now() - started }; + // ONLY a confirmed revoke clears storage — wiping on failure would orphan + // a LIVE key with no way to retry the revoke (audit find). + } catch { + return null; // revoke failed — session stays stored so you can retry; + // worst case the token expires on its own at validUntil + } +} diff --git a/examples/predict/lib/signer.ts b/examples/predict/lib/signer.ts new file mode 100644 index 0000000..84a53a5 --- /dev/null +++ b/examples/predict/lib/signer.ts @@ -0,0 +1,40 @@ +// ───────────────────────────────────────────────────────────────────────────── +// lib/signer.ts — the trading signer: the SESSION keypair signs every tap. +// THE HARD PART: routing — trading txs sign+submit to network.erRpc and NEVER +// touch the blockhash (the API pre-signed it). Setup txs are handled by +// lib/enable.ts on the base chain behind ONE wallet approval. +// GOTCHAS.md → "Two chains, one flow" (../../GOTCHAS.md) +// ───────────────────────────────────────────────────────────────────────────── + +import { signAndSend, type NetworkConfig, type SendResult } from "flash-v2"; +import type { LoadedSession, SessionWallet } from "./session"; + +/** Everything a tap needs to know about "who signs and where it goes". */ +export interface ActiveSigner { + /** Basket owner (base58) — the connected wallet. */ + owner: string; + /** Sign + submit a TRADING tx → Ephemeral Rollup. Popup-free. */ + sendTrade(transactionBase64: string): Promise; + /** Extra request fields so the API builds for the session signer. */ + tradeFields: { signer: string; sessionToken: string }; +} + +/** + * One-Click Trading signer: the wallet approved setup ONCE (lib/enable.ts); + * every TRADE auto-signs with the ephemeral session key — `signer` + + * `sessionToken` ride along in the request so the API builds for it. + */ +export function makeSessionSigner( + wallet: SessionWallet, + session: LoadedSession, + network: NetworkConfig, +): ActiveSigner { + return { + owner: wallet.publicKey.toBase58(), + sendTrade: (tx64) => signAndSend(network.erRpc, tx64, session.keypair, { skipPreflight: true }), + tradeFields: { + signer: session.keypair.publicKey.toBase58(), + sessionToken: session.token, + }, + }; +} diff --git a/examples/predict/lib/stream.tsx b/examples/predict/lib/stream.tsx new file mode 100644 index 0000000..4662012 --- /dev/null +++ b/examples/predict/lib/stream.tsx @@ -0,0 +1,81 @@ +// ───────────────────────────────────────────────────────────────────────────── +// lib/stream.tsx — ONE owner stream for the whole app, shared via context. +// THE HARD PART: the WS caps at 5 connections per owner (429) — per-component +// subscriptions are how you hit it. One subscribeOwner lives here; everything +// reads through useStream(). flash-v2 merges basket+metrics frames and falls +// back to polling for us. GOTCHAS.md → "WS connection limits are real" · +// "The WS sends two frame types" (../../GOTCHAS.md) +// ───────────────────────────────────────────────────────────────────────────── +"use client"; + +import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { subscribeOwner, type BasketSnapshot, type OwnerStream } from "flash-v2"; +import { flash } from "./flash"; + +export type StreamStatus = "connecting" | "open" | "reconnecting" | "polling" | "closed"; + +export interface StreamCtx { + snapshot: BasketSnapshot | null; + loaded: boolean; + status: StreamStatus; + refresh(): Promise; +} + +const StreamContext = createContext({ + snapshot: null, + loaded: false, + status: "closed", + refresh: async () => {}, +}); + +export function StreamProvider({ owner, children }: { owner: string | null; children: ReactNode }) { + const [snapshot, setSnapshot] = useState(null); + const [loaded, setLoaded] = useState(false); + const [status, setStatus] = useState("closed"); + const streamRef = useRef(null); + + useEffect(() => { + setSnapshot(null); + setLoaded(false); + if (!owner) { + setStatus("closed"); + return; + } + setStatus("connecting"); + const stream = subscribeOwner({ + owner, + network: flash.network, + onUpdate: (snap) => { + setSnapshot(snap); + setLoaded(true); + }, + onStatus: (s) => setStatus(s), + }); + streamRef.current = stream; + return () => { + stream.close(); + streamRef.current = null; + }; + }, [owner]); + + const value = useMemo( + () => ({ + snapshot, + loaded, + status, + refresh: async () => { + if (!owner) return; + const snap = await flash.owner(owner); + setSnapshot(snap); + setLoaded(true); + }, + }), + [snapshot, loaded, status, owner], + ); + + return {children}; +} + +export function useStream(): StreamCtx { + return useContext(StreamContext); +} diff --git a/examples/predict/lib/use-market-rounds.ts b/examples/predict/lib/use-market-rounds.ts new file mode 100644 index 0000000..2777e99 --- /dev/null +++ b/examples/predict/lib/use-market-rounds.ts @@ -0,0 +1,191 @@ +// ───────────────────────────────────────────────────────────────────────────── +// lib/use-market-rounds.ts — the prediction-market settlement engine. +// +// Makes the "by " honest: a bet is a capped-loss perp + bundled TP, so +// it resolves ON-CHAIN by itself when the strike is hit (WIN) or it liquidates +// (LOSE). But a bet that does NEITHER by its deadline would otherwise ride forever +// (and bleed borrow fees) — so at expiry we CLOSE it at mark, exactly like the +// proven Updown settleRound (lib/components/app.tsx). Reconciliation is on OBSERVED +// state (snapshot + clock), never a bare timer; an in-flight set + "settling" +// status guard double-close. Rounds are a CACHE — chain positions are the truth. +// +// SEPARATE store namespace from the Updown app (a shared key would cross- +// contaminate reconciliation). Chain truth always wins over the cache. +// ───────────────────────────────────────────────────────────────────────────── + +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { assertNoErr, type BasketSnapshot } from "flash-v2"; +import { flash } from "./flash"; +import { computePositionView } from "./format"; +import type { ActiveSigner } from "./signer"; +import { + loadRounds, + positionFor, + reconcile, + saveRounds, + withResult, + withStatus, + type Round, +} from "./rounds"; + +/** localStorage namespace for /markets bets — distinct from the Updown app. */ +const STORE_PREFIX = "predict-markets"; +const SETTLE_RETRY_MS = 8_000; + +export interface MarketRounds { + rounds: Round[]; + /** monotonic 1s clock for countdowns + expiry. */ + now: number; + addRound: (round: Round) => void; + settleNow: (round: Round) => void; +} + +export function useMarketRounds( + owner: string | null, + snapshot: BasketSnapshot | null, + signer: ActiveSigner | null, +): MarketRounds { + const [rounds, setRounds] = useState([]); + const [now, setNow] = useState(() => Date.now()); + const loadedFor = useRef(null); + const inflight = useRef>(new Set()); + const retryAt = useRef>(new Map()); + const resolving = useRef>(new Set()); + + // Load this owner's bets; persist on every change (only after the load landed, + // so we never stomp stored rounds with the empty initial state). + useEffect(() => { + // Wallet switch: drop cross-owner in-flight / backoff / resolving state so a + // prior owner's ids can't suppress the new owner's settlement (review M3). + inflight.current.clear(); + retryAt.current.clear(); + resolving.current.clear(); + if (owner) { + // A round persisted as "settling" means a close was dispatched but the tab + // closed before it confirmed (the in-flight guard is gone). Demote it to + // "active" so reconciliation re-drives it from chain truth (review C2). + setRounds(loadRounds(owner, STORE_PREFIX).map((r) => (r.status === "settling" ? { ...r, status: "active" as const } : r))); + loadedFor.current = owner; + } else { + setRounds([]); + loadedFor.current = null; + } + }, [owner]); + + useEffect(() => { + if (owner && loadedFor.current === owner) saveRounds(owner, rounds, STORE_PREFIX); + }, [owner, rounds]); + + useEffect(() => { + const t = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(t); + }, []); + + const addRound = useCallback((round: Round) => setRounds((rs) => [round, ...rs]), []); + + // Score a position that VANISHED on-chain (its take-profit fired = WIN, or it + // liquidated = LOSE). The exact realized amount is on-chain; we infer win/lose + // from the live mark vs the strike, ONCE per round. CRITICAL: on a failed price + // fetch we DO NOT resolve (return so a later tick retries) — branding a winner a + // total loss because one price GET failed is the worst outcome (review C1/C2). + // NOTE (documented residual): a sharp reversal between the vanish and this poll + // can still misread win↔lose; the exact realized PnL is the on-chain receipt. + const resolveVanished = useCallback(async (round: Round) => { + if (resolving.current.has(round.id)) return; + resolving.current.add(round.id); + const mark = await flash.price(round.market).then((p) => p.priceUi).catch(() => null); + if (mark == null || !Number.isFinite(mark)) { + resolving.current.delete(round.id); // no guess on a bad fetch — retry next tick + return; + } + const strike = round.strike ?? round.quote.entryPrice; + const won = round.side === "LONG" ? mark >= strike * 0.999 : mark <= strike * 1.001; + const pnlUsd = won ? (round.winProfitUsd ?? 0) : -round.stakeUsd; + setRounds((rs) => withResult(rs, round.id, { pnlUsd, won, settledAt: Date.now() })); + }, []); + + // Close a still-open position at mark (expiry). Explicit FULL close ("0" — the + // 97% trap never applies); the result is the PnL the user was watching. + const settleRound = useCallback( + async (round: Round, auto = false) => { + if (inflight.current.has(round.id) || !signer) return; + if (Date.now() < (retryAt.current.get(round.id) ?? 0)) return; + inflight.current.add(round.id); + // If the position is already GONE (TP/liq fired, or a prior timed-out close + // finally landed), don't fire a close against nothing — resolve it as + // vanished. This also stops a timeout-retry from double-closing (review H2). + const metrics = snapshot ? positionFor(snapshot, round.market, round.side) : undefined; + if (!metrics) { + // Already gone — don't close nothing. LEAVE the round "active" and let + // resolveVanished record the result; if its price fetch fails it stays + // "active" so reconcile re-emits it (the retry path needs "active" — a + // pre-flip to "closed-elsewhere" would strand it resultless). + inflight.current.delete(round.id); + void resolveVanished(round); + return; + } + // F7 guard: on AUTO-settle, refuse to FULL-close a position whose on-chain + // size is far larger than THIS bet recorded — it's BLENDED with another + // position (the same market+side opened in the other app, /, on this wallet; + // Flash keeps ONE position per market+side). A full close would settle funds + // that aren't this bet's. The user's EXPLICIT manual settle is allowed + // through; auto leaves it "active" until the shared position clears. + if (auto) { + const onChainSize = Number(metrics.sizeUsdUi) || 0; + const roundSize = round.quote.sizeUsd || 0; + if (roundSize > 0 && onChainSize > roundSize * 1.5) { + inflight.current.delete(round.id); + retryAt.current.set(round.id, Date.now() + SETTLE_RETRY_MS); + return; + } + } + setRounds((rs) => withStatus(rs, round.id, "settling")); + try { + const mark = await flash.price(round.market).then((p) => p.priceUi).catch(() => null); + const view = computePositionView(metrics, mark ?? Number(metrics.entryPriceUi)); + const res = await flash.closePosition({ + marketSymbol: round.market, + side: round.side, + inputUsdUi: "0", + withdrawTokenSymbol: "USDC", + owner: signer.owner, + slippagePercentage: "1", + ...signer.tradeFields, + }); + assertNoErr("close-position", res); + if (!res.transactionBase64) throw new Error(res.err ?? "no transaction returned"); + const sent = await signer.sendTrade(res.transactionBase64); + const pnlUsd = view?.pnlUsd ?? 0; + setRounds((rs) => withResult(rs, round.id, { pnlUsd, won: pnlUsd > 0, settledAt: Date.now(), signature: sent.signature })); + } catch (e) { + retryAt.current.set(round.id, Date.now() + SETTLE_RETRY_MS); + const gone = /Position is empty/i.test(e instanceof Error ? e.message : String(e)); + // Revert to "active" in BOTH cases. If the position is gone, also score it; + // keeping it "active" (not "closed-elsewhere") means a failed score-fetch + // is re-emitted by reconcile and retried — never stranded resultless (H4). + setRounds((rs) => withStatus(rs, round.id, "active")); + if (gone) void resolveVanished(round); + } finally { + inflight.current.delete(round.id); + } + }, + [signer, snapshot, resolveVanished], + ); + + const recon = useMemo(() => reconcile(rounds, snapshot, now), [rounds, snapshot, now]); + + // Expired but still open → AUTO-settle at mark (F7-guarded against closing a + // position blended with the other app's bet). + useEffect(() => { + for (const r of recon.toSettle) void settleRound(r, true); + }, [recon, settleRound]); + + // Vanished without our close → score it (retry-safe win/lose inference). + useEffect(() => { + for (const r of recon.closedElsewhere) void resolveVanished(r); + }, [recon, resolveVanished]); + + return { rounds, now, addRound, settleNow: (r) => void settleRound(r) }; +} diff --git a/examples/predict/lib/use-markets.ts b/examples/predict/lib/use-markets.ts new file mode 100644 index 0000000..aa9c3e9 --- /dev/null +++ b/examples/predict/lib/use-markets.ts @@ -0,0 +1,82 @@ +// ───────────────────────────────────────────────────────────────────────────── +// lib/use-markets.ts — live market rows for the Discover grid. +// One bulk /v2/tokens (static per session) + a polled /v2/prices; each tradeable +// token becomes a row {symbol, category, live price} the card prices into a +// YES/NO question via lib/markets.ts. Category taxonomy mirrors the picker's +// (Pyth-ticker prefix) and is HIDDEN unless most tokens classify — a partial +// taxonomy is a silently-wrong filter. +// ───────────────────────────────────────────────────────────────────────────── + +"use client"; + +import { useEffect, useState } from "react"; +import type { TokenInfo } from "flash-v2"; +import { flash } from "./flash"; + +const CATEGORY_LABELS: Record = { + Crypto: "Crypto", Equity: "Stocks", FX: "FX", Metal: "Metals", + Commodity: "Commodities", Commodities: "Commodities", Energy: "Commodities", Rates: "Rates", +}; + +function categoryOf(pythTicker?: string | null): string | null { + if (!pythTicker) return null; + return CATEGORY_LABELS[pythTicker.split(".")[0] ?? ""] ?? null; +} + +export interface PredictMarketRow { + token: string; + category: string | null; + /** live mark price (priceUi). */ + price: number; +} + +// token list is static per session — cache the promise. +let tokensOnce: Promise | null = null; +const cachedTokens = () => (tokensOnce ??= flash.tokens().catch((e) => ((tokensOnce = null), Promise.reject(e)))); + +export function usePredictMarkets(pollMs = 5000): { + rows: PredictMarketRow[]; + categories: string[]; + loading: boolean; + error: string | null; +} { + const [rows, setRows] = useState([]); + const [categories, setCategories] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let alive = true; + let timer: ReturnType | undefined; + const tick = async () => { + try { + const [tokens, prices] = await Promise.all([cachedTokens(), flash.prices()]); + if (!alive) return; + const tradeable = tokens.filter((t) => !t.isStable); + const built = tradeable + .map((t) => ({ token: t.symbol, category: categoryOf(t.pythTicker), price: prices[t.symbol]?.priceUi ?? 0 })) + .filter((r) => r.price > 0); + setRows(built); + const classified = built.filter((r) => r.category).length; + const cats = Array.from(new Set(built.map((r) => r.category).filter(Boolean) as string[])).sort(); + // hide the category filter unless ≥60% classify (a partial taxonomy lies). + setCategories(built.length > 0 && classified / built.length >= 0.6 ? cats : []); + setError(null); + setLoading(false); + } catch (e) { + if (alive) { + setError((e as Error).message); + setLoading(false); + } + } + if (alive) timer = setTimeout(() => void tick(), pollMs); + }; + void tick(); + return () => { + alive = false; + if (timer) clearTimeout(timer); + }; + }, [pollMs]); + + return { rows, categories, loading, error }; +} diff --git a/examples/predict/lib/use-sheet-mount.ts b/examples/predict/lib/use-sheet-mount.ts new file mode 100644 index 0000000..52146df --- /dev/null +++ b/examples/predict/lib/use-sheet-mount.ts @@ -0,0 +1,26 @@ +// ───────────────────────────────────────────────────────────────────────────── +// lib/use-sheet-mount.ts — sheets exist in the DOM only while open (or for +// the 260ms exit slide). THE HARD PART: the always-mounted translate-offscreen +// pattern renders the sheet's full markup inline whenever CSS fails to load — +// seen in the wild as a token grid dumped at the page bottom. Unmounted +// markup cannot fail. GOTCHAS.md → (no API gotchas here) (../../GOTCHAS.md) +// ───────────────────────────────────────────────────────────────────────────── +"use client"; + +import { useEffect, useState } from "react"; + +export function useSheetMount(open: boolean, exitMs = 260): { mounted: boolean; closing: boolean } { + // `open || kept` mounts in the SAME render open flips true — effects that + // focus the sheet's contents (deps: [open]) find their refs populated. + const [kept, setKept] = useState(open); + useEffect(() => { + if (open) { + setKept(true); + return; + } + if (!kept) return; + const t = setTimeout(() => setKept(false), exitMs); + return () => clearTimeout(t); + }, [open, kept, exitMs]); + return { mounted: open || kept, closing: !open && kept }; +} diff --git a/examples/predict/next-env.d.ts b/examples/predict/next-env.d.ts new file mode 100644 index 0000000..830fb59 --- /dev/null +++ b/examples/predict/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/examples/predict/next.config.ts b/examples/predict/next.config.ts new file mode 100644 index 0000000..6804dcf --- /dev/null +++ b/examples/predict/next.config.ts @@ -0,0 +1,30 @@ +// ───────────────────────────────────────────────────────────────────────────── +// next.config.ts — Next.js wiring for a workspace that ships raw TypeScript. +// THE HARD PART: flash-v2's entry point IS src/index.ts (no build step), so it +// must be transpiled by the app; anchor/gum-sdk pull Node-ish deps that need +// browser fallbacks. GOTCHAS.md → "Two chains, one flow" (../../GOTCHAS.md) +// ───────────────────────────────────────────────────────────────────────────── + +import path from "node:path"; +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + reactStrictMode: true, + // The monorepo root (bun workspaces) — keeps Next from guessing wrong. + outputFileTracingRoot: path.join(__dirname, "../.."), + // flash-v2 is consumed straight from packages/flash-v2/src/*.ts — compile it. + transpilePackages: ["flash-v2"], + // Browsers auto-request /favicon.ico; we only ship app/icon.svg — redirect + // instead of 404ing the console on every load. + async redirects() { + return [{ source: "/favicon.ico", destination: "/icon.svg", permanent: true }]; + }, + webpack: (config) => { + // @coral-xyz/anchor (via @magicblock-labs/gum-sdk) probes Node built-ins; + // none are needed in the browser paths this app uses. + config.resolve.fallback = { ...config.resolve.fallback, fs: false, os: false, path: false }; + return config; + }, +}; + +export default nextConfig; diff --git a/examples/predict/package.json b/examples/predict/package.json new file mode 100644 index 0000000..db7bbf1 --- /dev/null +++ b/examples/predict/package.json @@ -0,0 +1,34 @@ +{ + "name": "predict", + "private": true, + "version": "0.1.0", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@coral-xyz/anchor": "^0.30.1", + "@magicblock-labs/gum-sdk": "^3.0.10", + "@solana/spl-token": "^0.4.14", + "@solana/wallet-adapter-base": "^0.9.27", + "@solana/wallet-adapter-phantom": "^0.9.29", + "@solana/wallet-adapter-react": "^0.15.39", + "@solana/wallet-adapter-solflare": "^0.6.28", + "@solana/web3.js": "^1.98.2", + "flash-v2": "workspace:*", + "next": "^15.5.19", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.3.0", + "@types/bn.js": "^5.1.5", + "@types/node": "^22.10.0", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "tailwindcss": "^4.3.0", + "typescript": "^5.9.0" + } +} diff --git a/examples/predict/postcss.config.mjs b/examples/predict/postcss.config.mjs new file mode 100644 index 0000000..5e461c6 --- /dev/null +++ b/examples/predict/postcss.config.mjs @@ -0,0 +1,14 @@ +// ───────────────────────────────────────────────────────────────────────────── +// postcss.config.mjs — Tailwind v4 enters through PostCSS; no tailwind.config. +// THE HARD PART: nothing — v4 reads design tokens from @theme in globals.css, +// so the entire look lives in one CSS file instead of a JS config. +// GOTCHAS.md → (no API gotchas here) (../../GOTCHAS.md) +// ───────────────────────────────────────────────────────────────────────────── + +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/examples/predict/public/token-icons/2z.png b/examples/predict/public/token-icons/2z.png new file mode 100644 index 0000000..8170361 Binary files /dev/null and b/examples/predict/public/token-icons/2z.png differ diff --git a/examples/predict/public/token-icons/NOTICE b/examples/predict/public/token-icons/NOTICE new file mode 100644 index 0000000..a98ba8c --- /dev/null +++ b/examples/predict/public/token-icons/NOTICE @@ -0,0 +1,4 @@ +Token icons in this directory are Flash Trade's production asset set +(source: Flash Trade's web client / its CDN), vendored here for +this official Flash Trade starter. Third-party token logos remain the +property of their respective projects. diff --git a/examples/predict/public/token-icons/aapl.png b/examples/predict/public/token-icons/aapl.png new file mode 100644 index 0000000..00655f3 Binary files /dev/null and b/examples/predict/public/token-icons/aapl.png differ diff --git a/examples/predict/public/token-icons/ada.png b/examples/predict/public/token-icons/ada.png new file mode 100644 index 0000000..4c8f745 Binary files /dev/null and b/examples/predict/public/token-icons/ada.png differ diff --git a/examples/predict/public/token-icons/amd.png b/examples/predict/public/token-icons/amd.png new file mode 100644 index 0000000..a8d2a56 Binary files /dev/null and b/examples/predict/public/token-icons/amd.png differ diff --git a/examples/predict/public/token-icons/amzn.png b/examples/predict/public/token-icons/amzn.png new file mode 100644 index 0000000..70933db Binary files /dev/null and b/examples/predict/public/token-icons/amzn.png differ diff --git a/examples/predict/public/token-icons/asts.svg b/examples/predict/public/token-icons/asts.svg new file mode 100644 index 0000000..0330da5 --- /dev/null +++ b/examples/predict/public/token-icons/asts.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/predict/public/token-icons/aud.png b/examples/predict/public/token-icons/aud.png new file mode 100644 index 0000000..d6433b1 Binary files /dev/null and b/examples/predict/public/token-icons/aud.png differ diff --git a/examples/predict/public/token-icons/bnb.png b/examples/predict/public/token-icons/bnb.png new file mode 100644 index 0000000..3bd17d2 Binary files /dev/null and b/examples/predict/public/token-icons/bnb.png differ diff --git a/examples/predict/public/token-icons/btc.svg b/examples/predict/public/token-icons/btc.svg new file mode 100644 index 0000000..8152b86 --- /dev/null +++ b/examples/predict/public/token-icons/btc.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/predict/public/token-icons/chip.svg b/examples/predict/public/token-icons/chip.svg new file mode 100644 index 0000000..8abd78f --- /dev/null +++ b/examples/predict/public/token-icons/chip.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/predict/public/token-icons/coin.svg b/examples/predict/public/token-icons/coin.svg new file mode 100644 index 0000000..c3b9a85 --- /dev/null +++ b/examples/predict/public/token-icons/coin.svg @@ -0,0 +1,4 @@ + + + + diff --git a/examples/predict/public/token-icons/copper.svg b/examples/predict/public/token-icons/copper.svg new file mode 100644 index 0000000..f3fedf2 --- /dev/null +++ b/examples/predict/public/token-icons/copper.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/predict/public/token-icons/crcl.svg b/examples/predict/public/token-icons/crcl.svg new file mode 100644 index 0000000..5bfef5c --- /dev/null +++ b/examples/predict/public/token-icons/crcl.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/examples/predict/public/token-icons/crudeoil.png b/examples/predict/public/token-icons/crudeoil.png new file mode 100644 index 0000000..aad1850 Binary files /dev/null and b/examples/predict/public/token-icons/crudeoil.png differ diff --git a/examples/predict/public/token-icons/eth.svg b/examples/predict/public/token-icons/eth.svg new file mode 100644 index 0000000..d246c3c --- /dev/null +++ b/examples/predict/public/token-icons/eth.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/predict/public/token-icons/eur.png b/examples/predict/public/token-icons/eur.png new file mode 100644 index 0000000..2140c62 Binary files /dev/null and b/examples/predict/public/token-icons/eur.png differ diff --git a/examples/predict/public/token-icons/flp.x.png b/examples/predict/public/token-icons/flp.x.png new file mode 100644 index 0000000..bb85db6 Binary files /dev/null and b/examples/predict/public/token-icons/flp.x.png differ diff --git a/examples/predict/public/token-icons/gbp.png b/examples/predict/public/token-icons/gbp.png new file mode 100644 index 0000000..5fe8f03 Binary files /dev/null and b/examples/predict/public/token-icons/gbp.png differ diff --git a/examples/predict/public/token-icons/hype.png b/examples/predict/public/token-icons/hype.png new file mode 100644 index 0000000..8066734 Binary files /dev/null and b/examples/predict/public/token-icons/hype.png differ diff --git a/examples/predict/public/token-icons/intc.svg b/examples/predict/public/token-icons/intc.svg new file mode 100644 index 0000000..567fd9b --- /dev/null +++ b/examples/predict/public/token-icons/intc.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/predict/public/token-icons/iwm.png b/examples/predict/public/token-icons/iwm.png new file mode 100644 index 0000000..5b38988 Binary files /dev/null and b/examples/predict/public/token-icons/iwm.png differ diff --git a/examples/predict/public/token-icons/jitosol.png b/examples/predict/public/token-icons/jitosol.png new file mode 100644 index 0000000..63e79a9 Binary files /dev/null and b/examples/predict/public/token-icons/jitosol.png differ diff --git a/examples/predict/public/token-icons/jup.png b/examples/predict/public/token-icons/jup.png new file mode 100644 index 0000000..477c220 Binary files /dev/null and b/examples/predict/public/token-icons/jup.png differ diff --git a/examples/predict/public/token-icons/kmno.png b/examples/predict/public/token-icons/kmno.png new file mode 100644 index 0000000..0d41815 Binary files /dev/null and b/examples/predict/public/token-icons/kmno.png differ diff --git a/examples/predict/public/token-icons/lit.png b/examples/predict/public/token-icons/lit.png new file mode 100644 index 0000000..e2816ef Binary files /dev/null and b/examples/predict/public/token-icons/lit.png differ diff --git a/examples/predict/public/token-icons/lly.svg b/examples/predict/public/token-icons/lly.svg new file mode 100644 index 0000000..d2dd964 --- /dev/null +++ b/examples/predict/public/token-icons/lly.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/predict/public/token-icons/mega.svg b/examples/predict/public/token-icons/mega.svg new file mode 100644 index 0000000..0b232d8 --- /dev/null +++ b/examples/predict/public/token-icons/mega.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/examples/predict/public/token-icons/mon.png b/examples/predict/public/token-icons/mon.png new file mode 100644 index 0000000..67e0b18 Binary files /dev/null and b/examples/predict/public/token-icons/mon.png differ diff --git a/examples/predict/public/token-icons/mstr.svg b/examples/predict/public/token-icons/mstr.svg new file mode 100644 index 0000000..18b97e3 --- /dev/null +++ b/examples/predict/public/token-icons/mstr.svg @@ -0,0 +1,4 @@ + + + + diff --git a/examples/predict/public/token-icons/mu.svg b/examples/predict/public/token-icons/mu.svg new file mode 100644 index 0000000..150d85f --- /dev/null +++ b/examples/predict/public/token-icons/mu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/predict/public/token-icons/natgas.svg b/examples/predict/public/token-icons/natgas.svg new file mode 100644 index 0000000..95a850c --- /dev/null +++ b/examples/predict/public/token-icons/natgas.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/examples/predict/public/token-icons/near.png b/examples/predict/public/token-icons/near.png new file mode 100644 index 0000000..61bdae9 Binary files /dev/null and b/examples/predict/public/token-icons/near.png differ diff --git a/examples/predict/public/token-icons/nvda.png b/examples/predict/public/token-icons/nvda.png new file mode 100644 index 0000000..2253634 Binary files /dev/null and b/examples/predict/public/token-icons/nvda.png differ diff --git a/examples/predict/public/token-icons/ondo.svg b/examples/predict/public/token-icons/ondo.svg new file mode 100644 index 0000000..fb6c27b --- /dev/null +++ b/examples/predict/public/token-icons/ondo.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/examples/predict/public/token-icons/ore.png b/examples/predict/public/token-icons/ore.png new file mode 100644 index 0000000..f760016 Binary files /dev/null and b/examples/predict/public/token-icons/ore.png differ diff --git a/examples/predict/public/token-icons/pump.png b/examples/predict/public/token-icons/pump.png new file mode 100644 index 0000000..32ff719 Binary files /dev/null and b/examples/predict/public/token-icons/pump.png differ diff --git a/examples/predict/public/token-icons/pyth.png b/examples/predict/public/token-icons/pyth.png new file mode 100644 index 0000000..b82d855 Binary files /dev/null and b/examples/predict/public/token-icons/pyth.png differ diff --git a/examples/predict/public/token-icons/qcom.svg b/examples/predict/public/token-icons/qcom.svg new file mode 100644 index 0000000..4214a36 --- /dev/null +++ b/examples/predict/public/token-icons/qcom.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/predict/public/token-icons/samo.png b/examples/predict/public/token-icons/samo.png new file mode 100644 index 0000000..f0f6d0c Binary files /dev/null and b/examples/predict/public/token-icons/samo.png differ diff --git a/examples/predict/public/token-icons/sflp.x.png b/examples/predict/public/token-icons/sflp.x.png new file mode 100644 index 0000000..9d9a722 Binary files /dev/null and b/examples/predict/public/token-icons/sflp.x.png differ diff --git a/examples/predict/public/token-icons/sndk.svg b/examples/predict/public/token-icons/sndk.svg new file mode 100644 index 0000000..4bb76c1 --- /dev/null +++ b/examples/predict/public/token-icons/sndk.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/examples/predict/public/token-icons/sol.svg b/examples/predict/public/token-icons/sol.svg new file mode 100644 index 0000000..f23688d --- /dev/null +++ b/examples/predict/public/token-icons/sol.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/examples/predict/public/token-icons/spr.png b/examples/predict/public/token-icons/spr.png new file mode 100644 index 0000000..21ece61 Binary files /dev/null and b/examples/predict/public/token-icons/spr.png differ diff --git a/examples/predict/public/token-icons/spy.png b/examples/predict/public/token-icons/spy.png new file mode 100644 index 0000000..21ece61 Binary files /dev/null and b/examples/predict/public/token-icons/spy.png differ diff --git a/examples/predict/public/token-icons/spyr.png b/examples/predict/public/token-icons/spyr.png new file mode 100644 index 0000000..21ece61 Binary files /dev/null and b/examples/predict/public/token-icons/spyr.png differ diff --git a/examples/predict/public/token-icons/sui.png b/examples/predict/public/token-icons/sui.png new file mode 100644 index 0000000..afe064f Binary files /dev/null and b/examples/predict/public/token-icons/sui.png differ diff --git a/examples/predict/public/token-icons/tao.svg b/examples/predict/public/token-icons/tao.svg new file mode 100644 index 0000000..cd52231 --- /dev/null +++ b/examples/predict/public/token-icons/tao.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/examples/predict/public/token-icons/ton.svg b/examples/predict/public/token-icons/ton.svg new file mode 100644 index 0000000..34fe731 --- /dev/null +++ b/examples/predict/public/token-icons/ton.svg @@ -0,0 +1,4 @@ + + + + diff --git a/examples/predict/public/token-icons/trump.png b/examples/predict/public/token-icons/trump.png new file mode 100644 index 0000000..050540b Binary files /dev/null and b/examples/predict/public/token-icons/trump.png differ diff --git a/examples/predict/public/token-icons/trx.svg b/examples/predict/public/token-icons/trx.svg new file mode 100644 index 0000000..c6f7f60 --- /dev/null +++ b/examples/predict/public/token-icons/trx.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/examples/predict/public/token-icons/tsla.png b/examples/predict/public/token-icons/tsla.png new file mode 100644 index 0000000..366c64e Binary files /dev/null and b/examples/predict/public/token-icons/tsla.png differ diff --git a/examples/predict/public/token-icons/tsm.svg b/examples/predict/public/token-icons/tsm.svg new file mode 100644 index 0000000..1b04e5d --- /dev/null +++ b/examples/predict/public/token-icons/tsm.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/predict/public/token-icons/txn.svg b/examples/predict/public/token-icons/txn.svg new file mode 100644 index 0000000..fd3f9f8 --- /dev/null +++ b/examples/predict/public/token-icons/txn.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/predict/public/token-icons/usdcnh.png b/examples/predict/public/token-icons/usdcnh.png new file mode 100644 index 0000000..14da4bf Binary files /dev/null and b/examples/predict/public/token-icons/usdcnh.png differ diff --git a/examples/predict/public/token-icons/usdjpy.png b/examples/predict/public/token-icons/usdjpy.png new file mode 100644 index 0000000..36337d6 Binary files /dev/null and b/examples/predict/public/token-icons/usdjpy.png differ diff --git a/examples/predict/public/token-icons/vvv.png b/examples/predict/public/token-icons/vvv.png new file mode 100644 index 0000000..7e9d41e Binary files /dev/null and b/examples/predict/public/token-icons/vvv.png differ diff --git a/examples/predict/public/token-icons/wif.png b/examples/predict/public/token-icons/wif.png new file mode 100644 index 0000000..cdb89fe Binary files /dev/null and b/examples/predict/public/token-icons/wif.png differ diff --git a/examples/predict/public/token-icons/wsol.png b/examples/predict/public/token-icons/wsol.png new file mode 100644 index 0000000..c316e81 Binary files /dev/null and b/examples/predict/public/token-icons/wsol.png differ diff --git a/examples/predict/public/token-icons/xag.png b/examples/predict/public/token-icons/xag.png new file mode 100644 index 0000000..caf03de Binary files /dev/null and b/examples/predict/public/token-icons/xag.png differ diff --git a/examples/predict/public/token-icons/xau.png b/examples/predict/public/token-icons/xau.png new file mode 100644 index 0000000..35bc580 Binary files /dev/null and b/examples/predict/public/token-icons/xau.png differ diff --git a/examples/predict/public/token-icons/xaut.png b/examples/predict/public/token-icons/xaut.png new file mode 100644 index 0000000..5c6ac9b Binary files /dev/null and b/examples/predict/public/token-icons/xaut.png differ diff --git a/examples/predict/public/token-icons/xpd.svg b/examples/predict/public/token-icons/xpd.svg new file mode 100644 index 0000000..917779a --- /dev/null +++ b/examples/predict/public/token-icons/xpd.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/predict/public/token-icons/xpt.svg b/examples/predict/public/token-icons/xpt.svg new file mode 100644 index 0000000..53fd094 --- /dev/null +++ b/examples/predict/public/token-icons/xpt.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/predict/public/token-icons/xrp.png b/examples/predict/public/token-icons/xrp.png new file mode 100644 index 0000000..78cdc33 Binary files /dev/null and b/examples/predict/public/token-icons/xrp.png differ diff --git a/examples/predict/public/token-icons/zec.png b/examples/predict/public/token-icons/zec.png new file mode 100644 index 0000000..0af0208 Binary files /dev/null and b/examples/predict/public/token-icons/zec.png differ diff --git a/examples/predict/tsconfig.json b/examples/predict/tsconfig.json new file mode 100644 index 0000000..792d382 --- /dev/null +++ b/examples/predict/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "ES2022"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "allowImportingTsExtensions": true, + "plugins": [{ "name": "next" }], + "paths": { "@/*": ["./*"] } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/package.json b/package.json index 2e3615b..ffb1c76 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "hello": "bun run packages/flash-v2/src/hello.ts", "lifecycle": "bun run packages/flash-v2/src/lifecycle.ts", "typecheck": "bun run --filter '*' typecheck", - "tap-trade": "bun run --cwd examples/tap-trade dev" + "tap-trade": "bun run --cwd examples/tap-trade dev", + "predict": "bun run --cwd examples/predict dev" }, "engines": { "bun": ">=1.1.0"