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 (
+
+ );
+}
+
+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 (
+
+ );
+}
+
+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)}
+
+
+
+
+
+ } />
+
+ } />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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"
+ />
+
+
+
+
+
+
+
+ >
+ );
+}
+
+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]);
+}