Skip to content

Commit

Permalink
App: preferred approval method (#714)
Browse files Browse the repository at this point in the history
- 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.
  • Loading branch information
bpierre authored Jan 20, 2025
1 parent 11b162e commit 474170f
Show file tree
Hide file tree
Showing 10 changed files with 458 additions and 186 deletions.
199 changes: 182 additions & 17 deletions frontend/app/src/screens/TransactionsScreen/TransactionStatus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<FlowStepDeclaration["Status"]>,
props: ComponentPropsWithoutRef<FlowStepDeclaration["Status"]> & {
approval?: "all" | "approve-only" | null;
},
) {
const { preferredApproveMethod } = useStoredState();
return (
<>
<div
className={cx(
"tx-status",
css({
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 8,
}),
)}
>
{props.approval
&& (props.status === "idle" || props.status === "error")
&& <PreferredApproveMethodSelector selector={props.approval} />}
<StatusText
{...props}
type={props.approval === "all" && preferredApproveMethod === "permit"
? "permission"
: "transaction"}
/>
</div>
);
}

function TxLink({ txHash }: { txHash: string }) {
const account = useAccount();
return (
<AnchorTextButton
label="transaction"
href={account.safeStatus === null
? `${CHAIN_BLOCK_EXPLORER?.url}tx/${txHash}`
: `https://app.safe.global/transactions/tx?id=multisig_${account.address}_${txHash}&safe=sep:${account.address}`}
external
/>
);
}

function StatusText(
props: ComponentPropsWithoutRef<FlowStepDeclaration["Status"]> & {
type: "transaction" | "permission";
},
) {
if (props.type === "permission") {
return (
<div>
{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)}
</div>
);
}
return (
<div>
{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. <TextButton label="Retry" onClick={onRetry} />
Please sign the transaction in your wallet.{" "}
<TextButton
label="Retry"
onClick={onRetry}
/>
</>
))
.with({ status: "awaiting-verify" }, ({ artifact: txHash }) => (
Expand All @@ -32,21 +104,114 @@ export function TransactionStatus(
</>
))
.with({ status: "error" }, () => (
<>
An error occurred. Please try again.
</>
"An error occurred. Please try again."
))
.exhaustive()}
</>
</div>
);
}

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 (
<AnchorTextButton
label="transaction"
href={`${CHAIN_BLOCK_EXPLORER?.url}tx/${txHash}`}
external
/>
<div
className={css({
display: "flex",
gap: 4,
})}
>
<div>Approve via</div>
<Dropdown
customButton={({ menuVisible }) => (
<div
className={css({
display: "flex",
gap: 4,
color: "accent",
borderRadius: 4,
_groupFocusVisible: {
outline: "2px solid token(colors.focused)",
outlineOffset: 2,
},
})}
>
<div>{currentOption.label}</div>
<div
className={css({
display: "flex",
alignItems: "center",
transformOrigin: "50% 50%",
transition: "transform 80ms",
})}
style={{
transform: menuVisible ? "rotate(180deg)" : "rotate(0)",
}}
>
<IconChevronDown size={16} />
</div>
</div>
)}
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();
}
}}
/>
</div>
);
}
6 changes: 6 additions & 0 deletions frontend/app/src/services/StoredState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof StoredStateSchema>;

const defaultState: StoredStateType = {
loanModes: {},
preferredApproveMethod: "permit",
};

type StoredStateContext = StoredStateType & {
Expand Down
26 changes: 23 additions & 3 deletions frontend/app/src/services/TransactionFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ export type FlowParams<FlowRequest extends BaseFlowRequest = BaseFlowRequest> =
account: Address | null;
contracts: Contracts;
isSafe: boolean;
preferredApproveMethod: "permit" | "approve-amount" | "approve-infinite";
request: FlowRequest;
steps: FlowStep[] | null;
storedState: ReturnType<typeof useStoredState>;
Expand Down Expand Up @@ -205,6 +206,7 @@ export function getFlowDeclaration<K extends keyof FlowRequestMap>(
type TransactionFlowContext<
FlowRequest extends FlowRequestMap[keyof FlowRequestMap] = FlowRequestMap[keyof FlowRequestMap],
> = {
clearError: () => void;
currentStep: FlowStep | null;
currentStepIndex: number;
discard: () => void;
Expand All @@ -216,6 +218,7 @@ type TransactionFlowContext<
};

const TransactionFlowContext = createContext<TransactionFlowContext>({
clearError: noop,
currentStep: null,
currentStepIndex: -1,
discard: noop,
Expand All @@ -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) => {
Expand All @@ -258,11 +262,11 @@ export function TransactionFlow({
return (
<TransactionFlowContext.Provider
value={{
clearError,
commit,
currentStep,
currentStepIndex,
discard: discardFlow,
commit,
start,
flow,
flowDeclaration,
flowParams: flow && account.address
Expand All @@ -271,10 +275,12 @@ export function TransactionFlow({
account: account.address,
contracts: getContracts(),
isSafe: account.safeStatus !== null,
preferredApproveMethod: storedState.preferredApproveMethod,
storedState,
wagmiConfig,
}
: null,
start,
}}
>
{children}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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],
Expand All @@ -512,6 +531,7 @@ function useFlowManager(account: Address | null, isSafe: boolean = false) {
useResetQueriesOnPathChange(isFlowComplete);

return {
clearError,
currentStep,
currentStepIndex,
discardFlow,
Expand Down
Loading

0 comments on commit 474170f

Please sign in to comment.