Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions libs/evm-protocols/src/common-protocol/chainConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,9 @@ export const factoryContracts: FactoryContractsType = {
CommunityNominations: '0xDB04d3bdf53e3F7d2314d9C19Ec8420b2EeCda93',
TokenLaunchpad: '0x1fB82e534F0E81527970BFA3096ED1b728922ff8',
TokenBondingCurve: '0x3b3A346A679fd721710D778551766A71482926dd',
BinaryVault: '0xb3608fCB7Fa532aDC3cc10A66aF630A30cdB5E29',
FutarchyRouter: '0x7074A056B3ff6eCFBCc2F390a752cd0B00bAe53e',
FutarchyGovernor: '0x158F43491Ea75081752Dec4b906ECb3708250AdE',
chainId: 84532,
},
[ValidChains.Blast]: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,288 @@
/**
* Prediction market (Futarchy) governor contract helper.
* Address from chainConfig only (libs/evm-protocols factoryContracts).
* We wrap propose() and router(); the contract may expose more (e.g. resolve, cancel).
*/
import {
BinaryVaultAbi,
FutarchyGovernorAbi,
} from '@commonxyz/common-protocol-abis';
import { erc20Abi, factoryContracts } from '@hicommonwealth/evm-protocols';
import { ZERO_ADDRESS } from '@hicommonwealth/shared';
import { decodeEventLog, type Address } from 'viem';
import type { AbiItem } from 'web3-utils';
import ContractBase from './ContractBase';

export type DeployPredictionMarketPayload = {
vault_address: `0x${string}`;
governor_address: `0x${string}`;
router_address: `0x${string}`;
strategy_address: `0x${string}`;
p_token_address: `0x${string}`;
f_token_address: `0x${string}`;
start_time: Date;
end_time: Date;
};

export type DeployParams = {
user_address: string;
collateral_address: `0x${string}`;
duration_days: number;
resolution_threshold: number;
initial_liquidity: string;
};

function randomBytes32(): `0x${string}` {
const bytes = new Uint8Array(32);
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
crypto.getRandomValues(bytes);
} else {
for (let i = 0; i < 32; i++) bytes[i] = Math.floor(Math.random() * 256);
}
return `0x${Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('')}` as `0x${string}`;
}

/** Parse human-readable token amount to smallest units (e.g. "10", 6 decimals → 10_000_000n). */
function parseTokenAmount(value: string, decimals: number): bigint {
if (!value || value.trim() === '') return 0n;
const trimmed = value.trim();
const dot = trimmed.indexOf('.');
const whole = dot === -1 ? trimmed : trimmed.slice(0, dot);
const frac = dot === -1 ? '' : trimmed.slice(dot + 1).slice(0, decimals);
const combined = whole + frac.padEnd(decimals, '0').slice(0, decimals);
return BigInt(combined || '0');
}

