From 474170f62aa34cc7fb4a46dec40ea56e74cd1518 Mon Sep 17 00:00:00 2001 From: Pierre Bertet Date: Mon, 20 Jan 2025 13:27:14 +0000 Subject: [PATCH] App: preferred approval method (#714) - Add `preferredApproveMethod` to the stored state (`"permit"`, `"approve-amount"` or `"approve-infinite"`). - Add `clearError()` to the object returned by `useTransactionFlow()` (this is used to clear the error when users change their preferred approve method). - `TransactionStatus` now supports an option to show the preferred approve method selector, which can include the permit or not. This option allows `TransactionStatus` to provide a permission mode, replacing `PermissionStatus`. - The tx link is now fixed for Safe accounts and point to the Safe app. - Add approval method selector to the stakeDeposit tx flow: - Group the different approve steps into one, handling all the approval methods (including infinite which is new). - Allow users to change their preferred approval method. - When a Safe account is detected, only allow tx-based approvals. - Add approval method selector to non-permit flows: - Open borrow - Update borrow - Open leverage - Update leverage - Close loan - Dropdown: fully customizable button + popup position. --- .../TransactionsScreen/TransactionStatus.tsx | 199 ++++++++++++++++-- frontend/app/src/services/StoredState.tsx | 6 + frontend/app/src/services/TransactionFlow.tsx | 26 ++- .../app/src/tx-flows/closeLoanPosition.tsx | 22 +- .../app/src/tx-flows/openBorrowPosition.tsx | 22 +- .../app/src/tx-flows/openLeveragePosition.tsx | 22 +- frontend/app/src/tx-flows/stakeDeposit.tsx | 126 +++++------ .../app/src/tx-flows/updateBorrowPosition.tsx | 47 ++++- .../src/tx-flows/updateLeveragePosition.tsx | 21 +- frontend/uikit/src/Dropdown/Dropdown.tsx | 153 ++++++++------ 10 files changed, 458 insertions(+), 186 deletions(-) diff --git a/frontend/app/src/screens/TransactionsScreen/TransactionStatus.tsx b/frontend/app/src/screens/TransactionsScreen/TransactionStatus.tsx index b7ec65696..f79809787 100644 --- a/frontend/app/src/screens/TransactionsScreen/TransactionStatus.tsx +++ b/frontend/app/src/screens/TransactionsScreen/TransactionStatus.tsx @@ -2,23 +2,95 @@ import type { FlowStepDeclaration } from "@/src/services/TransactionFlow"; import type { ComponentPropsWithoutRef } from "react"; import { CHAIN_BLOCK_EXPLORER } from "@/src/env"; -import { AnchorTextButton, TextButton } from "@liquity2/uikit"; +import { useAccount } from "@/src/services/Ethereum"; +import { useStoredState } from "@/src/services/StoredState"; +import { useTransactionFlow } from "@/src/services/TransactionFlow"; +import { css, cx } from "@/styled-system/css"; +import { AnchorTextButton, Dropdown, IconChevronDown, TextButton } from "@liquity2/uikit"; import { match } from "ts-pattern"; export function TransactionStatus( - props: ComponentPropsWithoutRef, + props: ComponentPropsWithoutRef & { + approval?: "all" | "approve-only" | null; + }, ) { + const { preferredApproveMethod } = useStoredState(); return ( - <> +
+ {props.approval + && (props.status === "idle" || props.status === "error") + && } + +
+ ); +} + +function TxLink({ txHash }: { txHash: string }) { + const account = useAccount(); + return ( + + ); +} + +function StatusText( + props: ComponentPropsWithoutRef & { + type: "transaction" | "permission"; + }, +) { + if (props.type === "permission") { + 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: "confirmed" }, () => ( + "The permission has been signed." + )) + .with({ status: "error" }, () => ( + "An error occurred. Please try again." + )) + .otherwise(() => null)} +
+ ); + } + return ( +
{match(props) .with({ status: "idle" }, () => ( - <> - This action will open your wallet to sign the transaction. - + "This action will open your wallet to sign the transaction." )) .with({ status: "awaiting-commit" }, ({ onRetry }) => ( <> - Please sign the transaction in your wallet. + Please sign the transaction in your wallet.{" "} + )) .with({ status: "awaiting-verify" }, ({ artifact: txHash }) => ( @@ -32,21 +104,114 @@ export function TransactionStatus( )) .with({ status: "error" }, () => ( - <> - An error occurred. Please try again. - + "An error occurred. Please try again." )) .exhaustive()} - +
); } -function TxLink({ txHash }: { txHash: string }) { +const approveMethodOptions = [{ + method: "permit", + label: "Signature", + secondary: "You will be asked to sign a message. Instant and no gas fees. Recommended.", +}, { + method: "approve-amount", + label: "Transaction", + secondary: "You will be asked to approve the desired amount in your wallet via a transaction.", +}, { + method: "approve-infinite", + label: "Transaction (infinite)", + secondary: "You will be asked to approve an infinite amount in your wallet via a transaction.", +}] as const; + +function PreferredApproveMethodSelector({ + selector, +}: { + selector: "all" | "approve-only"; +}) { + const { preferredApproveMethod, setState } = useStoredState(); + + const options = approveMethodOptions.slice( + selector === "approve-only" ? 1 : 0, + ); + + const currentOption = options.find(({ method }) => ( + method === preferredApproveMethod + )) ?? (options[0] as typeof options[0]); + + const { clearError } = useTransactionFlow(); + return ( - +
+
Approve via
+ ( +
+
{currentOption.label}
+
+ +
+
+ )} + menuWidth={300} + menuPlacement="top-end" + items={options} + selected={options.indexOf(currentOption)} + floatingUpdater={({ computePosition, referenceElement, floatingElement }) => { + return async () => { + const container = referenceElement.closest(".tx-status"); + if (!container) { + return; + } + + const position = await computePosition(referenceElement, floatingElement); + const containerRect = container.getBoundingClientRect(); + const x = containerRect.left + containerRect.width / 2 + - floatingElement.offsetWidth / 2; + const y = position.y - floatingElement.offsetHeight - 32; + + floatingElement.style.left = `${x}px`; + floatingElement.style.top = `${y}px`; + }; + }} + onSelect={(index) => { + const method = options[index]?.method; + if (method) { + setState((state) => ({ + ...state, + preferredApproveMethod: method, + })); + clearError(); + } + }} + /> +
); } diff --git a/frontend/app/src/services/StoredState.tsx b/frontend/app/src/services/StoredState.tsx index c0ff86199..a3477f3d3 100644 --- a/frontend/app/src/services/StoredState.tsx +++ b/frontend/app/src/services/StoredState.tsx @@ -17,12 +17,18 @@ export const StoredStateSchema = v.object({ v.literal("multiply"), ]), ), + preferredApproveMethod: v.union([ + v.literal("permit"), + v.literal("approve-amount"), + v.literal("approve-infinite"), + ]), }); type StoredStateType = v.InferOutput; const defaultState: StoredStateType = { loanModes: {}, + preferredApproveMethod: "permit", }; type StoredStateContext = StoredStateType & { diff --git a/frontend/app/src/services/TransactionFlow.tsx b/frontend/app/src/services/TransactionFlow.tsx index f841ab4f2..00456541b 100644 --- a/frontend/app/src/services/TransactionFlow.tsx +++ b/frontend/app/src/services/TransactionFlow.tsx @@ -160,6 +160,7 @@ export type FlowParams = account: Address | null; contracts: Contracts; isSafe: boolean; + preferredApproveMethod: "permit" | "approve-amount" | "approve-infinite"; request: FlowRequest; steps: FlowStep[] | null; storedState: ReturnType; @@ -205,6 +206,7 @@ export function getFlowDeclaration( type TransactionFlowContext< FlowRequest extends FlowRequestMap[keyof FlowRequestMap] = FlowRequestMap[keyof FlowRequestMap], > = { + clearError: () => void; currentStep: FlowStep | null; currentStepIndex: number; discard: () => void; @@ -216,6 +218,7 @@ type TransactionFlowContext< }; const TransactionFlowContext = createContext({ + clearError: noop, currentStep: null, currentStepIndex: -1, discard: noop, @@ -237,13 +240,14 @@ export function TransactionFlow({ const wagmiConfig = useWagmiConfig(); const { + clearError, + commit, currentStep, currentStepIndex, discardFlow, flow, flowDeclaration, startFlow, - commit, } = useFlowManager(account.address ?? null, account.safeStatus !== null); const start: TransactionFlowContext["start"] = useCallback((request) => { @@ -258,11 +262,11 @@ export function TransactionFlow({ return ( {children} @@ -311,6 +317,7 @@ function useSteps( account: account.address, contracts: getContracts(), isSafe: account.safeStatus !== null, + preferredApproveMethod: storedState.preferredApproveMethod, request: flow.request, steps: flow.steps, storedState, @@ -360,6 +367,7 @@ function useFlowManager(account: Address | null, isSafe: boolean = false) { account, contracts: getContracts(), isSafe, + preferredApproveMethod: storedState.preferredApproveMethod, request: flow.request, steps: flow.steps, storedState, @@ -486,6 +494,17 @@ function useFlowManager(account: Address | null, isSafe: boolean = false) { await startStep(stepDef, currentStepIndex); }, [flow, flowDeclaration, currentStep, currentStepIndex, startStep]); + const clearError = useCallback(() => { + if (!flow?.steps || currentStepIndex === -1) return; + if (flow.steps[currentStepIndex]?.status === "error") { + updateFlowStep(currentStepIndex, { + status: "idle", + artifact: null, + error: null, + }); + } + }, [flow, currentStepIndex, updateFlowStep]); + const isFlowComplete = useMemo( () => flow?.steps?.at(-1)?.status === "confirmed", [flow], @@ -512,6 +531,7 @@ function useFlowManager(account: Address | null, isSafe: boolean = false) { useResetQueriesOnPathChange(isFlowComplete); return { + clearError, currentStep, currentStepIndex, discardFlow, diff --git a/frontend/app/src/tx-flows/closeLoanPosition.tsx b/frontend/app/src/tx-flows/closeLoanPosition.tsx index 9788a8cd5..73f8be429 100644 --- a/frontend/app/src/tx-flows/closeLoanPosition.tsx +++ b/frontend/app/src/tx-flows/closeLoanPosition.tsx @@ -14,6 +14,7 @@ import { sleep } from "@/src/utils"; import { vPositionLoanCommited } from "@/src/valibot-utils"; import * as dn from "dnum"; import * as v from "valibot"; +import { maxUint256 } from "viem"; import { readContract, writeContract } from "wagmi/actions"; import { createRequestSchema, verifyTransaction } from "./shared"; @@ -107,9 +108,18 @@ export const closeLoanPosition: FlowDeclaration = { steps: { approveBold: { name: () => "Approve BOLD", - Status: TransactionStatus, - - async commit({ contracts, request, wagmiConfig }) { + Status: (props) => ( + + ), + async commit({ + contracts, + request, + wagmiConfig, + preferredApproveMethod, + }) { const { loan } = request; const coll = contracts.collaterals[loan.collIndex]; if (!coll) { @@ -130,12 +140,12 @@ export const closeLoanPosition: FlowDeclaration = { functionName: "approve", args: [ Zapper.address, - // TODO: calculate the amount to approve in a more precise way - dn.mul([entireDebt, 18], 1.1)[0], + preferredApproveMethod === "approve-infinite" + ? maxUint256 // infinite approval + : dn.mul([entireDebt, 18], 1.1)[0], // exact amount (TODO: better estimate) ], }); }, - async verify({ wagmiConfig, isSafe }, hash) { await verifyTransaction(wagmiConfig, hash, isSafe); }, diff --git a/frontend/app/src/tx-flows/openBorrowPosition.tsx b/frontend/app/src/tx-flows/openBorrowPosition.tsx index 91414a1f6..5dd79c4fe 100644 --- a/frontend/app/src/tx-flows/openBorrowPosition.tsx +++ b/frontend/app/src/tx-flows/openBorrowPosition.tsx @@ -15,7 +15,7 @@ import { vAddress, vCollIndex, vDnum } from "@/src/valibot-utils"; import { ADDRESS_ZERO, shortenAddress } from "@liquity2/uikit"; import * as dn from "dnum"; import * as v from "valibot"; -import { parseEventLogs } from "viem"; +import { maxUint256, parseEventLogs } from "viem"; import { readContract, writeContract } from "wagmi/actions"; import { createRequestSchema, verifyTransaction } from "./shared"; @@ -179,9 +179,18 @@ export const openBorrowPosition: FlowDeclaration = { } return `Approve ${collateral.symbol}`; }, - Status: TransactionStatus, - - async commit({ contracts, request, wagmiConfig }) { + Status: (props) => ( + + ), + async commit({ + contracts, + request, + wagmiConfig, + preferredApproveMethod, + }) { const collateral = contracts.collaterals[request.collIndex]; if (!collateral) { throw new Error(`Invalid collateral index: ${request.collIndex}`); @@ -193,11 +202,12 @@ export const openBorrowPosition: FlowDeclaration = { functionName: "approve", args: [ LeverageLSTZapper.address, - request.collAmount[0], + preferredApproveMethod === "approve-infinite" + ? maxUint256 // infinite approval + : request.collAmount[0], // exact amount ], }); }, - async verify({ wagmiConfig, isSafe }, hash) { await verifyTransaction(wagmiConfig, hash, isSafe); }, diff --git a/frontend/app/src/tx-flows/openLeveragePosition.tsx b/frontend/app/src/tx-flows/openLeveragePosition.tsx index 3daa7cca3..04ea7f158 100644 --- a/frontend/app/src/tx-flows/openLeveragePosition.tsx +++ b/frontend/app/src/tx-flows/openLeveragePosition.tsx @@ -17,7 +17,7 @@ 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 { maxUint256, parseEventLogs } from "viem"; import { readContract, writeContract } from "wagmi/actions"; import { createRequestSchema, verifyTransaction } from "./shared"; @@ -130,9 +130,18 @@ export const openLeveragePosition: FlowDeclaration const collToken = getCollToken(request.loan.collIndex); return `Approve ${collToken?.name ?? ""}`; }, - Status: TransactionStatus, - - async commit({ contracts, request, wagmiConfig }) { + Status: (props) => ( + + ), + async commit({ + contracts, + request, + wagmiConfig, + preferredApproveMethod, + }) { const { loan } = request; const initialDeposit = dn.div(loan.deposit, request.leverageFactor); const collateral = contracts.collaterals[loan.collIndex]; @@ -146,11 +155,12 @@ export const openLeveragePosition: FlowDeclaration functionName: "approve", args: [ LeverageLSTZapper.address, - initialDeposit[0], + preferredApproveMethod === "approve-infinite" + ? maxUint256 // infinite approval + : initialDeposit[0], // exact amount ], }); }, - async verify({ wagmiConfig, isSafe }, hash) { await verifyTransaction(wagmiConfig, hash, isSafe); }, diff --git a/frontend/app/src/tx-flows/stakeDeposit.tsx b/frontend/app/src/tx-flows/stakeDeposit.tsx index 8fc6e7ce4..365b11a6c 100644 --- a/frontend/app/src/tx-flows/stakeDeposit.tsx +++ b/frontend/app/src/tx-flows/stakeDeposit.tsx @@ -5,14 +5,15 @@ 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 { useAccount } from "@/src/services/Ethereum"; import { usePrice } from "@/src/services/Prices"; import { GovernanceUserAllocated, graphQuery } from "@/src/subgraph-queries"; import { vDnum, vPositionStake } from "@/src/valibot-utils"; import * as dn from "dnum"; import * as v from "valibot"; +import { maxUint256 } from "viem"; import { getBytecode, readContract, writeContract } from "wagmi/actions"; import { createRequestSchema, verifyTransaction } from "./shared"; @@ -27,8 +28,6 @@ const RequestSchema = createRequestSchema( export type StakeDepositRequest = v.InferOutput; -const USE_PERMIT = true; - export const stakeDeposit: FlowDeclaration = { title: "Review & Send Transaction", @@ -67,115 +66,105 @@ export const stakeDeposit: FlowDeclaration = { deployUserProxy: { name: () => "Initialize Staking", 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, isSafe }, hash) { await verifyTransaction(wagmiConfig, hash, isSafe); }, }, - // reset allocations resetAllocations: { name: () => "Reset Allocations", Status: TransactionStatus, - async commit({ account, contracts, wagmiConfig }) { if (!account) { throw new Error("Account address is required"); } - const allocated = await graphQuery( GovernanceUserAllocated, { id: account.toLowerCase() }, ); - return writeContract(wagmiConfig, { ...contracts.Governance, functionName: "resetAllocations", args: [(allocated.governanceUser?.allocated ?? []) as Address[], true], }); }, - async verify({ wagmiConfig, isSafe }, hash) { await verifyTransaction(wagmiConfig, hash, isSafe); }, }, - // approve via permit - permitLqty: { + approve: { name: () => "Approve LQTY", - Status: PermissionStatus, - - async commit({ account, contracts, request, wagmiConfig }) { + Status: (props) => { + const account = useAccount(); + return ( + + ); + }, + async commit({ + account, + contracts, + request, + wagmiConfig, + preferredApproveMethod, + isSafe, + }) { if (!account) { throw new Error("Account address is required"); } - const { LqtyToken, Governance } = contracts; - const userProxyAddress = await readContract(wagmiConfig, { - ...Governance, + ...contracts.Governance, functionName: "deriveUserProxyAddress", args: [account], }); - const { deadline, ...permit } = await signPermit({ - token: LqtyToken.address, - spender: userProxyAddress, - value: request.lqtyAmount[0], - account, - wagmiConfig, - }); - - return JSON.stringify({ - ...permit, - deadline: Number(deadline), - userProxyAddress, - }); - }, - - async verify() { - // nothing to do - }, - }, - - // approve tx - approveLqty: { - name: () => "Approve LQTY", - Status: TransactionStatus, + // permit + if (preferredApproveMethod === "permit" && !isSafe) { + const { deadline, ...permit } = await signPermit({ + token: contracts.LqtyToken.address, + spender: userProxyAddress, + value: request.lqtyAmount[0], + account, + wagmiConfig, + }); - async commit({ account, contracts, request, wagmiConfig }) { - if (!account) { - throw new Error("Account address is required"); + return "permit:" + JSON.stringify({ + ...permit, + deadline: Number(deadline), + userProxyAddress, + }); } - const { LqtyToken, Governance } = contracts; - - const userProxyAddress = await readContract(wagmiConfig, { - ...Governance, - functionName: "deriveUserProxyAddress", - args: [account], - }); - + // approve() return writeContract(wagmiConfig, { - ...LqtyToken, + ...contracts.LqtyToken, functionName: "approve", - args: [userProxyAddress, request.lqtyAmount[0]], + args: [ + userProxyAddress, + preferredApproveMethod === "approve-infinite" + ? maxUint256 // infinite approval + : request.lqtyAmount[0], // exact amount + ], }); }, - async verify({ wagmiConfig, isSafe }, hash) { - await verifyTransaction(wagmiConfig, hash, isSafe); + if (!hash.startsWith("permit:")) { + await verifyTransaction(wagmiConfig, hash, isSafe); + } }, }, @@ -188,8 +177,11 @@ export const stakeDeposit: FlowDeclaration = { throw new Error("Account address is required"); } + const approveStep = steps?.find((step) => step.id === "approve"); + const isPermit = approveStep?.artifact?.startsWith("permit:") === true; + // deposit approved LQTY - if (!USE_PERMIT) { + if (!isPermit) { return writeContract(wagmiConfig, { ...contracts.Governance, functionName: "depositLQTY", @@ -198,8 +190,9 @@ export const stakeDeposit: FlowDeclaration = { } // deposit LQTY via permit - const permitStep = steps?.find((step) => step.id === "permitLqty"); - const { userProxyAddress, ...permit } = JSON.parse(permitStep?.artifact ?? ""); + const { userProxyAddress, ...permit } = JSON.parse( + approveStep?.artifact?.replace(/^permit:/, "") ?? "{}", + ); return writeContract(wagmiConfig, { ...contracts.Governance, @@ -262,15 +255,6 @@ 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, @@ -280,7 +264,7 @@ export const stakeDeposit: FlowDeclaration = { // approve if (dn.gt(request.lqtyAmount, dnum18(lqtyAllowance))) { - steps.push("approveLqty"); + steps.push("approve"); } // stake diff --git a/frontend/app/src/tx-flows/updateBorrowPosition.tsx b/frontend/app/src/tx-flows/updateBorrowPosition.tsx index a144ad244..ad1b64219 100644 --- a/frontend/app/src/tx-flows/updateBorrowPosition.tsx +++ b/frontend/app/src/tx-flows/updateBorrowPosition.tsx @@ -12,6 +12,7 @@ import { vDnum, vPositionLoanCommited } from "@/src/valibot-utils"; import * as dn from "dnum"; import { match, P } from "ts-pattern"; import * as v from "valibot"; +import { maxUint256 } from "viem"; import { readContract, writeContract } from "wagmi/actions"; import { createRequestSchema, verifyTransaction, verifyTroveUpdate } from "./shared"; @@ -134,9 +135,18 @@ export const updateBorrowPosition: FlowDeclaration steps: { approveBold: { name: () => "Approve BOLD", - Status: TransactionStatus, - - async commit({ contracts, request, wagmiConfig }) { + Status: (props) => ( + + ), + async commit({ + contracts, + request, + wagmiConfig, + preferredApproveMethod, + }) { const debtChange = getDebtChange(request.loan, request.prevLoan); const collateral = contracts.collaterals[request.loan.collIndex]; if (!collateral) { @@ -149,10 +159,14 @@ export const updateBorrowPosition: FlowDeclaration return writeContract(wagmiConfig, { ...contracts.BoldToken, functionName: "approve", - args: [Controller.address, dn.abs(debtChange)[0]], + args: [ + Controller.address, + preferredApproveMethod === "approve-infinite" + ? maxUint256 // infinite approval + : dn.abs(debtChange)[0], // exact amount + ], }); }, - async verify({ wagmiConfig, isSafe }, hash) { await verifyTransaction(wagmiConfig, hash, isSafe); }, @@ -166,9 +180,18 @@ export const updateBorrowPosition: FlowDeclaration } return `Approve ${coll.symbol}`; }, - Status: TransactionStatus, - - async commit({ contracts, request, wagmiConfig }) { + Status: (props) => ( + + ), + async commit({ + contracts, + request, + wagmiConfig, + preferredApproveMethod, + }) { const collChange = getCollChange(request.loan, request.prevLoan); const collateral = contracts.collaterals[request.loan.collIndex]; @@ -181,10 +204,14 @@ export const updateBorrowPosition: FlowDeclaration return writeContract(wagmiConfig, { ...collateral.contracts.CollToken, functionName: "approve", - args: [Controller.address, dn.abs(collChange)[0]], + args: [ + Controller.address, + preferredApproveMethod === "approve-infinite" + ? maxUint256 // infinite approval + : dn.abs(collChange)[0], // exact amount + ], }); }, - async verify({ wagmiConfig, isSafe }, hash) { await verifyTransaction(wagmiConfig, hash, isSafe); }, diff --git a/frontend/app/src/tx-flows/updateLeveragePosition.tsx b/frontend/app/src/tx-flows/updateLeveragePosition.tsx index 5eaf044a2..a017fc4aa 100644 --- a/frontend/app/src/tx-flows/updateLeveragePosition.tsx +++ b/frontend/app/src/tx-flows/updateLeveragePosition.tsx @@ -16,6 +16,7 @@ import { ADDRESS_ZERO } from "@liquity2/uikit"; import * as dn from "dnum"; import { match, P } from "ts-pattern"; import * as v from "valibot"; +import { maxUint256 } from "viem"; import { readContract, writeContract } from "wagmi/actions"; import { createRequestSchema, verifyTransaction, verifyTroveUpdate } from "./shared"; @@ -188,9 +189,18 @@ export const updateLeveragePosition: FlowDeclaration ( + + ), + async commit({ + contracts, + request, + wagmiConfig, + preferredApproveMethod, + }) { if (!request.depositChange) { throw new Error("Invalid step: depositChange is required with approveLst"); } @@ -206,11 +216,12 @@ export const updateLeveragePosition: FlowDeclaration ReactElement; + floatingUpdater?: (args: { + computePosition: typeof computePosition; + referenceElement: HTMLElement; + floatingElement: HTMLElement; + }) => () => Promise; items: DropdownItem[] | DropdownGroup[]; - menuPlacement?: "start" | "end"; + menuPlacement?: "start" | "end" | "top-start" | "top-end"; menuWidth?: number; onSelect: (index: number) => void; placeholder?: ReactNode | Exclude; @@ -58,14 +70,25 @@ export function Dropdown({ }) { const { groups, flatItems } = normalizeGroups(items); + let placement = menuPlacement === "start" || menuPlacement === "end" + ? `bottom-${menuPlacement}` as const + : menuPlacement; + const { refs: floatingRefs, floatingStyles } = useFloating({ - placement: `bottom-${menuPlacement}`, - whileElementsMounted: (referenceEl, floatingEl, update) => ( - autoUpdate(referenceEl, floatingEl, update, { + placement, + whileElementsMounted: (refEl, floatingEl, update) => { + const updateFromProps = refEl instanceof HTMLElement + ? floatingUpdater?.({ + computePosition, + referenceElement: refEl, + floatingElement: floatingEl, + }) + : null; + return autoUpdate(refEl, floatingEl, updateFromProps ?? update, { layoutShift: false, animationFrame: false, - }) - ), + }); + }, middleware: [ offset(8), shift(), @@ -169,12 +192,16 @@ export function Dropdown({ return placeholder; } - return placeholder - ? { label: placeholder } - : null; + return placeholder ? { label: placeholder } : null; })(); - const customButton = isValidElement(buttonDisplay) ? buttonDisplay : null; + const customButton_ = customButton?.({ + item: buttonItem, + index: selected, + menuVisible: showMenu, + }) ?? ( + isValidElement(buttonDisplay) ? buttonDisplay : null + ); return ( <> @@ -199,68 +226,70 @@ export function Dropdown({ cursor: "pointer", }), )} - style={customButton ? {} : { + style={customButton_ ? {} : { height: size === "small" ? 32 : 40, fontSize: size === "small" ? 16 : 24, }} > - {customButton ?? (buttonItem && ( -
- {buttonItem.icon && buttonDisplay !== "label-only" && ( -
- {buttonItem.icon} -
- )} + {customButton_ ?? ( + buttonItem && (
- {buttonItem.label} -
-
- + {buttonItem.icon && buttonDisplay !== "label-only" && ( +
+ {buttonItem.icon} +
+ )} +
+ {buttonItem.label} +
+
+ +
-
- ))} + ) + )} {menuVisibility((appearStyles, { groups }) => (