From 55a8627f854148ab0af1c17ed4453180851f6ee9 Mon Sep 17 00:00:00 2001 From: Pierre Bertet Date: Fri, 13 Dec 2024 10:41:45 +0000 Subject: [PATCH 01/29] Updated tx flow declaration format Also: - Staking through governance - Permit (untested) --- frontend/app/src/abi/Erc2612.ts | 39 + frontend/app/src/abi/LqtyToken.ts | 324 ++++--- frontend/app/src/contracts.ts | 3 +- frontend/app/src/permit.ts | 85 ++ .../TransactionsScreen/PermissionStatus.tsx | 36 + .../TransactionsScreen/TransactionStatus.tsx | 52 ++ .../TransactionsScreen/TransactionsScreen.tsx | 131 ++- frontend/app/src/services/TransactionFlow.tsx | 854 +++++++----------- .../src/tx-flows/claimCollateralSurplus.tsx | 89 +- .../app/src/tx-flows/closeLoanPosition.tsx | 280 +++--- .../app/src/tx-flows/earnClaimRewards.tsx | 75 +- frontend/app/src/tx-flows/earnDeposit.tsx | 103 +-- frontend/app/src/tx-flows/earnWithdraw.tsx | 97 +- .../app/src/tx-flows/openBorrowPosition.tsx | 386 ++++---- .../app/src/tx-flows/openLeveragePosition.tsx | 336 ++++--- frontend/app/src/tx-flows/shared.ts | 48 + .../app/src/tx-flows/stakeClaimRewards.tsx | 88 +- frontend/app/src/tx-flows/stakeDeposit.tsx | 214 +++-- frontend/app/src/tx-flows/unstakeDeposit.tsx | 90 +- .../app/src/tx-flows/updateBorrowPosition.tsx | 535 ++++++----- .../src/tx-flows/updateLeveragePosition.tsx | 491 +++++----- .../src/tx-flows/updateLoanInterestRate.tsx | 245 ++--- 22 files changed, 2400 insertions(+), 2201 deletions(-) create mode 100644 frontend/app/src/abi/Erc2612.ts create mode 100644 frontend/app/src/permit.ts create mode 100644 frontend/app/src/screens/TransactionsScreen/PermissionStatus.tsx create mode 100644 frontend/app/src/screens/TransactionsScreen/TransactionStatus.tsx create mode 100644 frontend/app/src/tx-flows/shared.ts diff --git a/frontend/app/src/abi/Erc2612.ts b/frontend/app/src/abi/Erc2612.ts new file mode 100644 index 000000000..6b7e5c09e --- /dev/null +++ b/frontend/app/src/abi/Erc2612.ts @@ -0,0 +1,39 @@ +import { erc20Abi } from "viem"; + +// https://eips.ethereum.org/EIPS/eip-2612 +const permitAbiExtension = [ + { + name: "permit", + type: "function", + stateMutability: "nonpayable", + inputs: [ + { name: "owner", type: "address" }, + { name: "spender", type: "address" }, + { name: "value", type: "uint256" }, + { name: "deadline", type: "uint256" }, + { name: "v", type: "uint8" }, + { name: "r", type: "bytes32" }, + { name: "s", type: "bytes32" }, + ], + outputs: [], + }, + { + name: "nonces", + type: "function", + stateMutability: "view", + inputs: [{ name: "owner", type: "address" }], + outputs: [{ type: "uint256" }], + }, + { + name: "DOMAIN_SEPARATOR", + type: "function", + stateMutability: "view", + inputs: [], + outputs: [{ type: "bytes32" }], + }, +] as const; + +export default [ + ...erc20Abi, + ...permitAbiExtension, +] as const; diff --git a/frontend/app/src/abi/LqtyToken.ts b/frontend/app/src/abi/LqtyToken.ts index a9730c6c9..2f1e8a48b 100644 --- a/frontend/app/src/abi/LqtyToken.ts +++ b/frontend/app/src/abi/LqtyToken.ts @@ -1,123 +1,201 @@ -export const LqtyToken = [{ - "anonymous": false, - "inputs": [{ "indexed": true, "internalType": "address", "name": "owner", "type": "address" }, { - "indexed": true, - "internalType": "address", - "name": "spender", - "type": "address", - }, { "indexed": false, "internalType": "uint256", "name": "amount", "type": "uint256" }], - "name": "Approval", - "type": "event", -}, { - "anonymous": false, - "inputs": [{ "indexed": true, "internalType": "address", "name": "from", "type": "address" }, { - "indexed": true, - "internalType": "address", - "name": "to", - "type": "address", - }, { "indexed": false, "internalType": "uint256", "name": "amount", "type": "uint256" }], - "name": "Transfer", - "type": "event", -}, { - "inputs": [], - "name": "DOMAIN_SEPARATOR", - "outputs": [{ "internalType": "bytes32", "name": "", "type": "bytes32" }], - "stateMutability": "view", - "type": "function", -}, { - "inputs": [{ "internalType": "address", "name": "", "type": "address" }, { - "internalType": "address", - "name": "", - "type": "address", - }], - "name": "allowance", - "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], - "stateMutability": "view", - "type": "function", -}, { - "inputs": [{ "internalType": "address", "name": "spender", "type": "address" }, { - "internalType": "uint256", - "name": "amount", - "type": "uint256", - }], - "name": "approve", - "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], - "stateMutability": "nonpayable", - "type": "function", -}, { - "inputs": [{ "internalType": "address", "name": "", "type": "address" }], - "name": "balanceOf", - "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], - "stateMutability": "view", - "type": "function", -}, { - "inputs": [], - "name": "decimals", - "outputs": [{ "internalType": "uint8", "name": "", "type": "uint8" }], - "stateMutability": "view", - "type": "function", -}, { - "inputs": [{ "internalType": "uint256", "name": "amt", "type": "uint256" }], - "name": "mint", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function", -}, { - "inputs": [], - "name": "name", - "outputs": [{ "internalType": "string", "name": "", "type": "string" }], - "stateMutability": "view", - "type": "function", -}, { - "inputs": [{ "internalType": "address", "name": "", "type": "address" }], - "name": "nonces", - "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], - "stateMutability": "view", - "type": "function", -}, { - "inputs": [ - { "internalType": "address", "name": "owner", "type": "address" }, - { "internalType": "address", "name": "spender", "type": "address" }, - { "internalType": "uint256", "name": "value", "type": "uint256" }, - { "internalType": "uint256", "name": "deadline", "type": "uint256" }, - { "internalType": "uint8", "name": "v", "type": "uint8" }, - { "internalType": "bytes32", "name": "r", "type": "bytes32" }, - { "internalType": "bytes32", "name": "s", "type": "bytes32" }, - ], - "name": "permit", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function", -}, { - "inputs": [], - "name": "symbol", - "outputs": [{ "internalType": "string", "name": "", "type": "string" }], - "stateMutability": "view", - "type": "function", -}, { - "inputs": [], - "name": "totalSupply", - "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], - "stateMutability": "view", - "type": "function", -}, { - "inputs": [{ "internalType": "address", "name": "to", "type": "address" }, { - "internalType": "uint256", - "name": "amount", - "type": "uint256", - }], - "name": "transfer", - "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], - "stateMutability": "nonpayable", - "type": "function", -}, { - "inputs": [{ "internalType": "address", "name": "from", "type": "address" }, { - "internalType": "address", - "name": "to", - "type": "address", - }, { "internalType": "uint256", "name": "amount", "type": "uint256" }], - "name": "transferFrom", - "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], - "stateMutability": "nonpayable", - "type": "function", -}] as const; +export const LqtyToken = [ + { + "inputs": [ + { "internalType": "string", "name": "_name", "type": "string" }, + { "internalType": "string", "name": "_symbol", "type": "string" }, + { "internalType": "uint256", "name": "_tapAmount", "type": "uint256" }, + { "internalType": "uint256", "name": "_tapPeriod", "type": "uint256" }, + ], + "stateMutability": "nonpayable", + "type": "constructor", + }, + { + "anonymous": false, + "inputs": [{ "indexed": true, "internalType": "address", "name": "owner", "type": "address" }, { + "indexed": true, + "internalType": "address", + "name": "spender", + "type": "address", + }, { "indexed": false, "internalType": "uint256", "name": "value", "type": "uint256" }], + "name": "Approval", + "type": "event", + }, + { + "anonymous": false, + "inputs": [{ "indexed": true, "internalType": "address", "name": "previousOwner", "type": "address" }, { + "indexed": true, + "internalType": "address", + "name": "newOwner", + "type": "address", + }], + "name": "OwnershipTransferred", + "type": "event", + }, + { + "anonymous": false, + "inputs": [{ "indexed": true, "internalType": "address", "name": "from", "type": "address" }, { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address", + }, { "indexed": false, "internalType": "uint256", "name": "value", "type": "uint256" }], + "name": "Transfer", + "type": "event", + }, + { + "inputs": [{ "internalType": "address", "name": "owner", "type": "address" }, { + "internalType": "address", + "name": "spender", + "type": "address", + }], + "name": "allowance", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [{ "internalType": "address", "name": "spender", "type": "address" }, { + "internalType": "uint256", + "name": "amount", + "type": "uint256", + }], + "name": "approve", + "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], + "stateMutability": "nonpayable", + "type": "function", + }, + { + "inputs": [{ "internalType": "address", "name": "account", "type": "address" }], + "name": "balanceOf", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [], + "name": "decimals", + "outputs": [{ "internalType": "uint8", "name": "", "type": "uint8" }], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [{ "internalType": "address", "name": "spender", "type": "address" }, { + "internalType": "uint256", + "name": "subtractedValue", + "type": "uint256", + }], + "name": "decreaseAllowance", + "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], + "stateMutability": "nonpayable", + "type": "function", + }, + { + "inputs": [{ "internalType": "address", "name": "spender", "type": "address" }, { + "internalType": "uint256", + "name": "addedValue", + "type": "uint256", + }], + "name": "increaseAllowance", + "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], + "stateMutability": "nonpayable", + "type": "function", + }, + { + "inputs": [{ "internalType": "address", "name": "", "type": "address" }], + "name": "lastTapped", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [{ "internalType": "address", "name": "_to", "type": "address" }, { + "internalType": "uint256", + "name": "_amount", + "type": "uint256", + }], + "name": "mint", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function", + }, + { + "inputs": [], + "name": "name", + "outputs": [{ "internalType": "string", "name": "", "type": "string" }], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [], + "name": "owner", + "outputs": [{ "internalType": "address", "name": "", "type": "address" }], + "stateMutability": "view", + "type": "function", + }, + { "inputs": [], "name": "renounceOwnership", "outputs": [], "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [], + "name": "symbol", + "outputs": [{ "internalType": "string", "name": "", "type": "string" }], + "stateMutability": "view", + "type": "function", + }, + { "inputs": [], "name": "tap", "outputs": [], "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [], + "name": "tapAmount", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [], + "name": "tapPeriod", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [{ "internalType": "address", "name": "receiver", "type": "address" }], + "name": "tapTo", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function", + }, + { + "inputs": [], + "name": "totalSupply", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [{ "internalType": "address", "name": "to", "type": "address" }, { + "internalType": "uint256", + "name": "amount", + "type": "uint256", + }], + "name": "transfer", + "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], + "stateMutability": "nonpayable", + "type": "function", + }, + { + "inputs": [{ "internalType": "address", "name": "from", "type": "address" }, { + "internalType": "address", + "name": "to", + "type": "address", + }, { "internalType": "uint256", "name": "amount", "type": "uint256" }], + "name": "transferFrom", + "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], + "stateMutability": "nonpayable", + "type": "function", + }, + { + "inputs": [{ "internalType": "address", "name": "newOwner", "type": "address" }], + "name": "transferOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function", + }, +] as const; diff --git a/frontend/app/src/contracts.ts b/frontend/app/src/contracts.ts index 1c8a84972..f5cf24134 100644 --- a/frontend/app/src/contracts.ts +++ b/frontend/app/src/contracts.ts @@ -24,6 +24,7 @@ import { CONTRACT_BOLD_TOKEN, CONTRACT_COLLATERAL_REGISTRY, CONTRACT_EXCHANGE_HELPERS, + CONTRACT_GOVERNANCE, CONTRACT_HINT_HELPERS, CONTRACT_LQTY_STAKING, CONTRACT_LQTY_TOKEN, @@ -105,7 +106,7 @@ export type Contracts = ProtocolContractMap & { const CONTRACTS: Contracts = { BoldToken: { abi: abis.BoldToken, address: CONTRACT_BOLD_TOKEN }, CollateralRegistry: { abi: abis.CollateralRegistry, address: CONTRACT_COLLATERAL_REGISTRY }, - Governance: { abi: abis.Governance, address: zeroAddress }, + Governance: { abi: abis.Governance, address: CONTRACT_GOVERNANCE }, ExchangeHelpers: { abi: abis.ExchangeHelpers, address: CONTRACT_EXCHANGE_HELPERS }, HintHelpers: { abi: abis.HintHelpers, address: CONTRACT_HINT_HELPERS }, LqtyStaking: { abi: abis.LqtyStaking, address: CONTRACT_LQTY_STAKING }, diff --git a/frontend/app/src/permit.ts b/frontend/app/src/permit.ts new file mode 100644 index 000000000..93d64ea9d --- /dev/null +++ b/frontend/app/src/permit.ts @@ -0,0 +1,85 @@ +// see https://eips.ethereum.org/EIPS/eip-2612 + +import type { Address } from "@/src/types"; +import type { Config as WagmiConfig } from "wagmi"; + +import Erc2612 from "@/src/abi/Erc2612"; +import { CHAIN_ID } from "@/src/env"; +import { slice } from "viem"; +import { getBlock, readContract, signTypedData } from "wagmi/actions"; + +export async function signPermit({ + account, + expiresAfter = 60n * 60n * 24n, // 1 day + spender, + token, + value, + wagmiConfig, +}: { + account: Address; + expiresAfter?: bigint; + spender: Address; + token: Address; + value: bigint; + wagmiConfig: WagmiConfig; +}) { + const [block, nonce] = await Promise.all([ + getBlock(wagmiConfig), + readContract(wagmiConfig, { + address: token, + abi: Erc2612, + functionName: "nonces", + args: [account], + }), + ]); + + const deadline = block.timestamp + expiresAfter; + + const signature = await signTypedData(wagmiConfig, { + domain: { + name: "name", + version: "1", + chainId: CHAIN_ID, + verifyingContract: token, + }, + types: { + Permit: [ + { name: "owner", type: "address" }, + { name: "spender", type: "address" }, + { name: "value", type: "uint256" }, + { name: "nonce", type: "uint256" }, + { name: "deadline", type: "uint256" }, + ], + }, + primaryType: "Permit", + message: { + owner: account, + spender, + value, + nonce, + deadline, + }, + }); + + return getPermitParamsFromSignature(signature, deadline); +} + +export type PermitParams = { + deadline: bigint; + v: number; + r: `0x${string}`; + s: `0x${string}`; +}; + +// Split signature into r, s, v + attach deadline +function getPermitParamsFromSignature( + signature: `0x${string}`, + deadline: bigint, +): PermitParams { + return { + deadline, + v: parseInt(slice(signature, 64, 65), 16), + r: slice(signature, 0, 32), + s: slice(signature, 32, 64), + }; +} diff --git a/frontend/app/src/screens/TransactionsScreen/PermissionStatus.tsx b/frontend/app/src/screens/TransactionsScreen/PermissionStatus.tsx new file mode 100644 index 000000000..2bf369898 --- /dev/null +++ b/frontend/app/src/screens/TransactionsScreen/PermissionStatus.tsx @@ -0,0 +1,36 @@ +import type { FlowStepDeclaration } from "@/src/services/TransactionFlow"; +import type { ComponentPropsWithoutRef } from "react"; + +import { match } from "ts-pattern"; + +export function PermissionStatus( + props: ComponentPropsWithoutRef, +) { + return ( + <> + {match(props) + .with({ status: "idle" }, () => ( + <> + This action will open your wallet to sign a permission. + + )) + .with({ status: "awaiting-commit" }, () => ( + <> + Please sign the permission in your wallet. + + )) + .with({ status: "awaiting-verify" }, () => null) + .with({ status: "confirmed" }, () => ( + <> + The permission has been signed. + + )) + .with({ status: "error" }, () => ( + <> + An error occurred. Please try again. + + )) + .exhaustive()} + + ); +} diff --git a/frontend/app/src/screens/TransactionsScreen/TransactionStatus.tsx b/frontend/app/src/screens/TransactionsScreen/TransactionStatus.tsx new file mode 100644 index 000000000..b146069f4 --- /dev/null +++ b/frontend/app/src/screens/TransactionsScreen/TransactionStatus.tsx @@ -0,0 +1,52 @@ +import type { FlowStepDeclaration } from "@/src/services/TransactionFlow"; +import type { ComponentPropsWithoutRef } from "react"; + +import { CHAIN_BLOCK_EXPLORER } from "@/src/env"; +import { AnchorTextButton } from "@liquity2/uikit"; +import { match } from "ts-pattern"; + +export function TransactionStatus( + props: ComponentPropsWithoutRef, +) { + return ( + <> + {match(props) + .with({ status: "idle" }, () => ( + <> + This action will open your wallet to sign the transaction. + + )) + .with({ status: "awaiting-commit" }, () => ( + <> + Please sign the transaction in your wallet. + + )) + .with({ status: "awaiting-verify" }, ({ artifact: txHash }) => ( + <> + Waiting for the to be confirmed… + + )) + .with({ status: "confirmed" }, ({ artifact: txHash }) => ( + <> + The has been confirmed. + + )) + .with({ status: "error" }, () => ( + <> + An error occurred. Please try again. + + )) + .exhaustive()} + + ); +} + +function TxLink({ txHash }: { txHash: string }) { + return ( + + ); +} diff --git a/frontend/app/src/screens/TransactionsScreen/TransactionsScreen.tsx b/frontend/app/src/screens/TransactionsScreen/TransactionsScreen.tsx index f9bbbaba8..33701efff 100644 --- a/frontend/app/src/screens/TransactionsScreen/TransactionsScreen.tsx +++ b/frontend/app/src/screens/TransactionsScreen/TransactionsScreen.tsx @@ -6,8 +6,6 @@ import type { ComponentProps, ReactNode } from "react"; import { ErrorBox } from "@/src/comps/ErrorBox/ErrorBox"; import { Screen } from "@/src/comps/Screen/Screen"; import { Spinner } from "@/src/comps/Spinner/Spinner"; -import { getContracts } from "@/src/contracts"; -import { CHAIN_BLOCK_EXPLORER } from "@/src/env"; import { useTransactionFlow } from "@/src/services/TransactionFlow"; import { css } from "@/styled-system/css"; import { AnchorButton, AnchorTextButton, Button, HFlex, IconCross, VFlex } from "@liquity2/uikit"; @@ -30,23 +28,24 @@ const boxTransitionConfig = { export function TransactionsScreen() { const { - currentStep, + commit, + currentStep: step, currentStepIndex, flow, flowDeclaration: fd, - signAndSend, + flowParams, } = useTransactionFlow(); const isLastStep = flow?.steps && currentStepIndex === flow.steps.length - 1; - const successMessageTransition = useTransition(isLastStep && currentStep?.txStatus === "confirmed", { + const successMessageTransition = useTransition(isLastStep && step?.status === "confirmed", { from: { height: 0, opacity: 0, transform: "scale(0.9)" }, enter: { height: 56, opacity: 1, transform: "scale(1)" }, leave: { height: 0, opacity: 0, transform: "scale(1)" }, config: boxTransitionConfig, }); - const errorBoxTransition = useTransition(currentStep?.error !== null, { + const errorBoxTransition = useTransition(Boolean(step?.error), { from: { height: 0, opacity: 0, transform: "scale(0.97)" }, enter: [ { height: 48, opacity: 1, transform: "scale(1)" }, @@ -61,25 +60,48 @@ export function TransactionsScreen() { config: boxTransitionConfig, }); - if (!currentStep || !flow || !fd || !flow.steps) { + if (!step || !flow || !fd || !flow.steps) { return ; } - const contracts = getContracts(); - const showBackLink = currentStepIndex === 0 && ( - currentStep.txStatus === "idle" - || currentStep.txStatus === "error" - || currentStep.txStatus === "awaiting-signature" + step.status === "idle" + || step.status === "error" + || step.status === "awaiting-commit" ); + const StepStatus = fd.steps[step.id].Status; + + const stepStatusProps = match(step) + .with({ + status: P.union("awaiting-verify", "confirmed"), + artifact: P.string, + }, (s) => ({ + status: s.status, + artifact: s.artifact, + })) + .with({ + status: P.union("awaiting-verify", "confirmed"), + artifact: P.nullish, + }, () => { + throw new Error("Expected txHash to be defined"); + }) + .with({ status: "error" }, (s) => ({ + status: s.status, + error: s.error ?? "Unknown error", + })) + .with({ status: "idle" }, { status: "awaiting-commit" }, (s) => ({ + status: s.status, + })) + .exhaustive(); + return ( } + heading={} >
- + @@ -136,11 +158,8 @@ export function TransactionsScreen() { steps={flow.steps.map((step) => ({ error: step.error, id: step.id, - label: fd.getStepName(step.id, { - contracts, - request: flow.request, - }), - txStatus: step.txStatus, + label: flowParams ? fd.steps[step.id].name(flowParams) : "", + status: step.status, }))} /> @@ -155,7 +174,7 @@ export function TransactionsScreen() { paddingBottom: 32, }} > - {currentStep.txStatus === "confirmed" + {step.status === "confirmed" ? ( @@ -193,44 +211,7 @@ export function TransactionsScreen() { color: "contentAlt", })} > - {match(currentStep.txStatus) - .with("idle", () => "This action will open your wallet to sign the transaction.") - .with("awaiting-signature", () => "Please sign the transaction in your wallet.") - .with("awaiting-confirmation", () => ( - <> - Waiting for the{" "} - {" "} - to be confirmed… - - )) - .with("post-check", () => ( - <> - Waiting for the{" "} - {" "} - to be indexed… - - )) - .with("confirmed", () => ( - <> - The{" "} - {" "} - has been confirmed. - - )) - .with("error", () => "An error occurred. Please try again.") - .exhaustive()} + @@ -243,7 +224,7 @@ export function TransactionsScreen() { }} > - {currentStep.error} + {step.error} ) @@ -363,7 +344,7 @@ function FlowSteps({ error: string | null; id: string; label: string; - txStatus: FlowStepStatus; + status: FlowStepStatus; }>; }) { return steps.length === 1 ? null : ( @@ -392,20 +373,18 @@ function FlowSteps({ "Error") - .with("awaiting-signature", () => "Awaiting signature…") - .with("awaiting-confirmation", () => "Awaiting confirmation…") + .with("awaiting-commit", () => "Awaiting signature…") + .with("awaiting-verify", () => "Awaiting confirmation…") .with("confirmed", () => "Confirmed") - .with("post-check", () => "Awaiting indexer…") .otherwise(() => index === currentStep ? "Ready to sign" : "Next transaction")} - mode={match(step.txStatus) + mode={match(step.status) .returnType["mode"]>() .with( P.union( - "awaiting-signature", - "awaiting-confirmation", - "post-check", + "awaiting-commit", + "awaiting-verify", ), () => "loading", ) diff --git a/frontend/app/src/services/TransactionFlow.tsx b/frontend/app/src/services/TransactionFlow.tsx index 2ff325e2c..35f9e28fc 100644 --- a/frontend/app/src/services/TransactionFlow.tsx +++ b/frontend/app/src/services/TransactionFlow.tsx @@ -1,95 +1,52 @@ "use client"; -// The TransactionFlow component represents a series of one transactions -// executed in sequence. It only stores the last series of transactions. -// -// Naming conventions: -// - Request: The initial request parameters that starts a flow. -// - Flow: A series of transactions that are executed in sequence. -// - Flow steps: Series of transactions in a flow (determined by the request). -// - Flow declaration: Contains the logic for a specific flow (get steps, parse request, tx params). -// - Flow context: a transaction flow as stored in local storage (steps + request). - import type { Contracts } from "@/src/contracts"; -import type { Request as ClaimCollateralSurplusRequest } from "@/src/tx-flows/claimCollateralSurplus"; -import type { Request as CloseLoanPositionRequest } from "@/src/tx-flows/closeLoanPosition"; -import type { Request as EarnClaimRewardsRequest } from "@/src/tx-flows/earnClaimRewards"; -import type { Request as EarnDepositRequest } from "@/src/tx-flows/earnDeposit"; -import type { Request as EarnWithdrawRequest } from "@/src/tx-flows/earnWithdraw"; -import type { Request as OpenBorrowPositionRequest } from "@/src/tx-flows/openBorrowPosition"; -import type { Request as OpenLeveragePositionRequest } from "@/src/tx-flows/openLeveragePosition"; -import type { Request as StakeClaimRewardsRequest } from "@/src/tx-flows/stakeClaimRewards"; -import type { Request as StakeDepositRequest } from "@/src/tx-flows/stakeDeposit"; -import type { Request as UnstakeDepositRequest } from "@/src/tx-flows/unstakeDeposit"; -import type { Request as UpdateBorrowPositionRequest } from "@/src/tx-flows/updateBorrowPosition"; -import type { Request as UpdateLeveragePositionRequest } from "@/src/tx-flows/updateLeveragePosition"; -import type { Request as UpdateLoanInterestRateRequest } from "@/src/tx-flows/updateLoanInterestRate"; import type { Address } from "@/src/types"; -import type { GetTransactionReceiptReturnType, WriteContractParameters } from "@wagmi/core"; import type { ComponentType, ReactNode } from "react"; +import type { Config as WagmiConfig } from "wagmi"; import { LOCAL_STORAGE_PREFIX } from "@/src/constants"; import { getContracts } from "@/src/contracts"; import { jsonParseWithDnum, jsonStringifyWithDnum } from "@/src/dnum-utils"; -import { useAccount, useWagmiConfig } from "@/src/services/Ethereum"; import { useStoredState } from "@/src/services/StoredState"; -import { claimCollateralSurplus } from "@/src/tx-flows/claimCollateralSurplus"; -import { closeLoanPosition } from "@/src/tx-flows/closeLoanPosition"; -import { earnClaimRewards } from "@/src/tx-flows/earnClaimRewards"; -import { earnDeposit } from "@/src/tx-flows/earnDeposit"; -import { earnWithdraw } from "@/src/tx-flows/earnWithdraw"; -import { openBorrowPosition } from "@/src/tx-flows/openBorrowPosition"; -import { openLeveragePosition } from "@/src/tx-flows/openLeveragePosition"; -import { stakeClaimRewards } from "@/src/tx-flows/stakeClaimRewards"; -import { stakeDeposit } from "@/src/tx-flows/stakeDeposit"; -import { unstakeDeposit } from "@/src/tx-flows/unstakeDeposit"; -import { updateBorrowPosition } from "@/src/tx-flows/updateBorrowPosition"; -import { updateLeveragePosition } from "@/src/tx-flows/updateLeveragePosition"; -import { updateLoanInterestRate } from "@/src/tx-flows/updateLoanInterestRate"; import { noop } from "@/src/utils"; -import { vAddress, vHash } from "@/src/valibot-utils"; +import { vAddress } from "@/src/valibot-utils"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { usePathname, useRouter } from "next/navigation"; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import * as v from "valibot"; -import { useTransactionReceipt, useWriteContract } from "wagmi"; - -const TRANSACTION_FLOW_KEY = `${LOCAL_STORAGE_PREFIX}transaction_flow`; - -export type FlowRequest = - | ClaimCollateralSurplusRequest - | CloseLoanPositionRequest - | EarnClaimRewardsRequest - | EarnDepositRequest - | EarnWithdrawRequest - | OpenBorrowPositionRequest - | OpenLeveragePositionRequest - | StakeClaimRewardsRequest - | StakeDepositRequest - | UnstakeDepositRequest - | UpdateBorrowPositionRequest - | UpdateLeveragePositionRequest - | UpdateLoanInterestRateRequest; - -const flowDeclarations: { - [K in FlowIdFromFlowRequest]: FlowDeclaration< - Extract, - any // Use 'any' here to allow any StepId type - >; -} = { - claimCollateralSurplus, - closeLoanPosition, - earnClaimRewards, - earnDeposit, - earnWithdraw, - openBorrowPosition, - openLeveragePosition, - stakeClaimRewards, - stakeDeposit, - unstakeDeposit, - updateBorrowPosition, - updateLeveragePosition, - updateLoanInterestRate, +import { useAccount, useConfig as useWagmiConfig } from "wagmi"; + +/* flows registration */ + +import { claimCollateralSurplus, type ClaimCollateralSurplusRequest } from "@/src/tx-flows/claimCollateralSurplus"; +import { closeLoanPosition, type CloseLoanPositionRequest } from "@/src/tx-flows/closeLoanPosition"; +import { earnClaimRewards, type EarnClaimRewardsRequest } from "@/src/tx-flows/earnClaimRewards"; +import { earnDeposit, type EarnDepositRequest } from "@/src/tx-flows/earnDeposit"; +import { earnWithdraw, type EarnWithdrawRequest } from "@/src/tx-flows/earnWithdraw"; +import { openBorrowPosition, type OpenBorrowPositionRequest } from "@/src/tx-flows/openBorrowPosition"; +import { openLeveragePosition, type OpenLeveragePositionRequest } from "@/src/tx-flows/openLeveragePosition"; +import { stakeClaimRewards, type StakeClaimRewardsRequest } from "@/src/tx-flows/stakeClaimRewards"; +import { stakeDeposit, type StakeDepositRequest } from "@/src/tx-flows/stakeDeposit"; +import { unstakeDeposit, type UnstakeDepositRequest } from "@/src/tx-flows/unstakeDeposit"; +import { updateBorrowPosition, type UpdateBorrowPositionRequest } from "@/src/tx-flows/updateBorrowPosition"; +import { updateLeveragePosition, type UpdateLeveragePositionRequest } from "@/src/tx-flows/updateLeveragePosition"; +import { updateLoanInterestRate, type UpdateLoanInterestRateRequest } from "@/src/tx-flows/updateLoanInterestRate"; + +export type FlowRequestMap = { + "claimCollateralSurplus": ClaimCollateralSurplusRequest; + "closeLoanPosition": CloseLoanPositionRequest; + "earnClaimRewards": EarnClaimRewardsRequest; + "earnDeposit": EarnDepositRequest; + "earnWithdraw": EarnWithdrawRequest; + "openBorrowPosition": OpenBorrowPositionRequest; + "openLeveragePosition": OpenLeveragePositionRequest; + "stakeClaimRewards": StakeClaimRewardsRequest; + "stakeDeposit": StakeDepositRequest; + "unstakeDeposit": UnstakeDepositRequest; + "updateBorrowPosition": UpdateBorrowPositionRequest; + "updateLeveragePosition": UpdateLeveragePositionRequest; + "updateLoanInterestRate": UpdateLoanInterestRateRequest; }; const FlowIdSchema = v.union([ @@ -108,186 +65,160 @@ const FlowIdSchema = v.union([ v.literal("updateLoanInterestRate"), ]); -type ExtractStepId = T extends FlowDeclaration ? S : never; +export const flows: FlowsMap = { + claimCollateralSurplus, + closeLoanPosition, + earnClaimRewards, + earnDeposit, + earnWithdraw, + openBorrowPosition, + openLeveragePosition, + stakeClaimRewards, + stakeDeposit, + unstakeDeposit, + updateBorrowPosition, + updateLeveragePosition, + updateLoanInterestRate, +}; -type FlowDeclarations = { - [K in FlowIdFromFlowRequest]: FlowDeclaration< - Extract, - ExtractStepId - >; +/* end of flows registration */ + +const TRANSACTION_FLOW_KEY = `${LOCAL_STORAGE_PREFIX}transaction_flow`; + +type FlowsMap = { + [K in keyof FlowRequestMap]: FlowDeclaration; }; -export type FlowId = keyof FlowDeclarations; +export type FlowStepStatus = + | "idle" + | "awaiting-commit" + | "awaiting-verify" + | "confirmed" + | "error"; + +export type FlowStep = { + // artifact is the result of a step, + // e.g. a transaction hash or a signed message + artifact: string | null; + error: string | null; + id: string; + status: FlowStepStatus; +}; -function getFlowDeclaration< - T extends FlowIdFromFlowRequest, ->(flowId: T): FlowDeclaration< - Extract, - ExtractStepId -> { - return flowDeclarations[flowId]; +// implemented by all flow requests +export interface BaseFlowRequest { + flowId: keyof FlowRequestMap; + backLink: [path: string, label: string] | null; + successLink: [path: string, label: string]; + successMessage: string; } -export type FlowIdFromFlowRequest = FR["flowId"]; -export type FlowRequestFromFlowId = Extract; -export type FlowContextFromFlowId = FlowContext>; - -export const FlowStepsSchema = v.union([ - v.null(), - v.array( - v.union([ - v.object({ - id: v.string(), - error: v.string(), - txHash: v.union([v.null(), vHash()]), - txReceiptData: v.null(), - txStatus: v.literal("error"), - }), - v.object({ - id: v.string(), - error: v.null(), - txHash: v.union([v.null(), vHash()]), - txReceiptData: v.null(), - txStatus: v.union([ - v.literal("idle"), - v.literal("awaiting-signature"), - v.literal("awaiting-confirmation"), - ]), - }), - v.object({ - id: v.string(), - error: v.null(), - txHash: vHash(), - txReceiptData: v.union([v.null(), v.string()]), - txStatus: v.union([ - v.literal("post-check"), - v.literal("confirmed"), - ]), - }), - ]), - ), -]); - -type FlowStepUpdate = - | { - error: string; - txHash: null | `0x${string}`; - txStatus: "error"; - txReceiptData: null; - } - | { - error: null; - txHash: null | `0x${string}`; - txStatus: "idle" | "awaiting-signature" | "awaiting-confirmation"; - txReceiptData: null; - } - | { - error: null; - txHash: `0x${string}`; - txStatus: "post-check" | "confirmed"; - txReceiptData: null | string; - }; +// individual step in a flow +export type FlowStepDeclaration = { + name: (params: FlowParams) => string; + commit: (params: FlowParams) => Promise; + verify: (params: FlowParams, artifact: string) => Promise; + Status: ComponentType< + | { status: "idle" | "awaiting-commit" } + | { status: "awaiting-verify" | "confirmed"; artifact: string } + | { status: "error"; error: string; artifact?: string } + >; +}; -export type FlowSteps = NonNullable< - v.InferOutput ->; +export type FlowDeclaration = { + title: ReactNode; + Summary: ComponentType<{ request: FlowRequest; steps: FlowStep[] | null }>; + Details: ComponentType<{ request: FlowRequest; steps: FlowStep[] | null }>; + steps: Record>; + getSteps: (params: FlowParams) => Promise; + parseRequest: (request: unknown) => FlowRequest | null; +}; -export type FlowStepStatus = FlowSteps[number]["txStatus"]; +// passed to the react context + saved in local storage +export type Flowstate = { + account: Address | null; + request: FlowRequest; + steps: FlowStep[] | null; +}; -// The context of a transaction flow, as stored in local storage, -// not to be confused with the React context used to manage the flow. -export type FlowContext = { +// passed to the step functions +export type FlowParams = { account: Address | null; - request: FR; - steps: FlowSteps | null; + contracts: Contracts; + request: FlowRequest; + steps: FlowStep[] | null; + storedState: ReturnType; + wagmiConfig: WagmiConfig; }; +// flow state as stored in local storage const FlowStateSchema = v.object({ account: vAddress(), request: v.looseObject({ flowId: FlowIdSchema, backLink: v.union([ v.null(), - v.tuple([ - v.string(), // path - v.string(), // label - ]), - ]), - successLink: v.tuple([ - v.string(), // path - v.string(), // label + v.tuple([v.string(), v.string()]), ]), + successLink: v.tuple([v.string(), v.string()]), + successMessage: v.string(), }), - steps: FlowStepsSchema, + steps: v.union([ + v.null(), + v.array(v.object({ + id: v.string(), + status: v.union([ + v.literal("idle"), + v.literal("awaiting-commit"), + v.literal("awaiting-verify"), + v.literal("confirmed"), + v.literal("error"), + ]), + artifact: v.union([v.string(), v.null()]), + error: v.union([v.string(), v.null()]), + })), + ]), }); -type FlowArgs = { - account: ReturnType; - contracts: Contracts; - request: FR; - steps: FlowSteps | null; - storedState: ReturnType; - wagmiConfig: ReturnType; -}; - -type GetStepsFn = (args: FlowArgs) => Promise; - -type WriteContractParamsFn = ( - stepId: StepId, - args: FlowArgs, -) => Promise; +export function getFlowDeclaration( + flowId: K, +): FlowDeclaration | null { + return flows[flowId] ?? null; +} -export type FlowDeclaration< - FR extends FlowRequest, - StepId extends string = string, +// flow react context +type TransactionFlowContext< + FlowRequest extends FlowRequestMap[keyof FlowRequestMap] = FlowRequestMap[keyof FlowRequestMap], > = { - title: ReactNode; - Summary: ComponentType<{ flow: FlowContext }>; - Details: ComponentType<{ flow: FlowContext }>; - - // optional, if present it will be called at the end of the - // last step of the flow, before the success status gets activated - postFlowCheck?: (args: FlowArgs) => Promise; - getSteps: GetStepsFn; - getStepName: (stepId: StepId, args: { - contracts: Contracts; - request: FR; - }) => string; - parseRequest: (request: unknown) => FR | null; - parseReceipt?: ( - stepId: StepId, - receipt: GetTransactionReceiptReturnType, - args: { - contracts: Contracts; - request: FR; - }, - ) => null | string; - writeContractParams: WriteContractParamsFn; -}; - -type TransactionFlowReactContext = { - currentStep: null | FlowSteps[number]; + currentStep: FlowStep | null; currentStepIndex: number; discard: () => void; - signAndSend: () => Promise; - start: (request: FR) => void; - flow: null | FlowContext; - flowDeclaration: null | FlowDeclaration>; + commit: () => Promise; + start: (request: FlowRequest) => void; + flow: Flowstate | null; + flowDeclaration: FlowDeclaration | null; + flowParams: FlowParams | null; }; -const TransactionFlowContext = createContext({ +const TransactionFlowContext = createContext({ currentStep: null, currentStepIndex: -1, discard: noop, - signAndSend: async () => {}, + commit: async () => {}, start: noop, flow: null, flowDeclaration: null, + flowParams: null, }); -export function TransactionFlow({ children }: { children: ReactNode }) { +export function TransactionFlow({ + children, +}: { + children: ReactNode; +}) { const account = useAccount(); const router = useRouter(); + const storedState = useStoredState(); const wagmiConfig = useWagmiConfig(); const { @@ -296,48 +227,89 @@ export function TransactionFlow({ children }: { children: ReactNode }) { discardFlow, flow, flowDeclaration, - setFlowSteps, startFlow, updateFlowStep, } = useFlowManager(account.address ?? null); - useSteps({ - flow, - enabled: Boolean( - flow - && account.address - && flow.account === account.address - && flow.steps === null, - ), - account, - wagmiConfig, - onSteps: (steps) => { - setFlowSteps(steps.map((id) => ({ - id, + const commit = useCallback(async () => { + if (!flow || !flowDeclaration || !currentStep || currentStepIndex === -1) { + return; + } + + const stepDef = flowDeclaration.steps[currentStep.id]; + if (!stepDef) return; + + updateFlowStep(currentStepIndex, { + status: "awaiting-commit", + artifact: null, + error: null, + }); + + try { + if (!account.address) { + throw new Error("Account address is required"); + } + + const params: FlowParams = { + account: account.address, + contracts: getContracts(), + request: flow.request, + steps: flow.steps, + storedState, + wagmiConfig, + }; + + const artifact = await stepDef.commit(params); + if (artifact === null) { + throw new Error("Commit failed - no artifact returned"); + } + + updateFlowStep(currentStepIndex, { + status: "awaiting-verify", + artifact, error: null, - txHash: null, - txReceiptData: null, - txStatus: "idle" as const, - }))); - }, - }); + }); - const txExecution = useTransactionExecution({ + try { + await stepDef.verify(params, artifact); + updateFlowStep(currentStepIndex, { + status: "confirmed", + artifact, + error: null, + }); + } catch (error) { + updateFlowStep(currentStepIndex, { + status: "error", + artifact, + error: error instanceof Error ? error.message : String(error), + }); + } + } catch (error) { + updateFlowStep(currentStepIndex, { + status: "error", + artifact: null, + error: error instanceof Error ? error.message : String(error), + }); + } + }, [ flow, + flowDeclaration, currentStep, currentStepIndex, - flowDeclaration, + account.address, + storedState, + wagmiConfig, updateFlowStep, - }); + ]); - const start: TransactionFlowReactContext["start"] = useCallback((request) => { + const start: TransactionFlowContext["start"] = useCallback((request) => { if (account.address) { startFlow(request, account.address); setTimeout(() => { router.push("/transactions"); }, 0); } - }, [account]); + }, [account.address, startFlow, router]); return ( {children} @@ -356,23 +337,15 @@ export function TransactionFlow({ children }: { children: ReactNode }) { ); } -function useSteps({ - flow, - enabled, - account, - wagmiConfig, - onSteps, -}: { - flow: FlowContext | null; - enabled: boolean; - account: ReturnType; - wagmiConfig: ReturnType; - onSteps: (steps: string[]) => void; -}) { - const contracts = getContracts(); +function useSteps( + flow: Flowstate | null, + enabled: boolean, +) { + const account = useAccount(); const storedState = useStoredState(); + const wagmiConfig = useWagmiConfig(); - const steps = useQuery({ + return useQuery({ enabled, queryKey: [ "transaction-flow-steps", @@ -384,40 +357,27 @@ function useSteps({ return null; } - const flowDeclaration = getFlowDeclaration(flow.request.flowId); - return flowDeclaration.getSteps({ - account, - contracts, + const flowDeclaration = getFlowDeclaration(flow?.request.flowId as keyof FlowRequestMap); + if (!flowDeclaration) { + throw new Error("Flow declaration not found: " + flow.request.flowId); + } + + const context = { + account: account.address, + contracts: getContracts(), request: flow.request, steps: flow.steps, storedState, wagmiConfig, - }); + }; + + return flowDeclaration.getSteps(context); }, }); - - useEffect(() => { - if (!flow || !steps.data) { - return; - } - - const newSteps = steps.data.map((id) => ({ - id, - error: null, - txHash: null, - })); - - const stepsKey = flow.steps?.map((s) => s.id).join(""); - const newStepsKey = newSteps.map((s) => s.id).join(""); - - if (stepsKey !== newStepsKey) { - onSteps(steps.data); - } - }, [steps.data, flow, onSteps]); } -export function useFlowManager(account: Address | null) { - const [flow, setFlow] = useState | null>(null); +function useFlowManager(account: Address | null) { + const [flow, setFlow] = useState | null>(null); useEffect(() => { // no account or wrong account => set flow to null (but preserve local storage state) @@ -435,7 +395,10 @@ export function useFlowManager(account: Address | null) { } }, [account, flow]); - const startFlow = useCallback((request: FlowRequest, account: Address) => { + const startFlow = useCallback(( + request: FlowRequestMap[keyof FlowRequestMap], + account: Address, + ) => { const newFlow = { account, request, steps: null }; setFlow(newFlow); FlowContextStorage.set(newFlow); @@ -446,7 +409,7 @@ export function useFlowManager(account: Address | null) { FlowContextStorage.clear(); }, []); - const setFlowSteps = useCallback((steps: FlowSteps | null) => { + const setFlowSteps = useCallback((steps: FlowStep[] | null) => { if (!flow) return; const newFlow = { ...flow, steps }; @@ -454,26 +417,56 @@ export function useFlowManager(account: Address | null) { FlowContextStorage.set(newFlow); }, [flow]); - const updateFlowStep = useCallback((stepIndex: number, update: FlowStepUpdate) => { - if (!flow) return; + const updateFlowStep = useCallback(( + stepIndex: number, + update: Omit, + ) => { + if (!flow?.steps) return; - const newSteps = flow.steps?.map((step, i) => ( + const newSteps = flow.steps.map((step, i) => ( i === stepIndex ? { ...step, ...update } : step - )) ?? null; + )); setFlowSteps(newSteps); }, [flow, setFlowSteps]); const currentStepIndex = useMemo(() => { - const firstUnconfirmed = flow?.steps?.findIndex((step) => step.txStatus !== "confirmed") ?? -1; + const firstUnconfirmed = flow?.steps?.findIndex( + (step) => step.status !== "confirmed", + ) ?? -1; return firstUnconfirmed === -1 ? (flow?.steps?.length ?? 0) - 1 : firstUnconfirmed; }, [flow]); - const currentStep = useMemo(() => flow?.steps?.[currentStepIndex] ?? null, [flow, currentStepIndex]); + const currentStep = useMemo( + () => flow?.steps?.[currentStepIndex] ?? null, + [flow, currentStepIndex], + ); + + const flowDeclaration = useMemo(() => ( + flow && (flow.request.flowId in flows) + ? getFlowDeclaration(flow.request.flowId as keyof FlowRequestMap) + : null + ), [flow]); - const flowDeclaration = useMemo(() => flow && getFlowDeclaration(flow.request.flowId), [flow]); + const isFlowComplete = useMemo( + () => flow?.steps?.at(-1)?.status === "confirmed", + [flow], + ); - const isFlowComplete = useMemo(() => flow?.steps?.at(-1)?.txStatus === "confirmed", [flow]); + // get steps when the flow starts + const awaitingSteps = flow !== null && flow.steps === null; + const steps = useSteps( + flow, + Boolean(awaitingSteps && account && flow.account === account), + ); + if (awaitingSteps && steps.data) { + setFlowSteps(steps.data.map((id) => ({ + id, + status: "idle", + artifact: null, + error: null, + }))); + } useResetQueriesOnPathChange(isFlowComplete); @@ -484,211 +477,53 @@ export function useFlowManager(account: Address | null) { flow, flowDeclaration, isFlowComplete, - setFlowSteps, startFlow, updateFlowStep, }; } -function useTransactionExecution({ - flow, - currentStep, - currentStepIndex, - flowDeclaration, - updateFlowStep, -}: { - flow: FlowContext | null; - currentStep: FlowSteps[number] | null; - currentStepIndex: number; - flowDeclaration: FlowDeclaration | null; - updateFlowStep: (index: number, update: FlowStepUpdate) => void; -}) { - const account = useAccount(); - const contracts = getContracts(); - const wagmiConfig = useWagmiConfig(); - const storedState = useStoredState(); - - // step status updates - const setStepToAwaitingSignature = () => { - updateFlowStep(currentStepIndex, { - error: null, - txHash: null, - txReceiptData: null, - txStatus: "awaiting-signature", - }); - }; - const setStepToAwaitingConfirmation = (txHash: `0x${string}`) => { - updateFlowStep(currentStepIndex, { - error: null, - txHash, - txReceiptData: null, - txStatus: "awaiting-confirmation", - }); - }; - const setStepToPostCheck = (receipt: GetTransactionReceiptReturnType) => { - if (!flow?.request) return; - updateFlowStep(currentStepIndex, { - error: null, - txHash: receipt.transactionHash, - txReceiptData: flowDeclaration?.parseReceipt?.( - flow.steps?.[currentStepIndex]?.id ?? "", - receipt, - { contracts, request: flow.request }, - ) ?? null, - txStatus: "post-check", - }); - }; - const setStepToConfirmed = (receipt: GetTransactionReceiptReturnType) => { - if (!flow?.request) return; - updateFlowStep(currentStepIndex, { - error: null, - txHash: receipt.transactionHash, - txReceiptData: flowDeclaration?.parseReceipt?.( - flow.steps?.[currentStepIndex]?.id ?? "", - receipt, - { contracts, request: flow.request }, - ) ?? null, - txStatus: "confirmed", - }); - }; - const setStepToError = (error: Error, txHash: `0x${string}` | null = null) => { - updateFlowStep(currentStepIndex, { - error: error.message, - txHash, - txReceiptData: null, - txStatus: "error", - }); - }; - - const contractWrite = useWriteContract({ - mutation: { - onMutate: setStepToAwaitingSignature, - onError: (err) => setStepToError(err), - onSuccess: setStepToAwaitingConfirmation, - }, - }); - - const txReceipt = useTransactionReceipt({ - hash: contractWrite.data ?? (( - currentStep?.txStatus === "awaiting-confirmation" && currentStep.txHash - ) || undefined), - query: { retry: true }, - }); - - const runPostCheck = useCallback(async (receipt: GetTransactionReceiptReturnType) => { - if (!flow?.request || !flowDeclaration?.postFlowCheck) { - return; - } - - while (true) { - try { - await flowDeclaration.postFlowCheck({ - account, - contracts, - request: flow.request, - steps: flow.steps, - storedState, - wagmiConfig, - }); - // check passed - setStepToConfirmed(receipt); - break; - } catch (error) { - console.error("Post-check failed, retrying in 1 second", error); - await new Promise((resolve) => setTimeout(resolve, 1000)); +const FlowContextStorage = { + set(flow: Flowstate) { + localStorage.setItem(TRANSACTION_FLOW_KEY, jsonStringifyWithDnum(flow)); + }, + get(): Flowstate | null { + try { + const storedFlowState = (localStorage.getItem(TRANSACTION_FLOW_KEY) ?? "").trim(); + if (!storedFlowState) { + return null; } - } - }, [ - account, - contracts, - flow, - flowDeclaration, - storedState, - wagmiConfig, - ]); - - const postCheckReceipt = useRef(null); - - useEffect(() => { - if (txReceipt.status !== "pending") { - contractWrite.reset(); - } - - if (txReceipt.status === "error") { - setStepToError(txReceipt.error); - return; - } - - if (txReceipt.data?.status === "reverted") { - setStepToError( - new Error("Transaction reverted."), - txReceipt.data.transactionHash, - ); - return; - } - if (txReceipt.status === "success") { - const isLastStep = currentStepIndex === (flow?.steps?.length ?? 0) - 1; + // parse the base flow structure + const baseState = v.parse(FlowStateSchema, jsonParseWithDnum(storedFlowState)); - if (isLastStep && flowDeclaration?.postFlowCheck) { - postCheckReceipt.current = txReceipt.data; - setStepToPostCheck(txReceipt.data); - } else { - setStepToConfirmed(txReceipt.data); + const flowDeclaration = getFlowDeclaration(baseState.request.flowId); + if (!flowDeclaration) { + throw new Error(`Unknown flow ID: ${baseState.request.flowId}`); } - } - }, [txReceipt]); - - useEffect(() => { - if (currentStep?.txStatus === "post-check" && postCheckReceipt.current) { - runPostCheck(postCheckReceipt.current).catch(console.error); - } - }, [currentStep?.txStatus, runPostCheck]); - - const signAndSend = useCallback(async () => { - const currentStepId = flow?.steps?.[currentStepIndex]?.id; - - if (!currentStepId || currentStepIndex < 0 || !account || !flow || !flowDeclaration) { - return; - } - - setStepToAwaitingSignature(); - - try { - const writeParams = await flowDeclaration.writeContractParams(currentStepId, { - account, - contracts, - request: flow.request, - steps: flow.steps, - storedState, - wagmiConfig, - }); - if (writeParams) { - contractWrite.writeContract(writeParams); + // parse the current flow request + const fullRequest = flowDeclaration.parseRequest(baseState.request); + if (!fullRequest) { + throw new Error(`Invalid request for flow ${baseState.request.flowId}`); } + + return { + ...baseState, + request: fullRequest, + }; } catch (err) { - if (!(err instanceof Error)) { - throw err; - } - setTimeout(() => { - setStepToError(err); - }, 0); + console.error(err); + localStorage.removeItem(TRANSACTION_FLOW_KEY); + return null; } - }, [ - account, - contractWrite, - contracts, - currentStepIndex, - flow, - flowDeclaration, - wagmiConfig, - ]); + }, + clear() { + localStorage.removeItem(TRANSACTION_FLOW_KEY); + }, +}; - return { - signAndSend, - isProcessing: txReceipt.status === "pending", - }; +export function useTransactionFlow() { + return useContext(TransactionFlowContext); } function useResetQueriesOnPathChange(condition: boolean) { @@ -706,50 +541,7 @@ function useResetQueriesOnPathChange(condition: boolean) { if (pathName !== "/transactions" && invalidateOnPathChange.current) { queryClient.resetQueries(); invalidateOnPathChange.current = false; + return; } }, [condition, pathName, queryClient]); } - -const FlowContextStorage = { - set(flow: FlowContext) { - localStorage.setItem(TRANSACTION_FLOW_KEY, jsonStringifyWithDnum(flow)); - }, - get(): FlowContext | null { - try { - const storedFlowState = (localStorage.getItem(TRANSACTION_FLOW_KEY) ?? "").trim(); - if (!storedFlowState) return null; - - const { request, steps, account } = v.parse(FlowStateSchema, jsonParseWithDnum(storedFlowState)); - const declaration = getFlowDeclaration(request.flowId); - const parsedRequest = declaration.parseRequest(request); - let parsedSteps = v.parse(FlowStepsSchema, steps); - - if (account && parsedRequest && parsedSteps) { - parsedSteps = parsedSteps.map((step) => ( - step.txStatus === "post-check" - ? { ...step, txStatus: "confirmed" } - : step - )); - - return { - account, - request: parsedRequest, - steps: parsedSteps, - }; - } - // eslint-disable-next-line no-unused-vars - } catch (err) { - console.error(err); - // clean up invalid state - localStorage.removeItem(TRANSACTION_FLOW_KEY); - } - return null; - }, - clear() { - localStorage.removeItem(TRANSACTION_FLOW_KEY); - }, -}; - -export function useTransactionFlow() { - return useContext(TransactionFlowContext); -} diff --git a/frontend/app/src/tx-flows/claimCollateralSurplus.tsx b/frontend/app/src/tx-flows/claimCollateralSurplus.tsx index f1c37924d..4d0d5a084 100644 --- a/frontend/app/src/tx-flows/claimCollateralSurplus.tsx +++ b/frontend/app/src/tx-flows/claimCollateralSurplus.tsx @@ -4,48 +4,32 @@ import type { ReactNode } from "react"; import { getCollateralContract } from "@/src/contracts"; import { fmtnum } from "@/src/formatting"; import { getCollToken } from "@/src/liquity-utils"; +import { TransactionStatus } from "@/src/screens/TransactionsScreen/TransactionStatus"; import { vAddress, vCollIndex, vDnum } from "@/src/valibot-utils"; import { css } from "@/styled-system/css"; import { shortenAddress, TokenIcon } from "@liquity2/uikit"; import { blo } from "blo"; import Image from "next/image"; import * as v from "valibot"; +import { waitForTransactionReceipt, writeContract } from "wagmi/actions"; +import { createRequestSchema } from "./shared"; + +const RequestSchema = createRequestSchema( + "claimCollateralSurplus", + { + borrower: vAddress(), + collSurplus: vDnum(), + collIndex: vCollIndex(), + }, +); -const FlowIdSchema = v.literal("claimCollateralSurplus"); - -const RequestSchema = v.object({ - flowId: FlowIdSchema, - backLink: v.union([ - v.null(), - v.tuple([ - v.string(), // path - v.string(), // label - ]), - ]), - successLink: v.tuple([ - v.string(), // path - v.string(), // label - ]), - successMessage: v.string(), - - borrower: vAddress(), - collSurplus: vDnum(), - collIndex: vCollIndex(), -}); - -export type Request = v.InferOutput; - -type Step = "claimCollateral"; - -const stepNames: Record = { - claimCollateral: "Claim remaining collateral", -}; +export type ClaimCollateralSurplusRequest = v.InferOutput; -export const claimCollateralSurplus: FlowDeclaration = { +export const claimCollateralSurplus: FlowDeclaration = { title: "Review & Send Transaction", - Summary({ flow }) { - const { collIndex, collSurplus, borrower } = flow.request; + Summary({ request }) { + const { collIndex, collSurplus, borrower } = request; const collToken = getCollToken(collIndex); if (!collToken) { @@ -173,8 +157,8 @@ export const claimCollateralSurplus: FlowDeclaration = { ); }, - Details({ flow }) { - const { collIndex } = flow.request; + Details({ request }) { + const { collIndex } = request; const collateral = getCollToken(collIndex); return ( @@ -187,8 +171,27 @@ export const claimCollateralSurplus: FlowDeclaration = { ); }, - getStepName(stepId) { - return stepNames[stepId]; + steps: { + claimCollateral: { + name: () => "Claim remaining collateral", + Status: TransactionStatus, + + async commit({ contracts, request, wagmiConfig }) { + const { collIndex } = request; + const { BorrowerOperations } = contracts.collaterals[collIndex].contracts; + return writeContract(wagmiConfig, { + ...BorrowerOperations, + functionName: "claimCollateral", + args: [], + }); + }, + + async verify({ wagmiConfig }, hash) { + await waitForTransactionReceipt(wagmiConfig, { + hash: hash as `0x${string}`, + }); + }, + }, }, async getSteps() { @@ -198,20 +201,6 @@ export const claimCollateralSurplus: FlowDeclaration = { parseRequest(request) { return v.parse(RequestSchema, request); }, - - async writeContractParams(stepId, { contracts, request }) { - const { collIndex } = request; - const { contracts: collContracts } = contracts.collaterals[collIndex]; - if (stepId === "claimCollateral") { - return { - ...collContracts.BorrowerOperations, - functionName: "claimCollateral", - args: [], - }; - } - - throw new Error("Invalid stepId: " + stepId); - }, }; function GridItem({ diff --git a/frontend/app/src/tx-flows/closeLoanPosition.tsx b/frontend/app/src/tx-flows/closeLoanPosition.tsx index bbcebe161..288ef9c40 100644 --- a/frontend/app/src/tx-flows/closeLoanPosition.tsx +++ b/frontend/app/src/tx-flows/closeLoanPosition.tsx @@ -7,69 +7,45 @@ import { getCloseFlashLoanAmount } from "@/src/liquity-leverage"; import { getCollToken, getPrefixedTroveId } from "@/src/liquity-utils"; import { LoanCard } from "@/src/screens/TransactionsScreen/LoanCard"; import { TransactionDetailsRow } from "@/src/screens/TransactionsScreen/TransactionsScreen"; +import { TransactionStatus } from "@/src/screens/TransactionsScreen/TransactionStatus"; import { usePrice } from "@/src/services/Prices"; import { graphQuery, TroveByIdQuery } from "@/src/subgraph-queries"; +import { sleep } from "@/src/utils"; import { vPositionLoanCommited } from "@/src/valibot-utils"; import { ADDRESS_ZERO } from "@liquity2/uikit"; import * as dn from "dnum"; import * as v from "valibot"; -import { readContract } from "wagmi/actions"; - -const FlowIdSchema = v.literal("closeLoanPosition"); - -const RequestSchema = v.object({ - flowId: FlowIdSchema, - - backLink: v.union([ - v.null(), - v.tuple([ - v.string(), // path - v.string(), // label - ]), - ]), - successLink: v.tuple([ - v.string(), // path - v.string(), // label - ]), - successMessage: v.string(), - - loan: vPositionLoanCommited(), - repayWithCollateral: v.boolean(), -}); - -export type Request = v.InferOutput; - -type Step = - | "closeLoanPosition" - | "closeLoanPositionFromCollateral" - | "approveBold"; - -const stepNames: Record = { - approveBold: "Approve BOLD", - closeLoanPosition: "Close loan", - closeLoanPositionFromCollateral: "Close loan", -}; +import { readContract, waitForTransactionReceipt, writeContract } from "wagmi/actions"; +import { createRequestSchema } from "./shared"; + +const RequestSchema = createRequestSchema( + "closeLoanPosition", + { + loan: vPositionLoanCommited(), + repayWithCollateral: v.boolean(), + }, +); -export const closeLoanPosition: FlowDeclaration = { - title: "Review & Send Transaction", +export type CloseLoanPositionRequest = v.InferOutput; - Summary({ flow }) { - const { loan } = flow.request; +export const closeLoanPosition: FlowDeclaration = { + title: "Review & Send Transaction", + Summary({ request }) { return ( {}} txPreviewMode /> ); }, - Details({ flow }) { - const { loan, repayWithCollateral } = flow.request; + Details({ request }) { + const { loan, repayWithCollateral } = request; const collateral = getCollToken(loan.collIndex); if (!collateral) { @@ -129,23 +105,133 @@ export const closeLoanPosition: FlowDeclaration = { ); }, - getStepName(stepid) { - return stepNames[stepid]; + steps: { + approveBold: { + name: () => "Approve BOLD", + Status: TransactionStatus, + + async commit({ contracts, request, wagmiConfig }) { + const { loan } = request; + const coll = contracts.collaterals[loan.collIndex]; + const { entireDebt } = await readContract(wagmiConfig, { + ...coll.contracts.TroveManager, + functionName: "getLatestTroveData", + args: [BigInt(loan.troveId)], + }); + + const Zapper = coll.symbol === "ETH" + ? coll.contracts.LeverageWETHZapper + : coll.contracts.LeverageLSTZapper; + + return writeContract(wagmiConfig, { + ...contracts.BoldToken, + functionName: "approve", + args: [ + Zapper.address, + // TODO: calculate the amount to approve in a more precise way + dn.mul([entireDebt, 18], 1.1)[0], + ], + }); + }, + + async verify({ wagmiConfig }, hash) { + await waitForTransactionReceipt(wagmiConfig, { hash: hash as `0x${string}` }); + }, + }, + + // Close a loan position, repaying with BOLD or with the collateral + closeLoanPosition: { + name: () => "Close loan", + Status: TransactionStatus, + + async commit({ contracts, request, wagmiConfig }) { + const { loan } = request; + const coll = contracts.collaterals[loan.collIndex]; + + // repay with BOLD => get ETH + if (!request.repayWithCollateral && coll.symbol === "ETH") { + return writeContract(wagmiConfig, { + ...coll.contracts.LeverageWETHZapper, + functionName: "closeTroveToRawETH", + args: [BigInt(loan.troveId)], + }); + } + + // repay with BOLD => get LST + if (!request.repayWithCollateral) { + return writeContract(wagmiConfig, { + ...coll.contracts.LeverageLSTZapper, + functionName: "closeTroveToRawETH", + args: [BigInt(loan.troveId)], + }); + } + + // from here, we are repaying with the collateral + + const closeFlashLoanAmount = await getCloseFlashLoanAmount( + loan.collIndex, + loan.troveId, + wagmiConfig, + ); + + if (closeFlashLoanAmount === null) { + throw new Error("The flash loan amount could not be calculated."); + } + + const closeParams = { + troveId: BigInt(loan.troveId), + flashLoanAmount: closeFlashLoanAmount, + receiver: ADDRESS_ZERO, + }; + + // repay with collateral => get ETH + if (coll.symbol === "ETH") { + return writeContract(wagmiConfig, { + ...coll.contracts.LeverageWETHZapper, + functionName: "closeTroveFromCollateral", + args: [closeParams], + }); + } + + // repay with collateral => get LST + return writeContract(wagmiConfig, { + ...coll.contracts.LeverageLSTZapper, + functionName: "closeTroveFromCollateral", + args: [closeParams], + }); + }, + + async verify({ request, wagmiConfig }, hash) { + await waitForTransactionReceipt(wagmiConfig, { hash: hash as `0x${string}` }); + + const prefixedTroveId = getPrefixedTroveId( + request.loan.collIndex, + request.loan.troveId, + ); + + // wait for the trove to be seen as closed in the subgraph + while (true) { + const { trove } = await graphQuery(TroveByIdQuery, { id: prefixedTroveId }); + if (trove?.closedAt !== undefined) { + break; + } + await sleep(1000); + } + }, + }, }, async getSteps({ account, contracts, request, wagmiConfig }) { - const { loan } = request; + if (!account) { + throw new Error("Account address is required"); + } + const { loan } = request; const coll = contracts.collaterals[loan.collIndex]; - const Zapper = coll.symbol === "ETH" ? coll.contracts.LeverageWETHZapper : coll.contracts.LeverageLSTZapper; - if (!account.address) { - throw new Error("Account address is required"); - } - const { entireDebt } = await readContract(wagmiConfig, { ...coll.contracts.TroveManager, functionName: "getLatestTroveData", @@ -156,99 +242,23 @@ export const closeLoanPosition: FlowDeclaration = { await readContract(wagmiConfig, { ...contracts.BoldToken, functionName: "allowance", - args: [account.address, Zapper.address], + args: [account, Zapper.address], }) ?? 0n, 18, ]); - const closeStep = request.repayWithCollateral - ? "closeLoanPositionFromCollateral" as const - : "closeLoanPosition" as const; - - return [ - isBoldApproved ? null : "approveBold" as const, - closeStep, - ].filter((step) => step !== null); - }, - - parseRequest(request) { - return v.parse(RequestSchema, request); - }, - - async writeContractParams(stepId, { contracts, request, wagmiConfig }) { - const { loan } = request; - - const coll = contracts.collaterals[loan.collIndex]; + const steps: string[] = []; - if (stepId === "approveBold") { - const { entireDebt } = await readContract(wagmiConfig, { - ...coll.contracts.TroveManager, - functionName: "getLatestTroveData", - args: [BigInt(loan.troveId)], - }); - - const Zapper = coll.symbol === "ETH" - ? coll.contracts.LeverageWETHZapper - : coll.contracts.LeverageLSTZapper; - - return { - ...contracts.BoldToken, - functionName: "approve", - args: [Zapper.address, dn.mul([entireDebt, 18], 1.1)[0]], // TODO: calculate the amount to approve in a more precise way - }; + if (!isBoldApproved) { + steps.push("approveBold"); } - if (stepId === "closeLoanPosition") { - return coll.symbol === "ETH" - ? { - ...coll.contracts.LeverageWETHZapper, - functionName: "closeTroveToRawETH" as const, - args: [loan.troveId], - } - : { - ...coll.contracts.LeverageLSTZapper, - functionName: "closeTroveToRawETH" as const, - args: [loan.troveId], - }; - } + steps.push("closeLoanPosition"); - if (stepId === "closeLoanPositionFromCollateral") { - const closeFlashLoanAmount = await getCloseFlashLoanAmount(loan.collIndex, loan.troveId, wagmiConfig); - - if (closeFlashLoanAmount === null) { - throw new Error("The flash loan amount could not be calculated."); - } - - const closeParams = { - troveId: BigInt(loan.troveId), - flashLoanAmount: closeFlashLoanAmount, - receiver: ADDRESS_ZERO, - }; - - return coll.symbol === "ETH" - ? { - ...coll.contracts.LeverageWETHZapper, - functionName: "closeTroveFromCollateral" as const, - args: [closeParams], - } - : { - ...coll.contracts.LeverageLSTZapper, - functionName: "closeTroveFromCollateral" as const, - args: [closeParams], - }; - } - - throw new Error("Invalid stepId: " + stepId); + return steps; }, - async postFlowCheck({ request }) { - const prefixedTroveId = getPrefixedTroveId( - request.loan.collIndex, - request.loan.troveId, - ); - while (true) { - const { trove } = await graphQuery(TroveByIdQuery, { id: prefixedTroveId }); - if (trove?.closedAt !== undefined) return; - } + parseRequest(request) { + return v.parse(RequestSchema, request); }, }; diff --git a/frontend/app/src/tx-flows/earnClaimRewards.tsx b/frontend/app/src/tx-flows/earnClaimRewards.tsx index c3be44326..7a59544cb 100644 --- a/frontend/app/src/tx-flows/earnClaimRewards.tsx +++ b/frontend/app/src/tx-flows/earnClaimRewards.tsx @@ -4,39 +4,27 @@ import { Amount } from "@/src/comps/Amount/Amount"; import { EarnPositionSummary } from "@/src/comps/EarnPositionSummary/EarnPositionSummary"; import { getCollToken } from "@/src/liquity-utils"; import { TransactionDetailsRow } from "@/src/screens/TransactionsScreen/TransactionsScreen"; +import { TransactionStatus } from "@/src/screens/TransactionsScreen/TransactionStatus"; import { usePrice } from "@/src/services/Prices"; import { vPositionEarn } from "@/src/valibot-utils"; import * as dn from "dnum"; import * as v from "valibot"; +import { waitForTransactionReceipt, writeContract } from "wagmi/actions"; +import { createRequestSchema } from "./shared"; -const FlowIdSchema = v.literal("earnClaimRewards"); - -const RequestSchema = v.object({ - flowId: FlowIdSchema, - backLink: v.union([ - v.null(), - v.tuple([ - v.string(), // path - v.string(), // label - ]), - ]), - successLink: v.tuple([ - v.string(), // path - v.string(), // label - ]), - successMessage: v.string(), - earnPosition: vPositionEarn(), -}); - -export type Request = v.InferOutput; +const RequestSchema = createRequestSchema( + "earnClaimRewards", + { + earnPosition: vPositionEarn(), + }, +); -type Step = "claimRewards"; +export type EarnClaimRewardsRequest = v.InferOutput; -export const earnClaimRewards: FlowDeclaration = { +export const earnClaimRewards: FlowDeclaration = { title: "Review & Send Transaction", - Summary({ flow }) { - const { request } = flow; + Summary({ request }) { return ( = { ); }, - Details({ flow }) { - const { request } = flow; + Details({ request }) { const collateral = getCollToken(request.earnPosition.collIndex); if (!collateral) { @@ -98,24 +85,34 @@ export const earnClaimRewards: FlowDeclaration = { ); }, - async getSteps() { - return ["claimRewards"]; + steps: { + claimRewards: { + name: () => "Claim rewards", + Status: TransactionStatus, + + async commit({ contracts, request, wagmiConfig }) { + const { collIndex } = request.earnPosition; + const { StabilityPool } = contracts.collaterals[collIndex].contracts; + return writeContract(wagmiConfig, { + ...StabilityPool, + functionName: "withdrawFromSP", + args: [0n, true], + }); + }, + + async verify({ wagmiConfig }, hash) { + await waitForTransactionReceipt(wagmiConfig, { + hash: hash as `0x${string}`, + }); + }, + }, }, - getStepName() { - return "Claim rewards"; // single step + async getSteps() { + return ["claimRewards"]; }, parseRequest(request) { return v.parse(RequestSchema, request); }, - - async writeContractParams(_stepId, { contracts, request }) { - const collateral = contracts.collaterals[request.earnPosition.collIndex]; - return { - ...collateral.contracts.StabilityPool, - functionName: "withdrawFromSP", - args: [0n, true], - }; - }, }; diff --git a/frontend/app/src/tx-flows/earnDeposit.tsx b/frontend/app/src/tx-flows/earnDeposit.tsx index c29eb92c1..9c34edf3b 100644 --- a/frontend/app/src/tx-flows/earnDeposit.tsx +++ b/frontend/app/src/tx-flows/earnDeposit.tsx @@ -3,49 +3,33 @@ import type { FlowDeclaration } from "@/src/services/TransactionFlow"; import { Amount } from "@/src/comps/Amount/Amount"; import { EarnPositionSummary } from "@/src/comps/EarnPositionSummary/EarnPositionSummary"; import { TransactionDetailsRow } from "@/src/screens/TransactionsScreen/TransactionsScreen"; +import { TransactionStatus } from "@/src/screens/TransactionsScreen/TransactionStatus"; import { usePrice } from "@/src/services/Prices"; import { vCollIndex, vPositionEarn } from "@/src/valibot-utils"; import * as dn from "dnum"; import * as v from "valibot"; +import { waitForTransactionReceipt, writeContract } from "wagmi/actions"; +import { createRequestSchema } from "./shared"; -const FlowIdSchema = v.literal("earnDeposit"); - -const RequestSchema = v.object({ - flowId: FlowIdSchema, - backLink: v.union([ - v.null(), - v.tuple([ - v.string(), // path - v.string(), // label +const RequestSchema = createRequestSchema( + "earnDeposit", + { + prevEarnPosition: v.union([ + v.null(), + vPositionEarn(), ]), - ]), - successLink: v.tuple([ - v.string(), // path - v.string(), // label - ]), - successMessage: v.string(), - prevEarnPosition: v.union([ - v.null(), - vPositionEarn(), - ]), - earnPosition: vPositionEarn(), - collIndex: vCollIndex(), - claim: v.boolean(), -}); - -export type Request = v.InferOutput; - -type Step = "provideToStabilityPool"; + earnPosition: vPositionEarn(), + collIndex: vCollIndex(), + claim: v.boolean(), + }, +); -const stepNames: Record = { - provideToStabilityPool: "Add deposit", -}; +export type EarnDepositRequest = v.InferOutput; -export const earnDeposit: FlowDeclaration = { +export const earnDeposit: FlowDeclaration = { title: "Review & Send Transaction", - Summary({ flow }) { - const { request } = flow; + Summary({ request }) { return ( = { ); }, - Details({ flow }) { - const { request } = flow; + Details({ request }) { const boldPrice = usePrice("BOLD"); const boldAmount = dn.sub( request.earnPosition.deposit, @@ -84,31 +67,41 @@ export const earnDeposit: FlowDeclaration = { ); }, - async getSteps() { - return ["provideToStabilityPool"]; + steps: { + provideToStabilityPool: { + name: () => "Add deposit", + Status: TransactionStatus, + + async commit({ contracts, request, wagmiConfig }) { + const collateral = contracts.collaterals[request.collIndex]; + const boldAmount = dn.sub( + request.earnPosition.deposit, + request.prevEarnPosition?.deposit ?? dn.from(0, 18), + ); + + return writeContract(wagmiConfig, { + ...collateral.contracts.StabilityPool, + functionName: "provideToSP", + args: [ + boldAmount[0], + request.claim, + ], + }); + }, + + async verify({ wagmiConfig }, hash) { + await waitForTransactionReceipt(wagmiConfig, { + hash: hash as `0x${string}`, + }); + }, + }, }, - getStepName(stepId) { - return stepNames[stepId]; + async getSteps() { + return ["provideToStabilityPool"]; }, parseRequest(request) { return v.parse(RequestSchema, request); }, - - async writeContractParams(_stepId, { contracts, request }) { - const collateral = contracts.collaterals[request.collIndex]; - const boldAmount = dn.sub( - request.earnPosition.deposit, - request.prevEarnPosition?.deposit ?? dn.from(0, 18), - ); - return { - ...collateral.contracts.StabilityPool, - functionName: "provideToSP", - args: [ - boldAmount[0], - request.claim, - ], - }; - }, }; diff --git a/frontend/app/src/tx-flows/earnWithdraw.tsx b/frontend/app/src/tx-flows/earnWithdraw.tsx index 1d8ea3e92..d005b1df1 100644 --- a/frontend/app/src/tx-flows/earnWithdraw.tsx +++ b/frontend/app/src/tx-flows/earnWithdraw.tsx @@ -3,46 +3,30 @@ import type { FlowDeclaration } from "@/src/services/TransactionFlow"; import { Amount } from "@/src/comps/Amount/Amount"; import { EarnPositionSummary } from "@/src/comps/EarnPositionSummary/EarnPositionSummary"; import { TransactionDetailsRow } from "@/src/screens/TransactionsScreen/TransactionsScreen"; +import { TransactionStatus } from "@/src/screens/TransactionsScreen/TransactionStatus"; import { usePrice } from "@/src/services/Prices"; import { vCollIndex, vPositionEarn } from "@/src/valibot-utils"; import * as dn from "dnum"; import * as v from "valibot"; +import { waitForTransactionReceipt, writeContract } from "wagmi/actions"; +import { createRequestSchema } from "./shared"; -const FlowIdSchema = v.literal("earnWithdraw"); - -const RequestSchema = v.object({ - flowId: FlowIdSchema, - backLink: v.union([ - v.null(), - v.tuple([ - v.string(), // path - v.string(), // label - ]), - ]), - successLink: v.tuple([ - v.string(), // path - v.string(), // label - ]), - successMessage: v.string(), - prevEarnPosition: vPositionEarn(), - earnPosition: vPositionEarn(), - collIndex: vCollIndex(), - claim: v.boolean(), -}); - -export type Request = v.InferOutput; - -type Step = "withdrawFromStabilityPool"; +const RequestSchema = createRequestSchema( + "earnWithdraw", + { + prevEarnPosition: vPositionEarn(), + earnPosition: vPositionEarn(), + collIndex: vCollIndex(), + claim: v.boolean(), + }, +); -const stepNames: Record = { - withdrawFromStabilityPool: "Withdraw", -}; +export type EarnWithdrawRequest = v.InferOutput; -export const earnWithdraw: FlowDeclaration = { +export const earnWithdraw: FlowDeclaration = { title: "Review & Send Transaction", - Summary({ flow }) { - const { request } = flow; + Summary({ request }) { return ( = { ); }, - Details({ flow }) { - const { request } = flow; + Details({ request }) { const boldPrice = usePrice("BOLD"); const boldAmount = dn.abs(dn.sub( request.earnPosition.deposit, @@ -81,31 +64,39 @@ export const earnWithdraw: FlowDeclaration = { ); }, - async getSteps() { - return ["withdrawFromStabilityPool"]; + steps: { + withdrawFromStabilityPool: { + name: () => "Withdraw", + Status: TransactionStatus, + + async commit({ contracts, request, wagmiConfig }) { + const { StabilityPool } = contracts.collaterals[request.collIndex].contracts; + + const boldAmount = dn.abs(dn.sub( + request.earnPosition.deposit, + request.prevEarnPosition.deposit, + )); + + return writeContract(wagmiConfig, { + ...StabilityPool, + functionName: "withdrawFromSP", + args: [boldAmount[0], request.claim], + }); + }, + + async verify({ wagmiConfig }, hash) { + await waitForTransactionReceipt(wagmiConfig, { + hash: hash as `0x${string}`, + }); + }, + }, }, - getStepName(stepId) { - return stepNames[stepId]; + async getSteps() { + return ["withdrawFromStabilityPool"]; }, parseRequest(request) { return v.parse(RequestSchema, request); }, - - async writeContractParams(_stepId, { contracts, request }) { - const collateral = contracts.collaterals[request.collIndex]; - const boldAmount = dn.abs(dn.sub( - request.earnPosition.deposit, - request.prevEarnPosition.deposit, - )); - return { - ...collateral.contracts.StabilityPool, - functionName: "withdrawFromSP", - args: [ - boldAmount[0], - request.claim, - ], - }; - }, }; diff --git a/frontend/app/src/tx-flows/openBorrowPosition.tsx b/frontend/app/src/tx-flows/openBorrowPosition.tsx index 9fbecd494..cc0c78f39 100644 --- a/frontend/app/src/tx-flows/openBorrowPosition.tsx +++ b/frontend/app/src/tx-flows/openBorrowPosition.tsx @@ -7,65 +7,47 @@ import { fmtnum } from "@/src/formatting"; import { getCollToken, getPrefixedTroveId, usePredictOpenTroveUpfrontFee } from "@/src/liquity-utils"; import { LoanCard } from "@/src/screens/TransactionsScreen/LoanCard"; import { TransactionDetailsRow } from "@/src/screens/TransactionsScreen/TransactionsScreen"; +import { TransactionStatus } from "@/src/screens/TransactionsScreen/TransactionStatus"; import { usePrice } from "@/src/services/Prices"; import { graphQuery, TroveByIdQuery } from "@/src/subgraph-queries"; -import { isTroveId } from "@/src/types"; +import { sleep } from "@/src/utils"; import { vAddress, vCollIndex, vDnum } from "@/src/valibot-utils"; -import { ADDRESS_ZERO, COLLATERALS as KNOWN_COLLATERALS, shortenAddress } from "@liquity2/uikit"; +import { ADDRESS_ZERO, shortenAddress } from "@liquity2/uikit"; import * as dn from "dnum"; import * as v from "valibot"; import { parseEventLogs } from "viem"; -import { readContract } from "wagmi/actions"; - -const FlowIdSchema = v.literal("openBorrowPosition"); - -const RequestSchema = v.object({ - flowId: FlowIdSchema, - backLink: v.union([ - v.null(), - v.tuple([ - v.string(), // path - v.string(), // label +import { readContract, waitForTransactionReceipt, writeContract } from "wagmi/actions"; +import { createRequestSchema } from "./shared"; + +const RequestSchema = createRequestSchema( + "openBorrowPosition", + { + collIndex: vCollIndex(), + owner: vAddress(), + ownerIndex: v.number(), + collAmount: vDnum(), + boldAmount: vDnum(), + upperHint: vDnum(), + lowerHint: vDnum(), + annualInterestRate: vDnum(), + maxUpfrontFee: vDnum(), + interestRateDelegate: v.union([ + v.null(), + v.tuple([ + vAddress(), + vDnum(), + vDnum(), + ]), ]), - ]), - successLink: v.tuple([ - v.string(), // path - v.string(), // label - ]), - successMessage: v.string(), - - collIndex: vCollIndex(), - owner: vAddress(), - ownerIndex: v.number(), - collAmount: vDnum(), - boldAmount: vDnum(), - upperHint: vDnum(), - lowerHint: vDnum(), - annualInterestRate: vDnum(), - maxUpfrontFee: vDnum(), - interestRateDelegate: v.union([ - v.null(), - v.tuple([ - vAddress(), // delegate - vDnum(), // min interest rate - vDnum(), // max interest rate - ]), - ]), -}); - -export type Request = v.InferOutput; + }, +); -type Step = - | "approveLst" - | "openTroveEth" - | "openTroveLst"; +export type OpenBorrowPositionRequest = v.InferOutput; -export const openBorrowPosition: FlowDeclaration = { +export const openBorrowPosition: FlowDeclaration = { title: "Review & Send Transaction", - Summary({ flow }) { - const { request } = flow; - + Summary({ request }) { const upfrontFee = usePredictOpenTroveUpfrontFee( request.collIndex, request.boldAmount, @@ -94,11 +76,10 @@ export const openBorrowPosition: FlowDeclaration = { ); }, - Details({ flow }) { - const { request } = flow; - const collateral = getCollToken(flow.request.collIndex); + Details({ request }) { + const collateral = getCollToken(request.collIndex); if (!collateral) { - throw new Error(`Invalid collateral index: ${flow.request.collIndex}`); + throw new Error(`Invalid collateral index: ${request.collIndex}`); } const collPrice = usePrice(collateral.symbol); @@ -108,6 +89,7 @@ export const openBorrowPosition: FlowDeclaration = { request.boldAmount, request.interestRateDelegate?.[0] ?? request.annualInterestRate, ); + const boldAmountWithFee = upfrontFee.data && dn.add(request.boldAmount, upfrontFee.data); return collateral && ( @@ -187,172 +169,174 @@ export const openBorrowPosition: FlowDeclaration = { ); }, - async getSteps({ - account, - contracts, - request, - wagmiConfig, - }) { - const collateral = contracts.collaterals[request.collIndex]; + steps: { + // Approve LST + approveLst: { + name: ({ contracts, request }) => { + const { symbol } = contracts.collaterals[request.collIndex]; + return `Approve ${symbol}`; + }, + Status: TransactionStatus, + + async commit({ contracts, request, wagmiConfig }) { + const { + LeverageLSTZapper, + CollToken, + } = contracts.collaterals[request.collIndex].contracts; + return writeContract(wagmiConfig, { + ...CollToken, + functionName: "approve", + args: [ + LeverageLSTZapper.address, + request.collAmount[0], + ], + }); + }, + + async verify({ wagmiConfig }, hash) { + await waitForTransactionReceipt(wagmiConfig, { + hash: hash as `0x${string}`, + }); + }, + }, - if (collateral.symbol === "ETH") { - return ["openTroveEth"]; + // LeverageLSTZapper mode + openTroveLst: { + name: () => "Open Position", + Status: TransactionStatus, + + async commit({ contracts, request, wagmiConfig }) { + const { LeverageLSTZapper } = contracts.collaterals[request.collIndex].contracts; + return writeContract(wagmiConfig, { + ...LeverageLSTZapper, + functionName: "openTroveWithRawETH" as const, + args: [{ + owner: request.owner, + ownerIndex: BigInt(request.ownerIndex), + collAmount: request.collAmount[0], + boldAmount: request.boldAmount[0], + upperHint: request.upperHint[0], + lowerHint: request.lowerHint[0], + annualInterestRate: request.interestRateDelegate + ? 0n + : request.annualInterestRate[0], + batchManager: request.interestRateDelegate + ? request.interestRateDelegate[0] + : ADDRESS_ZERO, + maxUpfrontFee: request.maxUpfrontFee[0], + addManager: ADDRESS_ZERO, + removeManager: ADDRESS_ZERO, + receiver: ADDRESS_ZERO, + }], + value: ETH_GAS_COMPENSATION[0], + }); + }, + + async verify({ contracts, request, wagmiConfig }, hash) { + const receipt = await waitForTransactionReceipt(wagmiConfig, { + hash: hash as `0x${string}`, + }); + + // extract trove ID from logs + const collateral = contracts.collaterals[request.collIndex]; + const [troveOperation] = parseEventLogs({ + abi: collateral.contracts.TroveManager.abi, + logs: receipt.logs, + eventName: "TroveOperation", + }); + + if (!troveOperation?.args?._troveId) { + throw new Error("Failed to extract trove ID from transaction"); + } + + const prefixedTroveId = getPrefixedTroveId( + request.collIndex, + `0x${troveOperation.args._troveId.toString(16)}`, + ); + + // wait for the trove to appear in the subgraph + while (true) { + const { trove } = await graphQuery(TroveByIdQuery, { + id: prefixedTroveId, + }); + if (trove !== null) { + break; + } + await sleep(1000); + } + }, + }, + + // LeverageWETHZapper mode + openTroveEth: { + name: () => "Open Position", + Status: TransactionStatus, + + async commit({ contracts, request, wagmiConfig }) { + const { LeverageWETHZapper } = contracts.collaterals[request.collIndex].contracts; + return writeContract(wagmiConfig, { + ...LeverageWETHZapper, + functionName: "openTroveWithRawETH", + args: [{ + owner: request.owner, + ownerIndex: BigInt(request.ownerIndex), + collAmount: 0n, + boldAmount: request.boldAmount[0], + upperHint: request.upperHint[0], + lowerHint: request.lowerHint[0], + annualInterestRate: request.interestRateDelegate + ? 0n + : request.annualInterestRate[0], + batchManager: request.interestRateDelegate + ? request.interestRateDelegate[0] + : ADDRESS_ZERO, + maxUpfrontFee: request.maxUpfrontFee[0], + addManager: ADDRESS_ZERO, + removeManager: ADDRESS_ZERO, + receiver: ADDRESS_ZERO, + }], + value: request.collAmount[0] + ETH_GAS_COMPENSATION[0], + }); + }, + + async verify(...args) { + // same verification as openTroveLst + return openBorrowPosition.steps.openTroveLst.verify(...args); + }, + }, + }, + + async getSteps({ account, contracts, request, wagmiConfig }) { + if (!account) { + throw new Error("Account address is required"); } + const collateral = contracts.collaterals[request.collIndex]; const { LeverageLSTZapper, CollToken } = collateral.contracts; - if (!LeverageLSTZapper || !CollToken) { - throw new Error(`Collateral ${collateral.symbol} not supported`); + // ETH collateral doesn't need approval + if (collateral.symbol === "ETH") { + return ["openTroveEth"]; } - const allowance = dnum18( - await readContract(wagmiConfig, { - ...CollToken, - functionName: "allowance", - args: [ - account.address ?? ADDRESS_ZERO, - LeverageLSTZapper.address, - ], - }), - ); - - const isApproved = !dn.gt( - dn.add(request.collAmount, ETH_GAS_COMPENSATION), - allowance, - ); + // Check if approval is needed + const allowance = await readContract(wagmiConfig, { + ...CollToken, + functionName: "allowance", + args: [account, LeverageLSTZapper.address], + }); - const steps: Step[] = []; + const steps: string[] = []; - if (!isApproved) { + if (allowance < request.collAmount[0]) { steps.push("approveLst"); } steps.push("openTroveLst"); - return steps; }, - getStepName(stepId, { contracts, request }) { - const { symbol } = contracts.collaterals[request.collIndex]; - const collateral = KNOWN_COLLATERALS.find((c) => c.symbol === symbol); - if (stepId === "approveLst") { - return `Approve ${collateral?.name ?? ""}`; - } - return `Open loan`; - }, - parseRequest(request) { return v.parse(RequestSchema, request); }, - - parseReceipt(stepId, receipt, { request, contracts }): string | null { - const collateral = contracts.collaterals[request.collIndex]; - - if (stepId === "openTroveEth" || stepId === "openTroveLst") { - const [troveOperation] = parseEventLogs({ - abi: collateral.contracts.TroveManager.abi, - logs: receipt.logs, - eventName: "TroveOperation", - }); - if (troveOperation) { - return "0x" + (troveOperation.args._troveId.toString(16)); - } - } - - return null; - }, - - async writeContractParams(stepId, { contracts, request }) { - const collateral = contracts.collaterals[request.collIndex]; - - const { LeverageLSTZapper, CollToken } = collateral.contracts; - if (!LeverageLSTZapper || !CollToken) { - throw new Error(`Collateral ${collateral.symbol} not supported`); - } - - if (stepId === "approveLst") { - return { - ...CollToken, - functionName: "approve" as const, - args: [ - LeverageLSTZapper.address, - request.collAmount[0], - ], - }; - } - - // LeverageWETHZapper mode - if (stepId === "openTroveEth") { - return { - ...collateral.contracts.LeverageWETHZapper, - functionName: "openTroveWithRawETH" as const, - args: [{ - owner: request.owner ?? ADDRESS_ZERO, - ownerIndex: BigInt(request.ownerIndex), - collAmount: 0n, - boldAmount: request.boldAmount[0], - upperHint: request.upperHint[0], - lowerHint: request.lowerHint[0], - annualInterestRate: request.interestRateDelegate - ? 0n - : request.annualInterestRate[0], - batchManager: request.interestRateDelegate - ? request.interestRateDelegate[0] - : ADDRESS_ZERO, - maxUpfrontFee: request.maxUpfrontFee[0], - addManager: ADDRESS_ZERO, - removeManager: ADDRESS_ZERO, - receiver: ADDRESS_ZERO, - }], - value: request.collAmount[0] + ETH_GAS_COMPENSATION[0], - }; - } - - // LeverageLSTZapper mode - if (stepId === "openTroveLst") { - return { - ...collateral.contracts.LeverageLSTZapper, - functionName: "openTroveWithRawETH" as const, - args: [{ - owner: request.owner ?? ADDRESS_ZERO, - ownerIndex: BigInt(request.ownerIndex), - collAmount: request.collAmount[0], - boldAmount: request.boldAmount[0], - upperHint: request.upperHint[0], - lowerHint: request.lowerHint[0], - annualInterestRate: request.interestRateDelegate - ? 0n - : request.annualInterestRate[0], - batchManager: request.interestRateDelegate - ? request.interestRateDelegate[0] - : ADDRESS_ZERO, - maxUpfrontFee: request.maxUpfrontFee[0], - addManager: ADDRESS_ZERO, - removeManager: ADDRESS_ZERO, - receiver: ADDRESS_ZERO, - }], - value: ETH_GAS_COMPENSATION[0], - }; - } - - throw new Error("Not implemented"); - }, - - async postFlowCheck({ request, steps }) { - const lastStep = steps?.at(-1); - - if (lastStep?.txStatus !== "post-check" || !isTroveId(lastStep.txReceiptData)) { - return; - } - - const prefixedTroveId = getPrefixedTroveId( - request.collIndex, - lastStep.txReceiptData, - ); - - while (true) { - const { trove } = await graphQuery(TroveByIdQuery, { id: prefixedTroveId }); - if (trove !== null) return; - } - }, }; diff --git a/frontend/app/src/tx-flows/openLeveragePosition.tsx b/frontend/app/src/tx-flows/openLeveragePosition.tsx index 52c95da13..f1a0e5b62 100644 --- a/frontend/app/src/tx-flows/openLeveragePosition.tsx +++ b/frontend/app/src/tx-flows/openLeveragePosition.tsx @@ -9,78 +9,52 @@ import { getCollToken, getPrefixedTroveId, usePredictOpenTroveUpfrontFee } from import { AccountButton } from "@/src/screens/TransactionsScreen/AccountButton"; import { LoanCard } from "@/src/screens/TransactionsScreen/LoanCard"; import { TransactionDetailsRow } from "@/src/screens/TransactionsScreen/TransactionsScreen"; +import { TransactionStatus } from "@/src/screens/TransactionsScreen/TransactionStatus"; import { usePrice } from "@/src/services/Prices"; import { graphQuery, TroveByIdQuery } from "@/src/subgraph-queries"; -import { isTroveId } from "@/src/types"; -import { noop } from "@/src/utils"; +import { noop, sleep } from "@/src/utils"; import { vPositionLoanUncommited } from "@/src/valibot-utils"; import { ADDRESS_ZERO } from "@liquity2/uikit"; import * as dn from "dnum"; import * as v from "valibot"; import { parseEventLogs } from "viem"; -import { readContract } from "wagmi/actions"; - -const FlowIdSchema = v.literal("openLeveragePosition"); - -const RequestSchema = v.object({ - flowId: FlowIdSchema, - backLink: v.union([ - v.null(), - v.tuple([ - v.string(), // path - v.string(), // label - ]), - ]), - successLink: v.tuple([ - v.string(), // path - v.string(), // label - ]), - successMessage: v.string(), - - ownerIndex: v.number(), - leverageFactor: v.number(), - loan: vPositionLoanUncommited(), -}); - -export type Request = v.InferOutput; - -type Step = - | "approveLst" - | "openLeveragedTrove"; - -export const openLeveragePosition: FlowDeclaration = { - title: "Review & Send Transaction", - Summary({ flow }) { - const { request } = flow; - const { loan } = request; +import { readContract, waitForTransactionReceipt, writeContract } from "wagmi/actions"; +import { createRequestSchema } from "./shared"; + +const RequestSchema = createRequestSchema( + "openLeveragePosition", + { + ownerIndex: v.number(), + leverageFactor: v.number(), + loan: vPositionLoanUncommited(), + }, +); - const collateral = getCollToken(loan.collIndex); +export type OpenLeveragePositionRequest = v.InferOutput; - if (!collateral) { - throw new Error("Invalid collateral index"); - } +export const openLeveragePosition: FlowDeclaration = { + title: "Review & Send Transaction", + Summary({ request }) { return ( ); }, - Details({ flow }) { - const { request } = flow; - const { loan } = request; + Details({ request }) { + const { loan } = request; const collToken = getCollToken(loan.collIndex); if (!collToken) { throw new Error(`Invalid collateral index: ${loan.collIndex}`); } const collPrice = usePrice(collToken.symbol); - const upfrontFee = usePredictOpenTroveUpfrontFee( loan.collIndex, loan.borrowed, @@ -150,44 +124,162 @@ export const openLeveragePosition: FlowDeclaration = { ); }, + steps: { + approveLst: { + name: ({ request }) => { + const collToken = getCollToken(request.loan.collIndex); + return `Approve ${collToken?.name ?? ""}`; + }, + Status: TransactionStatus, + + async commit({ contracts, request, wagmiConfig }) { + const { loan } = request; + const initialDeposit = dn.div(loan.deposit, request.leverageFactor); + const { LeverageLSTZapper, CollToken } = contracts.collaterals[loan.collIndex].contracts; + + return writeContract(wagmiConfig, { + ...CollToken, + functionName: "approve", + args: [ + LeverageLSTZapper.address, + initialDeposit[0], + ], + }); + }, + + async verify({ wagmiConfig }, hash) { + await waitForTransactionReceipt(wagmiConfig, { + hash: hash as `0x${string}`, + }); + }, + }, + + openLeveragedTrove: { + name: () => "Open Leveraged Position", + Status: TransactionStatus, + + async commit({ contracts, request, wagmiConfig }) { + const { loan } = request; + const initialDeposit = dn.div(loan.deposit, request.leverageFactor); + const collateral = contracts.collaterals[loan.collIndex]; + const { LeverageLSTZapper, LeverageWETHZapper } = collateral.contracts; + + const openLeveragedParams = await getOpenLeveragedTroveParams( + loan.collIndex, + initialDeposit[0], + request.leverageFactor, + wagmiConfig, + ); + + const txParams = { + owner: loan.borrower, + ownerIndex: BigInt(request.ownerIndex), + collAmount: initialDeposit[0], + flashLoanAmount: openLeveragedParams.flashLoanAmount, + boldAmount: openLeveragedParams.effectiveBoldAmount, + upperHint: 0n, + lowerHint: 0n, + annualInterestRate: loan.batchManager ? 0n : loan.interestRate[0], + batchManager: loan.batchManager ?? ADDRESS_ZERO, + maxUpfrontFee: MAX_UPFRONT_FEE, + addManager: ADDRESS_ZERO, + removeManager: ADDRESS_ZERO, + receiver: ADDRESS_ZERO, + }; + + // ETH collateral case + if (collateral.symbol === "ETH") { + return writeContract(wagmiConfig, { + ...LeverageWETHZapper, + functionName: "openLeveragedTroveWithRawETH", + args: [txParams], + value: initialDeposit[0] + ETH_GAS_COMPENSATION[0], + }); + } + + // LST collateral case + return writeContract(wagmiConfig, { + ...LeverageLSTZapper, + functionName: "openLeveragedTroveWithRawETH", + args: [txParams], + value: ETH_GAS_COMPENSATION[0], + }); + }, + + async verify({ contracts, request, wagmiConfig }, hash) { + const receipt = await waitForTransactionReceipt(wagmiConfig, { + hash: hash as `0x${string}`, + }); + + // Extract trove ID from logs + const collToken = getCollToken(request.loan.collIndex); + if (!collToken) throw new Error("Invalid collateral index"); + + const collateral = contracts.collaterals[request.loan.collIndex]; + + const [troveOperation] = parseEventLogs({ + abi: collateral.contracts.TroveManager.abi, + logs: receipt.logs, + eventName: "TroveOperation", + }); + + if (!troveOperation?.args?._troveId) { + throw new Error("Failed to extract trove ID from transaction"); + } + + // Wait for trove to appear in subgraph + const prefixedTroveId = getPrefixedTroveId( + request.loan.collIndex, + `0x${troveOperation.args._troveId.toString(16)}`, + ); + + while (true) { + const { trove } = await graphQuery(TroveByIdQuery, { id: prefixedTroveId }); + if (trove !== null) { + break; + } + await sleep(1000); + } + }, + }, + }, + async getSteps({ account, contracts, request, wagmiConfig, }) { + if (!account) { + throw new Error("Account address is required"); + } + const { loan } = request; const collToken = getCollToken(loan.collIndex); if (!collToken) { throw new Error("Invalid collateral index: " + loan.collIndex); } - const { contracts: collContracts } = contracts.collaterals[loan.collIndex]; - + // ETH doesn't need approval if (collToken.symbol === "ETH") { return ["openLeveragedTrove"]; } - const { LeverageLSTZapper, CollToken } = collContracts; + const { collaterals } = contracts; + const { LeverageLSTZapper, CollToken } = collaterals[loan.collIndex].contracts; const allowance = dnum18( await readContract(wagmiConfig, { ...CollToken, functionName: "allowance", - args: [ - account.address ?? ADDRESS_ZERO, - LeverageLSTZapper.address, - ], + args: [account, LeverageLSTZapper.address], }), ); - const initialDeposit = dn.div(loan.deposit, request.leverageFactor); - - const isApproved = dn.gte(allowance, initialDeposit); - - const steps: Step[] = []; + const steps: string[] = []; - if (!isApproved) { + const initialDeposit = dn.div(loan.deposit, request.leverageFactor); + if (dn.lt(allowance, initialDeposit)) { steps.push("approveLst"); } @@ -196,133 +288,7 @@ export const openLeveragePosition: FlowDeclaration = { return steps; }, - getStepName(stepId, { request }) { - const { loan } = request; - const collateral = getCollToken(loan.collIndex); - if (!collateral) { - throw new Error("Invalid collateral index: " + loan.collIndex); - } - if (stepId === "approveLst") { - return `Approve ${collateral.name ?? ""}`; - } - return "Open Leveraged Position"; - }, - parseRequest(request) { return v.parse(RequestSchema, request); }, - - parseReceipt(stepId, receipt, { request, contracts }): string | null { - const { loan } = request; - const collateral = contracts.collaterals[loan.collIndex]; - if (stepId === "openLeveragedTrove") { - const [troveOperation] = parseEventLogs({ - abi: collateral.contracts.TroveManager.abi, - logs: receipt.logs, - eventName: "TroveOperation", - }); - if (troveOperation) { - return "0x" + (troveOperation.args._troveId.toString(16)); - } - } - return null; - }, - - async writeContractParams(stepId, { contracts, request, wagmiConfig }) { - const { loan } = request; - const collateral = contracts.collaterals[loan.collIndex]; - const initialDeposit = dn.div(loan.deposit, request.leverageFactor); - - const { LeverageLSTZapper, CollToken, LeverageWETHZapper } = collateral.contracts; - - // Approve LST - if (stepId === "approveLst") { - return { - ...CollToken, - functionName: "approve" as const, - args: [ - LeverageLSTZapper.address, - initialDeposit[0], - ], - }; - } - - // LeverageWETHZapper - if (collateral.symbol === "ETH" && stepId === "openLeveragedTrove") { - const params = await getOpenLeveragedTroveParams( - loan.collIndex, - initialDeposit[0], - request.leverageFactor, - wagmiConfig, - ); - return { - ...LeverageWETHZapper, - functionName: "openLeveragedTroveWithRawETH" as const, - args: [{ - owner: loan.borrower, - ownerIndex: BigInt(request.ownerIndex), - collAmount: initialDeposit[0], - flashLoanAmount: params.flashLoanAmount, - boldAmount: params.effectiveBoldAmount, - upperHint: 0n, - lowerHint: 0n, - annualInterestRate: loan.batchManager ? 0n : loan.interestRate[0], - batchManager: loan.batchManager ?? ADDRESS_ZERO, - maxUpfrontFee: MAX_UPFRONT_FEE, - addManager: ADDRESS_ZERO, - removeManager: ADDRESS_ZERO, - receiver: ADDRESS_ZERO, - }], - value: initialDeposit[0] + ETH_GAS_COMPENSATION[0], - }; - } - - // LeverageLSTZapper - if (stepId === "openLeveragedTrove") { - const params = await getOpenLeveragedTroveParams( - loan.collIndex, - initialDeposit[0], - request.leverageFactor, - wagmiConfig, - ); - return { - ...LeverageLSTZapper, - functionName: "openLeveragedTroveWithRawETH" as const, - args: [{ - owner: loan.borrower, - ownerIndex: BigInt(request.ownerIndex), - collAmount: initialDeposit[0], - flashLoanAmount: params.flashLoanAmount, - boldAmount: params.effectiveBoldAmount, - upperHint: 0n, - lowerHint: 0n, - annualInterestRate: loan.batchManager ? 0n : loan.interestRate[0], - batchManager: loan.batchManager ?? ADDRESS_ZERO, - maxUpfrontFee: MAX_UPFRONT_FEE, - addManager: ADDRESS_ZERO, - removeManager: ADDRESS_ZERO, - receiver: ADDRESS_ZERO, - }], - value: ETH_GAS_COMPENSATION[0], - }; - } - - throw new Error(`Invalid stepId: ${stepId}`); - }, - - async postFlowCheck({ request, steps }) { - const lastStep = steps?.at(-1); - - if (lastStep?.txStatus !== "post-check" || !isTroveId(lastStep.txReceiptData)) { - return; - } - - const prefixedTroveId = getPrefixedTroveId(request.loan.collIndex, lastStep.txReceiptData); - while (true) { - const { trove } = await graphQuery(TroveByIdQuery, { id: prefixedTroveId }); - if (trove !== null) { - return; - } - } - }, }; diff --git a/frontend/app/src/tx-flows/shared.ts b/frontend/app/src/tx-flows/shared.ts new file mode 100644 index 000000000..af4773ffc --- /dev/null +++ b/frontend/app/src/tx-flows/shared.ts @@ -0,0 +1,48 @@ +import type { CollIndex } from "@/src/types"; +import type { Config as WagmiConfig } from "wagmi"; + +import { getPrefixedTroveId } from "@/src/liquity-utils"; +import { graphQuery, TroveByIdQuery } from "@/src/subgraph-queries"; +import { sleep } from "@/src/utils"; +import * as v from "valibot"; +import { waitForTransactionReceipt } from "wagmi/actions"; + +export function createRequestSchema< + Id extends string, + SchemaEntries extends Parameters[0], +>(id: Id, entries: SchemaEntries) { + return v.object({ + flowId: v.literal(id), + backLink: v.union([ + v.null(), + v.tuple([ + v.string(), // path + v.string(), // label + ]), + ]), + successLink: v.tuple([ + v.string(), // path + v.string(), // label + ]), + successMessage: v.string(), + ...entries, + }); +} + +export async function verifyTroveUpdate( + wagmiConfig: WagmiConfig, + hash: `0x${string}`, + collIndex: CollIndex, + lastUpdate: number, +) { + const receipt = await waitForTransactionReceipt(wagmiConfig, { hash }); + const prefixedTroveId = getPrefixedTroveId(collIndex, receipt.transactionHash); + while (true) { + // wait for the trove to be updated in the subgraph + const { trove } = await graphQuery(TroveByIdQuery, { id: prefixedTroveId }); + if (trove && Number(trove.updatedAt) * 1000 !== lastUpdate) { + break; + } + await sleep(1000); + } +} diff --git a/frontend/app/src/tx-flows/stakeClaimRewards.tsx b/frontend/app/src/tx-flows/stakeClaimRewards.tsx index 2c614f261..cff35f16a 100644 --- a/frontend/app/src/tx-flows/stakeClaimRewards.tsx +++ b/frontend/app/src/tx-flows/stakeClaimRewards.tsx @@ -3,57 +3,39 @@ import type { FlowDeclaration } from "@/src/services/TransactionFlow"; import { Amount } from "@/src/comps/Amount/Amount"; import { StakePositionSummary } from "@/src/comps/StakePositionSummary/StakePositionSummary"; import { TransactionDetailsRow } from "@/src/screens/TransactionsScreen/TransactionsScreen"; +import { TransactionStatus } from "@/src/screens/TransactionsScreen/TransactionStatus"; import { usePrice } from "@/src/services/Prices"; import { vPositionStake } from "@/src/valibot-utils"; import * as dn from "dnum"; import * as v from "valibot"; +import { waitForTransactionReceipt, writeContract } from "wagmi/actions"; +import { createRequestSchema } from "./shared"; + +const RequestSchema = createRequestSchema( + "stakeClaimRewards", + { + stakePosition: vPositionStake(), + prevStakePosition: v.union([v.null(), vPositionStake()]), + }, +); -const FlowIdSchema = v.literal("stakeClaimRewards"); - -const RequestSchema = v.object({ - flowId: FlowIdSchema, - backLink: v.union([ - v.null(), - v.tuple([ - v.string(), // path - v.string(), // label - ]), - ]), - successLink: v.tuple([ - v.string(), // path - v.string(), // label - ]), - successMessage: v.string(), - - stakePosition: vPositionStake(), - prevStakePosition: v.union([v.null(), vPositionStake()]), -}); - -export type Request = v.InferOutput; - -type Step = "stakeClaimRewards"; - -const stepNames: Record = { - stakeClaimRewards: "Claim rewards", -}; +export type StakeClaimRewardsRequest = v.InferOutput; -export const stakeClaimRewards: FlowDeclaration = { +export const stakeClaimRewards: FlowDeclaration = { title: "Review & Send Transaction", - Summary({ flow }) { + Summary({ request }) { return ( ); }, - Details({ flow }) { - const { request } = flow; + Details({ request }) { const { rewards } = request.stakePosition; - const lusdPrice = usePrice("LUSD"); const ethPrice = usePrice("ETH"); @@ -98,26 +80,32 @@ export const stakeClaimRewards: FlowDeclaration = { ); }, - async getSteps() { - return ["stakeClaimRewards"]; + steps: { + stakeClaimRewards: { + name: () => "Claim rewards", + Status: TransactionStatus, + + async commit({ contracts, wagmiConfig }) { + return writeContract(wagmiConfig, { + ...contracts.LqtyStaking, + functionName: "unstake", + args: [0n], + }); + }, + + async verify({ wagmiConfig }, hash) { + await waitForTransactionReceipt(wagmiConfig, { + hash: hash as `0x${string}`, + }); + }, + }, }, - getStepName(stepId) { - return stepNames[stepId]; + async getSteps() { + return ["stakeClaimRewards"]; }, parseRequest(request) { return v.parse(RequestSchema, request); }, - - async writeContractParams(stepId, { contracts }) { - if (stepId === "stakeClaimRewards") { - return { - ...contracts.LqtyStaking, - functionName: "unstake", - args: [0n], - }; - } - throw new Error(`Invalid stepId: ${stepId}`); - }, }; diff --git a/frontend/app/src/tx-flows/stakeDeposit.tsx b/frontend/app/src/tx-flows/stakeDeposit.tsx index 37e80605e..d980e40f4 100644 --- a/frontend/app/src/tx-flows/stakeDeposit.tsx +++ b/frontend/app/src/tx-flows/stakeDeposit.tsx @@ -3,59 +3,44 @@ import type { FlowDeclaration } from "@/src/services/TransactionFlow"; import { Amount } from "@/src/comps/Amount/Amount"; import { StakePositionSummary } from "@/src/comps/StakePositionSummary/StakePositionSummary"; import { dnum18 } from "@/src/dnum-utils"; +import { signPermit } from "@/src/permit"; +import { PermissionStatus } from "@/src/screens/TransactionsScreen/PermissionStatus"; import { TransactionDetailsRow } from "@/src/screens/TransactionsScreen/TransactionsScreen"; +import { TransactionStatus } from "@/src/screens/TransactionsScreen/TransactionStatus"; import { usePrice } from "@/src/services/Prices"; import { vDnum, vPositionStake } from "@/src/valibot-utils"; import * as dn from "dnum"; import * as v from "valibot"; -import { readContract } from "wagmi/actions"; - -const FlowIdSchema = v.literal("stakeDeposit"); - -const RequestSchema = v.object({ - flowId: FlowIdSchema, - backLink: v.union([ - v.null(), - v.tuple([ - v.string(), // path - v.string(), // label - ]), - ]), - successLink: v.tuple([ - v.string(), // path - v.string(), // label - ]), - successMessage: v.string(), - - lqtyAmount: vDnum(), - stakePosition: vPositionStake(), - prevStakePosition: v.union([v.null(), vPositionStake()]), -}); - -export type Request = v.InferOutput; - -type Step = "stakeDeposit" | "approveLqty"; - -const stepNames: Record = { - approveLqty: "Approve LQTY", - stakeDeposit: "Stake", -}; +import { readContract, waitForTransactionReceipt, writeContract } from "wagmi/actions"; +import { createRequestSchema } from "./shared"; + +const RequestSchema = createRequestSchema( + "stakeDeposit", + { + lqtyAmount: vDnum(), + stakePosition: vPositionStake(), + prevStakePosition: v.union([v.null(), vPositionStake()]), + }, +); + +export type StakeDepositRequest = v.InferOutput; + +const USE_PERMIT = false; -export const stakeDeposit: FlowDeclaration = { +export const stakeDeposit: FlowDeclaration = { title: "Review & Send Transaction", - Summary({ flow }) { + Summary({ request }) { return ( ); }, - Details({ flow }) { - const { request } = flow; + Details({ request }) { const { rewards } = request.stakePosition; const lqtyPrice = usePrice("LQTY"); @@ -116,48 +101,137 @@ export const stakeDeposit: FlowDeclaration = { ); }, + steps: { + // approve via permit + permitLqty: { + name: () => "Approve LQTY", + Status: PermissionStatus, + + async commit({ account, contracts, request, wagmiConfig }) { + if (!account) { + throw new Error("Account address is required"); + } + + const { deadline, ...permit } = await signPermit({ + token: contracts.LqtyToken.address, + spender: contracts.Governance.address, + value: request.lqtyAmount[0], + account, + wagmiConfig, + }); + + return JSON.stringify({ + ...permit, + deadline: Number(deadline), + }); + }, + + async verify() { + // nothing to do + }, + }, + + // approve tx + approveLqty: { + name: () => "Approve LQTY", + Status: TransactionStatus, + + async commit({ account, contracts, request, wagmiConfig }) { + if (!account) { + throw new Error("Account address is required"); + } + + const { LqtyToken, Governance } = contracts; + + return writeContract(wagmiConfig, { + ...LqtyToken, + functionName: "approve", + args: [Governance.address, request.lqtyAmount[0]], + }); + }, + + async verify({ wagmiConfig }, hash) { + await waitForTransactionReceipt(wagmiConfig, { + hash: hash as `0x${string}`, + }); + }, + }, + + stakeDeposit: { + name: () => "Stake", + Status: TransactionStatus, + + async commit({ account, contracts, request, wagmiConfig, steps }) { + if (!account) { + throw new Error("Account address is required"); + } + + const permitStep = steps?.find((step) => step.id === "permitLqty"); + const depositLqtyViaPermit = Boolean(permitStep?.artifact); + + // deposit LQTY + if (!depositLqtyViaPermit) { + return writeContract(wagmiConfig, { + ...contracts.Governance, + functionName: "depositLQTY", + args: [request.lqtyAmount[0]], + }); + } + + // deposit LQTY via permit + const permit = JSON.parse(permitStep?.artifact ?? ""); + return writeContract(wagmiConfig, { + ...contracts.Governance, + functionName: "depositLQTYViaPermit", + args: [ + request.lqtyAmount[0], + { + owner: account, + spender: contracts.Governance.address, + value: request.lqtyAmount[0], + deadline: permit.deadline, + v: permit.v, + r: permit.r, + s: permit.s, + }, + ], + }); + }, + + async verify({ wagmiConfig }, hash) { + await waitForTransactionReceipt(wagmiConfig, { hash: hash as `0x${string}` }); + }, + }, + }, + async getSteps({ account, contracts, request, wagmiConfig }) { - if (!account.address) { + if (!account) { throw new Error("Account address is required"); } - const lqtyAllowance = await readContract(wagmiConfig, { - ...contracts.LqtyToken, - functionName: "allowance", - args: [account.address, contracts.LqtyStaking.address], - }); + const steps: string[] = []; - const isLqtyApproved = dn.lte(request.lqtyAmount, dnum18(lqtyAllowance)); + // approve + if (USE_PERMIT) { + steps.push("permitLqty"); + } else { + const lqtyAllowance = await readContract(wagmiConfig, { + ...contracts.LqtyToken, + functionName: "allowance", + args: [account, contracts.LqtyStaking.address], + }); + if (dn.gt(request.lqtyAmount, dnum18(lqtyAllowance))) { + steps.push("approveLqty"); + } + } - return [ - isLqtyApproved ? null : "approveLqty" as const, - "stakeDeposit" as const, - ].filter((step): step is Step => step !== null); - }, + // stake + steps.push("stakeDeposit"); - getStepName(stepId) { - return stepNames[stepId]; + return steps; }, parseRequest(request) { return v.parse(RequestSchema, request); }, - - async writeContractParams(stepId, { contracts, request }) { - if (stepId === "approveLqty") { - return { - ...contracts.LqtyToken, - functionName: "approve", - args: [contracts.LqtyStaking.address, request.lqtyAmount[0]], - }; - } - if (stepId === "stakeDeposit") { - return { - ...contracts.LqtyStaking, - functionName: "stake", - args: [request.lqtyAmount[0]], - }; - } - throw new Error(`Invalid stepId: ${stepId}`); - }, }; diff --git a/frontend/app/src/tx-flows/unstakeDeposit.tsx b/frontend/app/src/tx-flows/unstakeDeposit.tsx index 43dd8e46f..33d9bac68 100644 --- a/frontend/app/src/tx-flows/unstakeDeposit.tsx +++ b/frontend/app/src/tx-flows/unstakeDeposit.tsx @@ -3,58 +3,40 @@ import type { FlowDeclaration } from "@/src/services/TransactionFlow"; import { Amount } from "@/src/comps/Amount/Amount"; import { StakePositionSummary } from "@/src/comps/StakePositionSummary/StakePositionSummary"; import { TransactionDetailsRow } from "@/src/screens/TransactionsScreen/TransactionsScreen"; +import { TransactionStatus } from "@/src/screens/TransactionsScreen/TransactionStatus"; import { usePrice } from "@/src/services/Prices"; import { vDnum, vPositionStake } from "@/src/valibot-utils"; import * as dn from "dnum"; import * as v from "valibot"; +import { waitForTransactionReceipt, writeContract } from "wagmi/actions"; +import { createRequestSchema } from "./shared"; + +const RequestSchema = createRequestSchema( + "unstakeDeposit", + { + lqtyAmount: vDnum(), + stakePosition: vPositionStake(), + prevStakePosition: v.union([v.null(), vPositionStake()]), + }, +); -const FlowIdSchema = v.literal("unstakeDeposit"); - -const RequestSchema = v.object({ - flowId: FlowIdSchema, - backLink: v.union([ - v.null(), - v.tuple([ - v.string(), // path - v.string(), // label - ]), - ]), - successLink: v.tuple([ - v.string(), // path - v.string(), // label - ]), - successMessage: v.string(), - - lqtyAmount: vDnum(), - stakePosition: vPositionStake(), - prevStakePosition: v.union([v.null(), vPositionStake()]), -}); - -export type Request = v.InferOutput; - -type Step = "unstakeDeposit"; - -const stepNames: Record = { - unstakeDeposit: "Unstake", -}; +export type UnstakeDepositRequest = v.InferOutput; -export const unstakeDeposit: FlowDeclaration = { +export const unstakeDeposit: FlowDeclaration = { title: "Review & Send Transaction", - Summary({ flow }) { + Summary({ request }) { return ( ); }, - Details({ flow }) { - const { request } = flow; + Details({ request }) { const { rewards } = request.stakePosition; - const lqtyPrice = usePrice("LQTY"); const lusdPrice = usePrice("LUSD"); const ethPrice = usePrice("ETH"); @@ -113,26 +95,32 @@ export const unstakeDeposit: FlowDeclaration = { ); }, - async getSteps() { - return ["unstakeDeposit"]; + steps: { + unstakeDeposit: { + name: () => "Unstake", + Status: TransactionStatus, + + async commit({ contracts, request, wagmiConfig }) { + return writeContract(wagmiConfig, { + ...contracts.LqtyStaking, + functionName: "unstake", + args: [request.lqtyAmount[0]], + }); + }, + + async verify({ wagmiConfig }, hash) { + await waitForTransactionReceipt(wagmiConfig, { + hash: hash as `0x${string}`, + }); + }, + }, }, - getStepName(stepId) { - return stepNames[stepId]; + async getSteps() { + return ["unstakeDeposit"]; }, parseRequest(request) { return v.parse(RequestSchema, request); }, - - async writeContractParams(stepId, { contracts, request }) { - if (stepId === "unstakeDeposit") { - return { - ...contracts.LqtyStaking, - functionName: "unstake", - args: [request.lqtyAmount[0]], - }; - } - throw new Error(`Invalid stepId: ${stepId}`); - }, }; diff --git a/frontend/app/src/tx-flows/updateBorrowPosition.tsx b/frontend/app/src/tx-flows/updateBorrowPosition.tsx index 64597f66b..dd8f954c1 100644 --- a/frontend/app/src/tx-flows/updateBorrowPosition.tsx +++ b/frontend/app/src/tx-flows/updateBorrowPosition.tsx @@ -3,105 +3,34 @@ import type { FlowDeclaration } from "@/src/services/TransactionFlow"; import { Amount } from "@/src/comps/Amount/Amount"; import { fmtnum } from "@/src/formatting"; -import { getCollToken, getPrefixedTroveId, usePredictAdjustTroveUpfrontFee } from "@/src/liquity-utils"; +import { getCollToken, usePredictAdjustTroveUpfrontFee } from "@/src/liquity-utils"; import { LoanCard } from "@/src/screens/TransactionsScreen/LoanCard"; import { TransactionDetailsRow } from "@/src/screens/TransactionsScreen/TransactionsScreen"; +import { TransactionStatus } from "@/src/screens/TransactionsScreen/TransactionStatus"; import { usePrice } from "@/src/services/Prices"; -import { graphQuery, TroveByIdQuery } from "@/src/subgraph-queries"; -import { isTroveId } from "@/src/types"; import { vDnum, vPositionLoanCommited } from "@/src/valibot-utils"; import * as dn from "dnum"; import { match, P } from "ts-pattern"; import * as v from "valibot"; -import { readContract } from "wagmi/actions"; - -const FlowIdSchema = v.literal("updateBorrowPosition"); - -const RequestSchema = v.object({ - flowId: FlowIdSchema, - backLink: v.union([ - v.null(), - v.tuple([ - v.string(), // path - v.string(), // label - ]), - ]), - successLink: v.tuple([ - v.string(), // path - v.string(), // label - ]), - successMessage: v.string(), - maxUpfrontFee: vDnum(), - prevLoan: vPositionLoanCommited(), - loan: vPositionLoanCommited(), -}); - -export type Request = v.InferOutput; - -type FinalStep = - | "adjustTrove" // update both collateral and borrowed - | "depositBold" - | "depositColl" - | "withdrawBold" - | "withdrawColl"; - -type Step = - | FinalStep - | "approveBold" - | "approveColl"; - -const stepNames: Record = { - approveBold: "Approve BOLD", - approveColl: "Approve {collSymbol}", - adjustTrove: "Update Position", - depositBold: "Update Position", - depositColl: "Update Position", - withdrawBold: "Update Position", - withdrawColl: "Update Position", -}; - -function getDebtChange(loan: Request["loan"], prevLoan: Request["prevLoan"]) { - return dn.sub(loan.borrowed, prevLoan.borrowed); -} - -function getCollChange(loan: Request["loan"], prevLoan: Request["prevLoan"]) { - return dn.sub(loan.deposit, prevLoan.deposit); -} - -function getFinalStep(request: Request): FinalStep { - const collChange = getCollChange(request.loan, request.prevLoan); - const debtChange = getDebtChange(request.loan, request.prevLoan); +import { readContract, waitForTransactionReceipt, writeContract } from "wagmi/actions"; +import { createRequestSchema, verifyTroveUpdate } from "./shared"; + +const RequestSchema = createRequestSchema( + "updateBorrowPosition", + { + maxUpfrontFee: vDnum(), + prevLoan: vPositionLoanCommited(), + loan: vPositionLoanCommited(), + }, +); - // both coll and debt change -> adjust trove - if (!dn.eq(collChange, 0) && !dn.eq(debtChange, 0)) { - return "adjustTrove"; - } - // coll increases -> deposit - if (dn.gt(collChange, 0)) { - return "depositColl"; - } - // coll decreases -> withdraw - if (dn.lt(collChange, 0)) { - return "withdrawColl"; - } - // debt increases -> withdraw BOLD (borrow) - if (dn.gt(debtChange, 0)) { - return "withdrawBold"; - } - // debt decreases -> deposit BOLD (repay) - if (dn.lt(debtChange, 0)) { - return "depositBold"; - } - throw new Error("Invalid request"); -} +export type UpdateBorrowPositionRequest = v.InferOutput; -export const updateBorrowPosition: FlowDeclaration = { +export const updateBorrowPosition: FlowDeclaration = { title: "Review & Send Transaction", - Summary({ flow }) { - const { request } = flow; + Summary({ request }) { const { loan, prevLoan } = request; - const collateral = getCollToken(loan.collIndex); if (!collateral) { throw new Error(`Invalid collateral index: ${loan.collIndex}`); @@ -134,8 +63,7 @@ export const updateBorrowPosition: FlowDeclaration = { ); }, - Details({ flow }) { - const { request } = flow; + Details({ request }) { const { loan, prevLoan } = request; const collChange = getCollChange(loan, prevLoan); @@ -203,27 +131,256 @@ export const updateBorrowPosition: FlowDeclaration = { ); }, - parseRequest(request) { - return v.parse(RequestSchema, request); - }, + steps: { + approveBold: { + name: () => "Approve BOLD", + Status: TransactionStatus, + + async commit({ contracts, request, wagmiConfig }) { + const debtChange = getDebtChange(request.loan, request.prevLoan); + const collateral = contracts.collaterals[request.loan.collIndex]; + const Controller = collateral.symbol === "ETH" + ? collateral.contracts.LeverageWETHZapper + : collateral.contracts.LeverageLSTZapper; + + return writeContract(wagmiConfig, { + ...contracts.BoldToken, + functionName: "approve", + args: [Controller.address, dn.abs(debtChange)[0]], + }); + }, + + async verify({ wagmiConfig }, hash) { + await waitForTransactionReceipt(wagmiConfig, { + hash: hash as `0x${string}`, + }); + }, + }, + + approveColl: { + name: ({ contracts, request }) => { + const coll = contracts.collaterals[request.loan.collIndex]; + return `Approve ${coll.symbol}`; + }, + Status: TransactionStatus, + + async commit({ contracts, request, wagmiConfig }) { + const collChange = getCollChange(request.loan, request.prevLoan); + const collateral = contracts.collaterals[request.loan.collIndex]; + const Controller = collateral.contracts.LeverageLSTZapper; + + return writeContract(wagmiConfig, { + ...collateral.contracts.CollToken, + functionName: "approve", + args: [Controller.address, dn.abs(collChange)[0]], + }); + }, + + async verify({ wagmiConfig }, hash) { + await waitForTransactionReceipt(wagmiConfig, { + hash: hash as `0x${string}`, + }); + }, + }, + + // update both collateral and debt + adjustTrove: { + name: () => "Update Position", + Status: TransactionStatus, + + async commit({ contracts, request, wagmiConfig }) { + const { loan, maxUpfrontFee } = request; + const collChange = getCollChange(loan, request.prevLoan); + const debtChange = getDebtChange(loan, request.prevLoan); + const collateral = contracts.collaterals[loan.collIndex]; + + if (collateral.symbol === "ETH") { + return writeContract(wagmiConfig, { + ...collateral.contracts.LeverageWETHZapper, + functionName: "adjustTroveWithRawETH", + args: [ + BigInt(loan.troveId), + dn.abs(collChange)[0], + !dn.lt(collChange, 0n), + dn.abs(debtChange)[0], + !dn.lt(debtChange, 0n), + maxUpfrontFee[0], + ], + value: dn.gt(collChange, 0n) ? collChange[0] : 0n, + }); + } + + return writeContract(wagmiConfig, { + ...collateral.contracts.LeverageLSTZapper, + functionName: "adjustTrove", + args: [ + BigInt(loan.troveId), + dn.abs(collChange)[0], + !dn.lt(collChange, 0n), + dn.abs(debtChange)[0], + !dn.lt(debtChange, 0n), + maxUpfrontFee[0], + ], + }); + }, + + async verify({ request, wagmiConfig }, hash) { + await verifyTroveUpdate( + wagmiConfig, + hash as `0x${string}`, + request.loan.collIndex, + request.loan.updatedAt, + ); + }, + }, - getStepName(stepId, { contracts, request }) { - const { loan } = request; - const name = stepNames[stepId]; - const coll = contracts.collaterals[loan.collIndex]; - return name.replace(/\{collSymbol\}/g, coll.symbol); + depositBold: { + name: () => "Update Position", + Status: TransactionStatus, + + async commit({ contracts, request, wagmiConfig }) { + const { loan } = request; + const debtChange = getDebtChange(loan, request.prevLoan); + const collateral = contracts.collaterals[loan.collIndex]; + + if (collateral.symbol === "ETH") { + return writeContract(wagmiConfig, { + ...collateral.contracts.LeverageWETHZapper, + functionName: "repayBold", + args: [BigInt(loan.troveId), dn.abs(debtChange)[0]], + }); + } + + return writeContract(wagmiConfig, { + ...collateral.contracts.LeverageLSTZapper, + functionName: "repayBold", + args: [BigInt(loan.troveId), dn.abs(debtChange)[0]], + }); + }, + + async verify({ request, wagmiConfig }, hash) { + await verifyTroveUpdate( + wagmiConfig, + hash as `0x${string}`, + request.loan.collIndex, + request.loan.updatedAt, + ); + }, + }, + + depositColl: { + name: () => "Update Position", + Status: TransactionStatus, + + async commit({ contracts, request, wagmiConfig }) { + const { loan } = request; + const collChange = getCollChange(loan, request.prevLoan); + const collateral = contracts.collaterals[loan.collIndex]; + + if (collateral.symbol === "ETH") { + return writeContract(wagmiConfig, { + ...collateral.contracts.LeverageWETHZapper, + functionName: "addCollWithRawETH", + args: [BigInt(loan.troveId)], + value: dn.abs(collChange)[0], + }); + } + + return writeContract(wagmiConfig, { + ...collateral.contracts.LeverageLSTZapper, + functionName: "addColl", + args: [BigInt(loan.troveId), dn.abs(collChange)[0]], + }); + }, + + async verify({ request, wagmiConfig }, hash) { + await verifyTroveUpdate( + wagmiConfig, + hash as `0x${string}`, + request.loan.collIndex, + request.loan.updatedAt, + ); + }, + }, + + withdrawBold: { + name: () => "Update Position", + Status: TransactionStatus, + + async commit({ contracts, request, wagmiConfig }) { + const { loan, maxUpfrontFee } = request; + const debtChange = getDebtChange(loan, request.prevLoan); + const collateral = contracts.collaterals[loan.collIndex]; + + if (collateral.symbol === "ETH") { + return writeContract(wagmiConfig, { + ...collateral.contracts.LeverageWETHZapper, + functionName: "withdrawBold", + args: [BigInt(loan.troveId), dn.abs(debtChange)[0], maxUpfrontFee[0]], + }); + } + + return writeContract(wagmiConfig, { + ...collateral.contracts.LeverageLSTZapper, + functionName: "withdrawBold", + args: [BigInt(loan.troveId), dn.abs(debtChange)[0], maxUpfrontFee[0]], + }); + }, + + async verify({ request, wagmiConfig }, hash) { + await verifyTroveUpdate( + wagmiConfig, + hash as `0x${string}`, + request.loan.collIndex, + request.loan.updatedAt, + ); + }, + }, + + withdrawColl: { + name: () => "Update Position", + Status: TransactionStatus, + + async commit({ contracts, request, wagmiConfig }) { + const { loan } = request; + const collChange = getCollChange(loan, request.prevLoan); + const collateral = contracts.collaterals[loan.collIndex]; + + if (collateral.symbol === "ETH") { + return writeContract(wagmiConfig, { + ...collateral.contracts.LeverageWETHZapper, + functionName: "withdrawCollToRawETH", + args: [BigInt(loan.troveId), dn.abs(collChange)[0]], + }); + } + + return writeContract(wagmiConfig, { + ...collateral.contracts.LeverageLSTZapper, + functionName: "withdrawColl", + args: [BigInt(loan.troveId), dn.abs(collChange)[0]], + }); + }, + + async verify({ request, wagmiConfig }, hash) { + await verifyTroveUpdate( + wagmiConfig, + hash as `0x${string}`, + request.loan.collIndex, + request.loan.updatedAt, + ); + }, + }, }, async getSteps({ account, contracts, request, wagmiConfig }) { const debtChange = getDebtChange(request.loan, request.prevLoan); const collChange = getCollChange(request.loan, request.prevLoan); const coll = contracts.collaterals[request.loan.collIndex]; - const Controller = coll.symbol === "ETH" ? coll.contracts.LeverageWETHZapper : coll.contracts.LeverageLSTZapper; - if (!account.address) { + if (!account) { throw new Error("Account address is required"); } @@ -231,7 +388,7 @@ export const updateBorrowPosition: FlowDeclaration = { await readContract(wagmiConfig, { ...contracts.BoldToken, functionName: "allowance", - args: [account.address, Controller.address], + args: [account, Controller.address], }) ?? 0n, 18, ]); @@ -241,156 +398,68 @@ export const updateBorrowPosition: FlowDeclaration = { await readContract(wagmiConfig, { ...coll.contracts.CollToken, functionName: "allowance", - args: [account.address, Controller.address], + args: [account, Controller.address], }) ?? 0n, 18, ]); - return [ - isBoldApproved ? null : "approveBold" as const, - isCollApproved ? null : "approveColl" as const, - getFinalStep(request), - ].filter((step) => step !== null); - }, + const steps: string[] = []; - async writeContractParams(stepId, { account, contracts, request }) { - const { loan, prevLoan, maxUpfrontFee } = request; - const collChange = getCollChange(loan, prevLoan); - const debtChange = getDebtChange(loan, prevLoan); - const { collIndex, troveId } = loan; + if (!isBoldApproved) steps.push("approveBold"); + if (!isCollApproved) steps.push("approveColl"); - const collateral = contracts.collaterals[collIndex]; - const { LeverageWETHZapper, LeverageLSTZapper } = collateral.contracts; + steps.push(getFinalStep(request)); - const Controller = collateral.symbol === "ETH" ? LeverageWETHZapper : LeverageLSTZapper; + return steps; + }, - if (!account.address) { - throw new Error("Account address is required"); - } + parseRequest(request) { + return v.parse(RequestSchema, request); + }, +}; - if (stepId === "approveBold") { - return { - ...contracts.BoldToken, - functionName: "approve", - args: [ - Controller.address, - dn.abs(debtChange)[0], - ], - }; - } +function getDebtChange( + loan: UpdateBorrowPositionRequest["loan"], + prevLoan: UpdateBorrowPositionRequest["prevLoan"], +) { + return dn.sub(loan.borrowed, prevLoan.borrowed); +} - if (stepId === "approveColl") { - return { - ...collateral.contracts.CollToken, - functionName: "approve", - args: [ - Controller.address, - dn.abs(collChange)[0], - ], - }; - } +function getCollChange( + loan: UpdateBorrowPositionRequest["loan"], + prevLoan: UpdateBorrowPositionRequest["prevLoan"], +) { + return dn.sub(loan.deposit, prevLoan.deposit); +} - // WETH zapper - if (collateral.symbol === "ETH") { - return match(stepId) - .with("adjustTrove", () => ({ - ...LeverageWETHZapper, - functionName: "adjustTroveWithRawETH", - args: [ - troveId, - dn.abs(collChange)[0], - !dn.lt(collChange, 0n), - dn.abs(debtChange)[0], - !dn.lt(debtChange, 0n), - maxUpfrontFee[0], - ], - value: dn.gt(collChange, 0n) ? collChange[0] : 0n, - })) - .with("depositColl", () => ({ - ...LeverageWETHZapper, - functionName: "addCollWithRawETH", - args: [troveId], - value: dn.abs(collChange)[0], - })) - .with("withdrawColl", () => ({ - ...LeverageWETHZapper, - functionName: "withdrawCollToRawETH", - args: [troveId, dn.abs(collChange)[0]], - })) - .with("depositBold", () => ({ - ...LeverageWETHZapper, - functionName: "repayBold", - args: [troveId, dn.abs(debtChange)[0]], - })) - .with("withdrawBold", () => ({ - ...LeverageWETHZapper, - functionName: "withdrawBold", - args: [troveId, dn.abs(debtChange)[0], maxUpfrontFee[0]], - })) - .exhaustive(); - } +function getFinalStep( + request: UpdateBorrowPositionRequest, +): "adjustTrove" | "depositBold" | "depositColl" | "withdrawBold" | "withdrawColl" { + const collChange = getCollChange(request.loan, request.prevLoan); + const debtChange = getDebtChange(request.loan, request.prevLoan); - // GasComp zapper - return match(stepId) - .with("adjustTrove", () => ({ - ...LeverageLSTZapper, - functionName: "adjustTrove", - args: [ - troveId, - dn.abs(collChange)[0], - !dn.lt(collChange, 0n), - dn.abs(debtChange)[0], - !dn.lt(debtChange, 0n), - maxUpfrontFee[0], - ], - })) - .with("depositColl", () => ({ - ...LeverageLSTZapper, - functionName: "addColl", - args: [troveId, dn.abs(collChange)[0]], - })) - .with("withdrawColl", () => ({ - ...LeverageLSTZapper, - functionName: "withdrawColl", - args: [troveId, dn.abs(collChange)[0]], - })) - .with("depositBold", () => ({ - ...LeverageLSTZapper, - functionName: "repayBold", - args: [troveId, dn.abs(debtChange)[0]], - })) - .with("withdrawBold", () => ({ - ...LeverageLSTZapper, - functionName: "withdrawBold", - args: [troveId, dn.abs(debtChange)[0], maxUpfrontFee[0]], - })) - .exhaustive(); - }, - async postFlowCheck({ request, steps }) { - const lastStep = steps?.at(-1); - if (lastStep?.txStatus !== "post-check" || !isTroveId(lastStep.txReceiptData)) { - return; - } + // both coll and debt change => adjust trove + if (!dn.eq(collChange, 0) && !dn.eq(debtChange, 0)) return "adjustTrove"; - const lastUpdate = request.loan.updatedAt; + // coll increases => deposit + if (dn.gt(collChange, 0)) return "depositColl"; - const prefixedTroveId = getPrefixedTroveId( - request.loan.collIndex, - lastStep.txReceiptData, - ); + // coll decreases => withdraw + if (dn.lt(collChange, 0)) return "withdrawColl"; - while (true) { - const { trove } = await graphQuery(TroveByIdQuery, { id: prefixedTroveId }); + // debt increases => withdraw BOLD (borrow) + if (dn.gt(debtChange, 0)) return "withdrawBold"; - // trove found and updated: check done - if (trove && Number(trove.updatedAt) * 1000 !== lastUpdate) { - break; - } - } - }, -}; + // debt decreases => deposit BOLD (repay) + if (dn.lt(debtChange, 0)) return "depositBold"; + + throw new Error("Invalid request"); +} -function useUpfrontFeeData(loan: Request["loan"], prevLoan: Request["prevLoan"]) { +function useUpfrontFeeData( + loan: UpdateBorrowPositionRequest["loan"], + prevLoan: UpdateBorrowPositionRequest["prevLoan"], +) { const debtChange = dn.sub(loan.borrowed, prevLoan.borrowed); const isBorrowing = dn.gt(debtChange, 0); diff --git a/frontend/app/src/tx-flows/updateLeveragePosition.tsx b/frontend/app/src/tx-flows/updateLeveragePosition.tsx index 52676dcc0..d8178a242 100644 --- a/frontend/app/src/tx-flows/updateLeveragePosition.tsx +++ b/frontend/app/src/tx-flows/updateLeveragePosition.tsx @@ -6,83 +6,70 @@ import { MAX_UPFRONT_FEE } from "@/src/constants"; import { dnum18 } from "@/src/dnum-utils"; import { fmtnum } from "@/src/formatting"; import { getLeverDownTroveParams, getLeverUpTroveParams } from "@/src/liquity-leverage"; -import { getCollToken, getPrefixedTroveId, usePredictAdjustTroveUpfrontFee } from "@/src/liquity-utils"; +import { getCollToken, usePredictAdjustTroveUpfrontFee } from "@/src/liquity-utils"; import { LoanCard } from "@/src/screens/TransactionsScreen/LoanCard"; import { TransactionDetailsRow } from "@/src/screens/TransactionsScreen/TransactionsScreen"; +import { TransactionStatus } from "@/src/screens/TransactionsScreen/TransactionStatus"; import { usePrice } from "@/src/services/Prices"; -import { graphQuery, TroveByIdQuery } from "@/src/subgraph-queries"; -import { isTroveId } from "@/src/types"; import { vDnum, vPositionLoanCommited } from "@/src/valibot-utils"; import { ADDRESS_ZERO } from "@liquity2/uikit"; import * as dn from "dnum"; import { match, P } from "ts-pattern"; import * as v from "valibot"; -import { readContract } from "wagmi/actions"; +import { readContract, waitForTransactionReceipt, writeContract } from "wagmi/actions"; +import { createRequestSchema, verifyTroveUpdate } from "./shared"; + +const RequestSchema = createRequestSchema( + "updateLeveragePosition", + { + depositChange: v.union([v.null(), vDnum()]), + leverageFactorChange: v.union([ + v.null(), + v.tuple([v.number(), v.number()]), + ]), + prevLoan: vPositionLoanCommited(), + loan: vPositionLoanCommited(), + }, +); -const FlowIdSchema = v.literal("updateLeveragePosition"); +export type UpdateLeveragePositionRequest = v.InferOutput; -const RequestSchema = v.object({ - flowId: FlowIdSchema, - backLink: v.union([ - v.null(), - v.tuple([ - v.string(), // path - v.string(), // label - ]), - ]), - successLink: v.tuple([ - v.string(), // path - v.string(), // label - ]), - successMessage: v.string(), - - // set to null to indicate no deposit change - depositChange: v.union([v.null(), vDnum()]), - - // set to null to indicate no leverage change - leverageFactorChange: v.union([ - v.null(), - v.tuple([ - v.number(), // prev leverage - v.number(), // new leverage - ]), - ]), - - prevLoan: vPositionLoanCommited(), - loan: vPositionLoanCommited(), -}); - -export type Request = v.InferOutput; - -type Step = - | "approveLst" - | "decreaseDeposit" - | "increaseDeposit" - | "leverDownTrove" - | "leverUpTrove"; - -const stepNames: Record = { - approveLst: "Approve {tokenName}", - decreaseDeposit: "Decrease Deposit", - increaseDeposit: "Increase Deposit", - leverDownTrove: "Decrease Leverage", - leverUpTrove: "Increase Leverage", -}; +function useUpfrontFeeData( + loan: UpdateLeveragePositionRequest["loan"], + prevLoan: UpdateLeveragePositionRequest["prevLoan"], +) { + const debtChange = dn.sub(loan.borrowed, prevLoan.borrowed); + const isBorrowing = dn.gt(debtChange, 0); -export const updateLeveragePosition: FlowDeclaration = { + const upfrontFee = usePredictAdjustTroveUpfrontFee( + loan.collIndex, + loan.troveId, + isBorrowing ? debtChange : [0n, 18], + ); + + return { + ...upfrontFee, + data: !upfrontFee.data ? null : { + isBorrowing, + debtChangeWithFee: isBorrowing + ? dn.add(debtChange, upfrontFee.data) + : debtChange, + upfrontFee: upfrontFee.data, + }, + }; +} + +export const updateLeveragePosition: FlowDeclaration = { title: "Review & Send Transaction", - Summary({ flow }) { - const { request } = flow; + Summary({ request }) { const { loan, prevLoan } = request; - const collateral = getCollToken(loan.collIndex); if (!collateral) { throw new Error(`Invalid collateral index: ${loan.collIndex}`); } const upfrontFeeData = useUpfrontFeeData(loan, prevLoan); - const loadingState = match(upfrontFeeData) .returnType() .with({ status: "error" }, () => "error") @@ -110,8 +97,7 @@ export const updateLeveragePosition: FlowDeclaration = { ); }, - Details({ flow }) { - const { request } = flow; + Details({ request }) { const { loan, prevLoan, depositChange, leverageFactorChange } = request; const collateral = getCollToken(loan.collIndex); @@ -192,28 +178,237 @@ export const updateLeveragePosition: FlowDeclaration = { ); }, - parseRequest(request) { - return v.parse(RequestSchema, request); + steps: { + approveLst: { + name: ({ request }) => { + const token = getCollToken(request.loan.collIndex); + return `Approve ${token?.name ?? ""}`; + }, + Status: TransactionStatus, + + async commit({ contracts, request, wagmiConfig }) { + if (!request.depositChange) { + throw new Error("Invalid step: depositChange is required with approveLst"); + } + + const collateral = contracts.collaterals[request.loan.collIndex]; + const Zapper = collateral.contracts.LeverageLSTZapper; + + return writeContract(wagmiConfig, { + ...collateral.contracts.CollToken, + functionName: "approve", + args: [ + Zapper.address, + request.depositChange[0], + ], + }); + }, + + async verify({ wagmiConfig }, hash) { + await waitForTransactionReceipt(wagmiConfig, { + hash: hash as `0x${string}`, + }); + }, + }, + + increaseDeposit: { + name: () => "Increase Deposit", + Status: TransactionStatus, + + async commit({ contracts, request, wagmiConfig }) { + if (!request.depositChange) { + throw new Error("Invalid step: depositChange is required with increaseDeposit"); + } + + const collateral = contracts.collaterals[request.loan.collIndex]; + + // add ETH + if (collateral.symbol === "ETH") { + return writeContract(wagmiConfig, { + ...collateral.contracts.LeverageWETHZapper, + functionName: "addCollWithRawETH", + args: [BigInt(request.loan.troveId)], + value: request.depositChange[0], + }); + } + + // add LST + return writeContract(wagmiConfig, { + ...collateral.contracts.LeverageLSTZapper, + functionName: "addColl", + args: [BigInt(request.loan.troveId), request.depositChange[0]], + }); + }, + + async verify({ request, wagmiConfig }, hash) { + await verifyTroveUpdate( + wagmiConfig, + hash as `0x${string}`, + request.loan.collIndex, + request.loan.updatedAt, + ); + }, + }, + + decreaseDeposit: { + name: () => "Decrease Deposit", + Status: TransactionStatus, + + async commit({ contracts, request, wagmiConfig }) { + if (!request.depositChange) { + throw new Error("Invalid step: depositChange is required with decreaseDeposit"); + } + + const collateral = contracts.collaterals[request.loan.collIndex]; + const args = [BigInt(request.loan.troveId), request.depositChange[0] * -1n] as const; + + // withdraw ETH + if (collateral.symbol === "ETH") { + return writeContract(wagmiConfig, { + ...collateral.contracts.LeverageWETHZapper, + functionName: "withdrawCollToRawETH", + args, + }); + } + + // withdraw LST + return writeContract(wagmiConfig, { + ...collateral.contracts.LeverageLSTZapper, + functionName: "withdrawColl", + args, + }); + }, + + async verify({ request, wagmiConfig }, hash) { + await verifyTroveUpdate( + wagmiConfig, + hash as `0x${string}`, + request.loan.collIndex, + request.loan.updatedAt, + ); + }, + }, + + leverUpTrove: { + name: () => "Increase Leverage", + Status: TransactionStatus, + + async commit({ contracts, request, wagmiConfig }) { + if (!request.leverageFactorChange) { + throw new Error("Invalid step: leverageFactorChange is required with leverUpTrove"); + } + + const params = await getLeverUpTroveParams( + request.loan.collIndex, + request.loan.troveId, + request.leverageFactorChange[1], + wagmiConfig, + ); + if (!params) { + throw new Error("Couldn't fetch trove lever up params"); + } + + const collateral = contracts.collaterals[request.loan.collIndex]; + const args = [{ + troveId: BigInt(request.loan.troveId), + flashLoanAmount: params.flashLoanAmount, + boldAmount: params.effectiveBoldAmount, + maxUpfrontFee: MAX_UPFRONT_FEE, + }] as const; + + // leverage up ETH trove + if (collateral.symbol === "ETH") { + return writeContract(wagmiConfig, { + ...collateral.contracts.LeverageWETHZapper, + functionName: "leverUpTrove", + args, + }); + } + + // leverage up LST trove + return writeContract(wagmiConfig, { + ...collateral.contracts.LeverageLSTZapper, + functionName: "leverUpTrove", + args, + }); + }, + + async verify({ request, wagmiConfig }, hash) { + await verifyTroveUpdate( + wagmiConfig, + hash as `0x${string}`, + request.loan.collIndex, + request.loan.updatedAt, + ); + }, + }, + + leverDownTrove: { + name: () => "Decrease Leverage", + Status: TransactionStatus, + + async commit({ contracts, request, wagmiConfig }) { + if (!request.leverageFactorChange) { + throw new Error("Invalid step: leverageFactorChange is required with leverDownTrove"); + } + + const params = await getLeverDownTroveParams( + request.loan.collIndex, + request.loan.troveId, + request.leverageFactorChange[1], + wagmiConfig, + ); + if (!params) { + throw new Error("Couldn't fetch trove lever down params"); + } + + const collateral = contracts.collaterals[request.loan.collIndex]; + + const args = [{ + troveId: BigInt(request.loan.troveId), + flashLoanAmount: params.flashLoanAmount, + minBoldAmount: params.minBoldAmount, + }] as const; + + if (collateral.symbol === "ETH") { + return writeContract(wagmiConfig, { + ...collateral.contracts.LeverageWETHZapper, + functionName: "leverDownTrove", + args, + }); + } + + return writeContract(wagmiConfig, { + ...collateral.contracts.LeverageLSTZapper, + functionName: "leverDownTrove", + args, + }); + }, + + async verify({ request, wagmiConfig }, hash) { + await verifyTroveUpdate( + wagmiConfig, + hash as `0x${string}`, + request.loan.collIndex, + request.loan.updatedAt, + ); + }, + }, }, async getSteps({ account, contracts, request, wagmiConfig }) { const { depositChange, leverageFactorChange, loan } = request; const collateral = contracts.collaterals[loan.collIndex]; - - const steps: Step[] = []; + const steps: string[] = []; // only check approval for non-ETH collaterals if (collateral.symbol !== "ETH" && depositChange && dn.gt(depositChange, 0)) { const { LeverageLSTZapper, CollToken } = collateral.contracts; - const allowance = dnum18( await readContract(wagmiConfig, { ...CollToken, functionName: "allowance", - args: [ - account.address ?? ADDRESS_ZERO, - LeverageLSTZapper.address, - ], + args: [account ?? ADDRESS_ZERO, LeverageLSTZapper.address], }), ); @@ -234,163 +429,7 @@ export const updateLeveragePosition: FlowDeclaration = { return steps; }, - getStepName(stepId, { request }) { - const token = getCollToken(request.loan.collIndex); - if (!token) { - throw new Error(`Invalid collateral index: ${request.loan.collIndex}`); - } - return stepNames[stepId].replace(/\{tokenName\}/g, token.name); - }, - - async writeContractParams(stepId, { account, contracts, request, wagmiConfig }) { - const { loan, leverageFactorChange } = request; - const collateral = contracts.collaterals[loan.collIndex]; - - const Zapper = collateral.symbol === "ETH" - ? collateral.contracts.LeverageWETHZapper - : collateral.contracts.LeverageLSTZapper; - - if (!account.address) { - throw new Error("Account address is required"); - } - - if (stepId === "approveLst") { - if (!request.depositChange) { - throw new Error("Invalid step: depositChange is required with approveLst"); - } - return { - ...collateral.contracts.CollToken, - functionName: "approve", - args: [ - Zapper.address, - request.depositChange[0], - ], - }; - } - - if (stepId === "increaseDeposit") { - if (!request.depositChange) { - throw new Error("Invalid step: depositChange is required with increaseDeposit"); - } - return collateral.symbol === "ETH" - ? { - ...Zapper, - functionName: "addCollWithRawETH", - args: [loan.troveId], - value: request.depositChange[0], - } - : { - ...Zapper, - functionName: "addColl", - args: [loan.troveId, request.depositChange[0]], - }; - } - - if (stepId === "decreaseDeposit") { - if (!request.depositChange) { - throw new Error("Invalid step: depositChange is required with decreaseDeposit"); - } - return { - ...Zapper, - functionName: collateral.symbol === "ETH" ? "withdrawCollToRawETH" : "withdrawColl", - args: [loan.troveId, request.depositChange[0] * -1n], - }; - } - - if (stepId === "leverUpTrove") { - if (!leverageFactorChange) { - throw new Error("Invalid step: leverageFactorChange is required with leverUpTrove"); - } - const params = await getLeverUpTroveParams( - loan.collIndex, - loan.troveId, - leverageFactorChange[1], - wagmiConfig, - ); - if (!params) { - throw new Error("Couldn't fetch trove lever up params"); - } - return { - ...Zapper, - functionName: "leverUpTrove", - args: [{ - troveId: loan.troveId, - flashLoanAmount: params.flashLoanAmount, - boldAmount: params.effectiveBoldAmount, - maxUpfrontFee: MAX_UPFRONT_FEE, - }], - }; - } - - if (stepId === "leverDownTrove") { - if (!leverageFactorChange) { - throw new Error("Invalid step: leverageFactorChange is required with leverDownTrove"); - } - const params = await getLeverDownTroveParams( - loan.collIndex, - loan.troveId, - leverageFactorChange[1], - wagmiConfig, - ); - if (!params) { - throw new Error("Couldn't fetch trove lever down params"); - } - return { - ...Zapper, - functionName: "leverDownTrove", - args: [{ - troveId: loan.troveId, - flashLoanAmount: params.flashLoanAmount, - minBoldAmount: params.minBoldAmount, - }], - }; - } - - throw new Error("Invalid step"); - }, - - async postFlowCheck({ request, steps }) { - const lastStep = steps?.at(-1); - if (lastStep?.txStatus !== "post-check" || !isTroveId(lastStep.txReceiptData)) { - return; - } - - const lastUpdate = request.loan.updatedAt; - - const prefixedTroveId = getPrefixedTroveId( - request.loan.collIndex, - lastStep.txReceiptData, - ); - - while (true) { - const { trove } = await graphQuery(TroveByIdQuery, { id: prefixedTroveId }); - - // trove found and updated: check done - if (trove && Number(trove.updatedAt) * 1000 !== lastUpdate) { - break; - } - } + parseRequest(request) { + return v.parse(RequestSchema, request); }, }; - -function useUpfrontFeeData(loan: Request["loan"], prevLoan: Request["prevLoan"]) { - const debtChange = dn.sub(loan.borrowed, prevLoan.borrowed); - const isBorrowing = dn.gt(debtChange, 0); - - const upfrontFee = usePredictAdjustTroveUpfrontFee( - loan.collIndex, - loan.troveId, - isBorrowing ? debtChange : [0n, 18], - ); - - return { - ...upfrontFee, - data: !upfrontFee.data ? null : { - isBorrowing, - debtChangeWithFee: isBorrowing - ? dn.add(debtChange, upfrontFee.data) - : debtChange, - upfrontFee: upfrontFee.data, - }, - }; -} diff --git a/frontend/app/src/tx-flows/updateLoanInterestRate.tsx b/frontend/app/src/tx-flows/updateLoanInterestRate.tsx index e9ed78297..978aa90e4 100644 --- a/frontend/app/src/tx-flows/updateLoanInterestRate.tsx +++ b/frontend/app/src/tx-flows/updateLoanInterestRate.tsx @@ -5,12 +5,11 @@ import { Amount } from "@/src/comps/Amount/Amount"; import { MAX_ANNUAL_INTEREST_RATE, MIN_ANNUAL_INTEREST_RATE } from "@/src/constants"; import { dnum18 } from "@/src/dnum-utils"; import { fmtnum } from "@/src/formatting"; -import { getPrefixedTroveId, usePredictAdjustInterestRateUpfrontFee } from "@/src/liquity-utils"; +import { usePredictAdjustInterestRateUpfrontFee } from "@/src/liquity-utils"; import { AccountButton } from "@/src/screens/TransactionsScreen/AccountButton"; import { LoanCard } from "@/src/screens/TransactionsScreen/LoanCard"; import { TransactionDetailsRow } from "@/src/screens/TransactionsScreen/TransactionsScreen"; -import { graphQuery, TroveByIdQuery } from "@/src/subgraph-queries"; -import { isTroveId } from "@/src/types"; +import { TransactionStatus } from "@/src/screens/TransactionsScreen/TransactionStatus"; import { vPositionLoanCommited } from "@/src/valibot-utils"; import { css } from "@/styled-system/css"; import { ADDRESS_ZERO } from "@liquity2/uikit"; @@ -18,43 +17,24 @@ import * as dn from "dnum"; import { match, P } from "ts-pattern"; import * as v from "valibot"; import { maxUint256 } from "viem"; -import { readContract } from "wagmi/actions"; +import { readContract, writeContract } from "wagmi/actions"; +import { createRequestSchema, verifyTroveUpdate } from "./shared"; -const FlowIdSchema = v.literal("updateLoanInterestRate"); - -const RequestSchema = v.object({ - flowId: FlowIdSchema, - - backLink: v.union([ - v.null(), - v.tuple([ - v.string(), // path - v.string(), // label - ]), - ]), - successLink: v.tuple([ - v.string(), // path - v.string(), // label - ]), - successMessage: v.string(), - - prevLoan: vPositionLoanCommited(), - loan: vPositionLoanCommited(), -}); - -export type Request = v.InferOutput; +const RequestSchema = createRequestSchema( + "updateLoanInterestRate", + { + prevLoan: vPositionLoanCommited(), + loan: vPositionLoanCommited(), + }, +); -type Step = - | "adjustInterestRate" - | "setInterestBatchManager" - | "unsetInterestBatchManager"; +export type UpdateLoanInterestRateRequest = v.InferOutput; -export const updateLoanInterestRate: FlowDeclaration = { +export const updateLoanInterestRate: FlowDeclaration = { title: "Review & Confirm", - Summary({ flow }) { - const { request } = flow; - const { loan, prevLoan } = request; + Summary({ request }) { + const { loan, prevLoan } = request; const upfrontFee = usePredictAdjustInterestRateUpfrontFee( loan.collIndex, loan.troveId, @@ -63,7 +43,6 @@ export const updateLoanInterestRate: FlowDeclaration = { ); const borrowedWithFee = upfrontFee.data && dn.add(loan.borrowed, upfrontFee.data); - const loadingState = match(upfrontFee) .returnType() .with({ status: "error" }, () => "error") @@ -87,8 +66,8 @@ export const updateLoanInterestRate: FlowDeclaration = { /> ); }, - Details({ flow }) { - const { request } = flow; + + Details({ request }) { const { loan, prevLoan } = request; const upfrontFee = usePredictAdjustInterestRateUpfrontFee( @@ -168,104 +147,126 @@ export const updateLoanInterestRate: FlowDeclaration = { ); }, - async getSteps({ request, contracts, wagmiConfig }) { - const loan = request.loan; - const collateral = contracts.collaterals[loan.collIndex]; - if (loan.batchManager) { - return ["setInterestBatchManager"]; - } + steps: { + adjustInterestRate: { + name: () => "Update interest rate", + Status: TransactionStatus, - const isInBatch = (await readContract(wagmiConfig, { - ...collateral.contracts.BorrowerOperations, - functionName: "interestBatchManagerOf", - args: [BigInt(loan.troveId)], - })) !== ADDRESS_ZERO; + async commit({ contracts, request, wagmiConfig }) { + const { loan } = request; + const { BorrowerOperations } = contracts.collaterals[loan.collIndex].contracts; - return isInBatch ? ["unsetInterestBatchManager"] : ["adjustInterestRate"]; - }, + return writeContract(wagmiConfig, { + ...BorrowerOperations, + functionName: "adjustTroveInterestRate", + args: [ + BigInt(loan.troveId), + loan.interestRate[0], + 0n, + 0n, + maxUint256, + ], + }); + }, - getStepName(stepId) { - return match(stepId) - .with("adjustInterestRate", () => "Update interest rate") - .with("setInterestBatchManager", () => "Set interest rate delegate") - .with("unsetInterestBatchManager", () => "Update interest rate") - .exhaustive(); - }, + async verify({ request, wagmiConfig }, hash) { + await verifyTroveUpdate( + wagmiConfig, + hash as `0x${string}`, + request.loan.collIndex, + request.loan.updatedAt, + ); + }, + }, - parseRequest(request) { - return v.parse(RequestSchema, request); - }, + setInterestBatchManager: { + name: () => "Set interest rate delegate", + Status: TransactionStatus, - async writeContractParams(stepId, { contracts, request }) { - const { loan } = request; - const { BorrowerOperations } = contracts.collaterals[loan.collIndex].contracts; + async commit({ contracts, request, wagmiConfig }) { + const { loan } = request; + const { BorrowerOperations } = contracts.collaterals[loan.collIndex].contracts; - if (stepId === "adjustInterestRate") { - return { - ...BorrowerOperations, - functionName: "adjustTroveInterestRate" as const, - args: [ - BigInt(loan.troveId), - loan.interestRate[0], - 0n, - 0n, - maxUint256, - ], - }; - } + if (!loan.batchManager) { + throw new Error("No batch manager provided"); + } - if (stepId === "unsetInterestBatchManager") { - return { - ...BorrowerOperations, - functionName: "removeFromBatch" as const, - args: [ - BigInt(loan.troveId), - loan.interestRate[0], - 0n, - 0n, - maxUint256, - ], - }; - } + return writeContract(wagmiConfig, { + ...BorrowerOperations, + functionName: "setInterestBatchManager", + args: [ + BigInt(loan.troveId), + loan.batchManager, + MIN_ANNUAL_INTEREST_RATE[0], + MAX_ANNUAL_INTEREST_RATE[0], + maxUint256, + ], + }); + }, - if (stepId === "setInterestBatchManager") { - return { - ...BorrowerOperations, - functionName: "setInterestBatchManager" as const, - args: [ - BigInt(loan.troveId), - loan.batchManager, - MIN_ANNUAL_INTEREST_RATE[0], - MAX_ANNUAL_INTEREST_RATE[0], - maxUint256, - ], - }; - } + async verify({ request, wagmiConfig }, hash) { + await verifyTroveUpdate( + wagmiConfig, + hash as `0x${string}`, + request.loan.collIndex, + request.loan.updatedAt, + ); + }, + }, - return null; - }, - async postFlowCheck({ request, steps }) { - const lastStep = steps?.at(-1); - if (lastStep?.txStatus !== "post-check" || !isTroveId(lastStep.txReceiptData)) { - return; - } + unsetInterestBatchManager: { + name: () => "Update interest rate", + Status: TransactionStatus, - const { loan } = request; - const lastUpdate = loan.updatedAt; + async commit({ contracts, request, wagmiConfig }) { + const { loan } = request; + const { BorrowerOperations } = contracts.collaterals[loan.collIndex].contracts; - const prefixedTroveId = getPrefixedTroveId( - loan.collIndex, - lastStep.txReceiptData, - ); + return writeContract(wagmiConfig, { + ...BorrowerOperations, + functionName: "removeFromBatch", + args: [ + BigInt(loan.troveId), + loan.interestRate[0], + 0n, + 0n, + maxUint256, + ], + }); + }, - while (true) { - const { trove } = await graphQuery(TroveByIdQuery, { id: prefixedTroveId }); + async verify({ request, wagmiConfig }, hash) { + await verifyTroveUpdate( + wagmiConfig, + hash as `0x${string}`, + request.loan.collIndex, + request.loan.updatedAt, + ); + }, + }, + }, + + async getSteps({ contracts, request, wagmiConfig }) { + const loan = request.loan; + const collateral = contracts.collaterals[loan.collIndex]; - // trove found and updated: check done - if (trove && Number(trove.updatedAt) * 1000 !== lastUpdate) { - break; - } + if (loan.batchManager) { + return ["setInterestBatchManager"]; } + + const isInBatch = ( + await readContract(wagmiConfig, { + ...collateral.contracts.BorrowerOperations, + functionName: "interestBatchManagerOf", + args: [BigInt(loan.troveId)], + }) + ) !== ADDRESS_ZERO; + + return isInBatch ? ["unsetInterestBatchManager"] : ["adjustInterestRate"]; + }, + + parseRequest(request) { + return v.parse(RequestSchema, request); }, }; From 763cc82df5a817f2d8f028a41b573d59bc76cb17 Mon Sep 17 00:00:00 2001 From: Pierre Bertet Date: Fri, 13 Dec 2024 15:46:49 +0000 Subject: [PATCH 02/29] LQTY ABI update --- frontend/app/src/screens/AccountScreen/AccountScreen.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/app/src/screens/AccountScreen/AccountScreen.tsx b/frontend/app/src/screens/AccountScreen/AccountScreen.tsx index beb173022..1ff51e8d3 100644 --- a/frontend/app/src/screens/AccountScreen/AccountScreen.tsx +++ b/frontend/app/src/screens/AccountScreen/AccountScreen.tsx @@ -203,8 +203,7 @@ function Balance({ writeContract({ abi: LqtyToken.abi, address: LqtyToken.address, - functionName: "mint", - args: [100n * 10n ** 18n], + functionName: "tap", }, { onError: (error) => { alert(error.message); From a0099ab84240ab13d622578308f7d84c283dccbd Mon Sep 17 00:00:00 2001 From: Pierre Bertet Date: Sat, 14 Dec 2024 19:54:09 +0000 Subject: [PATCH 03/29] Update Governance ABI --- frontend/app/src/abi/Governance.ts | 502 +++++++++++++++++++++-------- 1 file changed, 362 insertions(+), 140 deletions(-) diff --git a/frontend/app/src/abi/Governance.ts b/frontend/app/src/abi/Governance.ts index 5782cdb76..92bcdd11b 100644 --- a/frontend/app/src/abi/Governance.ts +++ b/frontend/app/src/abi/Governance.ts @@ -14,7 +14,6 @@ export const Governance = [ { "name": "registrationFee", "type": "uint128", "internalType": "uint128" }, { "name": "registrationThresholdFactor", "type": "uint128", "internalType": "uint128" }, { "name": "unregistrationThresholdFactor", "type": "uint128", "internalType": "uint128" }, - { "name": "registrationWarmUpPeriod", "type": "uint16", "internalType": "uint16" }, { "name": "unregistrationAfterEpochs", "type": "uint16", "internalType": "uint16" }, { "name": "votingThresholdFactor", "type": "uint128", "internalType": "uint128" }, { "name": "minClaim", "type": "uint88", "internalType": "uint88" }, @@ -24,6 +23,7 @@ export const Governance = [ { "name": "epochVotingCutoff", "type": "uint32", "internalType": "uint32" }, ], }, + { "name": "_owner", "type": "address", "internalType": "address" }, { "name": "_initiatives", "type": "address[]", "internalType": "address[]" }, ], "stateMutability": "nonpayable", @@ -79,9 +79,9 @@ export const Governance = [ }, { "type": "function", - "name": "REGISTRATION_WARM_UP_PERIOD", + "name": "TIMESTAMP_PRECISION", "inputs": [], - "outputs": [{ "name": "", "type": "uint256", "internalType": "uint256" }], + "outputs": [{ "name": "", "type": "uint120", "internalType": "uint120" }], "stateMutability": "view", }, { @@ -108,11 +108,12 @@ export const Governance = [ { "type": "function", "name": "allocateLQTY", - "inputs": [{ "name": "_initiatives", "type": "address[]", "internalType": "address[]" }, { - "name": "_deltaLQTYVotes", - "type": "int176[]", - "internalType": "int176[]", - }, { "name": "_deltaLQTYVetos", "type": "int176[]", "internalType": "int176[]" }], + "inputs": [ + { "name": "_initiativesToReset", "type": "address[]", "internalType": "address[]" }, + { "name": "_initiatives", "type": "address[]", "internalType": "address[]" }, + { "name": "_absoluteLQTYVotes", "type": "int88[]", "internalType": "int88[]" }, + { "name": "_absoluteLQTYVetos", "type": "int88[]", "internalType": "int88[]" }, + ], "outputs": [], "stateMutability": "nonpayable", }, @@ -133,10 +134,17 @@ export const Governance = [ { "type": "function", "name": "calculateVotingThreshold", - "inputs": [], + "inputs": [{ "name": "_votes", "type": "uint256", "internalType": "uint256" }], "outputs": [{ "name": "", "type": "uint256", "internalType": "uint256" }], "stateMutability": "view", }, + { + "type": "function", + "name": "calculateVotingThreshold", + "inputs": [], + "outputs": [{ "name": "", "type": "uint256", "internalType": "uint256" }], + "stateMutability": "nonpayable", + }, { "type": "function", "name": "claimForInitiative", @@ -148,11 +156,10 @@ export const Governance = [ "type": "function", "name": "claimFromStakingV1", "inputs": [{ "name": "_rewardRecipient", "type": "address", "internalType": "address" }], - "outputs": [{ "name": "accruedLUSD", "type": "uint256", "internalType": "uint256" }, { - "name": "accruedETH", - "type": "uint256", - "internalType": "uint256", - }], + "outputs": [ + { "name": "lusdSent", "type": "uint256", "internalType": "uint256" }, + { "name": "ethSent", "type": "uint256", "internalType": "uint256" }, + ], "stateMutability": "nonpayable", }, { @@ -169,23 +176,62 @@ export const Governance = [ "outputs": [], "stateMutability": "nonpayable", }, + { + "type": "function", + "name": "depositLQTY", + "inputs": [ + { "name": "_lqtyAmount", "type": "uint88", "internalType": "uint88" }, + { "name": "_doSendRewards", "type": "bool", "internalType": "bool" }, + { "name": "_recipient", "type": "address", "internalType": "address" }, + ], + "outputs": [], + "stateMutability": "nonpayable", + }, { "type": "function", "name": "depositLQTYViaPermit", - "inputs": [{ "name": "_lqtyAmount", "type": "uint88", "internalType": "uint88" }, { - "name": "_permitParams", - "type": "tuple", - "internalType": "struct PermitParams", - "components": [ - { "name": "owner", "type": "address", "internalType": "address" }, - { "name": "spender", "type": "address", "internalType": "address" }, - { "name": "value", "type": "uint256", "internalType": "uint256" }, - { "name": "deadline", "type": "uint256", "internalType": "uint256" }, - { "name": "v", "type": "uint8", "internalType": "uint8" }, - { "name": "r", "type": "bytes32", "internalType": "bytes32" }, - { "name": "s", "type": "bytes32", "internalType": "bytes32" }, - ], - }], + "inputs": [ + { "name": "_lqtyAmount", "type": "uint88", "internalType": "uint88" }, + { + "name": "_permitParams", + "type": "tuple", + "internalType": "struct PermitParams", + "components": [ + { "name": "owner", "type": "address", "internalType": "address" }, + { "name": "spender", "type": "address", "internalType": "address" }, + { "name": "value", "type": "uint256", "internalType": "uint256" }, + { "name": "deadline", "type": "uint256", "internalType": "uint256" }, + { "name": "v", "type": "uint8", "internalType": "uint8" }, + { "name": "r", "type": "bytes32", "internalType": "bytes32" }, + { "name": "s", "type": "bytes32", "internalType": "bytes32" }, + ], + }, + ], + "outputs": [], + "stateMutability": "nonpayable", + }, + { + "type": "function", + "name": "depositLQTYViaPermit", + "inputs": [ + { "name": "_lqtyAmount", "type": "uint88", "internalType": "uint88" }, + { + "name": "_permitParams", + "type": "tuple", + "internalType": "struct PermitParams", + "components": [ + { "name": "owner", "type": "address", "internalType": "address" }, + { "name": "spender", "type": "address", "internalType": "address" }, + { "name": "value", "type": "uint256", "internalType": "uint256" }, + { "name": "deadline", "type": "uint256", "internalType": "uint256" }, + { "name": "v", "type": "uint8", "internalType": "uint8" }, + { "name": "r", "type": "bytes32", "internalType": "bytes32" }, + { "name": "s", "type": "bytes32", "internalType": "bytes32" }, + ], + }, + { "name": "_doSendRewards", "type": "bool", "internalType": "bool" }, + { "name": "_recipient", "type": "address", "internalType": "address" }, + ], "outputs": [], "stateMutability": "nonpayable", }, @@ -210,15 +256,136 @@ export const Governance = [ "outputs": [{ "name": "", "type": "uint32", "internalType": "uint32" }], "stateMutability": "view", }, + { + "type": "function", + "name": "getInitiativeSnapshotAndState", + "inputs": [{ "name": "_initiative", "type": "address", "internalType": "address" }], + "outputs": [ + { + "name": "initiativeSnapshot", + "type": "tuple", + "internalType": "struct IGovernance.InitiativeVoteSnapshot", + "components": [ + { "name": "votes", "type": "uint224", "internalType": "uint224" }, + { "name": "forEpoch", "type": "uint16", "internalType": "uint16" }, + { "name": "lastCountedEpoch", "type": "uint16", "internalType": "uint16" }, + { "name": "vetos", "type": "uint224", "internalType": "uint224" }, + ], + }, + { + "name": "initiativeState", + "type": "tuple", + "internalType": "struct IGovernance.InitiativeState", + "components": [ + { "name": "voteLQTY", "type": "uint88", "internalType": "uint88" }, + { "name": "vetoLQTY", "type": "uint88", "internalType": "uint88" }, + { "name": "averageStakingTimestampVoteLQTY", "type": "uint120", "internalType": "uint120" }, + { "name": "averageStakingTimestampVetoLQTY", "type": "uint120", "internalType": "uint120" }, + { "name": "lastEpochClaim", "type": "uint16", "internalType": "uint16" }, + ], + }, + { "name": "shouldUpdate", "type": "bool", "internalType": "bool" }, + ], + "stateMutability": "view", + }, + { + "type": "function", + "name": "getInitiativeState", + "inputs": [ + { "name": "_initiative", "type": "address", "internalType": "address" }, + { + "name": "_votesSnapshot", + "type": "tuple", + "internalType": "struct IGovernance.VoteSnapshot", + "components": [ + { "name": "votes", "type": "uint240", "internalType": "uint240" }, + { "name": "forEpoch", "type": "uint16", "internalType": "uint16" }, + ], + }, + { + "name": "_votesForInitiativeSnapshot", + "type": "tuple", + "internalType": "struct IGovernance.InitiativeVoteSnapshot", + "components": [ + { "name": "votes", "type": "uint224", "internalType": "uint224" }, + { "name": "forEpoch", "type": "uint16", "internalType": "uint16" }, + { "name": "lastCountedEpoch", "type": "uint16", "internalType": "uint16" }, + { "name": "vetos", "type": "uint224", "internalType": "uint224" }, + ], + }, + { + "name": "_initiativeState", + "type": "tuple", + "internalType": "struct IGovernance.InitiativeState", + "components": [ + { "name": "voteLQTY", "type": "uint88", "internalType": "uint88" }, + { "name": "vetoLQTY", "type": "uint88", "internalType": "uint88" }, + { "name": "averageStakingTimestampVoteLQTY", "type": "uint120", "internalType": "uint120" }, + { "name": "averageStakingTimestampVetoLQTY", "type": "uint120", "internalType": "uint120" }, + { "name": "lastEpochClaim", "type": "uint16", "internalType": "uint16" }, + ], + }, + ], + "outputs": [ + { "name": "status", "type": "uint8", "internalType": "enum IGovernance.InitiativeStatus" }, + { "name": "lastEpochClaim", "type": "uint16", "internalType": "uint16" }, + { "name": "claimableAmount", "type": "uint256", "internalType": "uint256" }, + ], + "stateMutability": "view", + }, + { + "type": "function", + "name": "getInitiativeState", + "inputs": [{ "name": "_initiative", "type": "address", "internalType": "address" }], + "outputs": [ + { "name": "status", "type": "uint8", "internalType": "enum IGovernance.InitiativeStatus" }, + { "name": "lastEpochClaim", "type": "uint16", "internalType": "uint16" }, + { "name": "claimableAmount", "type": "uint256", "internalType": "uint256" }, + ], + "stateMutability": "nonpayable", + }, + { + "type": "function", + "name": "getLatestVotingThreshold", + "inputs": [], + "outputs": [{ "name": "", "type": "uint256", "internalType": "uint256" }], + "stateMutability": "view", + }, + { + "type": "function", + "name": "getTotalVotesAndState", + "inputs": [], + "outputs": [ + { + "name": "snapshot", + "type": "tuple", + "internalType": "struct IGovernance.VoteSnapshot", + "components": [ + { "name": "votes", "type": "uint240", "internalType": "uint240" }, + { "name": "forEpoch", "type": "uint16", "internalType": "uint16" }, + ], + }, + { + "name": "state", + "type": "tuple", + "internalType": "struct IGovernance.GlobalState", + "components": [ + { "name": "countedVoteLQTY", "type": "uint88", "internalType": "uint88" }, + { "name": "countedVoteLQTYAverageTimestamp", "type": "uint120", "internalType": "uint120" }, + ], + }, + { "name": "shouldUpdate", "type": "bool", "internalType": "bool" }, + ], + "stateMutability": "view", + }, { "type": "function", "name": "globalState", "inputs": [], - "outputs": [{ "name": "countedVoteLQTY", "type": "uint88", "internalType": "uint88" }, { - "name": "countedVoteLQTYAverageTimestamp", - "type": "uint32", - "internalType": "uint32", - }], + "outputs": [ + { "name": "countedVoteLQTY", "type": "uint88", "internalType": "uint88" }, + { "name": "countedVoteLQTYAverageTimestamp", "type": "uint120", "internalType": "uint120" }, + ], "stateMutability": "view", }, { @@ -228,12 +395,19 @@ export const Governance = [ "outputs": [ { "name": "voteLQTY", "type": "uint88", "internalType": "uint88" }, { "name": "vetoLQTY", "type": "uint88", "internalType": "uint88" }, - { "name": "averageStakingTimestampVoteLQTY", "type": "uint32", "internalType": "uint32" }, - { "name": "averageStakingTimestampVetoLQTY", "type": "uint32", "internalType": "uint32" }, - { "name": "counted", "type": "uint16", "internalType": "uint16" }, + { "name": "averageStakingTimestampVoteLQTY", "type": "uint120", "internalType": "uint120" }, + { "name": "averageStakingTimestampVetoLQTY", "type": "uint120", "internalType": "uint120" }, + { "name": "lastEpochClaim", "type": "uint16", "internalType": "uint16" }, ], "stateMutability": "view", }, + { + "type": "function", + "name": "isOwner", + "inputs": [], + "outputs": [{ "name": "", "type": "bool", "internalType": "bool" }], + "stateMutability": "view", + }, { "type": "function", "name": "lqty", @@ -244,35 +418,48 @@ export const Governance = [ { "type": "function", "name": "lqtyAllocatedByUserToInitiative", - "inputs": [{ "name": "", "type": "address", "internalType": "address" }, { - "name": "", - "type": "address", - "internalType": "address", - }], - "outputs": [{ "name": "voteLQTY", "type": "uint88", "internalType": "uint88" }, { - "name": "vetoLQTY", - "type": "uint88", - "internalType": "uint88", - }, { "name": "atEpoch", "type": "uint16", "internalType": "uint16" }], + "inputs": [ + { "name": "", "type": "address", "internalType": "address" }, + { "name": "", "type": "address", "internalType": "address" }, + ], + "outputs": [ + { "name": "voteLQTY", "type": "uint88", "internalType": "uint88" }, + { "name": "vetoLQTY", "type": "uint88", "internalType": "uint88" }, + { "name": "atEpoch", "type": "uint16", "internalType": "uint16" }, + ], "stateMutability": "view", }, { "type": "function", "name": "lqtyToVotes", - "inputs": [{ "name": "_lqtyAmount", "type": "uint88", "internalType": "uint88" }, { - "name": "_currentTimestamp", - "type": "uint256", - "internalType": "uint256", - }, { "name": "_averageTimestamp", "type": "uint32", "internalType": "uint32" }], - "outputs": [{ "name": "", "type": "uint240", "internalType": "uint240" }], + "inputs": [ + { "name": "_lqtyAmount", "type": "uint88", "internalType": "uint88" }, + { "name": "_currentTimestamp", "type": "uint120", "internalType": "uint120" }, + { "name": "_averageTimestamp", "type": "uint120", "internalType": "uint120" }, + ], + "outputs": [{ "name": "", "type": "uint208", "internalType": "uint208" }], "stateMutability": "pure", }, { "type": "function", - "name": "multicall", - "inputs": [{ "name": "data", "type": "bytes[]", "internalType": "bytes[]" }], - "outputs": [{ "name": "results", "type": "bytes[]", "internalType": "bytes[]" }], - "stateMutability": "payable", + "name": "multiDelegateCall", + "inputs": [{ "name": "inputs", "type": "bytes[]", "internalType": "bytes[]" }], + "outputs": [{ "name": "returnValues", "type": "bytes[]", "internalType": "bytes[]" }], + "stateMutability": "nonpayable", + }, + { + "type": "function", + "name": "owner", + "inputs": [], + "outputs": [{ "name": "", "type": "address", "internalType": "address" }], + "stateMutability": "view", + }, + { + "type": "function", + "name": "registerInitialInitiatives", + "inputs": [{ "name": "_initiatives", "type": "address[]", "internalType": "address[]" }], + "outputs": [], + "stateMutability": "nonpayable", }, { "type": "function", @@ -288,6 +475,16 @@ export const Governance = [ "outputs": [{ "name": "", "type": "uint16", "internalType": "uint16" }], "stateMutability": "view", }, + { + "type": "function", + "name": "resetAllocations", + "inputs": [ + { "name": "_initiativesToReset", "type": "address[]", "internalType": "address[]" }, + { "name": "checkAll", "type": "bool", "internalType": "bool" }, + ], + "outputs": [], + "stateMutability": "nonpayable", + }, { "type": "function", "name": "secondsWithinEpoch", @@ -299,25 +496,28 @@ export const Governance = [ "type": "function", "name": "snapshotVotesForInitiative", "inputs": [{ "name": "_initiative", "type": "address", "internalType": "address" }], - "outputs": [{ - "name": "voteSnapshot", - "type": "tuple", - "internalType": "struct IGovernance.VoteSnapshot", - "components": [{ "name": "votes", "type": "uint240", "internalType": "uint240" }, { - "name": "forEpoch", - "type": "uint16", - "internalType": "uint16", - }], - }, { - "name": "initiativeVoteSnapshot", - "type": "tuple", - "internalType": "struct IGovernance.InitiativeVoteSnapshot", - "components": [{ "name": "votes", "type": "uint224", "internalType": "uint224" }, { - "name": "forEpoch", - "type": "uint16", - "internalType": "uint16", - }, { "name": "lastCountedEpoch", "type": "uint16", "internalType": "uint16" }], - }], + "outputs": [ + { + "name": "voteSnapshot", + "type": "tuple", + "internalType": "struct IGovernance.VoteSnapshot", + "components": [ + { "name": "votes", "type": "uint240", "internalType": "uint240" }, + { "name": "forEpoch", "type": "uint16", "internalType": "uint16" }, + ], + }, + { + "name": "initiativeVoteSnapshot", + "type": "tuple", + "internalType": "struct IGovernance.InitiativeVoteSnapshot", + "components": [ + { "name": "votes", "type": "uint224", "internalType": "uint224" }, + { "name": "forEpoch", "type": "uint16", "internalType": "uint16" }, + { "name": "lastCountedEpoch", "type": "uint16", "internalType": "uint16" }, + { "name": "vetos", "type": "uint224", "internalType": "uint224" }, + ], + }, + ], "stateMutability": "nonpayable", }, { @@ -345,33 +545,32 @@ export const Governance = [ "type": "function", "name": "userStates", "inputs": [{ "name": "", "type": "address", "internalType": "address" }], - "outputs": [{ "name": "allocatedLQTY", "type": "uint88", "internalType": "uint88" }, { - "name": "averageStakingTimestamp", - "type": "uint32", - "internalType": "uint32", - }], + "outputs": [ + { "name": "allocatedLQTY", "type": "uint88", "internalType": "uint88" }, + { "name": "averageStakingTimestamp", "type": "uint120", "internalType": "uint120" }, + ], "stateMutability": "view", }, { "type": "function", "name": "votesForInitiativeSnapshot", "inputs": [{ "name": "", "type": "address", "internalType": "address" }], - "outputs": [{ "name": "votes", "type": "uint224", "internalType": "uint224" }, { - "name": "forEpoch", - "type": "uint16", - "internalType": "uint16", - }, { "name": "lastCountedEpoch", "type": "uint16", "internalType": "uint16" }], + "outputs": [ + { "name": "votes", "type": "uint224", "internalType": "uint224" }, + { "name": "forEpoch", "type": "uint16", "internalType": "uint16" }, + { "name": "lastCountedEpoch", "type": "uint16", "internalType": "uint16" }, + { "name": "vetos", "type": "uint224", "internalType": "uint224" }, + ], "stateMutability": "view", }, { "type": "function", "name": "votesSnapshot", "inputs": [], - "outputs": [{ "name": "votes", "type": "uint240", "internalType": "uint240" }, { - "name": "forEpoch", - "type": "uint16", - "internalType": "uint16", - }], + "outputs": [ + { "name": "votes", "type": "uint240", "internalType": "uint240" }, + { "name": "forEpoch", "type": "uint16", "internalType": "uint16" }, + ], "stateMutability": "view", }, { @@ -381,103 +580,127 @@ export const Governance = [ "outputs": [], "stateMutability": "nonpayable", }, + { + "type": "function", + "name": "withdrawLQTY", + "inputs": [ + { "name": "_lqtyAmount", "type": "uint88", "internalType": "uint88" }, + { "name": "_doSendRewards", "type": "bool", "internalType": "bool" }, + { "name": "_recipient", "type": "address", "internalType": "address" }, + ], + "outputs": [], + "stateMutability": "nonpayable", + }, { "type": "event", "name": "AllocateLQTY", "inputs": [ - { "name": "user", "type": "address", "indexed": false, "internalType": "address" }, - { "name": "initiative", "type": "address", "indexed": false, "internalType": "address" }, + { "name": "user", "type": "address", "indexed": true, "internalType": "address" }, + { "name": "initiative", "type": "address", "indexed": true, "internalType": "address" }, { "name": "deltaVoteLQTY", "type": "int256", "indexed": false, "internalType": "int256" }, { "name": "deltaVetoLQTY", "type": "int256", "indexed": false, "internalType": "int256" }, - { "name": "atEpoch", "type": "uint16", "indexed": false, "internalType": "uint16" }, + { "name": "atEpoch", "type": "uint256", "indexed": false, "internalType": "uint256" }, + { "name": "hookSuccess", "type": "bool", "indexed": false, "internalType": "bool" }, ], "anonymous": false, }, { "type": "event", "name": "ClaimForInitiative", - "inputs": [{ "name": "initiative", "type": "address", "indexed": false, "internalType": "address" }, { - "name": "bold", - "type": "uint256", - "indexed": false, - "internalType": "uint256", - }, { "name": "forEpoch", "type": "uint256", "indexed": false, "internalType": "uint256" }], + "inputs": [ + { "name": "initiative", "type": "address", "indexed": true, "internalType": "address" }, + { "name": "bold", "type": "uint256", "indexed": false, "internalType": "uint256" }, + { "name": "forEpoch", "type": "uint256", "indexed": false, "internalType": "uint256" }, + { "name": "hookSuccess", "type": "bool", "indexed": false, "internalType": "bool" }, + ], "anonymous": false, }, { "type": "event", "name": "DeployUserProxy", - "inputs": [{ "name": "user", "type": "address", "indexed": true, "internalType": "address" }, { - "name": "userProxy", - "type": "address", - "indexed": true, - "internalType": "address", - }], + "inputs": [ + { "name": "user", "type": "address", "indexed": true, "internalType": "address" }, + { "name": "userProxy", "type": "address", "indexed": true, "internalType": "address" }, + ], "anonymous": false, }, { "type": "event", "name": "DepositLQTY", - "inputs": [{ "name": "user", "type": "address", "indexed": false, "internalType": "address" }, { - "name": "depositedLQTY", - "type": "uint256", - "indexed": false, - "internalType": "uint256", - }], + "inputs": [ + { "name": "user", "type": "address", "indexed": true, "internalType": "address" }, + { "name": "rewardRecipient", "type": "address", "indexed": false, "internalType": "address" }, + { "name": "lqtyAmount", "type": "uint256", "indexed": false, "internalType": "uint256" }, + { "name": "lusdReceived", "type": "uint256", "indexed": false, "internalType": "uint256" }, + { "name": "lusdSent", "type": "uint256", "indexed": false, "internalType": "uint256" }, + { "name": "ethReceived", "type": "uint256", "indexed": false, "internalType": "uint256" }, + { "name": "ethSent", "type": "uint256", "indexed": false, "internalType": "uint256" }, + ], + "anonymous": false, + }, + { + "type": "event", + "name": "OwnershipTransferred", + "inputs": [ + { "name": "previousOwner", "type": "address", "indexed": true, "internalType": "address" }, + { "name": "newOwner", "type": "address", "indexed": true, "internalType": "address" }, + ], "anonymous": false, }, { "type": "event", "name": "RegisterInitiative", - "inputs": [{ "name": "initiative", "type": "address", "indexed": false, "internalType": "address" }, { - "name": "registrant", - "type": "address", - "indexed": false, - "internalType": "address", - }, { "name": "atEpoch", "type": "uint16", "indexed": false, "internalType": "uint16" }], + "inputs": [ + { "name": "initiative", "type": "address", "indexed": false, "internalType": "address" }, + { "name": "registrant", "type": "address", "indexed": false, "internalType": "address" }, + { "name": "atEpoch", "type": "uint256", "indexed": false, "internalType": "uint256" }, + { "name": "hookSuccess", "type": "bool", "indexed": false, "internalType": "bool" }, + ], "anonymous": false, }, { "type": "event", "name": "SnapshotVotes", - "inputs": [{ "name": "votes", "type": "uint240", "indexed": false, "internalType": "uint240" }, { - "name": "forEpoch", - "type": "uint16", - "indexed": false, - "internalType": "uint16", - }], + "inputs": [ + { "name": "votes", "type": "uint256", "indexed": false, "internalType": "uint256" }, + { "name": "forEpoch", "type": "uint256", "indexed": false, "internalType": "uint256" }, + { "name": "boldAccrued", "type": "uint256", "indexed": false, "internalType": "uint256" }, + ], "anonymous": false, }, { "type": "event", "name": "SnapshotVotesForInitiative", - "inputs": [{ "name": "initiative", "type": "address", "indexed": false, "internalType": "address" }, { - "name": "votes", - "type": "uint240", - "indexed": false, - "internalType": "uint240", - }, { "name": "forEpoch", "type": "uint16", "indexed": false, "internalType": "uint16" }], + "inputs": [ + { "name": "initiative", "type": "address", "indexed": true, "internalType": "address" }, + { "name": "votes", "type": "uint256", "indexed": false, "internalType": "uint256" }, + { "name": "vetos", "type": "uint256", "indexed": false, "internalType": "uint256" }, + { "name": "forEpoch", "type": "uint256", "indexed": false, "internalType": "uint256" }, + ], "anonymous": false, }, { "type": "event", "name": "UnregisterInitiative", - "inputs": [{ "name": "initiative", "type": "address", "indexed": false, "internalType": "address" }, { - "name": "atEpoch", - "type": "uint16", - "indexed": false, - "internalType": "uint16", - }], + "inputs": [ + { "name": "initiative", "type": "address", "indexed": false, "internalType": "address" }, + { "name": "atEpoch", "type": "uint256", "indexed": false, "internalType": "uint256" }, + { "name": "hookSuccess", "type": "bool", "indexed": false, "internalType": "bool" }, + ], "anonymous": false, }, { "type": "event", "name": "WithdrawLQTY", "inputs": [ - { "name": "user", "type": "address", "indexed": false, "internalType": "address" }, - { "name": "withdrawnLQTY", "type": "uint256", "indexed": false, "internalType": "uint256" }, - { "name": "accruedLUSD", "type": "uint256", "indexed": false, "internalType": "uint256" }, - { "name": "accruedETH", "type": "uint256", "indexed": false, "internalType": "uint256" }, + { "name": "user", "type": "address", "indexed": true, "internalType": "address" }, + { "name": "recipient", "type": "address", "indexed": false, "internalType": "address" }, + { "name": "lqtyReceived", "type": "uint256", "indexed": false, "internalType": "uint256" }, + { "name": "lqtySent", "type": "uint256", "indexed": false, "internalType": "uint256" }, + { "name": "lusdReceived", "type": "uint256", "indexed": false, "internalType": "uint256" }, + { "name": "lusdSent", "type": "uint256", "indexed": false, "internalType": "uint256" }, + { "name": "ethReceived", "type": "uint256", "indexed": false, "internalType": "uint256" }, + { "name": "ethSent", "type": "uint256", "indexed": false, "internalType": "uint256" }, ], "anonymous": false, }, @@ -500,4 +723,3 @@ export const Governance = [ "inputs": [{ "name": "token", "type": "address", "internalType": "address" }], }, ] as const; - From 4d1622d569de28007ef1e26fe3be8b6be2172a4e Mon Sep 17 00:00:00 2001 From: Pierre Bertet Date: Sat, 14 Dec 2024 19:54:40 +0000 Subject: [PATCH 04/29] Fix signPermit() (add the token name) --- frontend/app/src/permit.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/frontend/app/src/permit.ts b/frontend/app/src/permit.ts index 93d64ea9d..cfd708c6b 100644 --- a/frontend/app/src/permit.ts +++ b/frontend/app/src/permit.ts @@ -23,7 +23,7 @@ export async function signPermit({ value: bigint; wagmiConfig: WagmiConfig; }) { - const [block, nonce] = await Promise.all([ + const [block, nonce, name] = await Promise.all([ getBlock(wagmiConfig), readContract(wagmiConfig, { address: token, @@ -31,13 +31,18 @@ export async function signPermit({ functionName: "nonces", args: [account], }), + readContract(wagmiConfig, { + address: token, + abi: Erc2612, + functionName: "name", + }), ]); const deadline = block.timestamp + expiresAfter; const signature = await signTypedData(wagmiConfig, { domain: { - name: "name", + name, version: "1", chainId: CHAIN_ID, verifyingContract: token, From afa75d5283cde274fb9730e9492d2925578c59ad Mon Sep 17 00:00:00 2001 From: Pierre Bertet Date: Sat, 14 Dec 2024 19:55:09 +0000 Subject: [PATCH 05/29] useStakePosition() is now checking the user proxy address --- frontend/app/src/liquity-utils.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/frontend/app/src/liquity-utils.ts b/frontend/app/src/liquity-utils.ts index 93bf658d3..647b54479 100644 --- a/frontend/app/src/liquity-utils.ts +++ b/frontend/app/src/liquity-utils.ts @@ -180,12 +180,21 @@ function earnPositionFromGraph( export function useStakePosition(address: null | Address) { const LqtyStaking = getProtocolContract("LqtyStaking"); + const Governance = getProtocolContract("Governance"); + + const userProxyAddress = useReadContract({ + ...Governance, + functionName: "deriveUserProxyAddress", + args: [address ?? "0x"], + query: { enabled: Boolean(address) }, + }); + return useReadContracts({ contracts: [ { ...LqtyStaking, functionName: "stakes", - args: [address ?? "0x"], + args: [userProxyAddress.data ?? "0x"], }, { ...LqtyStaking, @@ -193,7 +202,7 @@ export function useStakePosition(address: null | Address) { }, ], query: { - enabled: Boolean(address), + enabled: Boolean(address) && userProxyAddress.isSuccess, refetchInterval: DATA_REFRESH_INTERVAL, select: ([deposit_, totalStaked_]): PositionStake => { const totalStaked = dnum18(totalStaked_); From 6fb8174e90f4c53e20270685a8b1751685b7463f Mon Sep 17 00:00:00 2001 From: Pierre Bertet Date: Sat, 14 Dec 2024 19:56:34 +0000 Subject: [PATCH 06/29] Add verifyTransaction() to the tx flows utils --- frontend/app/src/tx-flows/shared.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/frontend/app/src/tx-flows/shared.ts b/frontend/app/src/tx-flows/shared.ts index af4773ffc..d44373c8c 100644 --- a/frontend/app/src/tx-flows/shared.ts +++ b/frontend/app/src/tx-flows/shared.ts @@ -29,13 +29,24 @@ export function createRequestSchema< }); } +export async function verifyTransaction( + wagmiConfig: WagmiConfig, + hash: string, +) { + await waitForTransactionReceipt(wagmiConfig, { + hash: hash as `0x${string}`, + }); +} + export async function verifyTroveUpdate( wagmiConfig: WagmiConfig, - hash: `0x${string}`, + hash: string, collIndex: CollIndex, lastUpdate: number, ) { - const receipt = await waitForTransactionReceipt(wagmiConfig, { hash }); + const receipt = await waitForTransactionReceipt(wagmiConfig, { + hash: hash as `0x${string}`, + }); const prefixedTroveId = getPrefixedTroveId(collIndex, receipt.transactionHash); while (true) { // wait for the trove to be updated in the subgraph From 10dad0779037177c2122ab9ffb6e77ec6d5b74cd Mon Sep 17 00:00:00 2001 From: Pierre Bertet Date: Sat, 14 Dec 2024 19:57:23 +0000 Subject: [PATCH 07/29] stakeDeposit() tx flow: fix permit & approve flows --- frontend/app/src/tx-flows/stakeDeposit.tsx | 112 ++++++++++++++++----- 1 file changed, 85 insertions(+), 27 deletions(-) diff --git a/frontend/app/src/tx-flows/stakeDeposit.tsx b/frontend/app/src/tx-flows/stakeDeposit.tsx index d980e40f4..53fc9ffef 100644 --- a/frontend/app/src/tx-flows/stakeDeposit.tsx +++ b/frontend/app/src/tx-flows/stakeDeposit.tsx @@ -11,8 +11,8 @@ import { usePrice } from "@/src/services/Prices"; import { vDnum, vPositionStake } from "@/src/valibot-utils"; import * as dn from "dnum"; import * as v from "valibot"; -import { readContract, waitForTransactionReceipt, writeContract } from "wagmi/actions"; -import { createRequestSchema } from "./shared"; +import { getBytecode, readContract, writeContract } from "wagmi/actions"; +import { createRequestSchema, verifyTransaction } from "./shared"; const RequestSchema = createRequestSchema( "stakeDeposit", @@ -25,7 +25,7 @@ const RequestSchema = createRequestSchema( export type StakeDepositRequest = v.InferOutput; -const USE_PERMIT = false; +const USE_PERMIT = true; export const stakeDeposit: FlowDeclaration = { title: "Review & Send Transaction", @@ -102,6 +102,26 @@ export const stakeDeposit: FlowDeclaration = { }, steps: { + deployUserProxy: { + name: () => "Deploy Staking Proxy", + Status: TransactionStatus, + + async commit({ account, contracts, wagmiConfig }) { + if (!account) { + throw new Error("Account address is required"); + } + + return writeContract(wagmiConfig, { + ...contracts.Governance, + functionName: "deployUserProxy", + }); + }, + + async verify({ wagmiConfig }, hash) { + await verifyTransaction(wagmiConfig, hash); + }, + }, + // approve via permit permitLqty: { name: () => "Approve LQTY", @@ -112,9 +132,17 @@ export const stakeDeposit: FlowDeclaration = { throw new Error("Account address is required"); } + const { LqtyToken, Governance } = contracts; + + const userProxyAddress = await readContract(wagmiConfig, { + ...Governance, + functionName: "deriveUserProxyAddress", + args: [account], + }); + const { deadline, ...permit } = await signPermit({ - token: contracts.LqtyToken.address, - spender: contracts.Governance.address, + token: LqtyToken.address, + spender: userProxyAddress, value: request.lqtyAmount[0], account, wagmiConfig, @@ -143,17 +171,21 @@ export const stakeDeposit: FlowDeclaration = { const { LqtyToken, Governance } = contracts; + const userProxyAddress = await readContract(wagmiConfig, { + ...Governance, + functionName: "deriveUserProxyAddress", + args: [account], + }); + return writeContract(wagmiConfig, { ...LqtyToken, functionName: "approve", - args: [Governance.address, request.lqtyAmount[0]], + args: [userProxyAddress, request.lqtyAmount[0]], }); }, async verify({ wagmiConfig }, hash) { - await waitForTransactionReceipt(wagmiConfig, { - hash: hash as `0x${string}`, - }); + await verifyTransaction(wagmiConfig, hash); }, }, @@ -166,11 +198,8 @@ export const stakeDeposit: FlowDeclaration = { throw new Error("Account address is required"); } - const permitStep = steps?.find((step) => step.id === "permitLqty"); - const depositLqtyViaPermit = Boolean(permitStep?.artifact); - - // deposit LQTY - if (!depositLqtyViaPermit) { + // deposit approved LQTY + if (!USE_PERMIT) { return writeContract(wagmiConfig, { ...contracts.Governance, functionName: "depositLQTY", @@ -179,7 +208,15 @@ export const stakeDeposit: FlowDeclaration = { } // deposit LQTY via permit + const userProxyAddress = await readContract(wagmiConfig, { + ...contracts.Governance, + functionName: "deriveUserProxyAddress", + args: [account], + }); + + const permitStep = steps?.find((step) => step.id === "permitLqty"); const permit = JSON.parse(permitStep?.artifact ?? ""); + return writeContract(wagmiConfig, { ...contracts.Governance, functionName: "depositLQTYViaPermit", @@ -187,7 +224,7 @@ export const stakeDeposit: FlowDeclaration = { request.lqtyAmount[0], { owner: account, - spender: contracts.Governance.address, + spender: userProxyAddress, value: request.lqtyAmount[0], deadline: permit.deadline, v: permit.v, @@ -199,7 +236,7 @@ export const stakeDeposit: FlowDeclaration = { }, async verify({ wagmiConfig }, hash) { - await waitForTransactionReceipt(wagmiConfig, { hash: hash as `0x${string}` }); + await verifyTransaction(wagmiConfig, hash); }, }, }, @@ -211,18 +248,39 @@ export const stakeDeposit: FlowDeclaration = { const steps: string[] = []; - // approve + // approve via permit if (USE_PERMIT) { - steps.push("permitLqty"); - } else { - const lqtyAllowance = await readContract(wagmiConfig, { - ...contracts.LqtyToken, - functionName: "allowance", - args: [account, contracts.LqtyStaking.address], - }); - if (dn.gt(request.lqtyAmount, dnum18(lqtyAllowance))) { - steps.push("approveLqty"); - } + return ["permitLqty", "stakeDeposit"]; + } + + // get the user proxy address + const userProxyAddress = await readContract(wagmiConfig, { + ...contracts.Governance, + functionName: "deriveUserProxyAddress", + args: [account], + }); + + // check if the user proxy contract exists + const userProxyBytecode = await getBytecode(wagmiConfig, { + address: userProxyAddress, + }); + + // deploy the user proxy (optional, but prevents wallets + // to show a warning for approving a non-deployed contract) + if (!userProxyBytecode) { + steps.push("deployUserProxy"); + } + + // check for allowance + const lqtyAllowance = await readContract(wagmiConfig, { + ...contracts.LqtyToken, + functionName: "allowance", + args: [account, userProxyAddress], + }); + + // approve + if (dn.gt(request.lqtyAmount, dnum18(lqtyAllowance))) { + steps.push("approveLqty"); } // stake From e377110f6ff21a8a24a83985eaacd997c608198c Mon Sep 17 00:00:00 2001 From: Pierre Bertet Date: Sat, 14 Dec 2024 19:58:10 +0000 Subject: [PATCH 08/29] tx flows: use verifyTransaction() --- .../app/src/tx-flows/earnClaimRewards.tsx | 8 +++---- frontend/app/src/tx-flows/earnWithdraw.tsx | 8 +++---- .../app/src/tx-flows/openLeveragePosition.tsx | 6 ++--- frontend/app/src/tx-flows/unstakeDeposit.tsx | 8 +++---- .../app/src/tx-flows/updateBorrowPosition.tsx | 22 ++++++++----------- .../src/tx-flows/updateLeveragePosition.tsx | 8 +++---- .../src/tx-flows/updateLoanInterestRate.tsx | 6 ++--- 7 files changed, 27 insertions(+), 39 deletions(-) diff --git a/frontend/app/src/tx-flows/earnClaimRewards.tsx b/frontend/app/src/tx-flows/earnClaimRewards.tsx index 7a59544cb..ea2261098 100644 --- a/frontend/app/src/tx-flows/earnClaimRewards.tsx +++ b/frontend/app/src/tx-flows/earnClaimRewards.tsx @@ -9,8 +9,8 @@ import { usePrice } from "@/src/services/Prices"; import { vPositionEarn } from "@/src/valibot-utils"; import * as dn from "dnum"; import * as v from "valibot"; -import { waitForTransactionReceipt, writeContract } from "wagmi/actions"; -import { createRequestSchema } from "./shared"; +import { writeContract } from "wagmi/actions"; +import { createRequestSchema, verifyTransaction } from "./shared"; const RequestSchema = createRequestSchema( "earnClaimRewards", @@ -101,9 +101,7 @@ export const earnClaimRewards: FlowDeclaration = { }, async verify({ wagmiConfig }, hash) { - await waitForTransactionReceipt(wagmiConfig, { - hash: hash as `0x${string}`, - }); + await verifyTransaction(wagmiConfig, hash); }, }, }, diff --git a/frontend/app/src/tx-flows/earnWithdraw.tsx b/frontend/app/src/tx-flows/earnWithdraw.tsx index d005b1df1..085be35bd 100644 --- a/frontend/app/src/tx-flows/earnWithdraw.tsx +++ b/frontend/app/src/tx-flows/earnWithdraw.tsx @@ -8,8 +8,8 @@ import { usePrice } from "@/src/services/Prices"; import { vCollIndex, vPositionEarn } from "@/src/valibot-utils"; import * as dn from "dnum"; import * as v from "valibot"; -import { waitForTransactionReceipt, writeContract } from "wagmi/actions"; -import { createRequestSchema } from "./shared"; +import { writeContract } from "wagmi/actions"; +import { createRequestSchema, verifyTransaction } from "./shared"; const RequestSchema = createRequestSchema( "earnWithdraw", @@ -85,9 +85,7 @@ export const earnWithdraw: FlowDeclaration = { }, async verify({ wagmiConfig }, hash) { - await waitForTransactionReceipt(wagmiConfig, { - hash: hash as `0x${string}`, - }); + await verifyTransaction(wagmiConfig, hash); }, }, }, diff --git a/frontend/app/src/tx-flows/openLeveragePosition.tsx b/frontend/app/src/tx-flows/openLeveragePosition.tsx index f1a0e5b62..4c68344c8 100644 --- a/frontend/app/src/tx-flows/openLeveragePosition.tsx +++ b/frontend/app/src/tx-flows/openLeveragePosition.tsx @@ -19,7 +19,7 @@ import * as dn from "dnum"; import * as v from "valibot"; import { parseEventLogs } from "viem"; import { readContract, waitForTransactionReceipt, writeContract } from "wagmi/actions"; -import { createRequestSchema } from "./shared"; +import { createRequestSchema, verifyTransaction } from "./shared"; const RequestSchema = createRequestSchema( "openLeveragePosition", @@ -148,9 +148,7 @@ export const openLeveragePosition: FlowDeclaration }, async verify({ wagmiConfig }, hash) { - await waitForTransactionReceipt(wagmiConfig, { - hash: hash as `0x${string}`, - }); + await verifyTransaction(wagmiConfig, hash); }, }, diff --git a/frontend/app/src/tx-flows/unstakeDeposit.tsx b/frontend/app/src/tx-flows/unstakeDeposit.tsx index 33d9bac68..ee65474ef 100644 --- a/frontend/app/src/tx-flows/unstakeDeposit.tsx +++ b/frontend/app/src/tx-flows/unstakeDeposit.tsx @@ -8,8 +8,8 @@ import { usePrice } from "@/src/services/Prices"; import { vDnum, vPositionStake } from "@/src/valibot-utils"; import * as dn from "dnum"; import * as v from "valibot"; -import { waitForTransactionReceipt, writeContract } from "wagmi/actions"; -import { createRequestSchema } from "./shared"; +import { writeContract } from "wagmi/actions"; +import { createRequestSchema, verifyTransaction } from "./shared"; const RequestSchema = createRequestSchema( "unstakeDeposit", @@ -109,9 +109,7 @@ export const unstakeDeposit: FlowDeclaration = { }, async verify({ wagmiConfig }, hash) { - await waitForTransactionReceipt(wagmiConfig, { - hash: hash as `0x${string}`, - }); + await verifyTransaction(wagmiConfig, hash); }, }, }, diff --git a/frontend/app/src/tx-flows/updateBorrowPosition.tsx b/frontend/app/src/tx-flows/updateBorrowPosition.tsx index dd8f954c1..8920d0f5d 100644 --- a/frontend/app/src/tx-flows/updateBorrowPosition.tsx +++ b/frontend/app/src/tx-flows/updateBorrowPosition.tsx @@ -12,8 +12,8 @@ import { vDnum, vPositionLoanCommited } from "@/src/valibot-utils"; import * as dn from "dnum"; import { match, P } from "ts-pattern"; import * as v from "valibot"; -import { readContract, waitForTransactionReceipt, writeContract } from "wagmi/actions"; -import { createRequestSchema, verifyTroveUpdate } from "./shared"; +import { readContract, writeContract } from "wagmi/actions"; +import { createRequestSchema, verifyTransaction, verifyTroveUpdate } from "./shared"; const RequestSchema = createRequestSchema( "updateBorrowPosition", @@ -151,9 +151,7 @@ export const updateBorrowPosition: FlowDeclaration }, async verify({ wagmiConfig }, hash) { - await waitForTransactionReceipt(wagmiConfig, { - hash: hash as `0x${string}`, - }); + await verifyTransaction(wagmiConfig, hash); }, }, @@ -177,9 +175,7 @@ export const updateBorrowPosition: FlowDeclaration }, async verify({ wagmiConfig }, hash) { - await waitForTransactionReceipt(wagmiConfig, { - hash: hash as `0x${string}`, - }); + await verifyTransaction(wagmiConfig, hash); }, }, @@ -227,7 +223,7 @@ export const updateBorrowPosition: FlowDeclaration async verify({ request, wagmiConfig }, hash) { await verifyTroveUpdate( wagmiConfig, - hash as `0x${string}`, + hash, request.loan.collIndex, request.loan.updatedAt, ); @@ -261,7 +257,7 @@ export const updateBorrowPosition: FlowDeclaration async verify({ request, wagmiConfig }, hash) { await verifyTroveUpdate( wagmiConfig, - hash as `0x${string}`, + hash, request.loan.collIndex, request.loan.updatedAt, ); @@ -296,7 +292,7 @@ export const updateBorrowPosition: FlowDeclaration async verify({ request, wagmiConfig }, hash) { await verifyTroveUpdate( wagmiConfig, - hash as `0x${string}`, + hash, request.loan.collIndex, request.loan.updatedAt, ); @@ -330,7 +326,7 @@ export const updateBorrowPosition: FlowDeclaration async verify({ request, wagmiConfig }, hash) { await verifyTroveUpdate( wagmiConfig, - hash as `0x${string}`, + hash, request.loan.collIndex, request.loan.updatedAt, ); @@ -364,7 +360,7 @@ export const updateBorrowPosition: FlowDeclaration async verify({ request, wagmiConfig }, hash) { await verifyTroveUpdate( wagmiConfig, - hash as `0x${string}`, + hash, request.loan.collIndex, request.loan.updatedAt, ); diff --git a/frontend/app/src/tx-flows/updateLeveragePosition.tsx b/frontend/app/src/tx-flows/updateLeveragePosition.tsx index d8178a242..413923703 100644 --- a/frontend/app/src/tx-flows/updateLeveragePosition.tsx +++ b/frontend/app/src/tx-flows/updateLeveragePosition.tsx @@ -243,7 +243,7 @@ export const updateLeveragePosition: FlowDeclaration Date: Sat, 14 Dec 2024 19:59:00 +0000 Subject: [PATCH 09/29] TransactionFlow: revert awaiting-commit to idle when reloading a step --- frontend/app/src/services/TransactionFlow.tsx | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/frontend/app/src/services/TransactionFlow.tsx b/frontend/app/src/services/TransactionFlow.tsx index 35f9e28fc..b214408de 100644 --- a/frontend/app/src/services/TransactionFlow.tsx +++ b/frontend/app/src/services/TransactionFlow.tsx @@ -494,23 +494,28 @@ const FlowContextStorage = { } // parse the base flow structure - const baseState = v.parse(FlowStateSchema, jsonParseWithDnum(storedFlowState)); + const flow = v.parse(FlowStateSchema, jsonParseWithDnum(storedFlowState)); - const flowDeclaration = getFlowDeclaration(baseState.request.flowId); + const flowDeclaration = getFlowDeclaration(flow.request.flowId); if (!flowDeclaration) { - throw new Error(`Unknown flow ID: ${baseState.request.flowId}`); + throw new Error(`Unknown flow ID: ${flow.request.flowId}`); } // parse the current flow request - const fullRequest = flowDeclaration.parseRequest(baseState.request); - if (!fullRequest) { - throw new Error(`Invalid request for flow ${baseState.request.flowId}`); + const request = flowDeclaration.parseRequest(flow.request); + if (!request) { + throw new Error(`Invalid request for flow ${flow.request.flowId}`); } - return { - ...baseState, - request: fullRequest, - }; + // remove awaiting-commit status from steps so users + // can refresh & retry without getting stuck + const steps = flow.steps?.map((step) => ( + step.status === "awaiting-commit" + ? { ...step, status: "idle" as const } + : step + )) ?? null; + + return { ...flow, steps, request }; } catch (err) { console.error(err); localStorage.removeItem(TRANSACTION_FLOW_KEY); From eef25904d917000723e06c6d62889fd2117c8747 Mon Sep 17 00:00:00 2001 From: Pierre Bertet Date: Sat, 14 Dec 2024 19:59:31 +0000 Subject: [PATCH 10/29] Remove unused code --- .../app/src/screens/TransactionsScreen/TransactionsScreen.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/frontend/app/src/screens/TransactionsScreen/TransactionsScreen.tsx b/frontend/app/src/screens/TransactionsScreen/TransactionsScreen.tsx index 33701efff..a45b4a08a 100644 --- a/frontend/app/src/screens/TransactionsScreen/TransactionsScreen.tsx +++ b/frontend/app/src/screens/TransactionsScreen/TransactionsScreen.tsx @@ -55,8 +55,6 @@ export function TransactionsScreen() { { height: 48 }, { height: 0, opacity: 0, transform: "scale(0.97)" }, ], - onRest: () => { - }, config: boxTransitionConfig, }); From 921fb16b912b2769ff64780735681b01cf08e424 Mon Sep 17 00:00:00 2001 From: Pierre Bertet Date: Sat, 14 Dec 2024 21:43:58 +0000 Subject: [PATCH 11/29] StakeScreen: insufficient balance error --- .../src/screens/StakeScreen/PanelStaking.tsx | 265 ++++++++++++++++++ .../src/screens/StakeScreen/StakeScreen.tsx | 258 +---------------- 2 files changed, 269 insertions(+), 254 deletions(-) create mode 100644 frontend/app/src/screens/StakeScreen/PanelStaking.tsx diff --git a/frontend/app/src/screens/StakeScreen/PanelStaking.tsx b/frontend/app/src/screens/StakeScreen/PanelStaking.tsx new file mode 100644 index 000000000..de31b729d --- /dev/null +++ b/frontend/app/src/screens/StakeScreen/PanelStaking.tsx @@ -0,0 +1,265 @@ +import { Amount } from "@/src/comps/Amount/Amount"; +import { Field } from "@/src/comps/Field/Field"; +import { InputTokenBadge } from "@/src/comps/InputTokenBadge/InputTokenBadge"; +import content from "@/src/content"; +import { dnumMax } from "@/src/dnum-utils"; +import { parseInputFloat } from "@/src/form-utils"; +import { fmtnum } from "@/src/formatting"; +import { useStakePosition } from "@/src/liquity-utils"; +import { useAccount, useBalance } from "@/src/services/Ethereum"; +import { usePrice } from "@/src/services/Prices"; +import { useTransactionFlow } from "@/src/services/TransactionFlow"; +import { infoTooltipProps } from "@/src/uikit-utils"; +import { css } from "@/styled-system/css"; +import { Button, HFlex, InfoTooltip, InputField, Tabs, TextButton, TokenIcon } from "@liquity2/uikit"; +import * as dn from "dnum"; +import { useState } from "react"; + +export function PanelStaking() { + const account = useAccount(); + const txFlow = useTransactionFlow(); + const lqtyPrice = usePrice("LQTY"); + + const [mode, setMode] = useState<"deposit" | "withdraw">("deposit"); + const [value, setValue] = useState(""); + const [focused, setFocused] = useState(false); + + const stakePosition = useStakePosition(account.address ?? null); + + const parsedValue = parseInputFloat(value); + + const value_ = (focused || !parsedValue || dn.lte(parsedValue, 0)) + ? value + : `${dn.format(parsedValue)}`; + + const depositDifference = dn.mul( + parsedValue ?? dn.from(0, 18), + mode === "withdraw" ? -1 : 1, + ); + + const updatedDeposit = stakePosition.data?.deposit + ? dnumMax( + dn.add(stakePosition.data?.deposit, depositDifference), + dn.from(0, 18), + ) + : dn.from(0, 18); + + const updatedShare = stakePosition.data?.totalStaked + && dn.gt(stakePosition.data?.totalStaked, 0) + ? dn.div( + updatedDeposit, + dn.add(stakePosition.data.totalStaked, depositDifference), + ) + : dn.from(0, 18); + + const lqtyBalance = useBalance(account.address, "LQTY"); + const isDepositFilled = parsedValue && dn.gt(parsedValue, 0); + const hasDeposit = stakePosition.data?.deposit && dn.gt( + stakePosition.data?.deposit, + 0, + ); + const sufficientBalance = mode === "withdraw" || ( + lqtyBalance.data && dn.gte(lqtyBalance.data, depositDifference) + ); + + const allowSubmit = Boolean( + account.isConnected + && isDepositFilled + && sufficientBalance, + ); + + const rewardsLusd = dn.from(0, 18); + const rewardsEth = dn.from(0, 18); + + return ( + <> + } + label="LQTY" + /> + } + label={{ + start: mode === "withdraw" ? "You withdraw" : "You deposit", + end: ( + { + setMode(index === 1 ? "withdraw" : "deposit"); + setValue(""); + if (origin !== "keyboard") { + event.preventDefault(); + (event.target as HTMLElement).focus(); + } + }} + selected={mode === "withdraw" ? 1 : 0} + /> + ), + }} + labelHeight={32} + onFocus={() => setFocused(true)} + onChange={setValue} + onBlur={() => setFocused(false)} + value={value_} + placeholder="0.00" + secondary={{ + start: parsedValue && lqtyPrice.data ? `$${dn.format(dn.mul(parsedValue, lqtyPrice.data), 2)}` : null, + end: mode === "deposit" + ? ( + { + setValue(dn.toString(lqtyBalance.data ?? dn.from(0, 18))); + }} + /> + ) + : ( + stakePosition.data?.deposit && ( + { + setValue(dn.toString(stakePosition.data.deposit)); + }} + /> + ) + ), + }} + /> + } + footer={{ + start: ( + +
+ +
+ + Voting power is the percentage of the total staked LQTY that you own. + + + } + /> + ), + }} + /> +
+ {hasDeposit && ( + +
+ + +
+
+
+ {" "} + + LUSD + +
+
+ {" "} + + ETH + +
+
+
+ )} +
+ + ); +} diff --git a/frontend/app/src/screens/StakeScreen/StakeScreen.tsx b/frontend/app/src/screens/StakeScreen/StakeScreen.tsx index a7f87543a..a55ad43ac 100644 --- a/frontend/app/src/screens/StakeScreen/StakeScreen.tsx +++ b/frontend/app/src/screens/StakeScreen/StakeScreen.tsx @@ -1,35 +1,14 @@ "use client"; -import { Amount } from "@/src/comps/Amount/Amount"; -import { Field } from "@/src/comps/Field/Field"; -import { InputTokenBadge } from "@/src/comps/InputTokenBadge/InputTokenBadge"; import { Screen } from "@/src/comps/Screen/Screen"; import { StakePositionSummary } from "@/src/comps/StakePositionSummary/StakePositionSummary"; import content from "@/src/content"; -import { dnumMax } from "@/src/dnum-utils"; -import { parseInputFloat } from "@/src/form-utils"; -import { fmtnum } from "@/src/formatting"; import { useStakePosition } from "@/src/liquity-utils"; -import { useAccount, useBalance } from "@/src/services/Ethereum"; -import { usePrice } from "@/src/services/Prices"; -import { useTransactionFlow } from "@/src/services/TransactionFlow"; -import { infoTooltipProps } from "@/src/uikit-utils"; -import { css } from "@/styled-system/css"; -import { - AnchorTextButton, - Button, - HFlex, - InfoTooltip, - InputField, - Tabs, - TextButton, - TokenIcon, - VFlex, -} from "@liquity2/uikit"; -import * as dn from "dnum"; +import { useAccount } from "@/src/services/Ethereum"; +import { AnchorTextButton, HFlex, Tabs, TokenIcon, VFlex } from "@liquity2/uikit"; import { useParams, useRouter } from "next/navigation"; -import { useState } from "react"; import { PanelRewards } from "./PanelRewards"; +import { PanelStaking } from "./PanelStaking"; import { PanelVoting } from "./PanelVoting"; const TABS = [ @@ -81,239 +60,10 @@ export function StakeScreen() { }} /> - {action === "deposit" && } + {action === "deposit" && } {action === "rewards" && } {action === "voting" && }
); } - -function PanelUpdateStake() { - const account = useAccount(); - const txFlow = useTransactionFlow(); - const lqtyPrice = usePrice("LQTY"); - - const [mode, setMode] = useState<"deposit" | "withdraw">("deposit"); - const [value, setValue] = useState(""); - const [focused, setFocused] = useState(false); - - const stakePosition = useStakePosition(account.address ?? null); - - const parsedValue = parseInputFloat(value); - - const value_ = (focused || !parsedValue || dn.lte(parsedValue, 0)) - ? value - : `${dn.format(parsedValue)}`; - - const depositDifference = dn.mul( - parsedValue ?? dn.from(0, 18), - mode === "withdraw" ? -1 : 1, - ); - - const updatedDeposit = stakePosition.data?.deposit - ? dnumMax( - dn.add(stakePosition.data?.deposit, depositDifference), - dn.from(0, 18), - ) - : dn.from(0, 18); - - const hasDeposit = stakePosition.data?.deposit && dn.gt(stakePosition.data?.deposit, 0); - - const updatedShare = stakePosition.data?.totalStaked && dn.gt(stakePosition.data?.totalStaked, 0) - ? dn.div(updatedDeposit, dn.add(stakePosition.data.totalStaked, depositDifference)) - : dn.from(0, 18); - - const lqtyBalance = useBalance(account.address, "LQTY"); - - const allowSubmit = Boolean(account.isConnected && parsedValue && dn.gt(parsedValue, 0)); - - const rewardsLusd = dn.from(0, 18); - const rewardsEth = dn.from(0, 18); - - return ( - <> - } - label="LQTY" - /> - } - label={{ - start: mode === "withdraw" ? "You withdraw" : "You deposit", - end: ( - { - setMode(index === 1 ? "withdraw" : "deposit"); - setValue(""); - if (origin !== "keyboard") { - event.preventDefault(); - (event.target as HTMLElement).focus(); - } - }} - selected={mode === "withdraw" ? 1 : 0} - /> - ), - }} - labelHeight={32} - onFocus={() => setFocused(true)} - onChange={setValue} - onBlur={() => setFocused(false)} - value={value_} - placeholder="0.00" - secondary={{ - start: parsedValue && lqtyPrice.data ? `$${dn.format(dn.mul(parsedValue, lqtyPrice.data), 2)}` : null, - end: mode === "deposit" - ? ( - { - setValue(dn.toString(lqtyBalance.data ?? dn.from(0, 18))); - }} - /> - ) - : ( - stakePosition.data?.deposit && ( - { - setValue(dn.toString(stakePosition.data.deposit)); - }} - /> - ) - ), - }} - /> - } - footer={{ - start: ( - -
- -
- - Voting power is the percentage of the total staked LQTY that you own. - - - } - /> - ), - }} - /> -
- {hasDeposit && ( - -
- - -
-
-
- {" "} - - LUSD - -
-
- {" "} - - ETH - -
-
-
- )} -
- - ); -} From 411278753fa0d3d2200296a1db866c369988d9ce Mon Sep 17 00:00:00 2001 From: Pierre Bertet Date: Sat, 14 Dec 2024 21:44:16 +0000 Subject: [PATCH 12/29] Update Governance ABI --- frontend/app/src/abi/Governance.ts | 447 ++++++++++++++--------------- 1 file changed, 220 insertions(+), 227 deletions(-) diff --git a/frontend/app/src/abi/Governance.ts b/frontend/app/src/abi/Governance.ts index 92bcdd11b..cef7cb30d 100644 --- a/frontend/app/src/abi/Governance.ts +++ b/frontend/app/src/abi/Governance.ts @@ -11,16 +11,16 @@ export const Governance = [ "type": "tuple", "internalType": "struct IGovernance.Configuration", "components": [ - { "name": "registrationFee", "type": "uint128", "internalType": "uint128" }, - { "name": "registrationThresholdFactor", "type": "uint128", "internalType": "uint128" }, - { "name": "unregistrationThresholdFactor", "type": "uint128", "internalType": "uint128" }, - { "name": "unregistrationAfterEpochs", "type": "uint16", "internalType": "uint16" }, - { "name": "votingThresholdFactor", "type": "uint128", "internalType": "uint128" }, - { "name": "minClaim", "type": "uint88", "internalType": "uint88" }, - { "name": "minAccrual", "type": "uint88", "internalType": "uint88" }, - { "name": "epochStart", "type": "uint32", "internalType": "uint32" }, - { "name": "epochDuration", "type": "uint32", "internalType": "uint32" }, - { "name": "epochVotingCutoff", "type": "uint32", "internalType": "uint32" }, + { "name": "registrationFee", "type": "uint256", "internalType": "uint256" }, + { "name": "registrationThresholdFactor", "type": "uint256", "internalType": "uint256" }, + { "name": "unregistrationThresholdFactor", "type": "uint256", "internalType": "uint256" }, + { "name": "unregistrationAfterEpochs", "type": "uint256", "internalType": "uint256" }, + { "name": "votingThresholdFactor", "type": "uint256", "internalType": "uint256" }, + { "name": "minClaim", "type": "uint256", "internalType": "uint256" }, + { "name": "minAccrual", "type": "uint256", "internalType": "uint256" }, + { "name": "epochStart", "type": "uint256", "internalType": "uint256" }, + { "name": "epochDuration", "type": "uint256", "internalType": "uint256" }, + { "name": "epochVotingCutoff", "type": "uint256", "internalType": "uint256" }, ], }, { "name": "_owner", "type": "address", "internalType": "address" }, @@ -77,13 +77,6 @@ export const Governance = [ "outputs": [{ "name": "", "type": "uint256", "internalType": "uint256" }], "stateMutability": "view", }, - { - "type": "function", - "name": "TIMESTAMP_PRECISION", - "inputs": [], - "outputs": [{ "name": "", "type": "uint120", "internalType": "uint120" }], - "stateMutability": "view", - }, { "type": "function", "name": "UNREGISTRATION_AFTER_EPOCHS", @@ -111,8 +104,8 @@ export const Governance = [ "inputs": [ { "name": "_initiativesToReset", "type": "address[]", "internalType": "address[]" }, { "name": "_initiatives", "type": "address[]", "internalType": "address[]" }, - { "name": "_absoluteLQTYVotes", "type": "int88[]", "internalType": "int88[]" }, - { "name": "_absoluteLQTYVetos", "type": "int88[]", "internalType": "int88[]" }, + { "name": "_absoluteLQTYVotes", "type": "int256[]", "internalType": "int256[]" }, + { "name": "_absoluteLQTYVetos", "type": "int256[]", "internalType": "int256[]" }, ], "outputs": [], "stateMutability": "nonpayable", @@ -156,10 +149,11 @@ export const Governance = [ "type": "function", "name": "claimFromStakingV1", "inputs": [{ "name": "_rewardRecipient", "type": "address", "internalType": "address" }], - "outputs": [ - { "name": "lusdSent", "type": "uint256", "internalType": "uint256" }, - { "name": "ethSent", "type": "uint256", "internalType": "uint256" }, - ], + "outputs": [{ "name": "lusdSent", "type": "uint256", "internalType": "uint256" }, { + "name": "ethSent", + "type": "uint256", + "internalType": "uint256", + }], "stateMutability": "nonpayable", }, { @@ -172,18 +166,18 @@ export const Governance = [ { "type": "function", "name": "depositLQTY", - "inputs": [{ "name": "_lqtyAmount", "type": "uint88", "internalType": "uint88" }], + "inputs": [{ "name": "_lqtyAmount", "type": "uint256", "internalType": "uint256" }], "outputs": [], "stateMutability": "nonpayable", }, { "type": "function", "name": "depositLQTY", - "inputs": [ - { "name": "_lqtyAmount", "type": "uint88", "internalType": "uint88" }, - { "name": "_doSendRewards", "type": "bool", "internalType": "bool" }, - { "name": "_recipient", "type": "address", "internalType": "address" }, - ], + "inputs": [{ "name": "_lqtyAmount", "type": "uint256", "internalType": "uint256" }, { + "name": "_doSendRewards", + "type": "bool", + "internalType": "bool", + }, { "name": "_recipient", "type": "address", "internalType": "address" }], "outputs": [], "stateMutability": "nonpayable", }, @@ -191,7 +185,7 @@ export const Governance = [ "type": "function", "name": "depositLQTYViaPermit", "inputs": [ - { "name": "_lqtyAmount", "type": "uint88", "internalType": "uint88" }, + { "name": "_lqtyAmount", "type": "uint256", "internalType": "uint256" }, { "name": "_permitParams", "type": "tuple", @@ -206,6 +200,8 @@ export const Governance = [ { "name": "s", "type": "bytes32", "internalType": "bytes32" }, ], }, + { "name": "_doSendRewards", "type": "bool", "internalType": "bool" }, + { "name": "_recipient", "type": "address", "internalType": "address" }, ], "outputs": [], "stateMutability": "nonpayable", @@ -213,25 +209,20 @@ export const Governance = [ { "type": "function", "name": "depositLQTYViaPermit", - "inputs": [ - { "name": "_lqtyAmount", "type": "uint88", "internalType": "uint88" }, - { - "name": "_permitParams", - "type": "tuple", - "internalType": "struct PermitParams", - "components": [ - { "name": "owner", "type": "address", "internalType": "address" }, - { "name": "spender", "type": "address", "internalType": "address" }, - { "name": "value", "type": "uint256", "internalType": "uint256" }, - { "name": "deadline", "type": "uint256", "internalType": "uint256" }, - { "name": "v", "type": "uint8", "internalType": "uint8" }, - { "name": "r", "type": "bytes32", "internalType": "bytes32" }, - { "name": "s", "type": "bytes32", "internalType": "bytes32" }, - ], - }, - { "name": "_doSendRewards", "type": "bool", "internalType": "bool" }, - { "name": "_recipient", "type": "address", "internalType": "address" }, - ], + "inputs": [{ "name": "_lqtyAmount", "type": "uint256", "internalType": "uint256" }, { + "name": "_permitParams", + "type": "tuple", + "internalType": "struct PermitParams", + "components": [ + { "name": "owner", "type": "address", "internalType": "address" }, + { "name": "spender", "type": "address", "internalType": "address" }, + { "name": "value", "type": "uint256", "internalType": "uint256" }, + { "name": "deadline", "type": "uint256", "internalType": "uint256" }, + { "name": "v", "type": "uint8", "internalType": "uint8" }, + { "name": "r", "type": "bytes32", "internalType": "bytes32" }, + { "name": "s", "type": "bytes32", "internalType": "bytes32" }, + ], + }], "outputs": [], "stateMutability": "nonpayable", }, @@ -246,102 +237,94 @@ export const Governance = [ "type": "function", "name": "epoch", "inputs": [], - "outputs": [{ "name": "", "type": "uint16", "internalType": "uint16" }], + "outputs": [{ "name": "", "type": "uint256", "internalType": "uint256" }], "stateMutability": "view", }, { "type": "function", "name": "epochStart", "inputs": [], - "outputs": [{ "name": "", "type": "uint32", "internalType": "uint32" }], + "outputs": [{ "name": "", "type": "uint256", "internalType": "uint256" }], "stateMutability": "view", }, { "type": "function", "name": "getInitiativeSnapshotAndState", "inputs": [{ "name": "_initiative", "type": "address", "internalType": "address" }], - "outputs": [ - { - "name": "initiativeSnapshot", - "type": "tuple", - "internalType": "struct IGovernance.InitiativeVoteSnapshot", - "components": [ - { "name": "votes", "type": "uint224", "internalType": "uint224" }, - { "name": "forEpoch", "type": "uint16", "internalType": "uint16" }, - { "name": "lastCountedEpoch", "type": "uint16", "internalType": "uint16" }, - { "name": "vetos", "type": "uint224", "internalType": "uint224" }, - ], - }, - { - "name": "initiativeState", - "type": "tuple", - "internalType": "struct IGovernance.InitiativeState", - "components": [ - { "name": "voteLQTY", "type": "uint88", "internalType": "uint88" }, - { "name": "vetoLQTY", "type": "uint88", "internalType": "uint88" }, - { "name": "averageStakingTimestampVoteLQTY", "type": "uint120", "internalType": "uint120" }, - { "name": "averageStakingTimestampVetoLQTY", "type": "uint120", "internalType": "uint120" }, - { "name": "lastEpochClaim", "type": "uint16", "internalType": "uint16" }, - ], - }, - { "name": "shouldUpdate", "type": "bool", "internalType": "bool" }, - ], + "outputs": [{ + "name": "initiativeSnapshot", + "type": "tuple", + "internalType": "struct IGovernance.InitiativeVoteSnapshot", + "components": [ + { "name": "votes", "type": "uint256", "internalType": "uint256" }, + { "name": "forEpoch", "type": "uint256", "internalType": "uint256" }, + { "name": "lastCountedEpoch", "type": "uint256", "internalType": "uint256" }, + { "name": "vetos", "type": "uint256", "internalType": "uint256" }, + ], + }, { + "name": "initiativeState", + "type": "tuple", + "internalType": "struct IGovernance.InitiativeState", + "components": [ + { "name": "voteLQTY", "type": "uint256", "internalType": "uint256" }, + { "name": "voteOffset", "type": "uint256", "internalType": "uint256" }, + { "name": "vetoLQTY", "type": "uint256", "internalType": "uint256" }, + { "name": "vetoOffset", "type": "uint256", "internalType": "uint256" }, + { "name": "lastEpochClaim", "type": "uint256", "internalType": "uint256" }, + ], + }, { "name": "shouldUpdate", "type": "bool", "internalType": "bool" }], "stateMutability": "view", }, { "type": "function", "name": "getInitiativeState", - "inputs": [ - { "name": "_initiative", "type": "address", "internalType": "address" }, - { - "name": "_votesSnapshot", - "type": "tuple", - "internalType": "struct IGovernance.VoteSnapshot", - "components": [ - { "name": "votes", "type": "uint240", "internalType": "uint240" }, - { "name": "forEpoch", "type": "uint16", "internalType": "uint16" }, - ], - }, - { - "name": "_votesForInitiativeSnapshot", - "type": "tuple", - "internalType": "struct IGovernance.InitiativeVoteSnapshot", - "components": [ - { "name": "votes", "type": "uint224", "internalType": "uint224" }, - { "name": "forEpoch", "type": "uint16", "internalType": "uint16" }, - { "name": "lastCountedEpoch", "type": "uint16", "internalType": "uint16" }, - { "name": "vetos", "type": "uint224", "internalType": "uint224" }, - ], - }, - { - "name": "_initiativeState", - "type": "tuple", - "internalType": "struct IGovernance.InitiativeState", - "components": [ - { "name": "voteLQTY", "type": "uint88", "internalType": "uint88" }, - { "name": "vetoLQTY", "type": "uint88", "internalType": "uint88" }, - { "name": "averageStakingTimestampVoteLQTY", "type": "uint120", "internalType": "uint120" }, - { "name": "averageStakingTimestampVetoLQTY", "type": "uint120", "internalType": "uint120" }, - { "name": "lastEpochClaim", "type": "uint16", "internalType": "uint16" }, - ], - }, - ], - "outputs": [ - { "name": "status", "type": "uint8", "internalType": "enum IGovernance.InitiativeStatus" }, - { "name": "lastEpochClaim", "type": "uint16", "internalType": "uint16" }, - { "name": "claimableAmount", "type": "uint256", "internalType": "uint256" }, - ], + "inputs": [{ "name": "_initiative", "type": "address", "internalType": "address" }, { + "name": "_votesSnapshot", + "type": "tuple", + "internalType": "struct IGovernance.VoteSnapshot", + "components": [{ "name": "votes", "type": "uint256", "internalType": "uint256" }, { + "name": "forEpoch", + "type": "uint256", + "internalType": "uint256", + }], + }, { + "name": "_votesForInitiativeSnapshot", + "type": "tuple", + "internalType": "struct IGovernance.InitiativeVoteSnapshot", + "components": [ + { "name": "votes", "type": "uint256", "internalType": "uint256" }, + { "name": "forEpoch", "type": "uint256", "internalType": "uint256" }, + { "name": "lastCountedEpoch", "type": "uint256", "internalType": "uint256" }, + { "name": "vetos", "type": "uint256", "internalType": "uint256" }, + ], + }, { + "name": "_initiativeState", + "type": "tuple", + "internalType": "struct IGovernance.InitiativeState", + "components": [ + { "name": "voteLQTY", "type": "uint256", "internalType": "uint256" }, + { "name": "voteOffset", "type": "uint256", "internalType": "uint256" }, + { "name": "vetoLQTY", "type": "uint256", "internalType": "uint256" }, + { "name": "vetoOffset", "type": "uint256", "internalType": "uint256" }, + { "name": "lastEpochClaim", "type": "uint256", "internalType": "uint256" }, + ], + }], + "outputs": [{ "name": "status", "type": "uint8", "internalType": "enum IGovernance.InitiativeStatus" }, { + "name": "lastEpochClaim", + "type": "uint256", + "internalType": "uint256", + }, { "name": "claimableAmount", "type": "uint256", "internalType": "uint256" }], "stateMutability": "view", }, { "type": "function", "name": "getInitiativeState", "inputs": [{ "name": "_initiative", "type": "address", "internalType": "address" }], - "outputs": [ - { "name": "status", "type": "uint8", "internalType": "enum IGovernance.InitiativeStatus" }, - { "name": "lastEpochClaim", "type": "uint16", "internalType": "uint16" }, - { "name": "claimableAmount", "type": "uint256", "internalType": "uint256" }, - ], + "outputs": [{ "name": "status", "type": "uint8", "internalType": "enum IGovernance.InitiativeStatus" }, { + "name": "lastEpochClaim", + "type": "uint256", + "internalType": "uint256", + }, { "name": "claimableAmount", "type": "uint256", "internalType": "uint256" }], "stateMutability": "nonpayable", }, { @@ -355,37 +338,36 @@ export const Governance = [ "type": "function", "name": "getTotalVotesAndState", "inputs": [], - "outputs": [ - { - "name": "snapshot", - "type": "tuple", - "internalType": "struct IGovernance.VoteSnapshot", - "components": [ - { "name": "votes", "type": "uint240", "internalType": "uint240" }, - { "name": "forEpoch", "type": "uint16", "internalType": "uint16" }, - ], - }, - { - "name": "state", - "type": "tuple", - "internalType": "struct IGovernance.GlobalState", - "components": [ - { "name": "countedVoteLQTY", "type": "uint88", "internalType": "uint88" }, - { "name": "countedVoteLQTYAverageTimestamp", "type": "uint120", "internalType": "uint120" }, - ], - }, - { "name": "shouldUpdate", "type": "bool", "internalType": "bool" }, - ], + "outputs": [{ + "name": "snapshot", + "type": "tuple", + "internalType": "struct IGovernance.VoteSnapshot", + "components": [{ "name": "votes", "type": "uint256", "internalType": "uint256" }, { + "name": "forEpoch", + "type": "uint256", + "internalType": "uint256", + }], + }, { + "name": "state", + "type": "tuple", + "internalType": "struct IGovernance.GlobalState", + "components": [{ "name": "countedVoteLQTY", "type": "uint256", "internalType": "uint256" }, { + "name": "countedVoteOffset", + "type": "uint256", + "internalType": "uint256", + }], + }, { "name": "shouldUpdate", "type": "bool", "internalType": "bool" }], "stateMutability": "view", }, { "type": "function", "name": "globalState", "inputs": [], - "outputs": [ - { "name": "countedVoteLQTY", "type": "uint88", "internalType": "uint88" }, - { "name": "countedVoteLQTYAverageTimestamp", "type": "uint120", "internalType": "uint120" }, - ], + "outputs": [{ "name": "countedVoteLQTY", "type": "uint256", "internalType": "uint256" }, { + "name": "countedVoteOffset", + "type": "uint256", + "internalType": "uint256", + }], "stateMutability": "view", }, { @@ -393,11 +375,11 @@ export const Governance = [ "name": "initiativeStates", "inputs": [{ "name": "", "type": "address", "internalType": "address" }], "outputs": [ - { "name": "voteLQTY", "type": "uint88", "internalType": "uint88" }, - { "name": "vetoLQTY", "type": "uint88", "internalType": "uint88" }, - { "name": "averageStakingTimestampVoteLQTY", "type": "uint120", "internalType": "uint120" }, - { "name": "averageStakingTimestampVetoLQTY", "type": "uint120", "internalType": "uint120" }, - { "name": "lastEpochClaim", "type": "uint16", "internalType": "uint16" }, + { "name": "voteLQTY", "type": "uint256", "internalType": "uint256" }, + { "name": "voteOffset", "type": "uint256", "internalType": "uint256" }, + { "name": "vetoLQTY", "type": "uint256", "internalType": "uint256" }, + { "name": "vetoOffset", "type": "uint256", "internalType": "uint256" }, + { "name": "lastEpochClaim", "type": "uint256", "internalType": "uint256" }, ], "stateMutability": "view", }, @@ -418,26 +400,29 @@ export const Governance = [ { "type": "function", "name": "lqtyAllocatedByUserToInitiative", - "inputs": [ - { "name": "", "type": "address", "internalType": "address" }, - { "name": "", "type": "address", "internalType": "address" }, - ], + "inputs": [{ "name": "", "type": "address", "internalType": "address" }, { + "name": "", + "type": "address", + "internalType": "address", + }], "outputs": [ - { "name": "voteLQTY", "type": "uint88", "internalType": "uint88" }, - { "name": "vetoLQTY", "type": "uint88", "internalType": "uint88" }, - { "name": "atEpoch", "type": "uint16", "internalType": "uint16" }, + { "name": "voteLQTY", "type": "uint256", "internalType": "uint256" }, + { "name": "voteOffset", "type": "uint256", "internalType": "uint256" }, + { "name": "vetoLQTY", "type": "uint256", "internalType": "uint256" }, + { "name": "vetoOffset", "type": "uint256", "internalType": "uint256" }, + { "name": "atEpoch", "type": "uint256", "internalType": "uint256" }, ], "stateMutability": "view", }, { "type": "function", "name": "lqtyToVotes", - "inputs": [ - { "name": "_lqtyAmount", "type": "uint88", "internalType": "uint88" }, - { "name": "_currentTimestamp", "type": "uint120", "internalType": "uint120" }, - { "name": "_averageTimestamp", "type": "uint120", "internalType": "uint120" }, - ], - "outputs": [{ "name": "", "type": "uint208", "internalType": "uint208" }], + "inputs": [{ "name": "_lqtyAmount", "type": "uint256", "internalType": "uint256" }, { + "name": "_timestamp", + "type": "uint256", + "internalType": "uint256", + }, { "name": "_offset", "type": "uint256", "internalType": "uint256" }], + "outputs": [{ "name": "", "type": "uint256", "internalType": "uint256" }], "stateMutability": "pure", }, { @@ -472,16 +457,17 @@ export const Governance = [ "type": "function", "name": "registeredInitiatives", "inputs": [{ "name": "", "type": "address", "internalType": "address" }], - "outputs": [{ "name": "", "type": "uint16", "internalType": "uint16" }], + "outputs": [{ "name": "", "type": "uint256", "internalType": "uint256" }], "stateMutability": "view", }, { "type": "function", "name": "resetAllocations", - "inputs": [ - { "name": "_initiativesToReset", "type": "address[]", "internalType": "address[]" }, - { "name": "checkAll", "type": "bool", "internalType": "bool" }, - ], + "inputs": [{ "name": "_initiativesToReset", "type": "address[]", "internalType": "address[]" }, { + "name": "checkAll", + "type": "bool", + "internalType": "bool", + }], "outputs": [], "stateMutability": "nonpayable", }, @@ -489,35 +475,33 @@ export const Governance = [ "type": "function", "name": "secondsWithinEpoch", "inputs": [], - "outputs": [{ "name": "", "type": "uint32", "internalType": "uint32" }], + "outputs": [{ "name": "", "type": "uint256", "internalType": "uint256" }], "stateMutability": "view", }, { "type": "function", "name": "snapshotVotesForInitiative", "inputs": [{ "name": "_initiative", "type": "address", "internalType": "address" }], - "outputs": [ - { - "name": "voteSnapshot", - "type": "tuple", - "internalType": "struct IGovernance.VoteSnapshot", - "components": [ - { "name": "votes", "type": "uint240", "internalType": "uint240" }, - { "name": "forEpoch", "type": "uint16", "internalType": "uint16" }, - ], - }, - { - "name": "initiativeVoteSnapshot", - "type": "tuple", - "internalType": "struct IGovernance.InitiativeVoteSnapshot", - "components": [ - { "name": "votes", "type": "uint224", "internalType": "uint224" }, - { "name": "forEpoch", "type": "uint16", "internalType": "uint16" }, - { "name": "lastCountedEpoch", "type": "uint16", "internalType": "uint16" }, - { "name": "vetos", "type": "uint224", "internalType": "uint224" }, - ], - }, - ], + "outputs": [{ + "name": "voteSnapshot", + "type": "tuple", + "internalType": "struct IGovernance.VoteSnapshot", + "components": [{ "name": "votes", "type": "uint256", "internalType": "uint256" }, { + "name": "forEpoch", + "type": "uint256", + "internalType": "uint256", + }], + }, { + "name": "initiativeVoteSnapshot", + "type": "tuple", + "internalType": "struct IGovernance.InitiativeVoteSnapshot", + "components": [ + { "name": "votes", "type": "uint256", "internalType": "uint256" }, + { "name": "forEpoch", "type": "uint256", "internalType": "uint256" }, + { "name": "lastCountedEpoch", "type": "uint256", "internalType": "uint256" }, + { "name": "vetos", "type": "uint256", "internalType": "uint256" }, + ], + }], "stateMutability": "nonpayable", }, { @@ -546,8 +530,10 @@ export const Governance = [ "name": "userStates", "inputs": [{ "name": "", "type": "address", "internalType": "address" }], "outputs": [ - { "name": "allocatedLQTY", "type": "uint88", "internalType": "uint88" }, - { "name": "averageStakingTimestamp", "type": "uint120", "internalType": "uint120" }, + { "name": "unallocatedLQTY", "type": "uint256", "internalType": "uint256" }, + { "name": "unallocatedOffset", "type": "uint256", "internalType": "uint256" }, + { "name": "allocatedLQTY", "type": "uint256", "internalType": "uint256" }, + { "name": "allocatedOffset", "type": "uint256", "internalType": "uint256" }, ], "stateMutability": "view", }, @@ -556,10 +542,10 @@ export const Governance = [ "name": "votesForInitiativeSnapshot", "inputs": [{ "name": "", "type": "address", "internalType": "address" }], "outputs": [ - { "name": "votes", "type": "uint224", "internalType": "uint224" }, - { "name": "forEpoch", "type": "uint16", "internalType": "uint16" }, - { "name": "lastCountedEpoch", "type": "uint16", "internalType": "uint16" }, - { "name": "vetos", "type": "uint224", "internalType": "uint224" }, + { "name": "votes", "type": "uint256", "internalType": "uint256" }, + { "name": "forEpoch", "type": "uint256", "internalType": "uint256" }, + { "name": "lastCountedEpoch", "type": "uint256", "internalType": "uint256" }, + { "name": "vetos", "type": "uint256", "internalType": "uint256" }, ], "stateMutability": "view", }, @@ -567,27 +553,28 @@ export const Governance = [ "type": "function", "name": "votesSnapshot", "inputs": [], - "outputs": [ - { "name": "votes", "type": "uint240", "internalType": "uint240" }, - { "name": "forEpoch", "type": "uint16", "internalType": "uint16" }, - ], + "outputs": [{ "name": "votes", "type": "uint256", "internalType": "uint256" }, { + "name": "forEpoch", + "type": "uint256", + "internalType": "uint256", + }], "stateMutability": "view", }, { "type": "function", "name": "withdrawLQTY", - "inputs": [{ "name": "_lqtyAmount", "type": "uint88", "internalType": "uint88" }], + "inputs": [{ "name": "_lqtyAmount", "type": "uint256", "internalType": "uint256" }], "outputs": [], "stateMutability": "nonpayable", }, { "type": "function", "name": "withdrawLQTY", - "inputs": [ - { "name": "_lqtyAmount", "type": "uint88", "internalType": "uint88" }, - { "name": "_doSendRewards", "type": "bool", "internalType": "bool" }, - { "name": "_recipient", "type": "address", "internalType": "address" }, - ], + "inputs": [{ "name": "_lqtyAmount", "type": "uint256", "internalType": "uint256" }, { + "name": "_doSendRewards", + "type": "bool", + "internalType": "bool", + }, { "name": "_recipient", "type": "address", "internalType": "address" }], "outputs": [], "stateMutability": "nonpayable", }, @@ -618,10 +605,12 @@ export const Governance = [ { "type": "event", "name": "DeployUserProxy", - "inputs": [ - { "name": "user", "type": "address", "indexed": true, "internalType": "address" }, - { "name": "userProxy", "type": "address", "indexed": true, "internalType": "address" }, - ], + "inputs": [{ "name": "user", "type": "address", "indexed": true, "internalType": "address" }, { + "name": "userProxy", + "type": "address", + "indexed": true, + "internalType": "address", + }], "anonymous": false, }, { @@ -641,10 +630,12 @@ export const Governance = [ { "type": "event", "name": "OwnershipTransferred", - "inputs": [ - { "name": "previousOwner", "type": "address", "indexed": true, "internalType": "address" }, - { "name": "newOwner", "type": "address", "indexed": true, "internalType": "address" }, - ], + "inputs": [{ "name": "previousOwner", "type": "address", "indexed": true, "internalType": "address" }, { + "name": "newOwner", + "type": "address", + "indexed": true, + "internalType": "address", + }], "anonymous": false, }, { @@ -661,11 +652,12 @@ export const Governance = [ { "type": "event", "name": "SnapshotVotes", - "inputs": [ - { "name": "votes", "type": "uint256", "indexed": false, "internalType": "uint256" }, - { "name": "forEpoch", "type": "uint256", "indexed": false, "internalType": "uint256" }, - { "name": "boldAccrued", "type": "uint256", "indexed": false, "internalType": "uint256" }, - ], + "inputs": [{ "name": "votes", "type": "uint256", "indexed": false, "internalType": "uint256" }, { + "name": "forEpoch", + "type": "uint256", + "indexed": false, + "internalType": "uint256", + }, { "name": "boldAccrued", "type": "uint256", "indexed": false, "internalType": "uint256" }], "anonymous": false, }, { @@ -682,11 +674,12 @@ export const Governance = [ { "type": "event", "name": "UnregisterInitiative", - "inputs": [ - { "name": "initiative", "type": "address", "indexed": false, "internalType": "address" }, - { "name": "atEpoch", "type": "uint256", "indexed": false, "internalType": "uint256" }, - { "name": "hookSuccess", "type": "bool", "indexed": false, "internalType": "bool" }, - ], + "inputs": [{ "name": "initiative", "type": "address", "indexed": false, "internalType": "address" }, { + "name": "atEpoch", + "type": "uint256", + "indexed": false, + "internalType": "uint256", + }, { "name": "hookSuccess", "type": "bool", "indexed": false, "internalType": "bool" }], "anonymous": false, }, { From 0beee8d63bb1f43389bc87ede59889f4c3afb4d2 Mon Sep 17 00:00:00 2001 From: Pierre Bertet Date: Sat, 14 Dec 2024 21:45:14 +0000 Subject: [PATCH 13/29] Transactions screen: "Next transaction" => "Next step" --- .../app/src/screens/TransactionsScreen/TransactionsScreen.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/app/src/screens/TransactionsScreen/TransactionsScreen.tsx b/frontend/app/src/screens/TransactionsScreen/TransactionsScreen.tsx index a45b4a08a..23a8ef953 100644 --- a/frontend/app/src/screens/TransactionsScreen/TransactionsScreen.tsx +++ b/frontend/app/src/screens/TransactionsScreen/TransactionsScreen.tsx @@ -376,7 +376,7 @@ function FlowSteps({ .with("awaiting-commit", () => "Awaiting signature…") .with("awaiting-verify", () => "Awaiting confirmation…") .with("confirmed", () => "Confirmed") - .otherwise(() => index === currentStep ? "Ready to sign" : "Next transaction")} + .otherwise(() => index === currentStep ? "Current step" : "Next step")} mode={match(step.status) .returnType["mode"]>() .with( From 39eca4dbf5dec3a01792a2a922185ef6619d920d Mon Sep 17 00:00:00 2001 From: Pierre Bertet Date: Sat, 14 Dec 2024 21:45:53 +0000 Subject: [PATCH 14/29] stakeDeposit tx flow: remove rewards (they are now separated) --- frontend/app/src/tx-flows/stakeDeposit.tsx | 70 +++++----------------- 1 file changed, 15 insertions(+), 55 deletions(-) diff --git a/frontend/app/src/tx-flows/stakeDeposit.tsx b/frontend/app/src/tx-flows/stakeDeposit.tsx index 53fc9ffef..26f84c5f2 100644 --- a/frontend/app/src/tx-flows/stakeDeposit.tsx +++ b/frontend/app/src/tx-flows/stakeDeposit.tsx @@ -41,63 +41,23 @@ export const stakeDeposit: FlowDeclaration = { }, Details({ request }) { - const { rewards } = request.stakePosition; - const lqtyPrice = usePrice("LQTY"); - const lusdPrice = usePrice("LUSD"); - const ethPrice = usePrice("ETH"); - - const rewardsLusdInUsd = lusdPrice.data && dn.mul(rewards.lusd, lusdPrice.data); - const rewardsEthInUsd = ethPrice.data && dn.mul(rewards.eth, ethPrice.data); - return ( - <> - , - , - ]} - /> - , - , - ]} - /> - , - , - ]} - /> - + , + , + ]} + /> ); }, From 588f36b0f92f8c29de7f5477f37ae4d88750d6a1 Mon Sep 17 00:00:00 2001 From: Pierre Bertet Date: Sat, 14 Dec 2024 21:46:29 +0000 Subject: [PATCH 15/29] stakeDeposit tx flow: rename the user proxy deployment step to be "Initialize Staking" --- frontend/app/src/tx-flows/stakeDeposit.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/app/src/tx-flows/stakeDeposit.tsx b/frontend/app/src/tx-flows/stakeDeposit.tsx index 26f84c5f2..874a44a9b 100644 --- a/frontend/app/src/tx-flows/stakeDeposit.tsx +++ b/frontend/app/src/tx-flows/stakeDeposit.tsx @@ -63,7 +63,7 @@ export const stakeDeposit: FlowDeclaration = { steps: { deployUserProxy: { - name: () => "Deploy Staking Proxy", + name: () => "Initialize Staking", Status: TransactionStatus, async commit({ account, contracts, wagmiConfig }) { From a23b43fcbcc1b99a544ca3e9144f30f0cfb87726 Mon Sep 17 00:00:00 2001 From: Pierre Bertet Date: Sat, 14 Dec 2024 21:47:11 +0000 Subject: [PATCH 16/29] stakeDeposit tx flow: pass userProxyAddress from the previous step rather than fetching it again --- frontend/app/src/tx-flows/stakeDeposit.tsx | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/frontend/app/src/tx-flows/stakeDeposit.tsx b/frontend/app/src/tx-flows/stakeDeposit.tsx index 874a44a9b..d71378185 100644 --- a/frontend/app/src/tx-flows/stakeDeposit.tsx +++ b/frontend/app/src/tx-flows/stakeDeposit.tsx @@ -111,6 +111,7 @@ export const stakeDeposit: FlowDeclaration = { return JSON.stringify({ ...permit, deadline: Number(deadline), + userProxyAddress, }); }, @@ -168,14 +169,8 @@ export const stakeDeposit: FlowDeclaration = { } // deposit LQTY via permit - const userProxyAddress = await readContract(wagmiConfig, { - ...contracts.Governance, - functionName: "deriveUserProxyAddress", - args: [account], - }); - const permitStep = steps?.find((step) => step.id === "permitLqty"); - const permit = JSON.parse(permitStep?.artifact ?? ""); + const { userProxyAddress, ...permit } = JSON.parse(permitStep?.artifact ?? ""); return writeContract(wagmiConfig, { ...contracts.Governance, From e582cc8f08dfa16a7d50fc6337d45b06afc96736 Mon Sep 17 00:00:00 2001 From: Pierre Bertet Date: Sat, 14 Dec 2024 21:47:59 +0000 Subject: [PATCH 17/29] stakeDeposit tx flow: deploy the user proxy contract with permit too --- frontend/app/src/tx-flows/stakeDeposit.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/frontend/app/src/tx-flows/stakeDeposit.tsx b/frontend/app/src/tx-flows/stakeDeposit.tsx index d71378185..cef03e877 100644 --- a/frontend/app/src/tx-flows/stakeDeposit.tsx +++ b/frontend/app/src/tx-flows/stakeDeposit.tsx @@ -203,11 +203,6 @@ export const stakeDeposit: FlowDeclaration = { const steps: string[] = []; - // approve via permit - if (USE_PERMIT) { - return ["permitLqty", "stakeDeposit"]; - } - // get the user proxy address const userProxyAddress = await readContract(wagmiConfig, { ...contracts.Governance, @@ -226,6 +221,15 @@ export const stakeDeposit: FlowDeclaration = { steps.push("deployUserProxy"); } + // approve via permit + if (USE_PERMIT) { + return [ + ...steps, + "permitLqty", + "stakeDeposit", + ]; + } + // check for allowance const lqtyAllowance = await readContract(wagmiConfig, { ...contracts.LqtyToken, From 2e5f070cef7196e167b133c776ee579645d72666 Mon Sep 17 00:00:00 2001 From: Pierre Bertet Date: Sun, 15 Dec 2024 16:43:29 +0000 Subject: [PATCH 18/29] Stake summary card: loading state --- frontend/app/src/anim-utils.ts | 9 ++ .../StakePositionSummary.tsx | 130 +++++++++++------- .../src/screens/StakeScreen/StakeScreen.tsx | 1 + 3 files changed, 88 insertions(+), 52 deletions(-) diff --git a/frontend/app/src/anim-utils.ts b/frontend/app/src/anim-utils.ts index 86db80186..fdf63d60e 100644 --- a/frontend/app/src/anim-utils.ts +++ b/frontend/app/src/anim-utils.ts @@ -20,3 +20,12 @@ export function useFlashTransition(duration: number = 500) { }), }; } + +export function useAppear(show: boolean) { + return useTransition(show, { + from: { opacity: 0, transform: "scale(0.9)" }, + enter: { opacity: 1, transform: "scale(1)" }, + leave: { opacity: 0, transform: "scale(1)", immediate: true }, + config: { mass: 1, tension: 2000, friction: 80 }, + }); +} diff --git a/frontend/app/src/comps/StakePositionSummary/StakePositionSummary.tsx b/frontend/app/src/comps/StakePositionSummary/StakePositionSummary.tsx index 556e5bbf0..6195120d0 100644 --- a/frontend/app/src/comps/StakePositionSummary/StakePositionSummary.tsx +++ b/frontend/app/src/comps/StakePositionSummary/StakePositionSummary.tsx @@ -1,21 +1,26 @@ import type { PositionStake } from "@/src/types"; +import { useAppear } from "@/src/anim-utils"; import { Amount } from "@/src/comps/Amount/Amount"; import { TagPreview } from "@/src/comps/TagPreview/TagPreview"; import { fmtnum } from "@/src/formatting"; import { css } from "@/styled-system/css"; import { HFlex, IconStake, InfoTooltip, TokenIcon } from "@liquity2/uikit"; +import { a } from "@react-spring/web"; import * as dn from "dnum"; export function StakePositionSummary({ + loadingState = "success", prevStakePosition, stakePosition, txPreviewMode = false, }: { + loadingState?: "error" | "pending" | "success"; prevStakePosition?: null | PositionStake; stakePosition: null | PositionStake; txPreviewMode?: boolean; }) { + const appear = useAppear(loadingState === "success"); return (
-
- -
- + {appear((style, show) => ( + show && ( + +
+ +
+ +
+ ) + ))} {prevStakePosition && stakePosition && !dn.eq(prevStakePosition.deposit, stakePosition.deposit) @@ -219,47 +239,53 @@ export function StakePositionSummary({ > Voting power
-
-
- -
- {prevStakePosition && stakePosition && !dn.eq(prevStakePosition.share, stakePosition.share) - ? ( + + {appear((style, show) => ( + show && ( +
- ) - : " of pool"} - - Voting power is the percentage of the total staked LQTY that you own. - -
+ {prevStakePosition && stakePosition && !dn.eq(prevStakePosition.share, stakePosition.share) + ? ( +
+ +
+ ) + : " of pool"} + + Voting power is the percentage of the total staked LQTY that you own. + + + ) + ))} diff --git a/frontend/app/src/screens/StakeScreen/StakeScreen.tsx b/frontend/app/src/screens/StakeScreen/StakeScreen.tsx index a55ad43ac..4ced6cf75 100644 --- a/frontend/app/src/screens/StakeScreen/StakeScreen.tsx +++ b/frontend/app/src/screens/StakeScreen/StakeScreen.tsx @@ -46,6 +46,7 @@ export function StakeScreen() { > Date: Sun, 15 Dec 2024 16:43:59 +0000 Subject: [PATCH 19/29] Staking panel: improved errors --- .../src/screens/StakeScreen/PanelStaking.tsx | 38 ++++++++++++------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/frontend/app/src/screens/StakeScreen/PanelStaking.tsx b/frontend/app/src/screens/StakeScreen/PanelStaking.tsx index de31b729d..23b207295 100644 --- a/frontend/app/src/screens/StakeScreen/PanelStaking.tsx +++ b/frontend/app/src/screens/StakeScreen/PanelStaking.tsx @@ -44,12 +44,12 @@ export function PanelStaking() { ) : dn.from(0, 18); - const updatedShare = stakePosition.data?.totalStaked - && dn.gt(stakePosition.data?.totalStaked, 0) - ? dn.div( - updatedDeposit, - dn.add(stakePosition.data.totalStaked, depositDifference), - ) + const updatedTotalStaked = stakePosition.data?.totalStaked + ? dn.add(stakePosition.data.totalStaked, depositDifference) + : null; + + const updatedShare = updatedTotalStaked && dn.gt(updatedTotalStaked, 0) + ? dn.div(updatedDeposit, updatedTotalStaked) : dn.from(0, 18); const lqtyBalance = useBalance(account.address, "LQTY"); @@ -58,14 +58,19 @@ export function PanelStaking() { stakePosition.data?.deposit, 0, ); - const sufficientBalance = mode === "withdraw" || ( - lqtyBalance.data && dn.gte(lqtyBalance.data, depositDifference) + + const insufficientBalance = mode === "deposit" && isDepositFilled && ( + !lqtyBalance.data || dn.lt(lqtyBalance.data, parsedValue) + ); + + const withdrawOutOfRange = mode === "withdraw" && isDepositFilled && ( + !stakePosition.data || dn.lt(stakePosition.data.deposit, parsedValue) ); const allowSubmit = Boolean( account.isConnected && isDepositFilled - && sufficientBalance, + && !insufficientBalance, ); const rewardsLusd = dn.from(0, 18); @@ -77,10 +82,17 @@ export function PanelStaking() { field={ Date: Sun, 15 Dec 2024 16:44:38 +0000 Subject: [PATCH 20/29] Unstake tx flow: unstake from Governance + remove rewards --- frontend/app/src/tx-flows/unstakeDeposit.tsx | 74 +++++--------------- 1 file changed, 18 insertions(+), 56 deletions(-) diff --git a/frontend/app/src/tx-flows/unstakeDeposit.tsx b/frontend/app/src/tx-flows/unstakeDeposit.tsx index ee65474ef..97d826de0 100644 --- a/frontend/app/src/tx-flows/unstakeDeposit.tsx +++ b/frontend/app/src/tx-flows/unstakeDeposit.tsx @@ -36,62 +36,23 @@ export const unstakeDeposit: FlowDeclaration = { }, Details({ request }) { - const { rewards } = request.stakePosition; const lqtyPrice = usePrice("LQTY"); - const lusdPrice = usePrice("LUSD"); - const ethPrice = usePrice("ETH"); - - const rewardsLusdInUsd = lusdPrice.data && dn.mul(rewards.lusd, lusdPrice.data); - const rewardsEthInUsd = ethPrice.data && dn.mul(rewards.eth, ethPrice.data); - return ( - <> - , - , - ]} - /> - , - , - ]} - /> - , - , - ]} - /> - + , + , + ]} + /> ); }, @@ -101,9 +62,10 @@ export const unstakeDeposit: FlowDeclaration = { Status: TransactionStatus, async commit({ contracts, request, wagmiConfig }) { + const { Governance } = contracts; return writeContract(wagmiConfig, { - ...contracts.LqtyStaking, - functionName: "unstake", + ...Governance, + functionName: "withdrawLQTY", args: [request.lqtyAmount[0]], }); }, From c9624f5a9a170132d578fcea2c61958e5d6d6c3e Mon Sep 17 00:00:00 2001 From: Pierre Bertet Date: Mon, 16 Dec 2024 10:54:46 +0000 Subject: [PATCH 21/29] Update env vars format --- .../utils/deployment-manifest-to-app-env.ts | 11 +++++--- frontend/app/src/env.ts | 25 ++++++++++--------- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/contracts/utils/deployment-manifest-to-app-env.ts b/contracts/utils/deployment-manifest-to-app-env.ts index 7a488e7e1..2b54c3367 100644 --- a/contracts/utils/deployment-manifest-to-app-env.ts +++ b/contracts/utils/deployment-manifest-to-app-env.ts @@ -160,7 +160,10 @@ function deployedContractsToAppEnvVariables(manifest: DeploymentManifest) { // governance contracts for (const [contractName, address] of Object.entries(governance)) { - const envVarName = contractNameToAppEnvVariable(contractName, "CONTRACT"); + const envVarName = contractNameToAppEnvVariable( + contractName, + contractName.endsWith("Initiative") ? "INITIATIVE" : "CONTRACT", + ); if (envVarName) { appEnvVariables[envVarName] = address; } @@ -217,10 +220,12 @@ function contractNameToAppEnvVariable(contractName: string, prefix: string = "") return `${prefix}_LQTY_STAKING`; case "governance": return `${prefix}_GOVERNANCE`; + + // governance initiatives case "uniV4DonationsInitiative": - return `${prefix}_UNI_V4_DONATIONS_INITIATIVE`; + return `${prefix}_UNI_V4_DONATIONS`; case "curveV2GaugeRewardsInitiative": - return `${prefix}_CURVE_V2_GAUGE_REWARDS_INITIATIVE`; + return `${prefix}_CURVE_V2_GAUGE_REWARDS`; } return null; } diff --git a/frontend/app/src/env.ts b/frontend/app/src/env.ts index a4924a1f7..0c66692db 100644 --- a/frontend/app/src/env.ts +++ b/frontend/app/src/env.ts @@ -62,16 +62,16 @@ export const EnvSchema = v.pipe( WALLET_CONNECT_PROJECT_ID: v.string(), DELEGATE_AUTO: vAddress(), - - CONTRACT_LQTY_TOKEN: vAddress(), - CONTRACT_LQTY_STAKING: vAddress(), - CONTRACT_LUSD_TOKEN: vAddress(), - CONTRACT_GOVERNANCE: vAddress(), + INITIATIVE_UNI_V4_DONATIONS: vAddress(), CONTRACT_BOLD_TOKEN: vAddress(), CONTRACT_COLLATERAL_REGISTRY: vAddress(), CONTRACT_EXCHANGE_HELPERS: vAddress(), + CONTRACT_GOVERNANCE: vAddress(), CONTRACT_HINT_HELPERS: vAddress(), + CONTRACT_LQTY_STAKING: vAddress(), + CONTRACT_LQTY_TOKEN: vAddress(), + CONTRACT_LUSD_TOKEN: vAddress(), CONTRACT_MULTI_TROVE_GETTER: vAddress(), CONTRACT_WETH: vAddress(), @@ -193,18 +193,18 @@ const parsedEnv = v.parse(EnvSchema, { SUBGRAPH_URL: process.env.NEXT_PUBLIC_SUBGRAPH_URL, DELEGATE_AUTO: process.env.NEXT_PUBLIC_DELEGATE_AUTO, + INITIATIVE_UNI_V4_DONATIONS: process.env.NEXT_PUBLIC_INITIATIVE_UNI_V4_DONATIONS, CONTRACT_BOLD_TOKEN: process.env.NEXT_PUBLIC_CONTRACT_BOLD_TOKEN, CONTRACT_COLLATERAL_REGISTRY: process.env.NEXT_PUBLIC_CONTRACT_COLLATERAL_REGISTRY, CONTRACT_EXCHANGE_HELPERS: process.env.NEXT_PUBLIC_CONTRACT_EXCHANGE_HELPERS, - CONTRACT_HINT_HELPERS: process.env.NEXT_PUBLIC_CONTRACT_HINT_HELPERS, - CONTRACT_MULTI_TROVE_GETTER: process.env.NEXT_PUBLIC_CONTRACT_MULTI_TROVE_GETTER, - CONTRACT_WETH: process.env.NEXT_PUBLIC_CONTRACT_WETH, - CONTRACT_GOVERNANCE: process.env.NEXT_PUBLIC_CONTRACT_GOVERNANCE, - CONTRACT_LQTY_TOKEN: process.env.NEXT_PUBLIC_CONTRACT_LQTY_TOKEN, + CONTRACT_HINT_HELPERS: process.env.NEXT_PUBLIC_CONTRACT_HINT_HELPERS, CONTRACT_LQTY_STAKING: process.env.NEXT_PUBLIC_CONTRACT_LQTY_STAKING, + CONTRACT_LQTY_TOKEN: process.env.NEXT_PUBLIC_CONTRACT_LQTY_TOKEN, CONTRACT_LUSD_TOKEN: process.env.NEXT_PUBLIC_CONTRACT_LUSD_TOKEN, + CONTRACT_MULTI_TROVE_GETTER: process.env.NEXT_PUBLIC_CONTRACT_MULTI_TROVE_GETTER, + CONTRACT_WETH: process.env.NEXT_PUBLIC_CONTRACT_WETH, COLL_0_TOKEN_ID: process.env.NEXT_PUBLIC_COLL_0_TOKEN_ID, COLL_1_TOKEN_ID: process.env.NEXT_PUBLIC_COLL_1_TOKEN_ID, @@ -263,9 +263,9 @@ export const { CHAIN_ID, CHAIN_NAME, CHAIN_RPC_URL, + COINGECKO_API_KEY, COLLATERAL_CONTRACTS, COMMIT_HASH, - SUBGRAPH_URL, CONTRACT_BOLD_TOKEN, CONTRACT_COLLATERAL_REGISTRY, CONTRACT_EXCHANGE_HELPERS, @@ -277,9 +277,10 @@ export const { CONTRACT_MULTI_TROVE_GETTER, CONTRACT_WETH, DELEGATE_AUTO, - COINGECKO_API_KEY, DEMO_MODE, DEPLOYMENT_FLAVOR, + INITIATIVE_UNI_V4_DONATIONS, + SUBGRAPH_URL, VERCEL_ANALYTICS, WALLET_CONNECT_PROJECT_ID, } = parsedEnv; From d3936aa83dad992ace17dffebb67f78f32db42b2 Mon Sep 17 00:00:00 2001 From: Pierre Bertet Date: Mon, 16 Dec 2024 10:56:37 +0000 Subject: [PATCH 22/29] allocateVotingPower tx flow --- frontend/app/src/constants.ts | 4 + frontend/app/src/liquity-governance.ts | 246 +++++++++++++ .../src/screens/StakeScreen/PanelStaking.tsx | 4 +- .../src/screens/StakeScreen/PanelVoting.tsx | 336 ++++++++---------- frontend/app/src/services/TransactionFlow.tsx | 4 + .../app/src/tx-flows/allocateVotingPower.tsx | 261 ++++++++++++++ frontend/app/src/types.ts | 16 + frontend/app/src/valibot-utils.ts | 18 + 8 files changed, 701 insertions(+), 188 deletions(-) create mode 100644 frontend/app/src/liquity-governance.ts create mode 100644 frontend/app/src/tx-flows/allocateVotingPower.tsx diff --git a/frontend/app/src/constants.ts b/frontend/app/src/constants.ts index 4d5c201b4..1af971e28 100644 --- a/frontend/app/src/constants.ts +++ b/frontend/app/src/constants.ts @@ -68,3 +68,7 @@ export const REDEMPTION_RISK: Record, number> = { medium: 3.5 / 100, low: 5 / 100, }; + +// in seconds +export const GOVERNANCE_EPOCH_DURATION = 604800; +export const GOVERNANCE_EPOCH_VOTING_CUTOFF = 518400; diff --git a/frontend/app/src/liquity-governance.ts b/frontend/app/src/liquity-governance.ts new file mode 100644 index 000000000..ffbd99c10 --- /dev/null +++ b/frontend/app/src/liquity-governance.ts @@ -0,0 +1,246 @@ +import type { Address, Initiative } from "@/src/types"; +import type { UseQueryResult } from "@tanstack/react-query"; +import type { Config as WagmiConfig } from "wagmi"; + +import { GOVERNANCE_EPOCH_DURATION, GOVERNANCE_EPOCH_VOTING_CUTOFF } from "@/src/constants"; +import { getProtocolContract } from "@/src/contracts"; +import { INITIATIVE_UNI_V4_DONATIONS } from "@/src/env"; +import { useQuery } from "@tanstack/react-query"; +import { useReadContract, useReadContracts } from "wagmi"; +import { readContract } from "wagmi/actions"; + +export function useGovernanceState() { + const Governance = getProtocolContract("Governance"); + return useReadContracts({ + contracts: [{ + ...Governance, + functionName: "epochStart", + }, { + ...Governance, + functionName: "getTotalVotesAndState", + }, { + ...Governance, + functionName: "secondsWithinEpoch", + }], + query: { + select: ([ + epochStart, + totalVotesAndState, + secondsWithinEpoch, + ]) => { + const period: "cutoff" | "voting" = (secondsWithinEpoch.result ?? 0n) > GOVERNANCE_EPOCH_VOTING_CUTOFF + ? "cutoff" + : "voting"; + + const seconds = Number(secondsWithinEpoch.result ?? 0n); + const daysLeft = (Number(GOVERNANCE_EPOCH_DURATION) - seconds) / (24 * 60 * 60); + const daysLeftRounded = Math.ceil(daysLeft); + + return { + countedVoteLQTY: totalVotesAndState.result?.[1].countedVoteLQTY, + countedVoteOffset: totalVotesAndState.result?.[1].countedVoteOffset, + epochStart: epochStart.result, + secondsWithinEpoch: secondsWithinEpoch.result, + totalVotes: totalVotesAndState.result?.[0], + period, + daysLeft, + daysLeftRounded, + }; + }, + }, + }); +} + +type InitiativeStatus = + | "nonexistent" + | "warm up" + | "skip" + | "claimable" + | "claimed" + | "disabled" + | "unregisterable"; + +function initiativeStatusFromNumber(status: number): InitiativeStatus { + const statuses: Record = { + 0: "nonexistent", + 1: "warm up", + 2: "skip", + 3: "claimable", + 4: "claimed", + 5: "disabled", + 6: "unregisterable", + }; + return statuses[status] || "nonexistent"; +} + +export function useInitiativeState(initiativeAddress: Address | null) { + const Governance = getProtocolContract("Governance"); + + return useReadContracts({ + contracts: [{ + ...Governance, + functionName: "getInitiativeState", + args: [initiativeAddress ?? "0x"], + }, { + ...Governance, + functionName: "getInitiativeSnapshotAndState", + args: [initiativeAddress ?? "0x"], + }], + query: { + enabled: initiativeAddress !== null, + select: ([initiativeState, snapshotAndState]) => { + return { + status: initiativeStatusFromNumber(initiativeState.result?.[0] ?? 0), + lastEpochClaim: initiativeState.result?.[1], + claimableAmount: initiativeState.result?.[2], + snapshot: snapshotAndState.result?.[0], + state: snapshotAndState.result?.[1], + shouldUpdate: snapshotAndState.result?.[2], + }; + }, + }, + }); +} + +export function useUserStates(account: Address | null) { + const Governance = getProtocolContract("Governance"); + const userStates = useReadContract({ + ...Governance, + functionName: "userStates", + args: [account ?? "0x"], + query: { + enabled: account !== null, + select: (userStates) => ({ + allocatedLQTY: userStates[2], + allocatedOffset: userStates[3], + unallocatedLQTY: userStates[0], + unallocatedOffset: userStates[1], + }), + }, + }); + + return userStates; +} + +export async function getUserStates( + wagmiConfig: WagmiConfig, + account: Address, +) { + const Governance = getProtocolContract("Governance"); + const result = await readContract(wagmiConfig, { + ...Governance, + functionName: "userStates", + args: [account], + }); + + return { + allocatedLQTY: result[2], + allocatedOffset: result[3], + unallocatedLQTY: result[0], + unallocatedOffset: result[1], + }; +} + +// const INITIATIVES_STATIC: Initiative[] = [ +// { +// address: "0x0000000000000000000000000000000000000001", +// name: "WETH-BOLD 0.3%", +// protocol: "Uniswap V4", +// tvl: dn.from(2_420_000, 18), +// pairVolume: dn.from(1_420_000, 18), +// votesDistribution: dn.from(0.35, 18), +// }, +// { +// address: "0x0000000000000000000000000000000000000002", +// name: "WETH-BOLD 0.3%", +// protocol: "Uniswap V4", +// tvl: dn.from(2_420_000, 18), +// pairVolume: dn.from(1_420_000, 18), +// votesDistribution: dn.from(0.20, 18), +// }, +// { +// address: "0x0000000000000000000000000000000000000003", +// name: "crvUSD-BOLD 0.01%", +// protocol: "Curve V2", +// tvl: dn.from(2_420_000, 18), +// pairVolume: dn.from(1_420_000, 18), +// votesDistribution: dn.from(0.15, 18), +// }, +// { +// address: "0x0000000000000000000000000000000000000004", +// name: "3pool-BOLD 0.01%", +// protocol: "Curve V2", +// tvl: dn.from(2_420_000, 18), +// pairVolume: dn.from(1_420_000, 18), +// votesDistribution: dn.from(0.10, 18), +// }, +// { +// address: "0x0000000000000000000000000000000000000005", +// name: "3pool-BOLD 0.01%", +// protocol: "Curve V2", +// tvl: dn.from(2_420_000, 18), +// pairVolume: dn.from(1_420_000, 18), +// votesDistribution: dn.from(0.10, 18), +// }, +// { +// address: "0x0000000000000000000000000000000000000006", +// name: "3pool-BOLD 0.01%", +// protocol: "Curve V2", +// tvl: dn.from(2_420_000, 18), +// pairVolume: dn.from(1_420_000, 18), +// votesDistribution: dn.from(0.05, 18), +// }, +// { +// address: "0x0000000000000000000000000000000000000007", +// name: "DeFi Collective: BOLD incentives on Euler", +// protocol: "0x5305...1418", +// tvl: dn.from(0, 18), +// pairVolume: dn.from(0, 18), +// votesDistribution: dn.from(0.025, 18), +// }, +// { +// address: "0x0000000000000000000000000000000000000008", +// name: "DeFi Collective: BOLD-USDC on Balancer", +// protocol: "0x7179...9f8f", +// tvl: dn.from(0, 18), +// pairVolume: dn.from(0, 18), +// votesDistribution: dn.from(0, 18), +// }, +// ]; + +const INITIATIVES_STATIC: Initiative[] = [ + { + name: "UNI V4 donations", + protocol: "Uniswap V4", + address: INITIATIVE_UNI_V4_DONATIONS, + tvl: null, + pairVolume: null, + votesDistribution: null, + }, +]; + +export function useInitiatives(): UseQueryResult { + return useQuery({ + queryKey: ["initiatives"], + queryFn: () => { + return INITIATIVES_STATIC; + }, + }); +} + +// // export function useRegisteredInitiatives() { +// // const Governance = getProtocolContract("Governance"); + +// // return useReadContract({ +// // ...Governance, +// // functionName: "registeredInitiatives", +// // query: { +// // refetchInterval: DATA_REFRESH_INTERVAL, +// // select: (data: Record) => { +// // return Object.entries(data) +// // .filter(([_, epoch]) => epoch > 0) +// // .map(([address]) => address as Address); +// // }, +// // }, +// // }); +// // } diff --git a/frontend/app/src/screens/StakeScreen/PanelStaking.tsx b/frontend/app/src/screens/StakeScreen/PanelStaking.tsx index 23b207295..1e11a354a 100644 --- a/frontend/app/src/screens/StakeScreen/PanelStaking.tsx +++ b/frontend/app/src/screens/StakeScreen/PanelStaking.tsx @@ -244,8 +244,8 @@ export function PanelStaking() { if (account.address) { txFlow.start({ flowId: mode === "deposit" ? "stakeDeposit" : "unstakeDeposit", - backLink: [`/stake`, "Back to stake position"], - successLink: ["/", "Go to the Dashboard"], + backLink: ["/stake", "Back to stake position"], + successLink: ["/stake/voting", "Allocate voting power"], successMessage: "The stake position has been updated successfully.", lqtyAmount: dn.abs(depositDifference), diff --git a/frontend/app/src/screens/StakeScreen/PanelVoting.tsx b/frontend/app/src/screens/StakeScreen/PanelVoting.tsx index 93437e815..e28c4b3bf 100644 --- a/frontend/app/src/screens/StakeScreen/PanelVoting.tsx +++ b/frontend/app/src/screens/StakeScreen/PanelVoting.tsx @@ -1,51 +1,26 @@ -import type { Dnum } from "@/src/types"; -import type { UseQueryResult } from "@tanstack/react-query"; +import type { Address, Dnum, Initiative, Vote, VoteAllocations } from "@/src/types"; import { Amount } from "@/src/comps/Amount/Amount"; import { ConnectWarningBox } from "@/src/comps/ConnectWarningBox/ConnectWarningBox"; import { Tag } from "@/src/comps/Tag/Tag"; import { VoteInput } from "@/src/comps/VoteInput/VoteInput"; import content from "@/src/content"; -import { fmtnum } from "@/src/formatting"; +import { useGovernanceState, useInitiatives, useInitiativeState, useUserStates } from "@/src/liquity-governance"; +import { useAccount } from "@/src/services/Ethereum"; +import { useTransactionFlow } from "@/src/services/TransactionFlow"; import { css } from "@/styled-system/css"; import { AnchorTextButton, Button, IconExternal, VFlex } from "@liquity2/uikit"; -import { useQuery } from "@tanstack/react-query"; import * as dn from "dnum"; import { useState } from "react"; -type InitiativeId = string; - -type Initiative = { - id: InitiativeId; - name: string; - protocol: string; - tvl: Dnum; - pairVolume: Dnum; - votesDistribution: Dnum; -}; - -type Vote = "for" | "against"; - -function useInitiatives(): UseQueryResult { - return useQuery({ - queryKey: ["initiatives"], - queryFn: () => { - return INITIATIVES_DEMO; - }, - }); -} - export function PanelVoting() { + const txFlow = useTransactionFlow(); const initiatives = useInitiatives(); + const governanceState = useGovernanceState(); - const [votes, setVotes] = useState< - Record< - InitiativeId, - { vote: Vote | null; value: Dnum } - > - >({}); + const [voteAllocations, setVoteAllocations] = useState({}); - const remainingVotingPower = Object.values(votes).reduce( + const remainingVotingPower = Object.values(voteAllocations).reduce( (remaining, voteData) => { if (voteData.vote !== null) { return dn.sub(remaining, voteData.value); @@ -55,28 +30,29 @@ export function PanelVoting() { dn.from(1, 18), ); - const handleVote = (id: InitiativeId, vote: Vote | null) => { - setVotes((prev) => ({ - ...prev, - [id]: { - value: dn.from(0), - vote: prev[id]?.vote === vote ? null : vote, - }, - })); + const handleVote = (initiativeAddress: Address, vote: Vote | null) => { + setVoteAllocations((prev) => { + return ({ + ...prev, + [initiativeAddress]: { + value: dn.from(0), + vote: prev[initiativeAddress]?.vote === vote ? null : vote, + }, + }); + }); }; - const handleVoteInputChange = (id: InitiativeId, value: Dnum) => { - setVotes((prev) => ({ + const handleVoteInputChange = (initiativeAddress: Address, value: Dnum) => { + setVoteAllocations((prev) => ({ ...prev, - [id]: { - vote: prev[id]?.vote ?? null, + [initiativeAddress]: { + vote: prev[initiativeAddress]?.vote ?? null, value: dn.div(value, 100), }, })); }; - // const allowSubmit = dn.lt(remainingVotingPower, 1); - const allowSubmit = false; + const allowSubmit = dn.eq(remainingVotingPower, 0); return (
-
- Current voting round ends in 1 day -
+ {governanceState.data && ( +
+ Current voting round ends in{" "} + + {governanceState.data.daysLeftRounded} {governanceState.data.daysLeftRounded === 1 ? "day" : "days"} + +
+ )} + {governanceState.data?.period === "cutoff" && ( +
+ You can only veto today +
+ )} + {initiatives.data?.map((initiative, index) => ( - - - - - - - + ))} @@ -328,11 +260,18 @@ export function PanelVoting() { + + + + + + + ); +} diff --git a/frontend/app/src/services/TransactionFlow.tsx b/frontend/app/src/services/TransactionFlow.tsx index b214408de..7d24dba61 100644 --- a/frontend/app/src/services/TransactionFlow.tsx +++ b/frontend/app/src/services/TransactionFlow.tsx @@ -19,6 +19,7 @@ import { useAccount, useConfig as useWagmiConfig } from "wagmi"; /* flows registration */ +import { allocateVotingPower, type AllocateVotingPowerRequest } from "@/src/tx-flows/allocateVotingPower"; import { claimCollateralSurplus, type ClaimCollateralSurplusRequest } from "@/src/tx-flows/claimCollateralSurplus"; import { closeLoanPosition, type CloseLoanPositionRequest } from "@/src/tx-flows/closeLoanPosition"; import { earnClaimRewards, type EarnClaimRewardsRequest } from "@/src/tx-flows/earnClaimRewards"; @@ -34,6 +35,7 @@ import { updateLeveragePosition, type UpdateLeveragePositionRequest } from "@/sr import { updateLoanInterestRate, type UpdateLoanInterestRateRequest } from "@/src/tx-flows/updateLoanInterestRate"; export type FlowRequestMap = { + "allocateVotingPower": AllocateVotingPowerRequest; "claimCollateralSurplus": ClaimCollateralSurplusRequest; "closeLoanPosition": CloseLoanPositionRequest; "earnClaimRewards": EarnClaimRewardsRequest; @@ -50,6 +52,7 @@ export type FlowRequestMap = { }; const FlowIdSchema = v.union([ + v.literal("allocateVotingPower"), v.literal("claimCollateralSurplus"), v.literal("closeLoanPosition"), v.literal("earnClaimRewards"), @@ -66,6 +69,7 @@ const FlowIdSchema = v.union([ ]); export const flows: FlowsMap = { + allocateVotingPower, claimCollateralSurplus, closeLoanPosition, earnClaimRewards, diff --git a/frontend/app/src/tx-flows/allocateVotingPower.tsx b/frontend/app/src/tx-flows/allocateVotingPower.tsx new file mode 100644 index 000000000..0a122f621 --- /dev/null +++ b/frontend/app/src/tx-flows/allocateVotingPower.tsx @@ -0,0 +1,261 @@ +import type { FlowDeclaration } from "@/src/services/TransactionFlow"; +import type { Address, Initiative, VoteAllocation } from "@/src/types"; + +import { Amount } from "@/src/comps/Amount/Amount"; +import { getUserStates, useInitiatives } from "@/src/liquity-governance"; +import { TransactionDetailsRow } from "@/src/screens/TransactionsScreen/TransactionsScreen"; +import { TransactionStatus } from "@/src/screens/TransactionsScreen/TransactionStatus"; +import { vVoteAllocations } from "@/src/valibot-utils"; +import { css } from "@/styled-system/css"; +import { IconDownvote, IconUpvote } from "@liquity2/uikit"; +import { IconStake } from "@liquity2/uikit"; +import * as dn from "dnum"; +import * as v from "valibot"; +import { writeContract } from "wagmi/actions"; +import { createRequestSchema, verifyTransaction } from "./shared"; + +const RequestSchema = createRequestSchema( + "allocateVotingPower", + { + voteAllocations: vVoteAllocations(), + }, +); + +export type AllocateVotingPowerRequest = v.InferOutput; + +export const allocateVotingPower: FlowDeclaration = { + title: "Review & Send Transaction", + + Summary({ request }) { + const votesCount = Object.keys(request.voteAllocations).length; + return ( +
+
+

+
+
+ +
+ LQTY Stake +
+

+
+
+
+
+
+ {votesCount} +
+
+
+ Total votes +
+
+
+
+ ); + }, + + Details({ request }) { + const initiatives = useInitiatives(); + return ( + <> + {Object.entries(request.voteAllocations).map(([address, vote]) => { + const initiative = initiatives.data?.find((i) => i.address === address); + return !initiative || !vote ? null : ( + + ); + })} + + ); + }, + + steps: { + allocateVotingPower: { + name: () => "Cast votes", + Status: TransactionStatus, + + async commit({ request, account, wagmiConfig, contracts }) { + if (!account) { + throw new Error("Account address is required"); + } + const userStates = await getUserStates(wagmiConfig, account); + + const { voteAllocations } = request; + const { unallocatedLQTY } = userStates; + + const initiativeAddresses = Object.keys(voteAllocations) as Address[]; + + const allocationArgs = { + initiatives: initiativeAddresses, + votes: new Array(initiativeAddresses.length).fill(0n) as bigint[], + vetos: new Array(initiativeAddresses.length).fill(0n) as bigint[], + }; + + let remainingLqty = unallocatedLQTY; + + for (const [index, address] of initiativeAddresses.entries()) { + const vote = voteAllocations[address]; + if (!vote) { + throw new Error("Vote not found"); + } + + let qty = dn.mul([unallocatedLQTY, 18], vote.value)[0]; + remainingLqty -= qty; + + // allocate any remaining LQTY to the last initiative + if (index === initiativeAddresses.length - 1 && remainingLqty > 0n) { + qty += remainingLqty; + } + + if (vote?.vote === "for") { + allocationArgs.votes[index] = qty; + } else if (vote?.vote === "against") { + allocationArgs.vetos[index] = qty; + } + } + + return writeContract(wagmiConfig, { + ...contracts.Governance, + functionName: "allocateLQTY", + args: [ + [], + allocationArgs.initiatives, + allocationArgs.votes, + allocationArgs.vetos, + ], + }); + }, + + async verify({ wagmiConfig }, hash) { + await verifyTransaction(wagmiConfig, hash); + }, + }, + }, + + async getSteps() { + return ["allocateVotingPower"]; + }, + + parseRequest(request) { + return v.parse(RequestSchema, request); + }, +}; + +function VoteAllocation({ + initiative, + vote, +}: { + initiative: Initiative; + vote: VoteAllocation; +}) { + return ( + + + {vote.vote === "for" + ? + : } + , +
+ {vote.vote === "for" ? "Support" : "Oppose"} +
, + ]} + /> + ); +} diff --git a/frontend/app/src/types.ts b/frontend/app/src/types.ts index df4936e43..707716121 100644 --- a/frontend/app/src/types.ts +++ b/frontend/app/src/types.ts @@ -145,3 +145,19 @@ export type LoanDetails = { | "liquidatable" // above the max LTV before liquidation | "underwater"; // above 100% LTV }; + +// governance +export type Initiative = + & { + name: string; + protocol: string; + address: Address; + } + & ( + | { tvl: Dnum; pairVolume: Dnum; votesDistribution: Dnum } + | { tvl: null; pairVolume: null; votesDistribution: null } + ); + +export type Vote = "for" | "against"; +export type VoteAllocation = { vote: Vote | null; value: Dnum }; +export type VoteAllocations = Record; diff --git a/frontend/app/src/valibot-utils.ts b/frontend/app/src/valibot-utils.ts index 206116e36..a5d23c2ec 100644 --- a/frontend/app/src/valibot-utils.ts +++ b/frontend/app/src/valibot-utils.ts @@ -206,3 +206,21 @@ export function vPositionEarn() { }), }); } + +export function vVote() { + return v.union([ + v.literal("for"), + v.literal("against"), + ]); +} + +export function vVoteAllocation() { + return v.object({ + vote: v.union([v.null(), vVote()]), + value: vDnum(), + }); +} + +export function vVoteAllocations() { + return v.record(vAddress(), vVoteAllocation()); +} From 31354d57c05e2d19c36a5c5c018baf7a57da077b Mon Sep 17 00:00:00 2001 From: Pierre Bertet Date: Thu, 19 Dec 2024 18:35:47 +0000 Subject: [PATCH 23/29] AnchorTextButton: className inheritance --- frontend/uikit/src/TextButton/AnchorTextButton.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/uikit/src/TextButton/AnchorTextButton.tsx b/frontend/uikit/src/TextButton/AnchorTextButton.tsx index b356dabb3..47df26275 100644 --- a/frontend/uikit/src/TextButton/AnchorTextButton.tsx +++ b/frontend/uikit/src/TextButton/AnchorTextButton.tsx @@ -16,6 +16,7 @@ export const AnchorTextButton = forwardRef< external, label, size, + className, ...props }, ref) { const textButtonStyles = useTextButtonStyles(size); @@ -28,7 +29,7 @@ export const AnchorTextButton = forwardRef< ref={ref} className={cx( textButtonStyles.className, - props.className, + className, )} {...externalProps} {...props} From 2397c4e7e10b7b2ac88b655a0b508d9dfd055ae9 Mon Sep 17 00:00:00 2001 From: Pierre Bertet Date: Fri, 20 Dec 2024 16:44:51 +0000 Subject: [PATCH 24/29] Add missing env vars to .env --- frontend/app/.env | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/app/.env b/frontend/app/.env index 03b456917..062097e2f 100644 --- a/frontend/app/.env +++ b/frontend/app/.env @@ -21,6 +21,8 @@ NEXT_PUBLIC_COINGECKO_API_KEY= # NEXT_PUBLIC_CHAIN_CONTRACT_ENS_REGISTRY=0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e # NEXT_PUBLIC_CHAIN_CONTRACT_ENS_RESOLVER=0xce01f8eee7E479C928F8919abD53E553a36CeF67|19258213 # NEXT_PUBLIC_CHAIN_CONTRACT_MULTICALL=0xca11bde05977b3631167028862be2a173976ca11|14353601 +# NEXT_PUBLIC_LIQUITY_STATS_URL= +# NEXT_PUBLIC_KNOWN_INITIATIVES_URL= # Hardhat / Anvil (local) # NEXT_PUBLIC_CHAIN_ID=31337 @@ -35,6 +37,7 @@ NEXT_PUBLIC_CHAIN_CURRENCY=Ether|ETH|18 NEXT_PUBLIC_CHAIN_RPC_URL=https://ethereum-sepolia-rpc.publicnode.com NEXT_PUBLIC_CHAIN_BLOCK_EXPLORER=Etherscan Sepolia|https://sepolia.etherscan.io/ NEXT_PUBLIC_CHAIN_CONTRACT_MULTICALL=0xcA11bde05977b3631167028862bE2a173976CA11 +NEXT_PUBLIC_KNOWN_INITIATIVES_URL=https://liquity2-sepolia.vercel.app/known-initiatives/sepolia.json NEXT_PUBLIC_LIQUITY_STATS_URL=https://api.liquity.org/v2/testnet/sepolia.json NEXT_PUBLIC_SUBGRAPH_URL=https://api.studio.thegraph.com/query/42403/liquity2-sepolia/version/latest From f8687c70acaa76304ba450bf393f8ae7b1165444 Mon Sep 17 00:00:00 2001 From: Pierre Bertet Date: Fri, 20 Dec 2024 16:46:00 +0000 Subject: [PATCH 25/29] Fetch initiatives from subgraph --- frontend/app/src/subgraph-hooks.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/frontend/app/src/subgraph-hooks.ts b/frontend/app/src/subgraph-hooks.ts index d215ebfe9..6ab28fd29 100644 --- a/frontend/app/src/subgraph-hooks.ts +++ b/frontend/app/src/subgraph-hooks.ts @@ -15,6 +15,7 @@ import { isAddress, shortenAddress } from "@liquity2/uikit"; import { useQuery } from "@tanstack/react-query"; import * as dn from "dnum"; import { + GovernanceInitiatives, graphQuery, InterestBatchQuery, InterestRateBracketsQuery, @@ -402,6 +403,23 @@ export function useInterestRateBrackets( }); } +export function useGovernanceInitiatives(options?: Options) { + let queryFn = async () => { + const { governanceInitiatives } = await graphQuery(GovernanceInitiatives); + return governanceInitiatives.map((initiative) => initiative.id as Address); + }; + + if (DEMO_MODE) { + queryFn = async () => []; + } + + return useQuery({ + queryKey: ["GovernanceInitiatives"], + queryFn, + ...prepareOptions(options), + }); +} + function subgraphTroveToLoan( trove: TrovesByAccountQueryType["troves"][number], ): PositionLoanCommitted { From 9d939f36613803f71867bcab8ea42dcf4ede2c48 Mon Sep 17 00:00:00 2001 From: Pierre Bertet Date: Fri, 20 Dec 2024 16:46:56 +0000 Subject: [PATCH 26/29] Publish known initiatives with the app --- frontend/app/next.config.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/frontend/app/next.config.js b/frontend/app/next.config.js index 6fcf7a779..754fbe729 100644 --- a/frontend/app/next.config.js +++ b/frontend/app/next.config.js @@ -29,4 +29,17 @@ export default withBundleAnalyzer({ eslint: { ignoreDuringBuilds: true, }, + async headers() { + return [ + { + source: "/known-initiatives/:path*", + headers: [ + { key: "Access-Control-Allow-Credentials", value: "true" }, + { key: "Access-Control-Allow-Origin", value: "*" }, + { key: "Access-Control-Allow-Methods", value: "GET,OPTIONS" }, + { key: "Access-Control-Allow-Headers", value: "Origin, Content-Type, Accept" }, + ], + }, + ]; + }, }); From 9374cc81f9a54e3719646a2effe57052a1e93156 Mon Sep 17 00:00:00 2001 From: Pierre Bertet Date: Fri, 20 Dec 2024 16:47:39 +0000 Subject: [PATCH 27/29] Env: add KNOWN_INITIATIVES_URL --- frontend/app/src/env.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/app/src/env.ts b/frontend/app/src/env.ts index 48d2b57ef..cfe6e7c54 100644 --- a/frontend/app/src/env.ts +++ b/frontend/app/src/env.ts @@ -53,7 +53,6 @@ export const EnvSchema = v.pipe( CHAIN_CONTRACT_ENS_RESOLVER: v.optional(vEnvAddressAndBlock()), CHAIN_CONTRACT_MULTICALL: vAddress(), COMMIT_HASH: v.string(), - SUBGRAPH_URL: v.string(), COINGECKO_API_KEY: v.pipe( v.optional(v.string(), ""), v.rawTransform(({ dataset, addIssue, NEVER }) => { @@ -81,6 +80,8 @@ export const EnvSchema = v.pipe( v.transform((value) => value.trim()), ), INITIATIVE_UNI_V4_DONATIONS: vAddress(), + KNOWN_INITIATIVES_URL: v.optional(v.pipe(v.string(), v.url())), + SUBGRAPH_URL: v.pipe(v.string(), v.url()), VERCEL_ANALYTICS: v.optional(vEnvFlag(), "false"), WALLET_CONNECT_PROJECT_ID: v.string(), @@ -219,6 +220,7 @@ const parsedEnv = v.safeParse(EnvSchema, { DEMO_MODE: process.env.NEXT_PUBLIC_DEMO_MODE, DEPLOYMENT_FLAVOR: process.env.NEXT_PUBLIC_DEPLOYMENT_FLAVOR, INITIATIVE_UNI_V4_DONATIONS: process.env.NEXT_PUBLIC_INITIATIVE_UNI_V4_DONATIONS, + KNOWN_INITIATIVES_URL: process.env.NEXT_PUBLIC_KNOWN_INITIATIVES_URL, SUBGRAPH_URL: process.env.NEXT_PUBLIC_SUBGRAPH_URL, VERCEL_ANALYTICS: process.env.NEXT_PUBLIC_VERCEL_ANALYTICS, WALLET_CONNECT_PROJECT_ID: process.env.NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID, @@ -312,6 +314,7 @@ export const { DEMO_MODE, DEPLOYMENT_FLAVOR, INITIATIVE_UNI_V4_DONATIONS, + KNOWN_INITIATIVES_URL, SUBGRAPH_URL, VERCEL_ANALYTICS, WALLET_CONNECT_PROJECT_ID, From a314b6d98180d91a5f3bb6c80c63f94dca83c6db Mon Sep 17 00:00:00 2001 From: Pierre Bertet Date: Fri, 20 Dec 2024 16:48:11 +0000 Subject: [PATCH 28/29] Update graphql files --- frontend/app/src/graphql/gql.ts | 5 + frontend/app/src/graphql/graphql.ts | 148 +++++++++++----------------- 2 files changed, 63 insertions(+), 90 deletions(-) diff --git a/frontend/app/src/graphql/gql.ts b/frontend/app/src/graphql/gql.ts index 5b2ddddf4..d36af04c8 100644 --- a/frontend/app/src/graphql/gql.ts +++ b/frontend/app/src/graphql/gql.ts @@ -27,6 +27,7 @@ const documents = { "\n query StabilityPoolEpochScale($id: ID!) {\n stabilityPoolEpochScale(id: $id) {\n id\n B\n S\n }\n }\n": types.StabilityPoolEpochScaleDocument, "\n query InterestBatch($id: ID!) {\n interestBatch(id: $id) {\n collateral {\n collIndex\n }\n batchManager\n debt\n coll\n annualInterestRate\n annualManagementFee\n }\n }\n": types.InterestBatchDocument, "\n query InterestRateBrackets($collId: String!) {\n interestRateBrackets(where: { collateral: $collId }, orderBy: rate) {\n rate\n totalDebt\n }\n }\n": types.InterestRateBracketsDocument, + "\n query GovernanceInitiatives {\n governanceInitiatives {\n id\n }\n }\n": types.GovernanceInitiativesDocument, }; /** @@ -77,6 +78,10 @@ export function graphql(source: "\n query InterestBatch($id: ID!) {\n intere * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function graphql(source: "\n query InterestRateBrackets($collId: String!) {\n interestRateBrackets(where: { collateral: $collId }, orderBy: rate) {\n rate\n totalDebt\n }\n }\n"): typeof import('./graphql').InterestRateBracketsDocument; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n query GovernanceInitiatives {\n governanceInitiatives {\n id\n }\n }\n"): typeof import('./graphql').GovernanceInitiativesDocument; export function graphql(source: string) { diff --git a/frontend/app/src/graphql/graphql.ts b/frontend/app/src/graphql/graphql.ts index 86437ac3b..35ce37962 100644 --- a/frontend/app/src/graphql/graphql.ts +++ b/frontend/app/src/graphql/graphql.ts @@ -394,7 +394,7 @@ export enum Collateral_OrderBy { export type GovernanceAllocation = { __typename?: 'GovernanceAllocation'; - atEpoch: Scalars['Int']['output']; + atEpoch: Scalars['BigInt']['output']; id: Scalars['ID']['output']; initiative: GovernanceInitiative; user: GovernanceUser; @@ -406,14 +406,14 @@ export type GovernanceAllocation_Filter = { /** Filter for the block changed event. */ _change_block?: InputMaybe; and?: InputMaybe>>; - atEpoch?: InputMaybe; - atEpoch_gt?: InputMaybe; - atEpoch_gte?: InputMaybe; - atEpoch_in?: InputMaybe>; - atEpoch_lt?: InputMaybe; - atEpoch_lte?: InputMaybe; - atEpoch_not?: InputMaybe; - atEpoch_not_in?: InputMaybe>; + atEpoch?: InputMaybe; + atEpoch_gt?: InputMaybe; + atEpoch_gte?: InputMaybe; + atEpoch_in?: InputMaybe>; + atEpoch_lt?: InputMaybe; + atEpoch_lte?: InputMaybe; + atEpoch_not?: InputMaybe; + atEpoch_not_in?: InputMaybe>; id?: InputMaybe; id_gt?: InputMaybe; id_gte?: InputMaybe; @@ -494,14 +494,10 @@ export enum GovernanceAllocation_OrderBy { InitiativeRegisteredAt = 'initiative__registeredAt', InitiativeRegisteredAtEpoch = 'initiative__registeredAtEpoch', InitiativeRegistrant = 'initiative__registrant', - InitiativeTotalBoldClaimed = 'initiative__totalBoldClaimed', - InitiativeTotalVetos = 'initiative__totalVetos', - InitiativeTotalVotes = 'initiative__totalVotes', InitiativeUnregisteredAt = 'initiative__unregisteredAt', InitiativeUnregisteredAtEpoch = 'initiative__unregisteredAtEpoch', User = 'user', UserAllocatedLqty = 'user__allocatedLQTY', - UserAverageStakingTimestamp = 'user__averageStakingTimestamp', UserId = 'user__id', VetoLqty = 'vetoLQTY', VoteLqty = 'voteLQTY' @@ -510,17 +506,14 @@ export enum GovernanceAllocation_OrderBy { export type GovernanceInitiative = { __typename?: 'GovernanceInitiative'; id: Scalars['ID']['output']; - lastClaimEpoch?: Maybe; - lastVoteSnapshotEpoch?: Maybe; + lastClaimEpoch?: Maybe; + lastVoteSnapshotEpoch?: Maybe; lastVoteSnapshotVotes?: Maybe; registeredAt: Scalars['BigInt']['output']; - registeredAtEpoch: Scalars['Int']['output']; + registeredAtEpoch: Scalars['BigInt']['output']; registrant: Scalars['Bytes']['output']; - totalBoldClaimed: Scalars['BigInt']['output']; - totalVetos: Scalars['BigInt']['output']; - totalVotes: Scalars['BigInt']['output']; unregisteredAt?: Maybe; - unregisteredAtEpoch?: Maybe; + unregisteredAtEpoch?: Maybe; }; export type GovernanceInitiative_Filter = { @@ -535,22 +528,22 @@ export type GovernanceInitiative_Filter = { id_lte?: InputMaybe; id_not?: InputMaybe; id_not_in?: InputMaybe>; - lastClaimEpoch?: InputMaybe; - lastClaimEpoch_gt?: InputMaybe; - lastClaimEpoch_gte?: InputMaybe; - lastClaimEpoch_in?: InputMaybe>; - lastClaimEpoch_lt?: InputMaybe; - lastClaimEpoch_lte?: InputMaybe; - lastClaimEpoch_not?: InputMaybe; - lastClaimEpoch_not_in?: InputMaybe>; - lastVoteSnapshotEpoch?: InputMaybe; - lastVoteSnapshotEpoch_gt?: InputMaybe; - lastVoteSnapshotEpoch_gte?: InputMaybe; - lastVoteSnapshotEpoch_in?: InputMaybe>; - lastVoteSnapshotEpoch_lt?: InputMaybe; - lastVoteSnapshotEpoch_lte?: InputMaybe; - lastVoteSnapshotEpoch_not?: InputMaybe; - lastVoteSnapshotEpoch_not_in?: InputMaybe>; + lastClaimEpoch?: InputMaybe; + lastClaimEpoch_gt?: InputMaybe; + lastClaimEpoch_gte?: InputMaybe; + lastClaimEpoch_in?: InputMaybe>; + lastClaimEpoch_lt?: InputMaybe; + lastClaimEpoch_lte?: InputMaybe; + lastClaimEpoch_not?: InputMaybe; + lastClaimEpoch_not_in?: InputMaybe>; + lastVoteSnapshotEpoch?: InputMaybe; + lastVoteSnapshotEpoch_gt?: InputMaybe; + lastVoteSnapshotEpoch_gte?: InputMaybe; + lastVoteSnapshotEpoch_in?: InputMaybe>; + lastVoteSnapshotEpoch_lt?: InputMaybe; + lastVoteSnapshotEpoch_lte?: InputMaybe; + lastVoteSnapshotEpoch_not?: InputMaybe; + lastVoteSnapshotEpoch_not_in?: InputMaybe>; lastVoteSnapshotVotes?: InputMaybe; lastVoteSnapshotVotes_gt?: InputMaybe; lastVoteSnapshotVotes_gte?: InputMaybe; @@ -561,14 +554,14 @@ export type GovernanceInitiative_Filter = { lastVoteSnapshotVotes_not_in?: InputMaybe>; or?: InputMaybe>>; registeredAt?: InputMaybe; - registeredAtEpoch?: InputMaybe; - registeredAtEpoch_gt?: InputMaybe; - registeredAtEpoch_gte?: InputMaybe; - registeredAtEpoch_in?: InputMaybe>; - registeredAtEpoch_lt?: InputMaybe; - registeredAtEpoch_lte?: InputMaybe; - registeredAtEpoch_not?: InputMaybe; - registeredAtEpoch_not_in?: InputMaybe>; + registeredAtEpoch?: InputMaybe; + registeredAtEpoch_gt?: InputMaybe; + registeredAtEpoch_gte?: InputMaybe; + registeredAtEpoch_in?: InputMaybe>; + registeredAtEpoch_lt?: InputMaybe; + registeredAtEpoch_lte?: InputMaybe; + registeredAtEpoch_not?: InputMaybe; + registeredAtEpoch_not_in?: InputMaybe>; registeredAt_gt?: InputMaybe; registeredAt_gte?: InputMaybe; registeredAt_in?: InputMaybe>; @@ -586,39 +579,15 @@ export type GovernanceInitiative_Filter = { registrant_not?: InputMaybe; registrant_not_contains?: InputMaybe; registrant_not_in?: InputMaybe>; - totalBoldClaimed?: InputMaybe; - totalBoldClaimed_gt?: InputMaybe; - totalBoldClaimed_gte?: InputMaybe; - totalBoldClaimed_in?: InputMaybe>; - totalBoldClaimed_lt?: InputMaybe; - totalBoldClaimed_lte?: InputMaybe; - totalBoldClaimed_not?: InputMaybe; - totalBoldClaimed_not_in?: InputMaybe>; - totalVetos?: InputMaybe; - totalVetos_gt?: InputMaybe; - totalVetos_gte?: InputMaybe; - totalVetos_in?: InputMaybe>; - totalVetos_lt?: InputMaybe; - totalVetos_lte?: InputMaybe; - totalVetos_not?: InputMaybe; - totalVetos_not_in?: InputMaybe>; - totalVotes?: InputMaybe; - totalVotes_gt?: InputMaybe; - totalVotes_gte?: InputMaybe; - totalVotes_in?: InputMaybe>; - totalVotes_lt?: InputMaybe; - totalVotes_lte?: InputMaybe; - totalVotes_not?: InputMaybe; - totalVotes_not_in?: InputMaybe>; unregisteredAt?: InputMaybe; - unregisteredAtEpoch?: InputMaybe; - unregisteredAtEpoch_gt?: InputMaybe; - unregisteredAtEpoch_gte?: InputMaybe; - unregisteredAtEpoch_in?: InputMaybe>; - unregisteredAtEpoch_lt?: InputMaybe; - unregisteredAtEpoch_lte?: InputMaybe; - unregisteredAtEpoch_not?: InputMaybe; - unregisteredAtEpoch_not_in?: InputMaybe>; + unregisteredAtEpoch?: InputMaybe; + unregisteredAtEpoch_gt?: InputMaybe; + unregisteredAtEpoch_gte?: InputMaybe; + unregisteredAtEpoch_in?: InputMaybe>; + unregisteredAtEpoch_lt?: InputMaybe; + unregisteredAtEpoch_lte?: InputMaybe; + unregisteredAtEpoch_not?: InputMaybe; + unregisteredAtEpoch_not_in?: InputMaybe>; unregisteredAt_gt?: InputMaybe; unregisteredAt_gte?: InputMaybe; unregisteredAt_in?: InputMaybe>; @@ -636,9 +605,6 @@ export enum GovernanceInitiative_OrderBy { RegisteredAt = 'registeredAt', RegisteredAtEpoch = 'registeredAtEpoch', Registrant = 'registrant', - TotalBoldClaimed = 'totalBoldClaimed', - TotalVetos = 'totalVetos', - TotalVotes = 'totalVotes', UnregisteredAt = 'unregisteredAt', UnregisteredAtEpoch = 'unregisteredAtEpoch' } @@ -691,7 +657,6 @@ export type GovernanceUser = { __typename?: 'GovernanceUser'; allocatedLQTY: Scalars['BigInt']['output']; allocations: Array; - averageStakingTimestamp: Scalars['BigInt']['output']; id: Scalars['ID']['output']; }; @@ -717,14 +682,6 @@ export type GovernanceUser_Filter = { allocatedLQTY_not_in?: InputMaybe>; allocations_?: InputMaybe; and?: InputMaybe>>; - averageStakingTimestamp?: InputMaybe; - averageStakingTimestamp_gt?: InputMaybe; - averageStakingTimestamp_gte?: InputMaybe; - averageStakingTimestamp_in?: InputMaybe>; - averageStakingTimestamp_lt?: InputMaybe; - averageStakingTimestamp_lte?: InputMaybe; - averageStakingTimestamp_not?: InputMaybe; - averageStakingTimestamp_not_in?: InputMaybe>; id?: InputMaybe; id_gt?: InputMaybe; id_gte?: InputMaybe; @@ -739,7 +696,6 @@ export type GovernanceUser_Filter = { export enum GovernanceUser_OrderBy { AllocatedLqty = 'allocatedLQTY', Allocations = 'allocations', - AverageStakingTimestamp = 'averageStakingTimestamp', Id = 'id' } @@ -2283,6 +2239,11 @@ export type InterestRateBracketsQueryVariables = Exact<{ export type InterestRateBracketsQuery = { __typename?: 'Query', interestRateBrackets: Array<{ __typename?: 'InterestRateBracket', rate: bigint, totalDebt: bigint }> }; +export type GovernanceInitiativesQueryVariables = Exact<{ [key: string]: never; }>; + + +export type GovernanceInitiativesQuery = { __typename?: 'Query', governanceInitiatives: Array<{ __typename?: 'GovernanceInitiative', id: string }> }; + export class TypedDocumentString extends String implements DocumentTypeDecoration @@ -2507,4 +2468,11 @@ export const InterestRateBracketsDocument = new TypedDocumentString(` totalDebt } } - `) as unknown as TypedDocumentString; \ No newline at end of file + `) as unknown as TypedDocumentString; +export const GovernanceInitiativesDocument = new TypedDocumentString(` + query GovernanceInitiatives { + governanceInitiatives { + id + } +} + `) as unknown as TypedDocumentString; \ No newline at end of file From 97fc2ae0e55dba517b7cdcf8e6ca8d19d8c5ffd7 Mon Sep 17 00:00:00 2001 From: Pierre Bertet Date: Fri, 20 Dec 2024 16:50:18 +0000 Subject: [PATCH 29/29] useInitiatives() + useKnownInitiatives() --- frontend/app/src/liquity-governance.ts | 92 +++++++++++++++----------- 1 file changed, 52 insertions(+), 40 deletions(-) diff --git a/frontend/app/src/liquity-governance.ts b/frontend/app/src/liquity-governance.ts index ffbd99c10..3c7fade9c 100644 --- a/frontend/app/src/liquity-governance.ts +++ b/frontend/app/src/liquity-governance.ts @@ -1,11 +1,13 @@ import type { Address, Initiative } from "@/src/types"; -import type { UseQueryResult } from "@tanstack/react-query"; import type { Config as WagmiConfig } from "wagmi"; import { GOVERNANCE_EPOCH_DURATION, GOVERNANCE_EPOCH_VOTING_CUTOFF } from "@/src/constants"; import { getProtocolContract } from "@/src/contracts"; -import { INITIATIVE_UNI_V4_DONATIONS } from "@/src/env"; +import { KNOWN_INITIATIVES_URL } from "@/src/env"; +import { useGovernanceInitiatives } from "@/src/subgraph-hooks"; +import { vAddress } from "@/src/valibot-utils"; import { useQuery } from "@tanstack/react-query"; +import * as v from "valibot"; import { useReadContract, useReadContracts } from "wagmi"; import { readContract } from "wagmi/actions"; @@ -118,7 +120,6 @@ export function useUserStates(account: Address | null) { }), }, }); - return userStates; } @@ -141,6 +142,54 @@ export async function getUserStates( }; } +export function useInitiatives() { + const initiatives = useGovernanceInitiatives(); + const knownInitiatives = useKnownInitiatives(); + return { + ...initiatives, + data: initiatives.data && knownInitiatives.data + ? initiatives.data.map((address): Initiative => { + const knownInitiative = knownInitiatives.data[address]; + return { + address, + name: knownInitiative?.name ?? null, + pairVolume: null, + protocol: knownInitiative?.group ?? null, + tvl: null, + votesDistribution: null, + }; + }) + : null, + }; +} + +const KnownInitiativesSchema = v.record( + v.pipe( + vAddress(), + v.transform((address) => address.toLowerCase()), + ), + v.object({ + name: v.string(), + group: v.string(), + }), +); + +export function useKnownInitiatives() { + return useQuery({ + queryKey: ["knownInitiatives"], + queryFn: async () => { + if (KNOWN_INITIATIVES_URL === undefined) { + throw new Error("KNOWN_INITIATIVES_URL is not defined"); + } + const response = await fetch(KNOWN_INITIATIVES_URL, { + headers: { "Content-Type": "application/json" }, + }); + return v.parse(KnownInitiativesSchema, await response.json()); + }, + enabled: KNOWN_INITIATIVES_URL !== undefined, + }); +} + // const INITIATIVES_STATIC: Initiative[] = [ // { // address: "0x0000000000000000000000000000000000000001", @@ -207,40 +256,3 @@ export async function getUserStates( // votesDistribution: dn.from(0, 18), // }, // ]; - -const INITIATIVES_STATIC: Initiative[] = [ - { - name: "UNI V4 donations", - protocol: "Uniswap V4", - address: INITIATIVE_UNI_V4_DONATIONS, - tvl: null, - pairVolume: null, - votesDistribution: null, - }, -]; - -export function useInitiatives(): UseQueryResult { - return useQuery({ - queryKey: ["initiatives"], - queryFn: () => { - return INITIATIVES_STATIC; - }, - }); -} - -// // export function useRegisteredInitiatives() { -// // const Governance = getProtocolContract("Governance"); - -// // return useReadContract({ -// // ...Governance, -// // functionName: "registeredInitiatives", -// // query: { -// // refetchInterval: DATA_REFRESH_INTERVAL, -// // select: (data: Record) => { -// // return Object.entries(data) -// // .filter(([_, epoch]) => epoch > 0) -// // .map(([address]) => address as Address); -// // }, -// // }, -// // }); -// // }
-
-
- {initiative.name} -
-
- {initiative.protocol} -
-
-
-
- {fmtnum(initiative.tvl, "compact")} -
-
-
- {fmtnum(initiative.pairVolume, "compact")} -
-
-
- {fmtnum(initiative.votesDistribution, 2, 100)}% -
-
-
- { - handleVoteInputChange(initiative.id, value); - }} - onVote={(vote) => handleVote(initiative.id, vote)} - /> -
-
+
+
+ {initiative.name} +
+
+ {initiative.protocol} +
+
+
+ + +
+ +
+
+
+ +
+
+
+ { + onVoteInputChange(initiative.address, value); + }} + onVote={(vote) => { + onVote(initiative.address, vote); + }} + /> +
+