Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
25a90f3
feat(#1444): add validation schema and orchestrator
DSanich Mar 10, 2026
c7274b6
feat(#1444): add treasury plugin and governance form
DSanich Mar 10, 2026
cc977f0
feat(#1444): add Settings route and navigation
DSanich Mar 10, 2026
26e42d2
feat(#1444): added space-token-purchase components
DSanich Mar 10, 2026
4d234f5
feat(#1444): add Buy Space tokens on profile
DSanich Mar 10, 2026
e27237d
feat(#1444): add web3 token purchase proposal flow and detail view
DSanich Mar 24, 2026
87b0fcf
feat(#1444): enforce purchase eligibility and limit calculations in UI
DSanich Mar 24, 2026
e319873
feat(#1444): polish token purchase proposal detail labels
DSanich Mar 24, 2026
85b315a
feat(#1444): add approve step to token purchase proposal
DSanich Mar 24, 2026
a6d46b9
feat(#1444): refine token purchase proposal setup flow
DSanich Mar 24, 2026
3ca0116
feat(#1444): align profile purchase UI with standard forms
DSanich Mar 24, 2026
6515c5d
fix(token-purchase): persist resubmit data and one-click buy flow
cursoragent Mar 28, 2026
5a3ac8e
fix(token-purchase): read fresh allowance during one-click buy
cursoragent Mar 28, 2026
ad29208
fix(token-purchase): keep form hidden when no purchasable tokens
cursoragent Mar 28, 2026
20107b3
Merge pull request #2069 from hypha-dao/cursor/token-purchase-form-ex…
alexprate Mar 28, 2026
059449c
fix(governance): restore space token purchase form and hydrate from c…
cursoragent Mar 28, 2026
3f457a0
fix(governance): sync activate token purchase toggle from chain
cursoragent Mar 28, 2026
cb077ab
refactor(governance): derive sale toggle from paymentToken() on token
cursoragent Mar 28, 2026
05d1d54
fix(governance): unblock space token purchase publish when chain retu…
cursoragent Mar 28, 2026
cd2b57e
fix(governance): restore Zod extend for space token purchase form
cursoragent Mar 28, 2026
2d9ca63
Merge pull request #2076 from hypha-dao/cursor/space-token-redemption…
alexprate Mar 28, 2026
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import {
SidePanel,
SpaceTokenPurchaseForm,
SpaceTokenPurchasePlugin,
} from '@hypha-platform/epics';
import { Locale } from '@hypha-platform/i18n';
import { notFound } from 'next/navigation';
import { PATH_SELECT_SETTINGS_ACTION } from '@web/app/constants';
import { getDhoPathAgreements } from '../../../../@tab/agreements/constants';
import { findSpaceBySlug } from '@hypha-platform/core/server';
import { db } from '@hypha-platform/storage-postgres';

type PageProps = {
params: Promise<{ lang: Locale; id: string; tab: string }>;
searchParams: Promise<{ hideBack?: string }>;
};

export default async function SpaceTokenPurchasePage({
params,
searchParams,
}: PageProps) {
const { lang, id, tab } = await params;
const { hideBack = 'false' } = await searchParams;
const hideBackUrl = hideBack?.toLowerCase?.() === 'true';

const spaceFromDb = await findSpaceBySlug({ slug: id }, { db });

if (!spaceFromDb) notFound();

const { id: spaceId, web3SpaceId, slug } = spaceFromDb;

const successfulUrl = getDhoPathAgreements(lang, id);
const closeUrl = `/${lang}/dho/${id}/${tab}`;
const backUrl = hideBackUrl
? undefined
: `${closeUrl}${PATH_SELECT_SETTINGS_ACTION}`;

return (
<SidePanel>
<SpaceTokenPurchaseForm
spaceId={spaceId}
web3SpaceId={web3SpaceId}
successfulUrl={successfulUrl}
backUrl={backUrl}
closeUrl={closeUrl}
plugin={
<SpaceTokenPurchasePlugin
spaceSlug={slug}
spaceId={spaceId}
web3SpaceId={web3SpaceId}
/>
}
/>
</SidePanel>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,14 @@ export const SelectSettingsAction = ({
baseTab: 'agreements',
disabled: isPaymentExpired,
},
{
group: t('groups.treasury'),
title: t('actions.spaceTokenPurchase.title'),
description: t('actions.spaceTokenPurchase.description'),
href: 'create/space-token-purchase',
icon: <ArrowLeftIcon />,
disabled: isPaymentExpired,
},
{
group: t('groups.treasury'),
title: t('actions.buyHyphaTokensRewards.title'),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import {
SidePanel,
ButtonBack,
ButtonClose,
ProfilePageParams,
PeopleBuySpaceTokens,
} from '@hypha-platform/epics';
import { tryDecodeUriPart } from '@hypha-platform/ui-utils';

type PageProps = {
params: Promise<ProfilePageParams>;
};

export default async function BuySpaceTokensProfile(props: PageProps) {
const { lang, personSlug: personSlugRaw } = await props.params;
const personSlug = tryDecodeUriPart(personSlugRaw);
const closeUrl = `/${lang}/profile/${personSlug}`;

return (
<SidePanel>
<div className="flex flex-col gap-5">
<div className="flex gap-5 justify-between">
<h2 className="text-4 text-secondary-foreground justify-start items-center">
Buy Space Tokens
</h2>
<div className="flex gap-5 justify-end items-center">
<ButtonBack
label="Back to actions"
backUrl={`/${lang}/profile/${personSlug}/actions`}
/>
<ButtonClose closeUrl={closeUrl} />
</div>
</div>
<span className="text-2 text-neutral-11">
Purchase your space&apos;s native tokens using the configured payment
currency.
</span>
<PeopleBuySpaceTokens personSlug={personSlug} closeUrl={closeUrl} />
</div>
</SidePanel>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ export default function ProfileWallet() {
href: 'purchase-hypha-tokens',
icon: <ArrowLeftIcon />,
},
{
id: 'buySpaceTokens',
title: 'Buy Space Tokens',
description:
"Purchase your space's native tokens using the configured payment currency.",
href: 'buy-space-tokens',
icon: <ArrowLeftIcon />,
},
{
id: 'activateSpaces',
title: tActions('actions.activateSpaces.title'),
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/governance/client/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,6 @@ export * from './useTokenBackingVaultOrchestrator';
export * from './useMembershipExitOrchestrator';
export * from './useMembershipExitMutations.web.rpc';
export * from './useWithdrawProposal';
export * from './useSpaceTokenPurchaseOrchestrator';
export * from './useSpaceTokenPurchaseMutations.web3.rsc';
export * from './useSpaceTokenSaleDetailsFromChain';
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,14 @@ export const useProposalDetailsWeb3Rpc = ({
whitelistedAddresses?: string[];
} = {};

let spaceTokenPurchaseData: {
tokenAddress?: string;
paymentToken?: string;
paymentTokenPricePerToken?: bigint;
tokensForSale?: bigint;
isActive?: boolean;
} = {};

(transactions as any[]).forEach((tx) => {
const decoded = decodeTransaction(tx);

Expand Down Expand Up @@ -384,6 +392,16 @@ export const useProposalDetailsWeb3Rpc = ({
break;
}

case 'spaceTokenPurchase':
spaceTokenPurchaseData = {
...decoded.data,
tokenAddress: tx.target,
isActive:
decoded.data.paymentToken !==
'0x0000000000000000000000000000000000000000',
};
break;

default:
break;
}
Expand Down Expand Up @@ -415,6 +433,7 @@ export const useProposalDetailsWeb3Rpc = ({
membershipExitData,
transparencySettingsData,
tokenBackingVaultData,
spaceTokenPurchaseData,
};
}, [data]);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
'use client';

import useSWRMutation from 'swr/mutation';
import useSWR from 'swr';
import { encodeFunctionData, erc20Abi, maxUint256 } from 'viem';
import { useSmartWallets } from '@privy-io/react-auth/smart-wallets';
import {
schemaCreateProposalWeb3,
publicClient,
getSpaceMinProposalDuration,
TOKENS,
getTokenDecimals,
} from '@hypha-platform/core/client';
import {
getProposalFromLogs,
mapToCreateProposalWeb3Input,
createProposal,
} from '../web3';
import { decayingSpaceTokenAbi } from '@hypha-platform/core/generated';
import { getDuration } from '@hypha-platform/ui-utils';

const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' as const;
const USDC_TOKEN = TOKENS.find((token) => token.symbol === 'USDC');
const EURC_TOKEN = TOKENS.find((token) => token.symbol === 'EURC');

type SpaceTokenPurchaseInput = {
spaceId: number;
tokenAddress: `0x${string}`;
activatePurchase: boolean;
purchasePrice?: number;
purchaseCurrency?: string;
tokensAvailableForPurchase?: number;
};

export const useSpaceTokenPurchaseMutationsWeb3Rpc = ({
proposalSlug,
}: {
proposalSlug?: string | null;
}) => {
const { client } = useSmartWallets();

const {
trigger: createSpaceTokenPurchaseProposal,
reset: resetCreateSpaceTokenPurchaseProposal,
isMutating: isCreatingSpaceTokenPurchaseProposal,
data: createSpaceTokenPurchaseHash,
error: createSpaceTokenPurchaseError,
} = useSWRMutation(
`spaceTokenPurchase-${proposalSlug}`,
async (_: string, { arg }: { arg: SpaceTokenPurchaseInput }) => {
if (!client) {
throw new Error('Smart wallet client not available');
}

const duration = await publicClient.readContract(
getSpaceMinProposalDuration({ spaceId: BigInt(arg.spaceId) }),
);

const transactions: Array<{
target: `0x${string}`;
value: bigint;
data: `0x${string}`;
}> = [];

const paymentTokenAddress = (() => {
if (!arg.activatePurchase) return ZERO_ADDRESS;
if (arg.purchaseCurrency === 'EUR') {
return (EURC_TOKEN?.address as `0x${string}`) ?? ZERO_ADDRESS;
}
return (USDC_TOKEN?.address as `0x${string}`) ?? ZERO_ADDRESS;
})();
const paymentTokenDecimals =
paymentTokenAddress !== ZERO_ADDRESS
? await getTokenDecimals(paymentTokenAddress)
: 6;
const paymentTokenPricePerToken =
arg.activatePurchase && arg.purchasePrice
? BigInt(Math.round(arg.purchasePrice * 10 ** paymentTokenDecimals))
: 0n;
const tokensForSale =
arg.activatePurchase && arg.tokensAvailableForPurchase
? BigInt(arg.tokensAvailableForPurchase) * 10n ** 18n
: 0n;

// Allow the token contract to move sale inventory from the space executor
// when the proposal is executed.
if (arg.activatePurchase && tokensForSale > 0n) {
transactions.push({
target: arg.tokenAddress,
value: 0n,
data: encodeFunctionData({
abi: erc20Abi,
functionName: 'approve',
args: [arg.tokenAddress, maxUint256],
}),
});
}

transactions.push({
target: arg.tokenAddress,
value: 0n,
data: encodeFunctionData({
abi: decayingSpaceTokenAbi,
functionName: 'configureTokenSale',
args: [paymentTokenAddress, paymentTokenPricePerToken, tokensForSale],
}),
});

const parsedInput = schemaCreateProposalWeb3.parse({
spaceId: BigInt(arg.spaceId),
duration: duration && duration > 0 ? duration : getDuration(4),
transactions,
});

const proposalArgs = mapToCreateProposalWeb3Input(parsedInput);
const txHash = await client.writeContract(createProposal(proposalArgs));

return txHash;
},
);

const {
data: createdSpaceTokenPurchaseProposal,
isLoading: isLoadingCreatedSpaceTokenPurchaseProposal,
error: errorWaitCreatedSpaceTokenPurchaseProposal,
} = useSWR(
createSpaceTokenPurchaseHash
? [createSpaceTokenPurchaseHash, 'waitForSpaceTokenPurchaseProposal']
: null,
async ([hash]) => {
const receipt = await publicClient.waitForTransactionReceipt({
hash: hash as `0x${string}`,
});
return getProposalFromLogs(receipt.logs);
},
);

return {
createSpaceTokenPurchaseProposal,
resetCreateSpaceTokenPurchaseProposal,
isCreatingSpaceTokenPurchaseProposal,
createSpaceTokenPurchaseHash,
createSpaceTokenPurchaseError,
createdSpaceTokenPurchaseProposal,
isLoadingCreatedSpaceTokenPurchaseProposal,
errorWaitCreatedSpaceTokenPurchaseProposal,
};
};
Loading
Loading