diff --git a/src/api/index.tsx b/src/api/index.tsx index a306baa5..41126957 100644 --- a/src/api/index.tsx +++ b/src/api/index.tsx @@ -1,4 +1,5 @@ import { API_URL, NETWORK } from "../constants/amm"; +import { ApiResponse, TokenPriceData } from "../types/api"; export type ApiConfig = { network?: "testnet" | "mainnet"; @@ -25,3 +26,13 @@ export const apiUrl = (path: string, config?: ApiConfig): string => { return url.toString(); }; + +export const fetchTokenPrices = async () => { + const url = apiUrl("token-prices"); + const res = await fetch(url); + const body = (await res.json()) as ApiResponse; + if (body.status === "success") { + return body.data; + } + throw Error("Failed getting token prices"); +}; diff --git a/src/classes/Option.ts b/src/classes/Option.ts index 5414a20d..9874adb0 100644 --- a/src/classes/Option.ts +++ b/src/classes/Option.ts @@ -47,13 +47,13 @@ export class Option extends Pool { typeof strike === "string" ? strike : "0x" + strike.toString(16); this.strike = math64toDecimal(strike, mathBase); this.side = bnToOptionSide(side); - this.optionId = this.generateId(); + this.optionId = this.generateOptionId(); } /** * Generates id that uniquily describes option */ - generateId(): string { + generateOptionId(): string { return JSON.stringify({ base: this.baseToken.id, quote: this.quoteToken.id, diff --git a/src/classes/Pool.ts b/src/classes/Pool.ts index 3703ebb5..f9979dc7 100644 --- a/src/classes/Pool.ts +++ b/src/classes/Pool.ts @@ -21,7 +21,7 @@ import { shortInteger } from "../utils/computations"; import { bnToOptionType } from "../utils/conversions"; import { toHex } from "../utils/utils"; import { Pair, PairKey } from "./Pair"; -import { Token } from "./Token"; +import { Token, TokenKey } from "./Token"; export class Pool extends Pair { public type: OptionType; @@ -203,6 +203,74 @@ export class Pool extends Pair { get name(): string { return `${this.baseToken.symbol}/${this.quoteToken.symbol} ${this.typeAsText} Pool (${this.symbol})`; } + + get strikeStep(): [number, number] { + if ( + this.baseToken.id === TokenKey.ETH && + this.quoteToken.id === TokenKey.USDC + ) { + // ETH/USDC + return [100, 200]; + } + if ( + this.baseToken.id === TokenKey.STRK && + this.quoteToken.id === TokenKey.USDC + ) { + // STRK/USDC + return [0.05, 0.05]; + } + if ( + this.baseToken.id === TokenKey.ETH && + this.quoteToken.id === TokenKey.STRK + ) { + // ETH/STRK + return [100, 300]; + } + if ( + this.baseToken.id === TokenKey.BTC && + this.quoteToken.id === TokenKey.USDC + ) { + // BTC/USDC + return [1000, 3000]; + } + + // unreachable + throw Error("Failed getting strike step"); + } + + get baseVolatility(): number { + if ( + this.baseToken.id === TokenKey.ETH && + this.quoteToken.id === TokenKey.USDC + ) { + // ETH/USDC + return 55; + } + if ( + this.baseToken.id === TokenKey.STRK && + this.quoteToken.id === TokenKey.USDC + ) { + // STRK/USDC + return 90; + } + if ( + this.baseToken.id === TokenKey.ETH && + this.quoteToken.id === TokenKey.STRK + ) { + // ETH/STRK + return 100; + } + if ( + this.baseToken.id === TokenKey.BTC && + this.quoteToken.id === TokenKey.USDC + ) { + // BTC/USDC + return 55; + } + + // unreachable + throw Error("Failed getting base volatility"); + } } export class PoolInfo extends Pool { diff --git a/src/components/AddProposal/AddProposal.tsx b/src/components/AddProposal/AddProposal.tsx new file mode 100644 index 00000000..b7c0c0a5 --- /dev/null +++ b/src/components/AddProposal/AddProposal.tsx @@ -0,0 +1,317 @@ +import { useState } from "react"; + +import styles from "./prop.module.css"; +import { IconButton, MenuItem, Select, Tooltip } from "@mui/material"; +import { Pool } from "../../classes/Pool"; +import { + STRK_ADDRESS, + USDC_ADDRESS, + ETH_ADDRESS, + BTC_ADDRESS, +} from "../../constants/amm"; +import { OptionType } from "../../types/options"; +import { PairNamedBadge } from "../TokenBadge"; +import { useQuery } from "react-query"; +import { QueryKeys } from "../../queries/keys"; +import { fetchOptions } from "../TradeTable/fetchOptions"; +import { LoadingAnimation } from "../Loading/Loading"; +import { handleDuplicates, suggestOptions } from "./suggest"; +import { timestampToDateAndTime } from "../../utils/utils"; +import { showToast } from "../../redux/actions"; +import { ToastType } from "../../redux/reducers/ui"; +import { decimalToMath64 } from "../../utils/units"; +import { Close } from "@mui/icons-material"; +import { ProposalText } from "./ProposalText"; +import { useAccount } from "../../hooks/useAccount"; +import { proposeOptions } from "./proposeOptions"; + +const strkUsdcCallPool = new Pool(STRK_ADDRESS, USDC_ADDRESS, OptionType.Call); + +export const pools = [ + strkUsdcCallPool, + new Pool(STRK_ADDRESS, USDC_ADDRESS, OptionType.Put), + new Pool(ETH_ADDRESS, USDC_ADDRESS, OptionType.Call), + new Pool(ETH_ADDRESS, USDC_ADDRESS, OptionType.Put), + new Pool(ETH_ADDRESS, STRK_ADDRESS, OptionType.Call), + new Pool(ETH_ADDRESS, STRK_ADDRESS, OptionType.Put), + new Pool(BTC_ADDRESS, USDC_ADDRESS, OptionType.Call), + new Pool(BTC_ADDRESS, USDC_ADDRESS, OptionType.Put), +]; + +const defaultOptionValue: ProposalOption = { + pool: strkUsdcCallPool.poolId, + maturity: 1735056000, + strike: 0.4, + volatility: 90, + active: false, +}; + +export type ProposalOption = { + pool: string; + maturity: number; + strike: number; + volatility: number; + active?: boolean; +}; + +const getRelevantMaturities = (count = 10, offset = 1) => { + const SOME_THURSDAY = 1726790399; + const WEEK = 604800; + + const now = Math.round(Date.now() / 1000); + const nextThursday = now - ((now - SOME_THURSDAY) % WEEK) + WEEK; + + const firstMaturity = nextThursday + offset * WEEK; + + const maturities = Array.from( + { length: count }, + (_, i) => firstMaturity + i * WEEK + ); + + return maturities; +}; + +export const AddProposal = () => { + const account = useAccount(); + const { isLoading, isError, data } = useQuery( + QueryKeys.options, + fetchOptions + ); + const [options, setOptions] = useState([]); + + const maturities = getRelevantMaturities(); + + const [selectedMaturity, setSelectedMaturity] = useState( + maturities[0] + ); + + if (isLoading) { + return ; + } + + if (isError || !data) { + return

Something went wrong

; + } + + const handleAddOption = () => { + setOptions([defaultOptionValue, ...options]); + }; + + const handleChange = (index: number, field: string, value: T) => { + const updatedOptions = options.map((option, i) => + i === index ? { ...option, [field]: value } : option + ); + setOptions(handleDuplicates(updatedOptions, data)); + }; + + const handleRemove = (index: number) => { + const optionsWithoutElement = options.filter((_, i) => i !== index); + setOptions(optionsWithoutElement); + showToast("Proposal option removed"); + }; + + const handleSuggest = () => { + suggestOptions(data, selectedMaturity).then((suggestedOptions) => + setOptions(suggestedOptions) + ); + }; + + const handleSave = () => { + localStorage.setItem("options-proposal-save", JSON.stringify(options)); + showToast("Proposal options saved", ToastType.Success); + }; + + const handleLoad = () => { + const loadedData = localStorage.getItem("options-proposal-save"); + + if (loadedData === null) { + showToast("Did not find any saved data", ToastType.Warn); + return; + } + + try { + const parsed = JSON.parse(loadedData); + setOptions(parsed); + showToast("Proposal options loaded", ToastType.Success); + } catch (error) { + showToast("Failed to read saved data", ToastType.Error); + } + }; + + const handleSubmit = () => { + const payload = options + .filter((o) => o.active !== false) // filter out active = false (duplicate with live options) + .flatMap((o) => { + const pool = pools.find((p) => p.poolId === o.pool); + if (!pool) { + throw Error("Could not find pool"); + } + const name = `${pool.baseToken.symbol}-${ + pool.quoteToken.symbol + }-${pool.typeAsText.toUpperCase()}-`; + return [ + name + "LONG", + name + "SHORT", + o.maturity.toString(10), + decimalToMath64(o.strike), + "0", // Fixed sign + pool.type, + pool.lpAddress, + pool.quoteToken.address, + pool.baseToken.address, + decimalToMath64(o.volatility), + "0", // Fixed sign + ]; + }); + if (!account) { + return; + } + proposeOptions(payload, account); + }; + + return ( +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ {options.map((option, index) => ( +
+
+ + handleRemove(index)} + > + + + +
+ {option.active === false && ( + +
+

This option is a duplicate

+
+
+ )} +
+

Pool

+ +
+
+

Maturity

+ +
+
+

Strike Price

+ + handleChange(index, "strike", parseFloat(e.target.value)) + } + /> +
+
+

Volatility

+ + handleChange(index, "volatility", parseFloat(e.target.value)) + } + /> +
+
+ ))} +
+ {options.length > 0 && } +
+ ); +}; diff --git a/src/components/AddProposal/ProposalText.tsx b/src/components/AddProposal/ProposalText.tsx new file mode 100644 index 00000000..bf178bb4 --- /dev/null +++ b/src/components/AddProposal/ProposalText.tsx @@ -0,0 +1,78 @@ +import { showToast } from "../../redux/actions"; +import { ToastType } from "../../redux/reducers/ui"; +import { timestampToReadableDateUtc } from "../../utils/utils"; +import { pools, ProposalOption } from "./AddProposal"; + +import styles from "./prop.module.css"; + +export const ProposalText = ({ + proposalOptions, +}: { + proposalOptions: ProposalOption[]; +}) => { + const groupedByPool = proposalOptions.reduce( + (acc: { [key: string]: { [key: number]: ProposalOption[] } }, item) => { + if (!acc[item.pool]) { + acc[item.pool] = []; + } + if (!acc[item.pool][item.maturity]) { + acc[item.pool][item.maturity] = []; + } + acc[item.pool][item.maturity].push(item); + return acc; + }, + {} + ); + const poolIds = Object.keys(groupedByPool); + const texts: string[] = []; + + poolIds.forEach((poolId) => { + const pool = pools.find((p) => p.poolId === poolId); + if (!pool) { + throw Error("Failed to find pool"); + } + const maturities = Object.keys(groupedByPool[poolId]); + + maturities.forEach((maturityStr) => { + const maturity = parseInt(maturityStr); + texts.push( + `${pool.baseToken.symbol}/${pool.quoteToken.symbol} ${ + pool.typeAsText + } options expiring on ${timestampToReadableDateUtc(maturity * 1000)}` + ); + const opts = groupedByPool[poolId][maturity]; + + opts.forEach((o) => { + texts.push(`strike ${o.strike}, volatility ${o.volatility}%`); + }); + texts.push(""); + }); + }); + + const handleCopy = () => { + const txt = texts.join("\n"); + navigator.clipboard + .writeText(txt) + .then(() => showToast("Copied to clipboard", ToastType.Success)) + .catch(() => showToast("Failed to copy to clipboard", ToastType.Warn)); + }; + + return ( +
+

This proposal adds following options:

+
+ {texts.map((t) => { + if (t === "") { + return
; + } + return

{t}

; + })} +
+
+ +
+
+ ); +}; diff --git a/src/components/AddProposal/index.ts b/src/components/AddProposal/index.ts new file mode 100644 index 00000000..41c34118 --- /dev/null +++ b/src/components/AddProposal/index.ts @@ -0,0 +1,3 @@ +import { AddProposal } from "./AddProposal"; + +export { AddProposal }; diff --git a/src/components/AddProposal/prop.module.css b/src/components/AddProposal/prop.module.css new file mode 100644 index 00000000..bc258092 --- /dev/null +++ b/src/components/AddProposal/prop.module.css @@ -0,0 +1,66 @@ +.buttons { + display: flex; + gap: 10px; + height: 25.5px; +} + +.buttonscontainer { + display: flex; + flex-flow: column; + gap: 20px; +} + +.optionbox { + display: flex; + flex-flow: column; + border: 1px solid var(--GREY); + padding: 5px; + width: 300px; +} + +.optionboxcontainer { + display: flex; + flex-flow: column; + gap: 10px; + margin-top: 20px; +} + +.optionbox>div { + display: flex; + align-items: center; +} + +.optionbox>div>p { + min-width: 85px; +} + +.optionbox>div input { + width: 100%; +} + +.poolselect { + display: flex; + align-items: center; + gap: 10px; +} + +.infobox { + border: 1px solid var(--ERROR-BG); + background: var(--ERROR-ACCENT); + border-radius: 4px; + padding: 5px; + width: fit-content; +} + +.close { + display: flex; + justify-content: flex-end; +} + +.proposaltext { + margin-top: 20px; + padding: 5px; + border: 1px solid var(--GREY); + border-radius: 4px; + width: fit-content; +} \ No newline at end of file diff --git a/src/components/AddProposal/proposeOptions.ts b/src/components/AddProposal/proposeOptions.ts new file mode 100644 index 00000000..d19573bb --- /dev/null +++ b/src/components/AddProposal/proposeOptions.ts @@ -0,0 +1,26 @@ +import { AccountInterface } from "starknet"; +import { AMM_ADDRESS, GOVERNANCE_ADDRESS } from "../../constants/amm"; + +export const proposeOptions = async ( + options: string[], + account: AccountInterface +) => { + const call = { + contractAddress: GOVERNANCE_ADDRESS, + entrypoint: "submit_custom_proposal", + calldata: [ + "0x2", // add options custom proposal prop id + options.length + 2, // length of the payload Span + AMM_ADDRESS, + options.length / 11, // length of the array of options (each option is 11 fields) + ...options, + ], + }; + + console.log("Executing add options proposal:", call); + + await account + .execute(call) + .then((res) => console.log("Send TX", res.transaction_hash)) + .catch(() => {}); +}; diff --git a/src/components/AddProposal/suggest.ts b/src/components/AddProposal/suggest.ts new file mode 100644 index 00000000..d7e9e3d9 --- /dev/null +++ b/src/components/AddProposal/suggest.ts @@ -0,0 +1,75 @@ +import { Option } from "./../../classes/Option"; +import { fetchTokenPrices } from "../../api"; +import { Pool } from "../../classes/Pool"; +import { TokenPriceData } from "../../types/api"; +import { pools, ProposalOption } from "./AddProposal"; + +export const isDuplicate = ( + options: Option[], + maturity: number, + strike: number, + poolId: string +): boolean => { + const filtered = options.filter((o) => { + return ( + o.poolId === poolId && o.strike === strike && o.maturity === maturity + ); + }); + + return filtered.length > 0; +}; + +export const handleDuplicates = ( + propOptions: ProposalOption[], + liveOptions: Option[] +): ProposalOption[] => { + return propOptions.map((o) => { + if (isDuplicate(liveOptions, o.maturity, o.strike, o.pool)) { + return { ...o, active: false }; + } + return { ...o, active: true }; + }); +}; + +const generateProposalOptions = ( + pool: Pool, + maturity: number, + prices: TokenPriceData, + live: Option[] +): ProposalOption[] => { + const basePrice = prices[pool.baseToken.id]; + const quotePrice = prices[pool.quoteToken.id]; + const [rounding, step] = pool.strikeStep; + + const cleanBaseTen = (n: number) => parseFloat(n.toFixed(3)); + + const rootStrike = Math.round(basePrice / quotePrice / rounding) * rounding; + + const strikes = pool.isCall + ? [rootStrike - step, rootStrike, rootStrike + step, rootStrike + 2 * step] + : [rootStrike + step, rootStrike, rootStrike - step, rootStrike - 2 * step]; + + const propOptions = strikes.map((strike) => { + const cleanStrike = cleanBaseTen(strike); + + return { + maturity, + volatility: pool.baseVolatility, + pool: pool.poolId, + strike: cleanStrike, + }; + }); + + return handleDuplicates(propOptions, live); +}; + +export const suggestOptions = async ( + live: Option[], + maturity: number +): Promise => { + const tokenPrices = await fetchTokenPrices(); + + return pools.flatMap((pool) => + generateProposalOptions(pool, maturity, tokenPrices, live) + ); +}; diff --git a/src/network/provider.ts b/src/network/provider.ts index fdabe8e1..eeef52de 100644 --- a/src/network/provider.ts +++ b/src/network/provider.ts @@ -14,7 +14,7 @@ export const testnetOptions: RpcProviderOptions = { export const mainnetOptions: RpcProviderOptions = { nodeUrl: apiUrl("call", { network: "mainnet" }), - // nodeUrl: "http://34.22.208.73:5051", dev mainnet + // nodeUrl: "http://178.32.172.155:5050/rpc", // dev mainnet chainId: constants.StarknetChainId.SN_MAIN, }; diff --git a/src/pages/governance.tsx b/src/pages/governance.tsx index 29029695..4674183c 100644 --- a/src/pages/governance.tsx +++ b/src/pages/governance.tsx @@ -12,6 +12,9 @@ import { Airdrop } from "../components/Airdrop/Airdrop"; import { useEffect } from "react"; import styles from "./governance.module.css"; +import { AddProposal } from "../components/AddProposal"; +import { useAccount } from "../hooks/useAccount"; +import { coreTeamAddresses } from "../constants/amm"; const VotingSubpage = () => { return ( @@ -55,7 +58,18 @@ const AirdropSubpage = () => { ); }; +const ProposeOptionsSubpage = () => { + return ( +
+

Propose

+
+ +
+ ); +}; + const Governance = () => { + const account = useAccount(); const subpage = useGovernanceSubpage(); const navigate = useNavigate(); @@ -120,12 +134,26 @@ const Governance = () => { > Staking + {/* CURRENTLY ONLY SHOW TO THE CORE TEAM MEMBERS */} + {account?.address && coreTeamAddresses.includes(account.address) && ( + + )}
{subpage === GovernanceSubpage.Voting && } {subpage === GovernanceSubpage.Staking && } {subpage === GovernanceSubpage.AirDrop && } + {subpage === GovernanceSubpage.Propose && } ); }; diff --git a/src/redux/reducers/ui.ts b/src/redux/reducers/ui.ts index 48dca54f..491fc8e1 100644 --- a/src/redux/reducers/ui.ts +++ b/src/redux/reducers/ui.ts @@ -33,6 +33,7 @@ export enum GovernanceSubpage { AirDrop = "airdrop", Voting = "voting", Staking = "staking", + Propose = "propose", } export type ToastState = { diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 084e9761..b9cf4beb 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -45,6 +45,17 @@ export const timestampToReadableDate = (ts: number): string => timeZoneName: "short", }).format(ts); +export const timestampToReadableDateUtc = (ts: number): string => + new Intl.DateTimeFormat("default", { + timeZone: "UTC", + hour: "numeric", + minute: "numeric", + month: "short", + day: "numeric", + year: "numeric", + timeZoneName: "short", + }).format(ts); + export const timestampToShortTimeDate = (ts: number): string => new Intl.DateTimeFormat("default", { hour: "numeric",