diff --git a/apps/examples/jstz-prediction-market/.gitignore b/apps/examples/jstz-prediction-market/.gitignore new file mode 100644 index 0000000..dc10efe --- /dev/null +++ b/apps/examples/jstz-prediction-market/.gitignore @@ -0,0 +1,44 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +src/app/api/market/index.js +src/app/api/market/market.js diff --git a/apps/examples/jstz-prediction-market/README.md b/apps/examples/jstz-prediction-market/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/apps/examples/jstz-prediction-market/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/apps/examples/jstz-prediction-market/components.json b/apps/examples/jstz-prediction-market/components.json new file mode 100644 index 0000000..5fe3556 --- /dev/null +++ b/apps/examples/jstz-prediction-market/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "../../../packages/jstz-ui/src/index.css", + "css": "src/app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "~/components", + "utils": "~/lib/utils", + "ui": "~/components/ui", + "lib": "~/lib", + "hooks": "~/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/apps/examples/jstz-prediction-market/eslint.config.mjs b/apps/examples/jstz-prediction-market/eslint.config.mjs new file mode 100644 index 0000000..ad354f4 --- /dev/null +++ b/apps/examples/jstz-prediction-market/eslint.config.mjs @@ -0,0 +1,92 @@ +import js from "@eslint/js"; +import reactQuery from "@tanstack/eslint-plugin-query"; +import { defineConfig } from "eslint/config"; + +import nextVitals from "eslint-config-next/core-web-vitals"; +import prettier from "eslint-config-prettier/flat"; +import jsdoc from "eslint-plugin-jsdoc"; +import globals from "globals"; +import tseslint from "typescript-eslint"; + +export default defineConfig( + { ignores: ["dist"] }, + { + extends: [ + js.configs.recommended, + ...tseslint.configs.strictTypeChecked, + prettier, + nextVitals, + reactQuery.configs["flat/recommended"], + ], + settings: { + react: { + version: "detect", + }, + }, + + files: ["src/**/*.{ts,tsx,js,jsx}"], + languageOptions: { + ecmaVersion: 2024, + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + ecmaFeatures: { + jsx: true, + }, + }, + globals: globals.browser, + }, + + rules: { + "func-style": ["warn", "declaration"], + + "react/function-component-definition": [ + "warn", + { + namedComponents: "function-declaration", + unnamedComponents: "arrow-function", + }, + ], + "react/jsx-boolean-value": ["warn", "never"], + "react/jsx-curly-brace-presence": ["warn", "never"], + "react/no-unstable-nested-components": ["error", { allowAsProps: true }], + "react/self-closing-comp": "error", + "react/prop-types": "off", + "@typescript-eslint/prefer-namespace-keyword": "off", + "@typescript-eslint/no-namespace": "off", + "@typescript-eslint/no-unsafe-enum-comparison": "off", + + "@typescript-eslint/no-unused-vars": "off", + "@typescript-eslint/no-confusing-void-expression": ["error", { ignoreArrowShorthand: true }], + "@typescript-eslint/no-misused-promises": [ + "error", + { + checksVoidReturn: { + arguments: false, + attributes: false, + inheritedMethods: true, + properties: true, + returns: true, + variables: true, + }, + }, + ], + "@typescript-eslint/restrict-template-expressions": [ + "error", + { + allowNumber: true, + allow: [{ name: ["Error", "URL", "URLSearchParams"], from: "lib" }], + }, + ], + }, + }, + { + extends: [jsdoc.configs["flat/recommended"]], + files: ["**/*.{js,jsx}"], + rules: { + "jsdoc/require-jsdoc": "off", + "jsdoc/require-param": "off", + "jsdoc/require-returns": "off", + }, + }, +); diff --git a/apps/examples/jstz-prediction-market/market-dapp/esbuild.config.ts b/apps/examples/jstz-prediction-market/market-dapp/esbuild.config.ts new file mode 100644 index 0000000..393d158 --- /dev/null +++ b/apps/examples/jstz-prediction-market/market-dapp/esbuild.config.ts @@ -0,0 +1,15 @@ +import { build } from "esbuild"; + +try { + await build({ + entryPoints: ["index.ts", "market.ts"], + bundle: true, + format: "esm", + target: "esnext", + // outdir: "../../polimarket/src/app/api/market", + outdir: "./dist", + }); +} catch (err) { + console.error(err); + process.exit(1); +} diff --git a/apps/examples/jstz-prediction-market/market-dapp/global.d.ts b/apps/examples/jstz-prediction-market/market-dapp/global.d.ts new file mode 100644 index 0000000..94151b2 --- /dev/null +++ b/apps/examples/jstz-prediction-market/market-dapp/global.d.ts @@ -0,0 +1,289 @@ +declare interface PairIterable { + keys(): IterableIterator; + values(): IterableIterator; + entries(): IterableIterator<[K, V]>; + [Symbol.iterator](): IterableIterator<[K, V]>; + forEach( + callback: (value: V, key: K, parent: this) => void, + thisArg?: any, + ): void; +} + +declare interface URLSearchParams extends PairIterable { + append(name: string, value: string): void; + delete(name: string, value?: string): void; + getAll(name: string): string[]; + get(name: string): string | null; + has(name: string, value?: string): boolean; + set(name: string, value: string): void; + sort(): void; + toString(): string; + size: number; +} + +declare var URLSearchParams: { + readonly prototype: URLSearchParams; + new ( + init?: [string, string][] | Record | string, + ): URLSearchParams; +}; + +declare interface URL { + hash: string; + host: string; + hostname: string; + href: string; + readonly origin: string; + password: string; + pathname: string; + port: string; + protocol: string; + search: string; + readonly searchParams: URLSearchParams; + username: string; + toString(): string; + toJSON(): string; +} + +declare var URL: { + readonly prototype: URL; + new (url: string, base?: string): URL; + canParse(url: string, base?: string): boolean; +}; + +declare interface URLPatternInit { + protocol?: string; + username?: string; + password?: string; + hostname?: string; + port?: string; + pathname?: string; + search?: string; + hash?: string; + baseURL?: string; +} + +declare type URLPatternInput = string | URLPatternInit; + +declare interface URLPatternComponentResult { + input: string; + groups: Record; +} + +declare interface URLPatternResult { + inputs: URLPatternInit[]; + protocol: URLPatternComponentResult; + username: URLPatternComponentResult; + password: URLPatternComponentResult; + hostname: URLPatternComponentResult; + port: URLPatternComponentResult; + pathname: URLPatternComponentResult; + search: URLPatternComponentResult; + hash: URLPatternComponentResult; +} + +declare interface URLPattern { + test(input?: URLPatternInput, baseURL?: string): boolean; + exec(input?: URLPatternInput, baseURL?: string): URLPatternResult | null; + readonly hash: string; + readonly hostname: string; + readonly password: string; + readonly pathname: string; + readonly port: string; + readonly protocol: string; + readonly search: string; + readonly username: string; +} + +declare var URLPattern: { + readonly prototype: URLPattern; + new (input?: URLPatternInput, baseURL?: string): URLPattern; +}; + +declare type BufferSource = ArrayBufferView | ArrayBuffer; + +declare type BodyInit = string | BufferSource; + +declare interface Body { + readonly bodyUsed: boolean; + arrayBuffer(): Promise; + json(): Promise; + text(): Promise; +} + +declare type HeadersInit = + | [string, string][] + | Record + | Headers; + +declare interface Headers extends PairIterable { + append(name: string, value: string): void; + delete(name: string): void; + get(name: string): string | null; + has(name: string): boolean; + set(name: string, value: string): void; + getSetCookie(): string[]; +} + +declare var Headers: { + readonly prototype: Headers; + new (init?: HeadersInit): Headers; +}; + +declare type RequestInfo = Request | string; + +declare interface RequestInit { + body?: BodyInit | null; + headers?: HeadersInit; + method?: string; +} + +declare interface Request extends Body { + readonly headers: Headers; + readonly method: string; + readonly url: string; +} + +declare var Request: { + readonly prototype: Request; + new (input: RequestInfo, init?: RequestInit): Request; +}; + +declare interface ResponseInit { + headers?: HeadersInit; + status?: number; +} + +declare interface Response extends Body { + readonly headers: Headers; + readonly ok: boolean; + readonly status: number; + readonly statusText: string; + readonly url: string; +} + +declare var Response: { + readonly prototype: Response; + new (body?: BodyInit | null, init?: ResponseInit): Response; + json(data: unknown): Response; + error(): Response; +}; + +declare interface Console { + log(...data: any[]): void; + error(...data: any[]): void; + debug(...data: any[]): void; + warn(...data: any[]): void; + info(...data: any[]): void; + assert(condition?: boolean, ...data: any[]): void; + group(...data: any[]): void; + groupCollapsed(...data: any[]): void; + groupEnd(): void; + clear(): void; +} + +declare var console: Console; + +declare type Address = string; + +declare interface Kv { + get(key: string): T | null; + set(key: string, value: unknown): void; + delete(key: string): void; + has(key: string): boolean; +} + +declare var Kv: Kv; + +declare type Mutez = number; + +declare interface Ledger { + readonly selfAddress: Address; + balance(address: Address): Mutez; + transfer(address: Address, amount: Mutez): void; +} + +declare var Ledger: Ledger; + +declare interface SmartFunction { + create(code: String): Promise
; + call(request: Request): Promise; +} + +declare var SmartFunction: SmartFunction; + +declare function fetch(request: Request): Promise; + +declare function atob(s: string): string; +declare function btoa(s: string): string; + +declare interface TextDecoderOptions { + fatal?: boolean; + ignoreBOM?: boolean; +} + +declare interface TextDecodeOptions { + stream?: boolean; +} + +declare interface TextDecoder { + readonly encoding: string; + readonly fatal: boolean; + readonly ignoreBOM: boolean; + decode(input?: BufferSource, options?: TextDecodeOptions): string; +} + +declare var TextDecoder: { + readonly prototype: TextDecoder; + new (label?: string, options?: TextDecoderOptions): TextDecoder; +}; + +declare interface TextEncoderEncodeIntoResult { + read: number; + written: number; +} + +declare interface TextEncoder { + readonly encoding: "utf-8"; + encode(input?: string): Uint8Array; + encodeInto(input: string, dest: Uint8Array): TextEncoderEncodeIntoResult; +} + +declare var TextEncoder: { + readonly prototype: TextEncoder; + new (): TextEncoder; +}; + +declare type BlobPart = BufferSource | Blob | string; + +declare interface BlobPropertyBag { + type?: string; + endings?: "transparent" | "native"; +} + +declare interface Blob { + readonly size: number; + readonly type: string; + arrayBuffer(): Promise; + slice(start?: number, end?: number, contentType?: string): Blob; + text(): Promise; +} + +declare var Blob: { + readonly prototype: Blob; + new (blobParts?: BlobPart[], options?: BlobPropertyBag): Blob; +}; + +declare interface FilePropertyBag extends BlobPropertyBag { + lastModified?: number; +} + +declare interface File extends Blob { + readonly lastModified: number; + readonly name: string; +} + +declare var File: { + readonly prototype: File; + new (fileBits: BlobPart[], fileName: string, options?: FilePropertyBag): File; +}; diff --git a/apps/examples/jstz-prediction-market/market-dapp/index.ts b/apps/examples/jstz-prediction-market/market-dapp/index.ts new file mode 100644 index 0000000..d71eab6 --- /dev/null +++ b/apps/examples/jstz-prediction-market/market-dapp/index.ts @@ -0,0 +1,189 @@ +import { AutoRouter, json } from "itty-router"; +import { z } from "zod"; + +const ONE_TEZ = 1_000_000; +const SUPER_ADMIN = "tz1ZXxxkNYSUutm5VZvfudakRxx2mjpWko4J"; +const KV_ROOT = "root"; +const REFERER_HEADER = "Referer"; + +// Schemas +const addressSchema = z.string().length(36); + +const tokenSchema = z.object({ + token: z.enum(["yes", "no"]), + amount: z.number().min(1), + price: z.number().min(1).max(ONE_TEZ), +}); + +const marketFormSchema = z.object({ + question: z.string(), + resolutionDate: z.iso.datetime(), + resolutionUrl: z.string().nullish(), + tokens: z.array(tokenSchema), + pool: z.number().min(0), +}); + +const marketSchema = marketFormSchema.extend({}); + +const betFormSchema = tokenSchema.extend({}); + +function successResponse(message: any, status = 200) { + return json({ ...message, status }, { status }); +} + +function errorResponse(message: any, status = 400) { + return successResponse({ message }, status); +} + +// Routes +const router = AutoRouter(); +router.post( + "/market", + // withAdmin( + async (request) => { + try { + const body = await request.json(); + const { success, error, data } = marketFormSchema.safeParse(body); + if (!success) return errorResponse(error.message); + + const referer = request.headers.get(REFERER_HEADER); + if (!referer) return errorResponse("Referer address not found"); + + const response = await fetch( + "https://glenda-belonoid-unvirtuously.ngrok-free.dev/api/market", + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + ...data, + master: Ledger.selfAddress, + admins: [referer, SUPER_ADMIN], + }), + }, + ); + + // TODO: add API error handling + if (!response.ok) { + console.log(response); + return errorResponse("Error creating the market."); + } + + const { address } = await response.json(); + + dispatch({ + type: "add-market", + address, + resolutionDate: data.resolutionDate, + referer, + }); + + return json({ address }, { status: 200 }); + } catch (err) { + if (err instanceof Error) { + return errorResponse(`Error: ${err.message}`); + } + return errorResponse(`Error: ${err}`); + } + }, + // ), +); + +router.post("/market/:address/resolve", (request) => { + const { address } = request.params; + return fetch( + new Request(`jstz://${address}/resolve`, { + method: "POST", + body: JSON.stringify(request.json()), + }), + ); +}); + +router.get("/market/:address/payout", (request) => { + const { address } = request.params; + return fetch(new Request(`jstz://${address}/payout`)); +}); + +const handler = (request: Request): Promise => router.fetch(request); + +// KV +const addAdminActionSchema = z.object({ + type: z.literal("add-admin"), + address: z.string().length(36), +}); + +const addMarketActionSchema = z.object({ + type: z.literal("add-market"), + resolutionDate: marketFormSchema.shape.resolutionDate, + address: addressSchema, + referer: addressSchema, +}); + +type AddAdminAction = z.infer; +type AddMarketAction = z.infer; + +type KvAction = AddAdminAction | AddMarketAction; + +const kvSchema = z.object({ + admins: z.array(addAdminActionSchema.shape.address).optional(), + markets: z + .record( + addMarketActionSchema.shape.address, + addMarketActionSchema.pick({ + address: true, + resolutionDate: true, + }), + ) + .optional(), +}); + +type KvState = z.infer; + +function getState(): KvState { + return JSON.parse(Kv.get(KV_ROOT) ?? "{}"); +} + +function dispatch(action: KvAction) { + const state = getState(); + Kv.set(KV_ROOT, JSON.stringify(reducer(state, action))); +} + +function reducer(state: Record, action: KvAction): KvState { + const newState = { ...state }; + switch (action.type) { + case "add-admin": + if (!newState.admins) newState.admins = []; + if (!newState.admins.includes(action.address)) { + newState.admins.push(action.address); + } + break; + case "add-market": + if (!newState.markets) newState.markets = {}; + newState.markets[action.address] = { + address: action.address, + resolutionDate: action.resolutionDate, + referer: action.referer, + }; + break; + } + return newState; +} + +// utils + +function isAdmin(address: string) { + return address === SUPER_ADMIN || getState().admins?.includes(address); +} + +function withAdmin(handler: (request: Request) => Promise) { + return async (request: Request) => { + const requester = request.headers.get("Referer") as Address; + if (!isAdmin(requester)) { + return errorResponse("You don't have authoritah"); + } + return handler(request); + }; +} + +export default handler; diff --git a/apps/examples/jstz-prediction-market/market-dapp/market.ts b/apps/examples/jstz-prediction-market/market-dapp/market.ts new file mode 100644 index 0000000..a099993 --- /dev/null +++ b/apps/examples/jstz-prediction-market/market-dapp/market.ts @@ -0,0 +1,288 @@ +import { AutoRouter, json } from "itty-router"; +import { z } from "zod"; + +const ONE_TEZ = 1_000_000; +const KV_ROOT = "root"; +const REFERER_HEADER = "Referer"; +const TRANSFER_HEADER = "X-JSTZ-TRANSFER"; +const AMOUNT_HEADER = "X-JSTZ-AMOUNT"; + +// Schemas +const tokenSchema = z.object({ + token: z.enum(["yes", "no"]), + amount: z.number().min(1), + price: z.number().min(1).max(ONE_TEZ), +}); + +const marketFormSchema = z.object({ + admins: z.array(z.string()).min(1), + master: z.string().length(36), // parent SF address + question: z.string(), + resolutionDate: z.iso.datetime(), + resolutionUrl: z.string().nullish(), + tokens: z.array(tokenSchema), +}); + +const marketSchema = marketFormSchema.extend({ + state: z.enum(["created", "on-going", "resolved"]), + resolvedToken: tokenSchema, +}); + +const betFormSchema = tokenSchema.pick({ token: true }); + +const resolutionFormSchema = tokenSchema.pick({ token: true }); + +interface ResponseMessage extends Record { + message: string; +} + +function successResponse(message: ResponseMessage | string, options?: ResponseInit) { + const payload = typeof message === "string" ? { message } : message; + + const { status = 200, ...rest } = options ?? {}; + return json({ ...payload, status }, { status, ...rest }); +} + +function errorResponse(message: ResponseMessage | string, options: ResponseInit = {}) { + return successResponse(message, { ...options, status: options.status ?? 400 }); +} + +const marketRouter = AutoRouter(); + +marketRouter.post( + "/init", + withParseBody(async (_, body) => { + const { state } = getState(); + if (!!state && state !== "created") return errorResponse("Market already initialized"); + const { success, error, data } = marketFormSchema.safeParse(body); + + if (!success) return errorResponse(error.message); + + dispatch({ + type: "init", + ...data, + }); + return successResponse("Market created"); + }), +); +marketRouter.post( + "/bet", + withParseBody(async (request, body) => { + const { state } = getState(); + if (!state || state === "created") return errorResponse("Market is not initialized yet"); + if (!!state && state !== "on-going") return errorResponse("Market is closed for betting"); + // TODO: bet amount is in XTZ and transferred via "Referrer" header + // add an approximate estimation of tokens on the FE basing on the current token price from Indexer + const { success, error, data } = betFormSchema.safeParse(body); + if (!success) return errorResponse(error.message); + + const referer = request.headers.get(REFERER_HEADER); + if (!referer) return errorResponse("Referer address not found"); + + const receivedMutez = request.headers.get(AMOUNT_HEADER); + + const tokenState = getTokenState(data.token); + + const mutez = Number(receivedMutez); + + if (!receivedMutez || isNaN(mutez) || mutez < tokenState.price) + return errorResponse("Not enough tez for to make a bet"); + + const amount = Math.floor(mutez / tokenState.price); + + dispatch({ + type: "bet", + isSynthetic: false, + referer, + token: data.token, + amount, + price: tokenState.price, + }); + + const headers: ResponseInit["headers"] = {}; + const change = mutez - amount * tokenState.price; + + if (change > 0) { + headers[TRANSFER_HEADER] = change.toString(); + } + + return successResponse("Bet placed", { headers }); + }), +); +marketRouter.post( + "/resolve", + withParseBody(async (_, body) => { + const { state } = getState(); + if (!state || state === "created") return errorResponse("Market is not initialized yet"); + if (state === "resolved") return errorResponse("Market already resolved"); + + const { success, error, data } = resolutionFormSchema.safeParse(body); + if (!success) return errorResponse(error.message); + + dispatch({ + type: "resolve", + token: data.token, + }); + + return successResponse("Market resolved"); + }), +); + +marketRouter.get("/payout", async () => { + const { state, bets = [], resolvedToken } = getState(); + if (!state || state === "created") return errorResponse("Market is not initialized yet"); + if (state !== "resolved" || !resolvedToken) return errorResponse("Market is not resolved"); + + const payoutReqs = []; + const payouts = []; + for (let i = 0; i < bets.length; i++) { + const bet = bets[i]; + if (!bet.isSynthetic && bet.token === resolvedToken.token) { + const transferAmount = bet.amount * resolvedToken.price; + + payouts.push({ transferAmount, bet }); + await fetch( + new Request(`jstz://${bet.referer}/`, { + headers: { + [TRANSFER_HEADER]: transferAmount.toString(), + }, + }), + ); + } + } + + return successResponse({ message: "Payout successful", payouts }); +}); + +// KV +const initActionSchema = marketFormSchema.extend({ + type: z.literal("init"), +}); +const betActionSchema = tokenSchema.extend({ + type: z.literal("bet"), + referer: z.string().length(36), + price: z.number().min(1).max(ONE_TEZ), + isSynthetic: z.boolean(), +}); +const resolveActionSchema = marketSchema.shape.resolvedToken.pick({ token: true }).extend({ + type: z.literal("resolve"), +}); + +type InitAction = z.infer; +type BetAction = z.infer; +type ResolveAction = z.infer; + +type KvAction = InitAction | BetAction | ResolveAction; + +const kvSchema = marketSchema.extend({ + bets: z.array(betActionSchema.omit({ type: true })).optional(), +}); + +type KvState = z.infer; + +function getState(): KvState { + return JSON.parse(Kv.get(KV_ROOT) ?? "{}"); +} + +function getTokenState(token: string): KvState["tokens"][number] { + const { tokens = [] } = getState(); + const tokenState = tokens.find((t) => t.token === token); + + if (!tokenState) throw errorResponse("Token not found"); + + return tokenState; +} + +function dispatch(action: KvAction) { + const state = getState(); + Kv.set(KV_ROOT, JSON.stringify(reducer(state, action))); +} + +function reducer(state: KvState, action: KvAction): KvState { + const newState = { ...state }; + switch (action.type) { + case "init": + newState.state = "on-going"; + newState.admins = action.admins; + newState.question = action.question; + newState.resolutionDate = action.resolutionDate; + newState.resolutionUrl = action.resolutionUrl; + newState.tokens = action.tokens; + newState.bets = []; + action.tokens.forEach((token) => + newState.bets!.push({ + referer: action.admins[0], + token: token.token, + amount: token.amount, + price: token.price, + isSynthetic: true, + }), + ); + break; + case "bet": + if (!newState.tokens || !newState.bets) throw errorResponse("Market in not initialized"); + + const token = newState.tokens.find((t) => t.token === action.token); + if (!token) throw errorResponse("Token not found"); + + // update purchased token's amount + token!.amount += action.amount; + + newState.bets.push({ + referer: action.referer, + token: action.token, + amount: action.amount, + price: action.price, + isSynthetic: false, + }); + // update tokens prices + const sumOfTokens = newState.tokens.reduce((acc, token) => acc + token.amount, 0); + newState.tokens.forEach((token) => { + token.price = Math.floor((token.amount / sumOfTokens) * ONE_TEZ); + }); + break; + + case "resolve": { + if (!newState.tokens || !newState.bets) throw errorResponse("Market in not initialized"); + const token = newState.tokens.find((t) => t.token === action.token); + if (!token) throw errorResponse("Token not found"); + + const balance = Ledger.balance(Ledger.selfAddress); + + const syntheticTokensAmount = newState.bets.reduce((acc, token) => { + if (token.isSynthetic) { + return acc + token.amount; + } + return acc; + }, 0); + + const resolvedTokenPrice = Math.floor(balance / (token.amount - syntheticTokensAmount)); + + newState.resolvedToken = { + token: action.token, + amount: token.amount - syntheticTokensAmount, + price: resolvedTokenPrice, + }; + + newState.state = "resolved"; + } + } + return newState; +} + +function withParseBody(handler: (request: Request, body: unknown) => Promise) { + return async (request: Request) => { + try { + const body = await request.json(); + return handler(request, body); + } catch (err) { + if (err instanceof Error) { + return errorResponse(`Error: ${err.message}`); + } + return errorResponse(`Error: ${err}`); + } + }; +} + +const marketHandler = (request: Request): Promise => marketRouter.fetch(request); +export default marketHandler; diff --git a/apps/examples/jstz-prediction-market/market-dapp/package.json b/apps/examples/jstz-prediction-market/market-dapp/package.json new file mode 100644 index 0000000..21d4961 --- /dev/null +++ b/apps/examples/jstz-prediction-market/market-dapp/package.json @@ -0,0 +1,20 @@ +{ + "name": "jstz-dex", + "version": "1.0.0", + "main": "index.ts", + "type": "module", + "private": true, + "dependencies": { + "@jstz-dev/jstz": "^0.0.0", + "itty-router": "^5.0.18", + "zod": "^4" + }, + "devDependencies": { + "@types/node": "^24.10.1", + "esbuild": "0.25.0", + "tsx": "^4.21.0" + }, + "scripts": { + "build": "tsx esbuild.config.ts" + } +} diff --git a/apps/examples/jstz-prediction-market/market-dapp/tsconfig.json b/apps/examples/jstz-prediction-market/market-dapp/tsconfig.json new file mode 100644 index 0000000..1b89160 --- /dev/null +++ b/apps/examples/jstz-prediction-market/market-dapp/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "target": "ES6", + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "types": ["@jstz-dev/types"], + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/apps/examples/jstz-prediction-market/next.config.ts b/apps/examples/jstz-prediction-market/next.config.ts new file mode 100644 index 0000000..4930589 --- /dev/null +++ b/apps/examples/jstz-prediction-market/next.config.ts @@ -0,0 +1,14 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + /* config options here */ + reactCompiler: true, + + transpilePackages: ["jstz-ui"], + + experimental: { + turbopackFileSystemCacheForDev: true, + }, +}; + +export default nextConfig; diff --git a/apps/examples/jstz-prediction-market/package.json b/apps/examples/jstz-prediction-market/package.json new file mode 100644 index 0000000..52a6173 --- /dev/null +++ b/apps/examples/jstz-prediction-market/package.json @@ -0,0 +1,53 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "jstz-prediction-market", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "bun run --bun next build", + "start": "bun run --bun next start", + "lint": "eslint" + }, + "dependencies": { + "@better-fetch/fetch": "^1.1.18", + "@jstz-dev/jstz-client": "0.1.1-alpha.5", + "@jstz-dev/jstz_sdk": "0.1.1-alpha.5", + "@lukemorales/query-key-factory": "^1.3.4", + "@radix-ui/react-slot": "^1.2.4", + "@t3-oss/env-nextjs": "^0.13.8", + "@tanstack/react-form": "^1.27.0", + "@tanstack/react-query": "^5.90.11", + "@tanstack/react-query-devtools": "^5.91.1", + "@uidotdev/usehooks": "^2.4.1", + "cva": "1.0.0-beta.4", + "jstz-ui": "workspace:*", + "lucide-react": "^0.555.0", + "next": "16.0.10", + "next-themes": "^0.4.6", + "nuqs": "^2.8.0", + "react": "19.2.1", + "react-dom": "19.2.1", + "superjson": "^2.2.6", + "zod": "^4.1.13" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@tailwindcss/postcss": "^4", + "@tanstack/eslint-plugin-query": "^5.91.2", + "@total-typescript/ts-reset": "^0.6.1", + "@types/bun": "^1.3.4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "babel-plugin-react-compiler": "1.0.0", + "eslint": "^9", + "eslint-config-next": "16.0.7", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-jsdoc": "^61.4.1", + "globals": "^16.5.0", + "tailwindcss": "^4", + "typescript": "^5", + "typescript-eslint": "^8.48.1" + } +} diff --git a/apps/examples/jstz-prediction-market/postcss.config.mjs b/apps/examples/jstz-prediction-market/postcss.config.mjs new file mode 100644 index 0000000..c2ddf74 --- /dev/null +++ b/apps/examples/jstz-prediction-market/postcss.config.mjs @@ -0,0 +1,5 @@ +export default { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; diff --git a/apps/examples/jstz-prediction-market/public/file.svg b/apps/examples/jstz-prediction-market/public/file.svg new file mode 100644 index 0000000..004145c --- /dev/null +++ b/apps/examples/jstz-prediction-market/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/examples/jstz-prediction-market/public/globe.svg b/apps/examples/jstz-prediction-market/public/globe.svg new file mode 100644 index 0000000..567f17b --- /dev/null +++ b/apps/examples/jstz-prediction-market/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/examples/jstz-prediction-market/public/next.svg b/apps/examples/jstz-prediction-market/public/next.svg new file mode 100644 index 0000000..5174b28 --- /dev/null +++ b/apps/examples/jstz-prediction-market/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/examples/jstz-prediction-market/public/vercel.svg b/apps/examples/jstz-prediction-market/public/vercel.svg new file mode 100644 index 0000000..7705396 --- /dev/null +++ b/apps/examples/jstz-prediction-market/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/examples/jstz-prediction-market/public/window.svg b/apps/examples/jstz-prediction-market/public/window.svg new file mode 100644 index 0000000..b2b2a44 --- /dev/null +++ b/apps/examples/jstz-prediction-market/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/examples/jstz-prediction-market/src/app/(sidebar-layout)/deploy/loading.tsx b/apps/examples/jstz-prediction-market/src/app/(sidebar-layout)/deploy/loading.tsx new file mode 100644 index 0000000..632515b --- /dev/null +++ b/apps/examples/jstz-prediction-market/src/app/(sidebar-layout)/deploy/loading.tsx @@ -0,0 +1,3 @@ +export default function DeployLoading() { + return null; +} diff --git a/apps/examples/jstz-prediction-market/src/app/(sidebar-layout)/deploy/page.tsx b/apps/examples/jstz-prediction-market/src/app/(sidebar-layout)/deploy/page.tsx new file mode 100644 index 0000000..1c3713d --- /dev/null +++ b/apps/examples/jstz-prediction-market/src/app/(sidebar-layout)/deploy/page.tsx @@ -0,0 +1,242 @@ +"use client"; + +import { useMutation } from "@tanstack/react-query"; +import { useIsClient } from "@uidotdev/usehooks"; +import { Alert, AlertDescription, AlertTitle } from "jstz-ui/ui/alert"; +import { Button } from "jstz-ui/ui/button"; +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "jstz-ui/ui/card"; +import { Input } from "jstz-ui/ui/input"; +import { Spinner } from "jstz-ui/ui/spinner"; +import { TriangleAlert } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { FormEvent, Suspense, use, useEffect, useEffectEvent, useState } from "react"; +import { z } from "zod/mini"; +import { useAppForm } from "~/components/ui/form"; +import { env } from "~/env"; +import { textDecode, textEncode } from "~/lib/encoder"; +import { useJstzSignerExtension } from "~/lib/hooks/useJstzSigner"; +import { SignWithJstzSignerParams } from "~/lib/jstz-signer.service"; +import { MarketForm, marketFormSchema } from "~/lib/validators/market"; +import { Token } from "~/lib/validators/token"; +import { useJstzClient } from "~/providers/jstz-client.context"; + +export const responseSchema = z.union([ + z.object({ + address: z.string(), + }), + z.object({ + message: z.string(), + }), +]); + +export default function DeployPage() { + const { signWithJstzExtension } = useJstzSignerExtension(); + const { getJstzClient } = useJstzClient(); + const router = useRouter(); + + const { mutateAsync: deployMarket } = useMutation({ + mutationFn: async (market: MarketForm) => { + const payload: SignWithJstzSignerParams = { + content: { + _type: "RunFunction", + uri: `jstz://${env.NEXT_PUBLIC_PARENT_SF_ADDRESS}/market`, + headers: {}, + method: "POST", + body: textEncode(market), + gasLimit: 55_000, + }, + }; + + const { operation, signature, verifier } = await signWithJstzExtension(payload); + const jstzClient = getJstzClient(); + + const { + result: { inner }, + } = await jstzClient.operations.injectAndPoll( + { + inner: operation, + signature, + verifier: verifier ?? null, + }, + { + timeout: 100 * 1_000, + }, + ); + + try { + if (typeof inner !== "string" && inner._type === "RunFunction") { + const { data: response, error } = responseSchema.safeParse(textDecode(inner.body)); + + if (error) { + console.error("Invalid response was given."); + return; + } else if ("message" in response) { + console.info(`Completed call. Response: ${response.message}`); + return; + } + + router.push(`/markets/${response.address}`); + return; + } + } catch (e) { + console.info(`Completed call. Couldn't parse it to JSON.`); + console.dir(textDecode(inner.body)); + } + }, + }); + + const form = useAppForm({ + defaultValues: { + admins: [] as string[], + question: "Will Jstz be released to the mainnet by the end of Q2 2026?", + resolutionDate: new Date().toISOString(), + pool: 0, + tokens: [ + { + token: "yes" as Token["token"], + amount: 500, + price: 500_000, + }, + { + token: "no" as Token["token"], + amount: 500, + price: 500_000, + }, + ], + }, + + validators: { + onSubmit: marketFormSchema, + }, + + onSubmit: async ({ value }) => { + await deployMarket(value); + }, + }); + + function onSubmit(e: FormEvent) { + e.preventDefault(); + e.stopPropagation(); + void form.handleSubmit(); + } + + return ( +
+
+ + + Deploy Page + + +
+ + + {(field) => ( + + Question + + + field.handleChange(e.target.value)} + onBlur={field.handleBlur} + /> + + + + + )} + + + + {(field) => ( + + Resolution Date + + + { + if (e.target.valueAsDate) { + field.handleChange(e.target.valueAsDate.toISOString()); + } + }} + onBlur={field.handleBlur} + /> + + + )} + + + + + [state.canSubmit, state.isSubmitting]}> + {([canSubmit, isSubmitting]) => ( + + Deploy + + } + > + + + )} + + +
+
+
+
+
+ ); +} + +function DeployButton({ canSubmit, isSubmitting }: { canSubmit: boolean; isSubmitting: boolean }) { + const isClient = useIsClient(); + + const { checkExtensionAvailability } = useJstzSignerExtension(); + const [availabilityPromise, setAvailabilityPromise] = useState>( + Promise.resolve(false), + ); + + const onRender = useEffectEvent(() => { + setAvailabilityPromise(checkExtensionAvailability()); + }); + + useEffect(() => { + onRender(); + }, []); + + const isSignerAvailable = use(availabilityPromise); + + return ( + <> + + + {isClient && !isSignerAvailable && ( + + + + Extension is unavailable. + + + To use this form you need to sign the operation with a dev-wallet. + + + )} + + ); +} diff --git a/apps/examples/jstz-prediction-market/src/app/(sidebar-layout)/layout.tsx b/apps/examples/jstz-prediction-market/src/app/(sidebar-layout)/layout.tsx new file mode 100644 index 0000000..e4d5903 --- /dev/null +++ b/apps/examples/jstz-prediction-market/src/app/(sidebar-layout)/layout.tsx @@ -0,0 +1,10 @@ +import NavigationBar from "~/components/navigation-bar"; + +export default function SidebarLayout({ children }: LayoutProps<"/">) { + return ( +
+ + {children} +
+ ); +} diff --git a/apps/examples/jstz-prediction-market/src/app/(sidebar-layout)/markets/[address]/loading.tsx b/apps/examples/jstz-prediction-market/src/app/(sidebar-layout)/markets/[address]/loading.tsx new file mode 100644 index 0000000..636a66c --- /dev/null +++ b/apps/examples/jstz-prediction-market/src/app/(sidebar-layout)/markets/[address]/loading.tsx @@ -0,0 +1,161 @@ +import { Button } from "jstz-ui/ui/button"; +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "jstz-ui/ui/card"; +import { Separator } from "jstz-ui/ui/separator"; +import { Skeleton } from "jstz-ui/ui/skeleton"; +import { ArrowLeft } from "lucide-react"; +import Link from "next/link"; + +export default function MarketPageLoading() { + return ( +
+
+ {/* Back Button */} + + + + + {/* Market Details */} +
+
+ {/* Market Info Card */} + + +
+ + +
+ + +
Current odds
+
+
+ + + + +
+ + {/* Probability Bar */} + +
+ + YES - + + + + NO - + +
+ + +
+ + + + +
+
Total Volume
+ + +
+ +
+
End Date
+ + +
+ +
+
Status
+ + +
+
+
+
+ + +
+
+
+ ); +} + +function BettingPanelLoading() { + return ( + + +
+
+ + + +
+ + +
+ + + + +
+ + {/* Betting Side Selection */} + +
+ +
+ + + +
+
+ + {/* Bet Amount Slider */} +
+
+ + +
+ + + +
+ + +
+
+ + {/* Potential Returns */} + + + {/* Action Buttons */} +
+ +
+
+ + + + {/* Footer Info */} + + + + + + + +
+ ); +} diff --git a/apps/examples/jstz-prediction-market/src/app/(sidebar-layout)/markets/[address]/page.tsx b/apps/examples/jstz-prediction-market/src/app/(sidebar-layout)/markets/[address]/page.tsx new file mode 100644 index 0000000..deda718 --- /dev/null +++ b/apps/examples/jstz-prediction-market/src/app/(sidebar-layout)/markets/[address]/page.tsx @@ -0,0 +1,149 @@ +"use client"; + +import { useSuspenseQuery } from "@tanstack/react-query"; +import { Button } from "jstz-ui/ui/button"; +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "jstz-ui/ui/card"; +import { Progress } from "jstz-ui/ui/progress"; +import { Separator } from "jstz-ui/ui/separator"; +import { ArrowLeft } from "lucide-react"; +import Link from "next/link"; +import { redirect, useParams } from "next/navigation"; +import { BettingPanel } from "~/components/betting-panel"; +import * as CurrencyConverter from "~/lib/currencyConverter"; +import { createJstzClient } from "~/lib/jstz-signer.service"; +import { marketSchema } from "~/lib/validators/market"; +import { accounts } from "~/queries/account.queries"; +import { smartFunctions } from "~/queries/smartFunctions.queries"; + +type Params = Awaited["params"]>; + +export default function MarketPage() { + const { address } = useParams(); + + const jstzClient = createJstzClient(); + + const { data: kv } = useSuspenseQuery(smartFunctions.getKv(address, "root", jstzClient)); + + const { data: balance } = useSuspenseQuery(accounts.balance(address)); + + const { data: market, error } = marketSchema.safeParse(JSON.parse(kv)); + + if (error) { + console.error(error); + return redirect("/404"); + } + + const [yesCount, noCount] = market.bets.reduce( + (acc, bet) => { + switch (bet.token) { + case "yes": + acc[0] += bet.amount; + break; + case "no": + acc[1] += bet.amount; + break; + } + + return acc; + }, + [0, 0], + ); + + const numberOfTokens = market.tokens.reduce((acc, token) => acc + token.amount, 0); + + const yesRatio = (yesCount / numberOfTokens) * 100; + const noRatio = (noCount / numberOfTokens) * 100; + + return ( +
+
+ {/* Back Button */} + + + + + {/* Market Details */} +
+
+ {/* Market Info Card */} + + +
+
+ XTZ +
+ +
+
+ {Number.isNaN(yesRatio) ? "No bets yet." : `${yesRatio.toFixed(2)}%`} +
+
Current odds
+
+
+ + {market.question} +
+ + {/* Probability Bar */} + +
+ + YES - {yesRatio.toFixed(2)} + {!Number.isNaN(yesRatio) && "%"} + + + + NO - {noRatio.toFixed(2)} + {!Number.isNaN(noRatio) && "%"} + +
+ + +
+ + + + +
+
Total Volume
+ +
+ {CurrencyConverter.toTez(balance)} +
+
+ +
+
End Date
+ +
+ {market.resolutionDate.split("T")[0]} +
+
+ +
+
Status
+ +
+ {market.state === "on-going" ? "Active" : "Resolved"} +
+
+
+
+
+ + +
+
+
+ ); +} diff --git a/apps/examples/jstz-prediction-market/src/app/(sidebar-layout)/markets/loading.tsx b/apps/examples/jstz-prediction-market/src/app/(sidebar-layout)/markets/loading.tsx new file mode 100644 index 0000000..cc04dc1 --- /dev/null +++ b/apps/examples/jstz-prediction-market/src/app/(sidebar-layout)/markets/loading.tsx @@ -0,0 +1,26 @@ +import { Skeleton } from "jstz-ui/ui/skeleton"; + +export default function MarketsPageLoading() { + const skeletonCards = Array.from(Array(9).keys()); + + return ( +
+
+ {/* Header */} +
+

Markets

+ +

+ Show you're an expert. Trade on the outcomes of future events. +

+
+ +
+ {skeletonCards.map((idx) => ( + + ))} +
+
+
+ ); +} diff --git a/apps/examples/jstz-prediction-market/src/app/(sidebar-layout)/markets/page.tsx b/apps/examples/jstz-prediction-market/src/app/(sidebar-layout)/markets/page.tsx new file mode 100644 index 0000000..7d56431 --- /dev/null +++ b/apps/examples/jstz-prediction-market/src/app/(sidebar-layout)/markets/page.tsx @@ -0,0 +1,90 @@ +import { dehydrate, HydrationBoundary } from "@tanstack/react-query"; +import { TrendingUp } from "lucide-react"; +import Link from "next/link"; +import { z } from "zod/mini"; +import { MarketCard } from "~/components/market-card"; +import { env } from "~/env"; +import { createJstzClient } from "~/lib/jstz-signer.service"; +import { marketSchema } from "~/lib/validators/market"; +import { getQueryClient } from "~/providers/query-provider"; +import { smartFunctions } from "~/queries/smartFunctions.queries"; + +const marketFromRoot = z.object({ + markets: z.record( + z.string(), + z.object({ + address: z.string().check(z.length(36)), + }), + ), +}); + +export default async function MarketsPage() { + const queryClient = getQueryClient(); + const jstzClient = createJstzClient(); + + const options = smartFunctions.getKv(env.NEXT_PUBLIC_PARENT_SF_ADDRESS, "root", jstzClient); + + await queryClient.prefetchQuery(options); + + const kv = await queryClient.getQueryData(options.queryKey); + + const markets = await (async () => { + if (!kv) return []; + + const { data: marketsFromRoot, error } = marketFromRoot.safeParse(JSON.parse(kv)); + if (error) { + throw error; + } + + return Promise.all( + Object.values(marketsFromRoot.markets).map(async ({ address }) => { + const options = smartFunctions.getKv(address, "root", jstzClient); + await queryClient.prefetchQuery(options); + + const kv = await queryClient.getQueryData(options.queryKey); + return { ...marketSchema.parse(JSON.parse(kv)), address }; + }), + ); + })(); + + return ( +
+
+ {/* Header */} +
+

Markets

+ +

+ Show you're an expert. Trade on the outcomes of future events. +

+
+ + {markets.length === 0 && ( +
+
+ +
+ +

No markets available

+

+ There are no prediction markets in this category yet. Check back soon or explore other + categories. +

+
+ )} + + + {markets.length !== 0 && ( +
+ {markets.map((market) => ( + + + + ))} +
+ )} +
+
+
+ ); +} diff --git a/apps/examples/jstz-prediction-market/src/app/api/market/route.ts b/apps/examples/jstz-prediction-market/src/app/api/market/route.ts new file mode 100644 index 0000000..464b2a7 --- /dev/null +++ b/apps/examples/jstz-prediction-market/src/app/api/market/route.ts @@ -0,0 +1,102 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { readFileSync } from "node:fs"; +import { z } from "zod/mini"; +import { env } from "~/env"; +import { createJstzClient } from "~/lib/jstz-signer.service"; +import { marketFormSchema } from "~/lib/validators/market"; + +import Jstz from "@jstz-dev/jstz-client"; +import * as signer from "@jstz-dev/jstz_sdk"; +import { textDecode, textEncode } from "~/lib/encoder"; + +const marketBodySchema = z.object({ + ...marketFormSchema.shape, + master: z.string(), +}); + +export async function POST(req: NextRequest) { + const contentLength = Number.parseInt(req.headers.get("content-length") ?? "0", 10); + + if (contentLength === 0) { + return NextResponse.json({ message: "No body was provided." }, { status: 401 }); + } + + const { data: body, error } = marketBodySchema.safeParse(await req.json()); + + if (error) { + console.log(error); + return NextResponse.json(error.message, { status: 401 }); + } + + const smartFunctionBody = readFileSync("./src/app/api/market/market.js").toString("utf8"); + + // Deploy smart function + const deployOperation: Jstz.Operation.DeployFunction = { + _type: "DeployFunction", + functionCode: smartFunctionBody, + accountCredit: 0, + }; + + const jstz = createJstzClient(); + let nonce = await jstz.accounts.getNonce(env.WALLET_ADDRESS); + + const signedDeployOperation = signer.sign_operation( + { + content: deployOperation, + publicKey: env.WALLET_PUBLIC_KEY, + nonce, + }, + env.WALLET_SECRET_KEY, + ); + + const { result } = await jstz.operations.injectAndPoll({ + inner: { + content: deployOperation, + publicKey: env.WALLET_PUBLIC_KEY, + nonce, + }, + + signature: signedDeployOperation, + }); + + const smartFunctionAddress = result.inner.address; + + // Init smart function + const initOperation: Jstz.Operation.RunFunction = { + _type: "RunFunction", + body: textEncode(body), + gasLimit: 55_000, + headers: {}, + uri: `jstz://${smartFunctionAddress}/init`, + method: "POST", + }; + + // NOTE: Getting the nonce once again, just for safeties sake. + // There is a possibility that in the meantime there will be another + // operation which mutate the nonce. + nonce = await jstz.accounts.getNonce(env.WALLET_ADDRESS); + + const signedInitOperation = signer.sign_operation( + { + content: initOperation, + publicKey: env.WALLET_PUBLIC_KEY, + nonce, + }, + env.WALLET_SECRET_KEY, + ); + + const { result: initResult } = await jstz.operations.injectAndPoll({ + inner: { + content: initOperation, + publicKey: env.WALLET_PUBLIC_KEY, + nonce, + }, + + signature: signedInitOperation, + }); + + console.log(initResult); + console.log(textDecode(initResult.inner.body)); + + return NextResponse.json({ address: smartFunctionAddress }); +} diff --git a/apps/examples/jstz-prediction-market/src/app/favicon.ico b/apps/examples/jstz-prediction-market/src/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/apps/examples/jstz-prediction-market/src/app/favicon.ico differ diff --git a/apps/examples/jstz-prediction-market/src/app/globals.css b/apps/examples/jstz-prediction-market/src/app/globals.css new file mode 100644 index 0000000..151ea79 --- /dev/null +++ b/apps/examples/jstz-prediction-market/src/app/globals.css @@ -0,0 +1 @@ +@import "jstz-ui/index.css"; diff --git a/apps/examples/jstz-prediction-market/src/app/layout.tsx b/apps/examples/jstz-prediction-market/src/app/layout.tsx new file mode 100644 index 0000000..da862aa --- /dev/null +++ b/apps/examples/jstz-prediction-market/src/app/layout.tsx @@ -0,0 +1,63 @@ +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import { NuqsAdapter } from "nuqs/adapters/next/app"; +import { SidebarProvider } from "~/components/ui/sidebar"; +import { JstzClientContextProvider } from "~/providers/jstz-client.context"; +import { QueryProvider } from "~/providers/query-provider"; +import { ThemeProvider } from "~/providers/theme-provider"; +import "./globals.css"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: "Jstz prediction market", + description: "Example app depicting a theoretical implementation of a prediction market betting site through Jstz.", + icons: { + icon: [ + { + url: "/icon-light-32x32.png", + media: "(prefers-color-scheme: light)", + }, + { + url: "/icon-dark-32x32.png", + media: "(prefers-color-scheme: dark)", + }, + { + url: "/icon.svg", + type: "image/svg+xml", + }, + ], + apple: "/apple-icon.png", + }, +}; + +export default function RootLayout({ children }: LayoutProps<"/">) { + return ( + + + + + + + {children} + + + + + + + ); +} diff --git a/apps/examples/jstz-prediction-market/src/app/route.ts b/apps/examples/jstz-prediction-market/src/app/route.ts new file mode 100644 index 0000000..218e014 --- /dev/null +++ b/apps/examples/jstz-prediction-market/src/app/route.ts @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export function GET() { + return redirect("/markets"); +} diff --git a/apps/examples/jstz-prediction-market/src/components/betting-panel.tsx b/apps/examples/jstz-prediction-market/src/components/betting-panel.tsx new file mode 100644 index 0000000..098197d --- /dev/null +++ b/apps/examples/jstz-prediction-market/src/components/betting-panel.tsx @@ -0,0 +1,394 @@ +"use client"; + +import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; +import { Badge } from "jstz-ui/ui/badge"; +import { Button } from "jstz-ui/ui/button"; +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "jstz-ui/ui/card"; +import { Separator } from "jstz-ui/ui/separator"; +import { Slider } from "jstz-ui/ui/slider"; +import { Spinner } from "jstz-ui/ui/spinner"; +import { cn } from "jstz-ui/utils"; +import { AlertTriangle, Clock, DollarSign } from "lucide-react"; +import assert from "node:assert"; +import { FormEvent } from "react"; +import { z } from "zod/mini"; +import * as CurrencyConverter from "~/lib/currencyConverter"; +import { textDecode, textEncode } from "~/lib/encoder"; +import { useJstzSignerExtension } from "~/lib/hooks/useJstzSigner"; +import { SignWithJstzSignerParams } from "~/lib/jstz-signer.service"; +import type { Market } from "~/lib/validators/market"; +import { Token, tokenSchema } from "~/lib/validators/token"; +import { useJstzClient } from "~/providers/jstz-client.context"; +import { accounts } from "~/queries/account.queries"; +import { smartFunctions } from "~/queries/smartFunctions.queries"; +import { useAppForm } from "./ui/form"; + +const betFormSchema = z.pick(tokenSchema, { token: true, amount: true }); + +interface BettingPanelProps extends Market { + address: string; +} + +export function BettingPanel({ + address, + question, + state, + tokens, + resolutionDate, + bets, +}: BettingPanelProps) { + const { signWithJstzExtension } = useJstzSignerExtension(); + const { getJstzClient } = useJstzClient(); + + const jstzClient = getJstzClient(); + + const { data: balance } = useSuspenseQuery(accounts.balance(address)); + + const queryClient = useQueryClient(); + + const { mutateAsync: placeBet } = useMutation({ + mutationFn: async ({ token, amount }: Pick & { amount: number }) => { + console.log(amount); + + const payload: SignWithJstzSignerParams = { + content: { + _type: "RunFunction", + uri: `jstz://${address}/bet`, + headers: { + "X-JSTZ-TRANSFER": amount.toString(), + }, + method: "POST", + body: textEncode({ token }), + gasLimit: 55_000, + }, + }; + + const { operation, signature, verifier } = await signWithJstzExtension(payload); + + const { + result: { inner }, + } = await jstzClient.operations.injectAndPoll( + { + inner: operation, + signature, + verifier: verifier ?? null, + }, + { + timeout: 100 * 1_000, + }, + ); + + console.log(textDecode(inner.body)); + }, + + onSuccess: () => { + void queryClient.invalidateQueries(smartFunctions.getKv(address, "root", jstzClient)); + void queryClient.invalidateQueries(accounts.balance(address, jstzClient)); + }, + }); + + const noToken = tokens.find((token) => token.token === "no"); + assert(noToken, "Token should be defined."); + + const yesToken = tokens.find((token) => token.token === "yes"); + assert(yesToken, "Token should be defined."); + + const form = useAppForm({ + defaultValues: { + token: "yes" as Token["token"], + amount: yesToken.price, + }, + + validators: { + onSubmit: betFormSchema, + }, + + onSubmit: async ({ value }) => { + await placeBet(value); + }, + }); + + function onSubmit(e: FormEvent) { + e.stopPropagation(); + e.preventDefault(); + void form.handleSubmit(); + } + + function calculatePotentialWin(side: "yes" | "no", betAmount: number) { + const token = side === "yes" ? yesToken : noToken; + + const syntheticTokensAmount = bets.reduce((acc, token) => { + if (token.isSynthetic && side === token.token) { + return acc + token.amount; + } + + return acc; + }, 0); + + const tokensToBuy = betAmount / token.price; + + const result = (balance + betAmount) / (token?.amount - syntheticTokensAmount + tokensToBuy); + + return result * tokensToBuy; + } + + function calculateProfit(side: "yes" | "no", betAmount: number) { + const win = calculatePotentialWin(side, betAmount); + return Math.max(win - betAmount, 0); + } + + function calculateReturn(side: "yes" | "no", betAmount: number) { + const profit = calculateProfit(side, betAmount); + return (profit / betAmount) * 100; + } + + const StateBadge = (() => { + switch (state) { + case "on-going": + return Active; + + case "created": + return ( + + Inactive + + ); + + case "resolved": + return ( + + Resolved + + ); + } + })(); + + if (state === "resolved") { + return ( + + +
+
+
+ M +
+ + Market {address} +
+ + {StateBadge} +
+ + {question} +
+ + +
+ + Market Resolution Required +
+ +

+ Select the winning side to resolve this market +

+ +
+ + + +
+
+
+ ); + } + + return ( + + +
+
+
+ M +
+ + Market {address} +
+ + {StateBadge} +
+ + {question} +
+ + +
+ {/* Betting Side Selection */} + + + {(field) => ( + + + Betting on: + + + +
+ + + +
+
+
+ )} +
+ + {/* Bet Amount Slider */} + + {(field) => { + const side = form.getFieldValue("token"); + + const token = side === "yes" ? yesToken : noToken; + const price = token.price; + + return ( + +
+ + Bet Amount: + + + + {CurrencyConverter.toTez(field.state.value)} XTZ + +
+ + + field.handleChange(value[0])} + min={price} + max={price * 100} + step={price} + className="mb-2" + /> + + +
+ {CurrencyConverter.toTez(price)} XTZ + {CurrencyConverter.toTez(price * 100)} XTZ +
+
+ ); + }} +
+ + {/* Potential Returns */} + [values.token, values.amount] as const}> + {([token, amount]) => ( +
+
+ Potential Win: + + {CurrencyConverter.toTez(calculatePotentialWin(token, amount))} XTZ + +
+ +
+ Profit: + + {CurrencyConverter.toTez(calculateProfit(token, amount))} XTZ + +
+ +
+ Return: + + +{calculateReturn(token, amount).toFixed(2)}% + +
+
+ )} +
+ + {/* Action Buttons */} +
+ [canSubmit, isSubmitting]}> + {([canSubmit, isSubmitting]) => ( + + )} + +
+
+ + + + {/* Footer Info */} + +
+ + {CurrencyConverter.toTez(balance)} XTZ +
+ +
+ + {resolutionDate.split("T")[0]} +
+ + {StateBadge} +
+ +
+
+ ); +} diff --git a/apps/examples/jstz-prediction-market/src/components/market-card.tsx b/apps/examples/jstz-prediction-market/src/components/market-card.tsx new file mode 100644 index 0000000..4418517 --- /dev/null +++ b/apps/examples/jstz-prediction-market/src/components/market-card.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { useSuspenseQuery } from "@tanstack/react-query"; +import { Badge } from "jstz-ui/ui/badge"; +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "jstz-ui/ui/card"; +import { Progress } from "jstz-ui/ui/progress"; +import { Separator } from "jstz-ui/ui/separator"; +import { Clock } from "lucide-react"; +import assert from "node:assert"; +import * as CurrencyConverter from "~/lib/currencyConverter"; +import { Market } from "~/lib/validators/market"; +import { accounts } from "~/queries/account.queries"; + +interface MarketCardProps extends Market { + address: string; +} + +export function MarketCard({ + state, + question, + tokens, + resolutionDate, + bets, + address, +}: MarketCardProps) { + const isResolved = state === "resolved"; + + const noToken = tokens.find((token) => token.token === "no"); + assert(noToken, "Token should be defined."); + + const yesToken = tokens.find((token) => token.token === "yes"); + assert(yesToken, "Token should be defined."); + + const { data: balance } = useSuspenseQuery(accounts.balance(address)); + + const [yesCount, noCount] = bets.reduce( + (acc, bet) => { + switch (bet.token) { + case "yes": + acc[0] += bet.amount; + break; + case "no": + acc[1] += bet.amount; + break; + } + + return acc; + }, + [0, 0], + ); + + const numberOfTokens = tokens.reduce((acc, token) => acc + token.amount, 0); + + const yesRatio = (yesCount / numberOfTokens) * 100; + const noRatio = (noCount / numberOfTokens) * 100; + + return ( + + {/* Header */} + +
+
+ XTZ +
+ + {bets.length === 0 && No bets yet.} +
+ + {/* Title */} + {question} +
+ + {/* Probability Display */} + + {!isResolved && ( + <> +
+ + YES - {yesRatio.toFixed(2)} + {!Number.isNaN(yesRatio) && "%"} + + + + NO - {noRatio.toFixed(2)} + {!Number.isNaN(noRatio) && "%"} + +
+ + {/* Probability Bar */} + + + )} + + {/* Resolved State */} + {isResolved && ( +
+
+
+
+ Market Resolved +
+ +

+ Winning side: {market.winningOutcome?.toUpperCase()} +

+
+
+ + {/* {market.userPosition && ( */} + {/* */} + {/* )} */} +
+ )} +
+ + + + {/* Footer */} + +
+ + {CurrencyConverter.toTez(balance)} +
+ +
+ + {resolutionDate.split("T")[0]} +
+ + {isResolved && ( + + Resolved + + )} + + {/* {!isResolved && market.userPosition && ( */} + {/* */} + {/* Active */} + {/* */} + {/* )} */} +
+
+ ); +} + +function DollarSign({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/examples/jstz-prediction-market/src/components/navigation-bar.tsx b/apps/examples/jstz-prediction-market/src/components/navigation-bar.tsx new file mode 100644 index 0000000..3e99ecf --- /dev/null +++ b/apps/examples/jstz-prediction-market/src/components/navigation-bar.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { cn } from "jstz-ui/utils"; +import { SquarePlus, TrendingUp } from "lucide-react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { + Sidebar, + SidebarContent, + SidebarGroup, + SidebarGroupContent, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "./ui/sidebar"; + +const navigation = [ + { name: "Markets", href: "/markets", icon: TrendingUp }, + { name: "Deploy", href: "/deploy", icon: SquarePlus }, +]; + +export default function NavigationBar() { + const pathname = usePathname(); + + return ( + + + +
+ +
+ + TezMarket + +
+ + + + + + {navigation.map((nav) => { + const isActive = pathname === nav.href; + + return ( + + + + + {nav.name} + + + + ); + })} + + + + +
+ ); +} diff --git a/apps/examples/jstz-prediction-market/src/components/ui/form.tsx b/apps/examples/jstz-prediction-market/src/components/ui/form.tsx new file mode 100644 index 0000000..152ed77 --- /dev/null +++ b/apps/examples/jstz-prediction-market/src/components/ui/form.tsx @@ -0,0 +1,125 @@ +import { Slot } from "@radix-ui/react-slot"; +import { createFormHook, createFormHookContexts, useStore } from "@tanstack/react-form"; + +import { Label } from "jstz-ui/ui/label"; +import { cn } from "jstz-ui/utils"; +import { createContext, useContext, useId, type ComponentPropsWithRef } from "react"; + +const { + fieldContext, + formContext, + useFieldContext: useTanstackFieldContext, + useFormContext, +} = createFormHookContexts(); + +const { useAppForm, withForm } = createFormHook({ + fieldContext, + formContext, + fieldComponents: { + FormLabel, + FormControl, + FormDescription, + FormMessage, + FormItem, + }, + formComponents: {}, +}); + +type FormItemContextValue = { + id: string; +}; + +const FormItemContext = createContext({} as FormItemContextValue); + +function FormItem({ className, ...props }: ComponentPropsWithRef<"div">) { + const id = useId(); + + return ( + +
+ + ); +} + +function useFieldContext() { + const { id } = useContext(FormItemContext); + const ctx = useTanstackFieldContext(); + + const errors = useStore(ctx.store, (state): unknown[] => state.meta.errors); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!ctx) { + throw new Error("useFieldContext should be used within "); + } + + return { + id, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + errors, + ...ctx, // eslint-disable-line @typescript-eslint/no-misused-spread + }; +} + +function FormLabel({ className, ...props }: ComponentPropsWithRef) { + const { formItemId, errors } = useFieldContext(); + + return ( +