diff --git a/auto-qa/tests/flm-config.test.mjs b/auto-qa/tests/flm-config.test.mjs new file mode 100644 index 0000000..0929ce4 --- /dev/null +++ b/auto-qa/tests/flm-config.test.mjs @@ -0,0 +1,79 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = resolve(here, '../..'); +const flmConfig = JSON.parse(await readFile(resolve(root, 'src/config/flm.json'), 'utf8')); + +const ADDRESS_RE = /^0x[a-fA-F0-9]{40}$/; +const optionalAddress = (value) => value === '' || ADDRESS_RE.test(value); +const bySlug = Object.fromEntries(flmConfig.map((config) => [config.slug, config])); + +test('FLM config pins Kleros and Gnosis organization routes', () => { + assert.deepEqual(Object.keys(bySlug).sort(), ['gnosis', 'kleros']); + + assert.equal(bySlug.kleros.path, '/flm/kleros'); + assert.equal(bySlug.kleros.organizationAddress, '0xaab097ead5c2db1ca7b1e5034224a2118edabe36'); + assert.equal(bySlug.kleros.companyId, 10); + + assert.equal(bySlug.gnosis.path, '/flm/gnosis'); + assert.equal(bySlug.gnosis.organizationAddress, '0x3fd2e8e71f75eed4b5c507706c413e33e0661bbf'); + assert.equal(bySlug.gnosis.companyId, 9); +}); + +test('FLM proposal metadata keeps the current official markets discoverable', () => { + assert.equal(bySlug.kleros.activeProposal.label, 'KIP-90'); + assert.equal(bySlug.kleros.activeProposal.marketAddress, '0x84412Fe9D088C1D8Dd676a7be9a3d5d0291Ab1Cf'); + assert.equal( + bySlug.kleros.activeProposal.snapshotId, + '0xba2749a4f1283da9d1ca925d9f17bf712fa06a23e6a07d759c54340277820932' + ); + assert.equal(bySlug.kleros.activeProposal.marketUrl, '/markets/0x84412Fe9D088C1D8Dd676a7be9a3d5d0291Ab1Cf'); + + assert.equal(bySlug.gnosis.activeProposal.label, 'GIP-151'); + assert.equal(bySlug.gnosis.activeProposal.marketAddress, '0xeCe80208CB8376Be311cE0f5Ea4eF73850a0dcF0'); + assert.equal( + bySlug.gnosis.activeProposal.snapshotId, + '0x657fbf8892200d24e887c68245cee73b59c466394192be1c10673b39814c74c4' + ); + assert.equal(bySlug.gnosis.activeProposal.marketUrl, '/markets/0xeCe80208CB8376Be311cE0f5Ea4eF73850a0dcF0'); +}); + +test('FLM token and contract fields use valid address shapes', () => { + for (const config of flmConfig) { + assert.equal(config.chainId, 100); + assert.match(config.token.address, ADDRESS_RE); + assert.match(config.collateral.address, ADDRESS_RE); + assert.ok(optionalAddress(config.managerAddress), `${config.slug} manager address shape`); + assert.ok(optionalAddress(config.proposalSourceAddress), `${config.slug} proposal source shape`); + assert.ok(optionalAddress(config.activeProposal.proposalMetadataAddress), `${config.slug} metadata shape`); + } +}); + +test('FLM helpers pin Swapr adapter calldata and manager overloads', async () => { + const utilsSource = await readFile(resolve(root, 'src/utils/flm.js'), 'utf8'); + const pageSource = await readFile(resolve(root, 'src/pages/flm/[org].jsx'), 'utf8'); + + assert.match( + utilsSource, + /tuple\(int24 tickLower,int24 tickUpper,uint256 amount0Min,uint256 amount1Min,uint256 deadline,uint160 sqrtPriceX96\)/ + ); + assert.match( + utilsSource, + /tuple\(uint256 amount0Min,uint256 amount1Min,uint256 deadline\)/ + ); + assert.match(pageSource, /depositToSpot\(uint256,uint256,bytes\)/); + assert.match(pageSource, /encodeDualExitParams\(yesExitData, noExitData\)/); +}); + +test('companies table links configured organizations to their FLM page', async () => { + const hookSource = await readFile(resolve(root, 'src/hooks/useAggregatorCompanies.js'), 'utf8'); + const rowSource = await readFile(resolve(root, 'src/components/futarchyFi/companyList/table/OrgRow.jsx'), 'utf8'); + + assert.match(hookSource, /flmPath: getFlmPathForOrg\(org\.id\)/); + assert.match(rowSource, /href=\{flmPath\}/); + assert.match(rowSource, /event\.stopPropagation\(\)/); +}); diff --git a/src/components/futarchyFi/companyList/table/OrgRow.jsx b/src/components/futarchyFi/companyList/table/OrgRow.jsx index 7d9b537..1a0df2a 100644 --- a/src/components/futarchyFi/companyList/table/OrgRow.jsx +++ b/src/components/futarchyFi/companyList/table/OrgRow.jsx @@ -1,5 +1,8 @@ import React from 'react'; import Image from 'next/image'; +import Link from 'next/link'; +import { ArrowTopRightOnSquareIcon } from '@heroicons/react/24/outline'; + import ChainBadge from '../components/ChainBadge'; /** @@ -13,6 +16,7 @@ const OrgRow = ({ activeProposals = 0, proposalsCount = 0, chainId = 100, // Default to Gnosis + flmPath = null, hasActiveMarket = false, isOwner = false, onClick, @@ -71,9 +75,24 @@ const OrgRow = ({ + + {/* FLM */} + + {flmPath ? ( + event.stopPropagation()} + className="inline-flex h-9 items-center gap-1 rounded-lg border border-futarchyGray5 bg-white px-3 text-sm font-semibold text-futarchyGray12 hover:bg-futarchyGray3" + > + FLM + + + ) : ( + - + )} + ); }; export default OrgRow; - diff --git a/src/components/futarchyFi/companyList/table/OrganizationsTable.jsx b/src/components/futarchyFi/companyList/table/OrganizationsTable.jsx index 61eec72..ec6a49a 100644 --- a/src/components/futarchyFi/companyList/table/OrganizationsTable.jsx +++ b/src/components/futarchyFi/companyList/table/OrganizationsTable.jsx @@ -135,6 +135,9 @@ const OrganizationsTable = ({ Chain + + FLM + @@ -154,6 +157,7 @@ const OrganizationsTable = ({ activeProposals={org.activeProposals || 0} proposalsCount={org.proposals || org.proposalsCount || 0} chainId={org.chainId || 100} + flmPath={org.flmPath} hasActiveMarket={(org.activeProposals || 0) > 0} isOwner={connectedWallet && org.owner?.toLowerCase() === connectedWallet.toLowerCase()} onClick={() => onOrgClick?.(org)} diff --git a/src/config/flm.json b/src/config/flm.json new file mode 100644 index 0000000..c2039c1 --- /dev/null +++ b/src/config/flm.json @@ -0,0 +1,78 @@ +[ + { + "slug": "kleros", + "path": "/flm/kleros", + "organizationName": "Kleros", + "organizationAddress": "0xaab097ead5c2db1ca7b1e5034224a2118edabe36", + "companyId": 10, + "chainId": 100, + "pair": "PNK/sDAI", + "status": "pending-deployment", + "managerAddress": "", + "proposalSourceAddress": "", + "token": { + "symbol": "PNK", + "address": "0x37b60f4E9A31A64cCc0024dce7D0fD07eAA0F7B3", + "decimals": 18 + }, + "collateral": { + "symbol": "sDAI", + "address": "0xaf204776c7245bF4147c2612BF6e5972Ee483701", + "decimals": 18 + }, + "activeProposal": { + "label": "KIP-90", + "marketAddress": "0x84412Fe9D088C1D8Dd676a7be9a3d5d0291Ab1Cf", + "questionId": "0xe1ef37c96013f3a1e4a9cb00bdb1bf158ecd91be210077316933bb9d5ba5738d", + "title": "What will the impact on PNK price be if KIP-90 is passed?", + "snapshotId": "0xba2749a4f1283da9d1ca925d9f17bf712fa06a23e6a07d759c54340277820932", + "snapshotUrl": "https://snapshot.org/#/s:kleros.eth/proposal/0xba2749a4f1283da9d1ca925d9f17bf712fa06a23e6a07d759c54340277820932", + "marketUrl": "/markets/0x84412Fe9D088C1D8Dd676a7be9a3d5d0291Ab1Cf", + "closeTimestamp": 1783086060, + "metadataAddress": "", + "proposalMetadataAddress": "" + }, + "deployment": { + "repo": "futarchy-fi/futarchy-liquidity-manager", + "config": "config/kleros.kip90.json" + } + }, + { + "slug": "gnosis", + "path": "/flm/gnosis", + "organizationName": "Gnosis", + "organizationAddress": "0x3fd2e8e71f75eed4b5c507706c413e33e0661bbf", + "companyId": 9, + "chainId": 100, + "pair": "GNO/sDAI", + "status": "pending-deployment", + "managerAddress": "", + "proposalSourceAddress": "", + "token": { + "symbol": "GNO", + "address": "0x9C58BAcC331c9aa871AFD802DB6379a98e80CEdb", + "decimals": 18 + }, + "collateral": { + "symbol": "sDAI", + "address": "0xaf204776c7245bF4147c2612BF6e5972Ee483701", + "decimals": 18 + }, + "activeProposal": { + "label": "GIP-151", + "marketAddress": "0xeCe80208CB8376Be311cE0f5Ea4eF73850a0dcF0", + "questionId": "", + "title": "What will the impact on GNO price be if GIP-151 is passed?", + "snapshotId": "0x657fbf8892200d24e887c68245cee73b59c466394192be1c10673b39814c74c4", + "snapshotUrl": "https://snapshot.box/#/s:gnosis.eth/proposal/0x657fbf8892200d24e887c68245cee73b59c466394192be1c10673b39814c74c4", + "marketUrl": "/markets/0xeCe80208CB8376Be311cE0f5Ea4eF73850a0dcF0", + "closeTimestamp": 1782486884, + "metadataAddress": "0x590D470C33beC64c16AAD52cC64C232240547702", + "proposalMetadataAddress": "0x590D470C33beC64c16AAD52cC64C232240547702" + }, + "deployment": { + "repo": "futarchy-fi/futarchy-liquidity-manager", + "config": "config/gnosis.production.json" + } + } +] diff --git a/src/hooks/useAggregatorCompanies.js b/src/hooks/useAggregatorCompanies.js index 2bedc3f..111604d 100644 --- a/src/hooks/useAggregatorCompanies.js +++ b/src/hooks/useAggregatorCompanies.js @@ -12,6 +12,7 @@ import { useState, useEffect } from 'react'; import { AGGREGATOR_SUBGRAPH_URL as SUBGRAPH_URL } from '../config/subgraphEndpoints'; +import { getFlmPathForOrg } from '../utils/flm'; import { isProposalActive, isProposalArchived } from '../utils/proposalLifecycle'; // Three flat queries — Checkpoint has no auto-generated reverse fields. @@ -101,6 +102,7 @@ function transformOrgToCard(org, proposalsForOrg) { website: meta.website, twitter: meta.twitter, metadataURI: org.metadataURI, + flmPath: getFlmPathForOrg(org.id), // Surface the parsed org metadata so downstream filters can // check archived/visibility without re-parsing. _orgMetadata: meta, diff --git a/src/pages/flm/[org].jsx b/src/pages/flm/[org].jsx new file mode 100644 index 0000000..41dfde2 --- /dev/null +++ b/src/pages/flm/[org].jsx @@ -0,0 +1,828 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import Head from 'next/head'; +import Link from 'next/link'; +import { ConnectButton } from '@rainbow-me/rainbowkit'; +import { useAccount } from 'wagmi'; +import { ethers } from 'ethers'; +import { + ArrowPathIcon, + ArrowTopRightOnSquareIcon, + BanknotesIcon, + CheckCircleIcon, +} from '@heroicons/react/24/outline'; + +import Header from '../../components/common/Header'; +import { + ERC20_ABI, + FLM_MANAGER_ABI, + FULCRUM_TICK_LOWER, + FULCRUM_TICK_UPPER, + encodeAddParams, + encodeDualExitParams, + encodeExitParams, + formatTokenAmount, + getBrowserProvider, + getFlmConfigBySlug, + getFlmConfigs, + getGnosisExplorerAddressUrl, + getGnosisExplorerTxUrl, + getReadOnlyGnosisProvider, + isConfiguredAddress, + parseTokenAmount, + shortAddress, +} from '../../utils/flm'; + +const DEFAULT_DEADLINE_SECONDS = 1800; +const ZERO = ethers.constants.Zero; + +function nextDeadline() { + return String(Math.floor(Date.now() / 1000) + DEFAULT_DEADLINE_SECONDS); +} + +function formatUtcTime(timestamp) { + if (!timestamp) return 'Pending'; + return `${new Date(timestamp * 1000).toISOString().slice(0, 16).replace('T', ' ')} UTC`; +} + +async function readOr(contractCall, fallback) { + try { + return await contractCall(); + } catch { + return fallback; + } +} + +function safeParseAmount(value, decimals) { + try { + return parseTokenAmount(value, decimals); + } catch { + return ZERO; + } +} + +function FormInput({ + label, + value, + onChange, + placeholder = '0', + inputMode = 'decimal', +}) { + return ( + + ); +} + +function Section({ title, action, children }) { + return ( +
+
+

{title}

+ {action} +
+ {children} +
+ ); +} + +function Metric({ label, value, tone = 'default' }) { + const toneClass = tone === 'accent' ? 'text-futarchyTeal9' : 'text-futarchyGray12'; + return ( +
+
{label}
+
{value}
+
+ ); +} + +function AddressLink({ address }) { + const href = getGnosisExplorerAddressUrl(address); + if (!href) { + return Pending; + } + + return ( + + {shortAddress(address)} + + + ); +} + +function TxStatus({ status }) { + if (!status.message) return null; + + const href = getGnosisExplorerTxUrl(status.hash); + const tone = status.type === 'error' + ? 'border-futarchyCrimson5 bg-futarchyCrimson3 text-futarchyCrimson11' + : 'border-futarchyTeal4 bg-futarchyTeal3 text-futarchyTeal11'; + + return ( +
+
+ {status.message} + {href && ( + + Transaction + + + )} +
+
+ ); +} + +function ActionButton({ + children, + disabled = false, + icon: Icon, + onClick, + variant = 'primary', +}) { + const base = 'inline-flex h-10 items-center justify-center gap-2 rounded-lg px-4 text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-50'; + const variants = { + primary: 'bg-futarchyGray12 text-white hover:bg-futarchyGray11', + secondary: 'border border-futarchyGray5 bg-white text-futarchyGray12 hover:bg-futarchyGray3', + accent: 'bg-futarchyTeal9 text-white hover:bg-futarchyTeal10', + }; + + return ( + + ); +} + +export default function FlmPage({ config }) { + const { address } = useAccount(); + const managerConfigured = isConfiguredAddress(config.managerAddress); + const proposalSourceConfigured = isConfiguredAddress(config.proposalSourceAddress); + + const [deposit, setDeposit] = useState({ + companyAmount: '', + collateralAmount: '', + tickLower: String(FULCRUM_TICK_LOWER), + tickUpper: String(FULCRUM_TICK_UPPER), + amount0Min: '0', + amount1Min: '0', + deadline: '0', + sqrtPriceX96: '0', + }); + + const [redeem, setRedeem] = useState({ + shares: '', + recipient: '', + spotAmount0Min: '0', + spotAmount1Min: '0', + spotDeadline: '0', + yesAmount0Min: '0', + yesAmount1Min: '0', + yesDeadline: '0', + noAmount0Min: '0', + noAmount1Min: '0', + noDeadline: '0', + }); + + const [readState, setReadState] = useState({ + loading: false, + error: null, + token: { + symbol: config.token.symbol, + decimals: config.token.decimals, + balance: ZERO, + allowance: ZERO, + }, + collateral: { + symbol: config.collateral.symbol, + decimals: config.collateral.decimals, + balance: ZERO, + allowance: ZERO, + }, + manager: { + name: 'FLM', + symbol: 'FLM', + shareDecimals: 18, + totalSupply: ZERO, + walletShares: ZERO, + totalManagedLiquidity: ZERO, + spotLiquidity: ZERO, + conditionalLiquidity: ZERO, + conditionalYesLiquidity: ZERO, + conditionalNoLiquidity: ZERO, + inConditionalMode: false, + activeProposal: ethers.constants.AddressZero, + }, + }); + + const [txStatus, setTxStatus] = useState({ type: 'idle', message: '', hash: '' }); + const [pendingAction, setPendingAction] = useState(''); + + useEffect(() => { + if (!address) return; + setRedeem((current) => current.recipient ? current : { ...current, recipient: address }); + }, [address]); + + useEffect(() => { + const deadline = nextDeadline(); + setDeposit((current) => current.deadline !== '0' ? current : { ...current, deadline }); + setRedeem((current) => { + if (current.spotDeadline !== '0' || current.yesDeadline !== '0' || current.noDeadline !== '0') { + return current; + } + + return { + ...current, + spotDeadline: deadline, + yesDeadline: deadline, + noDeadline: deadline, + }; + }); + }, []); + + const refreshPositions = useCallback(async () => { + if (!managerConfigured) { + setReadState((current) => ({ + ...current, + loading: false, + error: null, + })); + return; + } + + setReadState((current) => ({ ...current, loading: true, error: null })); + + try { + const provider = getReadOnlyGnosisProvider(); + const manager = new ethers.Contract(config.managerAddress, FLM_MANAGER_ABI, provider); + const companyToken = new ethers.Contract(config.token.address, ERC20_ABI, provider); + const collateralToken = new ethers.Contract(config.collateral.address, ERC20_ABI, provider); + const wallet = address || ethers.constants.AddressZero; + + const [ + managerName, + managerSymbol, + shareDecimals, + totalSupply, + walletShares, + totalManagedLiquidity, + spotLiquidity, + conditionalLiquidity, + conditionalYesLiquidity, + conditionalNoLiquidity, + inConditionalMode, + activeProposal, + companySymbol, + companyDecimals, + companyBalance, + companyAllowance, + collateralSymbol, + collateralDecimals, + collateralBalance, + collateralAllowance, + ] = await Promise.all([ + readOr(() => manager.name(), 'FLM'), + readOr(() => manager.symbol(), 'FLM'), + readOr(() => manager.decimals(), 18), + readOr(() => manager.totalSupply(), ZERO), + address ? readOr(() => manager.balanceOf(wallet), ZERO) : ZERO, + readOr(() => manager.totalManagedLiquidity(), ZERO), + readOr(() => manager.spotLiquidity(), ZERO), + readOr(() => manager.conditionalLiquidity(), ZERO), + readOr(() => manager.conditionalYesLiquidity(), ZERO), + readOr(() => manager.conditionalNoLiquidity(), ZERO), + readOr(() => manager.inConditionalMode(), false), + readOr(() => manager.activeProposal(), ethers.constants.AddressZero), + readOr(() => companyToken.symbol(), config.token.symbol), + readOr(() => companyToken.decimals(), config.token.decimals), + address ? readOr(() => companyToken.balanceOf(wallet), ZERO) : ZERO, + address ? readOr(() => companyToken.allowance(wallet, config.managerAddress), ZERO) : ZERO, + readOr(() => collateralToken.symbol(), config.collateral.symbol), + readOr(() => collateralToken.decimals(), config.collateral.decimals), + address ? readOr(() => collateralToken.balanceOf(wallet), ZERO) : ZERO, + address ? readOr(() => collateralToken.allowance(wallet, config.managerAddress), ZERO) : ZERO, + ]); + + setReadState({ + loading: false, + error: null, + token: { + symbol: companySymbol, + decimals: companyDecimals, + balance: companyBalance, + allowance: companyAllowance, + }, + collateral: { + symbol: collateralSymbol, + decimals: collateralDecimals, + balance: collateralBalance, + allowance: collateralAllowance, + }, + manager: { + name: managerName, + symbol: managerSymbol, + shareDecimals, + totalSupply, + walletShares, + totalManagedLiquidity, + spotLiquidity, + conditionalLiquidity, + conditionalYesLiquidity, + conditionalNoLiquidity, + inConditionalMode, + activeProposal, + }, + }); + } catch (error) { + setReadState((current) => ({ + ...current, + loading: false, + error: error.message || 'Unable to read FLM position state.', + })); + } + }, [address, config, managerConfigured]); + + useEffect(() => { + refreshPositions(); + }, [refreshPositions]); + + const amountChecks = useMemo(() => { + const companyAmount = safeParseAmount(deposit.companyAmount, readState.token.decimals); + const collateralAmount = safeParseAmount(deposit.collateralAmount, readState.collateral.decimals); + const shareAmount = safeParseAmount(redeem.shares, readState.manager.shareDecimals); + + return { + companyAmount, + collateralAmount, + shareAmount, + needsCompanyApproval: companyAmount.gt(0) && readState.token.allowance.lt(companyAmount), + needsCollateralApproval: collateralAmount.gt(0) && readState.collateral.allowance.lt(collateralAmount), + hasDepositAmount: companyAmount.gt(0) || collateralAmount.gt(0), + hasShares: shareAmount.gt(0), + }; + }, [deposit, readState, redeem.shares]); + + const updateDeposit = (field, value) => { + setDeposit((current) => ({ ...current, [field]: value })); + }; + + const updateRedeem = (field, value) => { + setRedeem((current) => ({ ...current, [field]: value })); + }; + + const runWalletAction = async (actionName, callback) => { + setPendingAction(actionName); + setTxStatus({ type: 'info', message: 'Waiting for wallet confirmation.', hash: '' }); + + try { + const provider = await getBrowserProvider(); + const signer = provider.getSigner(); + const tx = await callback(signer); + setTxStatus({ type: 'info', message: 'Transaction submitted.', hash: tx.hash }); + await tx.wait(); + setTxStatus({ type: 'success', message: 'Transaction confirmed.', hash: tx.hash }); + await refreshPositions(); + } catch (error) { + setTxStatus({ + type: 'error', + message: error?.data?.message || error?.reason || error?.message || 'Transaction failed.', + hash: '', + }); + } finally { + setPendingAction(''); + } + }; + + const approveToken = (kind) => { + const tokenConfig = kind === 'company' ? config.token : config.collateral; + const amount = kind === 'company' ? amountChecks.companyAmount : amountChecks.collateralAmount; + + return runWalletAction(`approve-${kind}`, async (signer) => { + if (!managerConfigured) throw new Error('FLM manager is not configured.'); + if (amount.lte(0)) throw new Error('Enter an approval amount first.'); + + const token = new ethers.Contract(tokenConfig.address, ERC20_ABI, signer); + return token.approve(config.managerAddress, amount); + }); + }; + + const depositToSpot = () => { + return runWalletAction('deposit', async (signer) => { + if (!managerConfigured) throw new Error('FLM manager is not configured.'); + if (!amountChecks.hasDepositAmount) throw new Error('Enter a deposit amount first.'); + if (amountChecks.needsCompanyApproval || amountChecks.needsCollateralApproval) { + throw new Error('Approve the deposit tokens first.'); + } + + const manager = new ethers.Contract(config.managerAddress, FLM_MANAGER_ABI, signer); + const addData = encodeAddParams({ + tickLower: deposit.tickLower, + tickUpper: deposit.tickUpper, + amount0Min: deposit.amount0Min, + amount1Min: deposit.amount1Min, + deadline: deposit.deadline, + sqrtPriceX96: deposit.sqrtPriceX96, + }); + + return manager['depositToSpot(uint256,uint256,bytes)']( + amountChecks.companyAmount, + amountChecks.collateralAmount, + addData + ); + }); + }; + + const redeemShares = () => { + return runWalletAction('redeem', async (signer) => { + if (!managerConfigured) throw new Error('FLM manager is not configured.'); + if (!amountChecks.hasShares) throw new Error('Enter a share amount first.'); + if (!isConfiguredAddress(redeem.recipient)) throw new Error('Enter a valid recipient address.'); + + const manager = new ethers.Contract(config.managerAddress, FLM_MANAGER_ABI, signer); + const spotExitData = encodeExitParams({ + amount0Min: redeem.spotAmount0Min, + amount1Min: redeem.spotAmount1Min, + deadline: redeem.spotDeadline, + }); + const yesExitData = encodeExitParams({ + amount0Min: redeem.yesAmount0Min, + amount1Min: redeem.yesAmount1Min, + deadline: redeem.yesDeadline, + }); + const noExitData = encodeExitParams({ + amount0Min: redeem.noAmount0Min, + amount1Min: redeem.noAmount1Min, + deadline: redeem.noDeadline, + }); + + return manager.redeem( + amountChecks.shareAmount, + redeem.recipient, + false, + spotExitData, + encodeDualExitParams(yesExitData, noExitData) + ); + }); + }; + + const disabledReason = !managerConfigured ? 'Pending deployment' : (!address ? 'Connect wallet' : ''); + const proposal = config.activeProposal; + + return ( + <> + + {config.organizationName} FLM | Futarchy + + + + +
+ +
+
+
+
+

+ {config.organizationName} FLM +

+

+ {config.pair} +

+
+ + Gnosis Chain + + + {config.status} + + {disabledReason && ( + + {disabledReason} + + )} +
+
+
+ +
+
+ + + +
+ {readState.loading ? 'Refreshing' : 'Refresh'} + + )} + > +
+ } /> + } /> + } /> + +
+ {readState.error && ( +
+ {readState.error} +
+ )} +
+ +
+
+
+
+ {proposal.label} +
+
{proposal.title}
+
+ Closes {formatUtcTime(proposal.closeTimestamp)} +
+
+
+ + + Market + + + + Snapshot + +
+
+
+ } /> + + } /> +
+
+ +
+
+ + + + + + + + +
+
+ + +
+
+ +
+
+
+ updateDeposit('companyAmount', value)} + /> + updateDeposit('collateralAmount', value)} + /> + updateDeposit('tickLower', value)} + inputMode="numeric" + /> + updateDeposit('tickUpper', value)} + inputMode="numeric" + /> + updateDeposit('amount0Min', value)} + /> + updateDeposit('amount1Min', value)} + /> + updateDeposit('deadline', value)} + inputMode="numeric" + /> + updateDeposit('sqrtPriceX96', value)} + inputMode="numeric" + /> +
+ +
+ approveToken('company')} + disabled={!managerConfigured || !address || Boolean(pendingAction) || !amountChecks.companyAmount.gt(0)} + > + Approve {config.token.symbol} + + approveToken('collateral')} + disabled={!managerConfigured || !address || Boolean(pendingAction) || !amountChecks.collateralAmount.gt(0)} + > + Approve {config.collateral.symbol} + + + Deposit + +
+ +
+ Allowance: {formatTokenAmount(readState.token.allowance, readState.token.decimals)} {config.token.symbol} + {' / '} + {formatTokenAmount(readState.collateral.allowance, readState.collateral.decimals)} {config.collateral.symbol} +
+
+ +
+
+ updateRedeem('shares', value)} + /> + updateRedeem('recipient', value)} + placeholder="0x..." + inputMode="text" + /> + updateRedeem('spotAmount0Min', value)} + /> + updateRedeem('spotAmount1Min', value)} + /> + updateRedeem('spotDeadline', value)} + inputMode="numeric" + /> + updateRedeem('yesAmount0Min', value)} + /> + updateRedeem('yesAmount1Min', value)} + /> + updateRedeem('yesDeadline', value)} + inputMode="numeric" + /> + updateRedeem('noAmount0Min', value)} + /> + updateRedeem('noAmount1Min', value)} + /> + updateRedeem('noDeadline', value)} + inputMode="numeric" + /> +
+ +
+ + Redeem + +
+
+
+
+
+ + ); +} + +export async function getStaticPaths() { + return { + paths: getFlmConfigs().map((config) => ({ params: { org: config.slug } })), + fallback: false, + }; +} + +export async function getStaticProps({ params }) { + const config = getFlmConfigBySlug(params.org); + + if (!config) { + return { notFound: true }; + } + + return { + props: { + config, + }, + }; +} diff --git a/src/utils/flm.js b/src/utils/flm.js new file mode 100644 index 0000000..ae5bd13 --- /dev/null +++ b/src/utils/flm.js @@ -0,0 +1,179 @@ +import { ethers } from 'ethers'; + +import flmConfig from '../config/flm.json'; + +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; +const ADDRESS_RE = /^0x[a-fA-F0-9]{40}$/; +const GNOSIS_CHAIN_ID = 100; +const GNOSIS_CHAIN_ID_HEX = '0x64'; +const GNOSIS_RPC_URL = process.env.NEXT_PUBLIC_GNOSIS_RPC || 'https://rpc.gnosischain.com'; +const GNOSIS_EXPLORER_URL = 'https://gnosisscan.io'; + +const ADDRESS_OVERRIDES = { + kleros: { + managerAddress: process.env.NEXT_PUBLIC_KLEROS_FLM_MANAGER, + proposalSourceAddress: process.env.NEXT_PUBLIC_KLEROS_FLM_PROPOSAL_SOURCE, + }, + gnosis: { + managerAddress: process.env.NEXT_PUBLIC_GNOSIS_FLM_MANAGER, + proposalSourceAddress: process.env.NEXT_PUBLIC_GNOSIS_FLM_PROPOSAL_SOURCE, + }, +}; + +export const FULCRUM_TICK_LOWER = -887220; +export const FULCRUM_TICK_UPPER = 887220; + +export const FLM_MANAGER_ABI = [ + 'function activeProposal() view returns (address)', + 'function balanceOf(address) view returns (uint256)', + 'function conditionalLiquidity() view returns (uint128)', + 'function conditionalNoLiquidity() view returns (uint128)', + 'function conditionalYesLiquidity() view returns (uint128)', + 'function decimals() view returns (uint8)', + 'function depositToSpot(uint256,uint256,bytes) returns (uint128,uint256)', + 'function inConditionalMode() view returns (bool)', + 'function name() view returns (string)', + 'function redeem(uint256,address,bool,bytes,bytes) returns (uint256,uint256)', + 'function spotLiquidity() view returns (uint128)', + 'function symbol() view returns (string)', + 'function totalManagedLiquidity() view returns (uint256)', + 'function totalSupply() view returns (uint256)', +]; + +export const ERC20_ABI = [ + 'function allowance(address,address) view returns (uint256)', + 'function approve(address,uint256) returns (bool)', + 'function balanceOf(address) view returns (uint256)', + 'function decimals() view returns (uint8)', + 'function symbol() view returns (string)', +]; + +function withAddressOverrides(config) { + const overrides = ADDRESS_OVERRIDES[config.slug] || {}; + return { + ...config, + managerAddress: overrides.managerAddress || config.managerAddress, + proposalSourceAddress: overrides.proposalSourceAddress || config.proposalSourceAddress, + }; +} + +export function getFlmConfigs() { + return flmConfig.map(withAddressOverrides); +} + +export function getFlmConfigBySlug(slug) { + const normalized = String(slug || '').toLowerCase(); + return getFlmConfigs().find((config) => config.slug === normalized) || null; +} + +export function getFlmConfigByOrgId(orgId) { + const normalized = String(orgId || '').toLowerCase(); + return getFlmConfigs().find((config) => { + return String(config.companyId) === normalized + || config.organizationAddress.toLowerCase() === normalized; + }) || null; +} + +export function getFlmPathForOrg(orgId) { + return getFlmConfigByOrgId(orgId)?.path || null; +} + +export function isConfiguredAddress(address) { + return ADDRESS_RE.test(String(address || '')) && String(address).toLowerCase() !== ZERO_ADDRESS; +} + +export function shortAddress(address) { + if (!address) return 'Pending'; + return `${address.slice(0, 6)}...${address.slice(-4)}`; +} + +export function getGnosisExplorerAddressUrl(address) { + return isConfiguredAddress(address) ? `${GNOSIS_EXPLORER_URL}/address/${address}` : null; +} + +export function getGnosisExplorerTxUrl(hash) { + return hash ? `${GNOSIS_EXPLORER_URL}/tx/${hash}` : null; +} + +export function getReadOnlyGnosisProvider() { + return new ethers.providers.JsonRpcProvider(GNOSIS_RPC_URL, GNOSIS_CHAIN_ID); +} + +export async function getBrowserProvider() { + if (typeof window === 'undefined' || !window.ethereum) { + throw new Error('No browser wallet was found.'); + } + + await window.ethereum.request({ method: 'eth_requestAccounts' }); + const provider = new ethers.providers.Web3Provider(window.ethereum); + const network = await provider.getNetwork(); + + if (network.chainId !== GNOSIS_CHAIN_ID) { + try { + await window.ethereum.request({ + method: 'wallet_switchEthereumChain', + params: [{ chainId: GNOSIS_CHAIN_ID_HEX }], + }); + } catch (error) { + if (error?.code !== 4902) { + throw error; + } + + await window.ethereum.request({ + method: 'wallet_addEthereumChain', + params: [{ + chainId: GNOSIS_CHAIN_ID_HEX, + chainName: 'Gnosis Chain', + nativeCurrency: { name: 'xDAI', symbol: 'xDAI', decimals: 18 }, + rpcUrls: [GNOSIS_RPC_URL], + blockExplorerUrls: [GNOSIS_EXPLORER_URL], + }], + }); + } + } + + return new ethers.providers.Web3Provider(window.ethereum); +} + +export function parseTokenAmount(value, decimals = 18) { + const normalized = String(value || '').trim(); + if (!normalized) return ethers.constants.Zero; + return ethers.utils.parseUnits(normalized, decimals); +} + +export function formatTokenAmount(value, decimals = 18, precision = 6) { + if (value === null || value === undefined) return '0'; + const formatted = ethers.utils.formatUnits(value, decimals); + const [whole, fraction = ''] = formatted.split('.'); + const trimmedFraction = fraction.slice(0, precision).replace(/0+$/, ''); + return trimmedFraction ? `${whole}.${trimmedFraction}` : whole; +} + +export function encodeAddParams({ + tickLower = FULCRUM_TICK_LOWER, + tickUpper = FULCRUM_TICK_UPPER, + amount0Min = '0', + amount1Min = '0', + deadline = '0', + sqrtPriceX96 = '0', +} = {}) { + return ethers.utils.defaultAbiCoder.encode( + ['tuple(int24 tickLower,int24 tickUpper,uint256 amount0Min,uint256 amount1Min,uint256 deadline,uint160 sqrtPriceX96)'], + [[tickLower, tickUpper, amount0Min, amount1Min, deadline, sqrtPriceX96]] + ); +} + +export function encodeExitParams({ + amount0Min = '0', + amount1Min = '0', + deadline = '0', +} = {}) { + return ethers.utils.defaultAbiCoder.encode( + ['tuple(uint256 amount0Min,uint256 amount1Min,uint256 deadline)'], + [[amount0Min, amount1Min, deadline]] + ); +} + +export function encodeDualExitParams(yesExitData, noExitData) { + return ethers.utils.defaultAbiCoder.encode(['bytes', 'bytes'], [yesExitData, noExitData]); +}