class PredictionMarket extends ContractBase {
constructor(governorAddress: string, rpc: string) {
super(governorAddress, FutarchyGovernorAbi as unknown as AbiItem[], rpc);
}

/** Governor address for the given chain from chainConfig, or null if not set / zero. */
static getGovernorAddress(ethChainId: number): string | null {
const entry = Object.values(factoryContracts).find(
(c) => c.chainId === ethChainId,
);
if (!entry) return null;
const addr = (entry as Partial<Record<'FutarchyGovernor', string>>)
.FutarchyGovernor;
if (
!addr ||
typeof addr !== 'string' ||
!addr.startsWith('0x') ||
addr === ZERO_ADDRESS
)
return null;
return addr;
}

static isDeployConfigured(ethChainId: number): boolean {
return PredictionMarket.getGovernorAddress(ethChainId) != null;
}

/** Read router address from the governor contract. */
async getRouter(): Promise<`0x${string}`> {
this.isInitialized();
const r = (await this.contract.methods.router().call()) as unknown;
if (typeof r === 'string' && r.startsWith('0x')) {
return r as `0x${string}`;
}
return '0x0000000000000000000000000000000000000000' as `0x${string}`;
}

/**
* Send propose tx. Returns the transaction receipt.
*/
async propose(
proposalId: `0x${string}`,
marketId: `0x${string}`,
collateralAddress: `0x${string}`,
durationSeconds: bigint,
resolutionThreshold: bigint,
initialLiquidityWei: bigint,
fromAddress: string,
): Promise<{
logs?: Array<{ address?: string; data?: string; topics?: string[] }>;
}> {
console.log('comes here => ', {
proposalId,
marketId,
collateralAddress,
durationSeconds,
resolutionThreshold,
initialLiquidityWei,
fromAddress,
});
this.isInitialized();

// Approve governor to spend collateral before propose (required when initialLiquidity > 0).
// Matches common-protocol prediction_market_helpers_frontend: approve then propose.
if (initialLiquidityWei > 0n) {
const collateralToken = new this.web3.eth.Contract(
erc20Abi as unknown as AbiItem[],
collateralAddress,
);
const spender = this.contractAddress;
const currentAllowance = BigInt(
(await collateralToken.methods
.allowance(fromAddress, spender)
.call()) as string,
);
if (currentAllowance < initialLiquidityWei) {
try {
await collateralToken.methods
.approve(spender, initialLiquidityWei)
.send({ from: fromAddress });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (/user rejected|denied|reject/i.test(msg)) {
throw new Error('Approval was rejected by the user.');
}
if (/insufficient funds|not enough balance/i.test(msg)) {
throw new Error('Insufficient collateral balance for approval.');
}
throw err;
}
}
}

const tx = this.contract.methods.propose(
proposalId,
marketId,
collateralAddress,
durationSeconds,
resolutionThreshold,
initialLiquidityWei,
);
try {
const gas = await tx.estimateGas({ from: fromAddress });
return await tx.send({
from: fromAddress,
gas: String(BigInt(gas.toString()) + 100000n),
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (/user rejected|denied|reject/i.test(msg)) {
throw new Error('Transaction was rejected by the user.');
}
if (/insufficient funds|not enough balance/i.test(msg)) {
throw new Error('Insufficient funds for gas.');
}
throw err;
}
}

/**
* Propose a new market, decode events, and return the payload for deployPredictionMarket mutation.
*/
async deploy(params: DeployParams): Promise<DeployPredictionMarketPayload> {
this.isInitialized();
const proposalId = randomBytes32();
const marketId = randomBytes32();
const durationSeconds = BigInt(params.duration_days * 86400);
// Contract expects resolution threshold in 1e18 scale
const resolutionThresholdWei = BigInt(
Math.round(params.resolution_threshold * 1e18),
);

// Use collateral token decimals so amount matches balance
let initialLiquidityWei = 0n;
const liquidityInput = params.initial_liquidity?.trim();
if (liquidityInput && liquidityInput !== '0') {
const collateralToken = new this.web3.eth.Contract(
erc20Abi as unknown as AbiItem[],
params.collateral_address,
);
const decimals = Number(
(await collateralToken.methods.decimals().call()) as string | number,
);
const decimalsNum =
typeof decimals === 'number' && !Number.isNaN(decimals) ? decimals : 18;
initialLiquidityWei = parseTokenAmount(liquidityInput, decimalsNum);
}

const rawReceipt = await this.propose(
proposalId,
marketId,
params.collateral_address,
durationSeconds,
resolutionThresholdWei,
initialLiquidityWei,
params.user_address,
);

const logs: Array<{ address: string; data: string; topics: string[] }> = (
rawReceipt.logs ?? []
).map((log: { address?: string; data?: string; topics?: string[] }) => ({
address: log.address ?? '',
data: log.data ?? '0x',
topics: Array.isArray(log.topics) ? log.topics : [],
}));

let strategy_address: `0x${string}` =
'0x0000000000000000000000000000000000000000' as `0x${string}`;
let startTime = 0n;
let endTime = 0n;
let vault_address: `0x${string}` =
'0x0000000000000000000000000000000000000000' as `0x${string}`;
let p_token_address: `0x${string}` =
'0x0000000000000000000000000000000000000000' as `0x${string}`;
let f_token_address: `0x${string}` =
'0x0000000000000000000000000000000000000000' as `0x${string}`;

for (const log of logs) {
try {
const decoded = decodeEventLog({
abi: FutarchyGovernorAbi,
data: log.data as `0x${string}`,
topics: log.topics as [`0x${string}`, ...`0x${string}`[]],
strict: false,
});
if (decoded.eventName === 'ProposalCreated') {
strategy_address = (decoded.args as { strategy: Address }).strategy;
startTime = (decoded.args as { startTime: bigint }).startTime;
endTime = (decoded.args as { endTime: bigint }).endTime;
break;
}
} catch {
// not ProposalCreated
}
}

for (const log of logs) {
try {
const decoded = decodeEventLog({
abi: BinaryVaultAbi,
data: log.data as `0x${string}`,
topics: log.topics as [`0x${string}`, ...`0x${string}`[]],
strict: false,
});
if (decoded.eventName === 'MarketCreated') {
vault_address = log.address as `0x${string}`;
p_token_address = (decoded.args as { pToken: Address }).pToken;
f_token_address = (decoded.args as { fToken: Address }).fToken;
break;
}
} catch {
// not MarketCreated
}
}

const router_address = await this.getRouter();

return {
governor_address: this.contractAddress as `0x${string}`,
vault_address,
router_address,
strategy_address,
p_token_address,
f_token_address,
start_time: new Date(Number(startTime) * 1000),
end_time: new Date(Number(endTime) * 1000),
};
}
}

export default PredictionMarket;
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { trpc } from 'client/scripts/utils/trpcClient';

// predictionMarket router is conditionally registered when MARKETS.FUTARCHY_ENABLED
const useCreatePredictionMarketMutation = () => {
const utils = trpc.useUtils();
return trpc.predictionMarket.createPredictionMarket.useMutation({
onSuccess: async (_data, variables) => {
await utils.predictionMarket.getPredictionMarkets.invalidate({
thread_id: variables.thread_id,
});
},
});
};

export default useCreatePredictionMarketMutation;
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { trpc } from 'client/scripts/utils/trpcClient';

const useDeployPredictionMarketMutation = () => {
const utils = trpc.useUtils();
return trpc.predictionMarket.deployPredictionMarket.useMutation({
onSuccess: async (_data, variables) => {
await utils.predictionMarket.getPredictionMarkets.invalidate({
thread_id: variables.thread_id,
});
},
});
};

export default useDeployPredictionMarketMutation;
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { trpc } from 'client/scripts/utils/trpcClient';

type GetPredictionMarketsParams = {
thread_id: number;
limit?: number;
cursor?: number;
};

const useGetPredictionMarketsQuery = (params: GetPredictionMarketsParams) => {
return trpc.predictionMarket.getPredictionMarkets.useQuery(
{ ...params, cursor: params.cursor ?? 1 },
{ enabled: !!params.thread_id },
);
};

export default useGetPredictionMarketsQuery;
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import useCreatePredictionMarketMutation from './createPredictionMarket';
import useDeployPredictionMarketMutation from './deployPredictionMarket';
import useGetPredictionMarketsQuery from './getPredictionMarkets';

export {
useCreatePredictionMarketMutation,
useDeployPredictionMarketMutation,
useGetPredictionMarketsQuery,
};
Loading
Loading