Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: session key demo: improve timers, styles, and error handling #1368

Merged
merged 9 commits into from
Feb 20, 2025
11 changes: 5 additions & 6 deletions examples/ui-demo/src/components/small-cards/MintCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,11 @@ const MintCardInner = ({
<LoadingIcon />
)}
</div>
<div>
<div className="mb-2">
<h3 className="text-fg-primary text-base xl:text-xl font-semibold">
Gasless transactions
</h3>
</div>
<div className="w-full mb-3">
<h3 className="text-fg-primary xl:text-xl font-semibold mb-2 xl:mb-3">
Gasless transactions
</h3>

{!mintStarted ? (
<>
<p className="text-fg-primary text-sm mb-3">
Expand Down
28 changes: 9 additions & 19 deletions examples/ui-demo/src/components/small-cards/Transactions.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { ExternalLinkIcon } from "@/components/icons/external-link";
import { CheckCircleFilledIcon } from "@/components/icons/check-circle-filled";
import { LoadingIcon } from "@/components/icons/loading";
import { useEffect, useState } from "react";
import { TransactionType } from "@/hooks/useRecurringTransactions";
import { cn } from "@/lib/utils";

export type loadingState = "loading" | "success" | "initial";

Expand All @@ -25,41 +25,31 @@ const Transaction = ({
externalLink,
buyAmountUsdc,
state,
secUntilBuy = 0,
}: TransactionType & { className?: string }) => {
const [countdownSeconds, setCountdownSeconds] = useState<number>(10);

useEffect(() => {
if (state === "next") {
const interval = setInterval(() => {
setCountdownSeconds((prev) => (prev === 0 ? 0 : prev - 1));
}, 1000);
return () => clearInterval(interval);
} else {
setCountdownSeconds(10);
}
}, [state]);

const getText = () => {
if (state === "initial") {
return "Waiting...";
}
if (state === "next") {
return secUntilBuy <= 0
? "Waiting for previous transaction..."
: `Next buy in ${secUntilBuy} second${secUntilBuy === 1 ? "" : "s"}`;
}
if (state === "initiating") {
return "Buying 1 ETH";
}
if (state === "next") {
return `Next buy in ${countdownSeconds} seconds`;
}
if (state === "complete") {
return `Bought 1 ETH for ${buyAmountUsdc.toLocaleString()} USDC`;
}
};

return (
<div className={`flex justify-between ${className} mb-4`}>
<div className={cn("flex justify-between mb-4", className)}>
<div className="flex items-center mr-1">
<div className="w-4 h-4 mr-2">
{state === "complete" ? (
<CheckCircleFilledIcon className=" h-4 w-4 fill-demo-surface-success" />
<CheckCircleFilledIcon className="h-4 w-4 fill-demo-surface-success" />
) : (
<LoadingIcon className="h-4 w-4" />
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,16 @@ const TransactionsCardInner = ({
}, [cardStatus]);

return (
<div className="bg-bg-surface-default rounded-lg p-4 w-full xl:p-6 xl:w-[326px] xl:h-[478px] flex flex-col shadow-smallCard mb-5 xl:mb-0">
<div className="flex gap-3 xl:gap-0 xl:flex-col">
<div className="flex-shrink-0 bg-[#EAEBFE] rounded-xl mb-4 flex justify-center items-center relative h-[67px] w-[60px] sm:h-[154px] sm:w-[140px] xl:h-[222px] xl:w-full">
<div className="bg-bg-surface-default rounded-lg p-4 xl:p-6 w-full xl:w-[326px] xl:h-[478px] flex flex-col shadow-smallCard">
<div className="flex xl:flex-col gap-4">
<div className="flex-shrink-0 bg-[#EAEBFE] rounded-xl sm:mb-3 xl:mb-0 flex justify-center items-center relative h-[67px] w-[60px] sm:h-[154px] sm:w-[140px] xl:h-[222px] xl:w-full">
<p className="absolute top-[-6px] left-[-6px] sm:top-1 sm:left-1 xl:left-auto xl:right-4 xl:top-4 px-2 py-1 font-semibold rounded-md text-xs text-[#7c3AED] bg-[#F3F3FF]">
New!
</p>
<Key className="h-9 w-9 sm:h-[74px] sm:w-[74px] xl:h-[94px] xl:w-[94px]" />
</div>
<div className="mb-3">
<h3 className="text-fg-primary xl:text-xl font-semibold mb-2 xl:mb-3">
<div className="mb-3 w-full">
<h3 className="text-fg-primary xl:text-xl font-semibold mb-2 xl:mb-3">
Recurring transactions
</h3>

Expand All @@ -59,17 +59,15 @@ const TransactionsCardInner = ({
) : cardStatus === "setup" ? (
<div className="flex items-center">
<LoadingIcon className="h-4 w-4 mr-2" />
<p className="text-fg-primary text-sm">
Creating session key and minting USDC...
</p>
<p className="text-fg-primary text-sm">Creating session key...</p>
</div>
) : (
<Transactions transactions={transactions} />
)}
</div>
</div>
<Button
className="mt-auto"
className="mt-auto w-full"
onClick={handleTransactions}
disabled={
isLoadingClient || cardStatus === "setup" || cardStatus === "active"
Expand Down
2 changes: 1 addition & 1 deletion examples/ui-demo/src/components/small-cards/Wrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export const SmallCardsWrapper = () => {
const { walletType } = useConfigStore();

return (
<div className="flex flex-col xl:flex-row gap-6 lg:mt-6 items-center p-6">
<div className="flex flex-col xl:flex-row gap-6 lg:mt-6 items-center p-6 w-full justify-center max-w-screen-sm xl:max-w-none">
{walletType === WalletTypes.smart ? (
<>
<MintCardDefault />
Expand Down
132 changes: 88 additions & 44 deletions examples/ui-demo/src/hooks/useRecurringTransactions.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import type { BatchUserOperationCallData } from "@aa-sdk/core";
import {
encodeFunctionData,
Expand All @@ -24,6 +24,7 @@ import { erc20MintableAbi } from "./7702/dca/abi/erc20Mintable";
import { genEntityId } from "./7702/genEntityId";
import { odyssey, splitOdysseyTransport } from "./7702/transportSetup";
import { SESSION_KEY_VALIDITY_TIME_SECONDS } from "./7702/constants";
import { useToast } from "@/hooks/useToast";

export type CardStatus = "initial" | "setup" | "active" | "done";

Expand All @@ -32,11 +33,13 @@ export type TransactionType = {
state: TransactionStages;
buyAmountUsdc: number;
externalLink?: string;
timeToBuy?: number; // timestamp when the txn will initiate
secUntilBuy?: number; // seconds until the txn will initiate
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

noob question: why do we need the timestamp here in addition to the countdown? could the countdown also serve as the time to reveal the transaction component's contents?

Copy link
Member Author

@jakehobbs jakehobbs Feb 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The timeToBuy is used to calculate the number of seconds remaining each time the interval ticks. At first I was just trying to only have the timeToBuy and NOT include the secToBuy here, but I ran into a lot of annoying edge cases where it would be off by 1 due to re-renders, i.e. sometimes it would start from 11 sec and other times from 9 sec. This solution resulted in the cleanest UX, since we explicitly start the timer at 10 sec.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think i found another good solution! will push shortly.

};

export const initialTransactions: TransactionType[] = [
{
state: "initiating",
state: "initial",
buyAmountUsdc: 4000,
},
{
Expand Down Expand Up @@ -87,22 +90,69 @@ export const useRecurringTransactions = ({
},
});

const handleTransaction = async (transactionIndex: number) => {
setTransactions((prev) => {
const newState = [...prev];
newState[transactionIndex].state = "initiating";
if (transactionIndex + 1 < newState.length) {
newState[transactionIndex + 1].state = "next";
}
return newState;
const { setToast } = useToast();

const handleError = (error: Error) => {
console.error(error);
setCardStatus("initial");
setTransactions(initialTransactions);
setToast({
type: "error",
text: "Something went wrong. Please try again.",
open: true,
});
};

if (!sessionKeyClient) {
console.error("no session key client");
setCardStatus("initial");
// Handle the ticking of the timers.
useEffect(() => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

related question re: timestamp and interval - could the reveal be handled in the 10s countdown promise? Not super high pri, just curious if we could simplify

Copy link
Member Author

@jakehobbs jakehobbs Feb 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not totally following what you mean.

Copy link
Member Author

@jakehobbs jakehobbs Feb 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, we could probably just give each of them the time they should fire at the beginning and set the timeouts to 0, 10, and 20 sec right away. But doing it how it is now also results in waiting extra time before starting the next txn if the current txn takes longer than 10 sec, which I think is nice to have.

if (
transactions.every(
(it) => it.state === "complete" || it.state === "initial"
)
) {
return;
}

const interval = setInterval(() => {
setTransactions((prev) =>
prev.map((txn) =>
txn.state === "next" && txn.timeToBuy
? {
...txn,
secUntilBuy: Math.ceil((txn.timeToBuy - Date.now()) / 1000),
}
: txn
)
);

if (transactions.every((it) => it.state === "complete")) {
clearInterval(interval);
}
}, 250);

return () => clearInterval(interval);
}, [transactions]);

const handleTransaction = async (transactionIndex: number) => {
if (!sessionKeyClient) {
return handleError(new Error("no session key client"));
}

setTransactions((prev) =>
prev.map((txn, idx) =>
idx === transactionIndex
? { ...txn, state: "initiating" }
: idx === transactionIndex + 1
? {
...txn,
state: "next",
timeToBuy: Date.now() + 10_000,
secUntilBuy: 10,
}
: txn
)
);

const usdcInAmount = transactions[transactionIndex].buyAmountUsdc;

const uoHash = await sessionKeyClient.sendUserOperation({
Expand All @@ -119,46 +169,36 @@ export const useRecurringTransactions = ({
const txnHash = await sessionKeyClient
.waitForUserOperationTransaction(uoHash)
.catch((e) => {
console.log(e);
console.error(e);
});

if (!txnHash) {
setCardStatus("initial");
return;
return handleError(new Error("missing swap txn hash"));
}

setTransactions((prev) => {
const newState = [...prev];
newState[transactionIndex].state = "complete";
newState[transactionIndex].externalLink = odyssey.blockExplorers
? `${odyssey.blockExplorers?.default.url}/tx/${txnHash}`
: undefined;
return newState;
});
setTransactions((prev) =>
prev.map((txn, idx) =>
idx === transactionIndex
? {
...txn,
state: "complete",
externalLink: odyssey.blockExplorers
? `${odyssey.blockExplorers?.default.url}/tx/${txnHash}`
: undefined,
}
: txn
)
);
};

// Mock method to fire transactions for 7702
// Mock method to fire transactions
const handleTransactions = async () => {
if (!client) {
console.error("no client");
return;
}

// initial state as referenced by `const initialTransactions` is mutated, so we need to re-create it.
setTransactions([
{
state: "initiating",
buyAmountUsdc: 4000,
},
{
state: "initial",
buyAmountUsdc: 3500,
},
{
state: "initial",
buyAmountUsdc: 4200,
},
]);
setTransactions(initialTransactions);
setCardStatus("setup");

// Start by minting the required USDC amount, and installing the session key, if not already installed.
Expand Down Expand Up @@ -257,19 +297,23 @@ export const useRecurringTransactions = ({
const txnHash = await client
.waitForUserOperationTransaction(uoHash)
.catch((e) => {
console.log(e);
console.error(e);
});

if (!txnHash) {
setCardStatus("initial");
return;
return handleError(new Error("missing batch txn hash"));
}

setSessionKeyAdded(true);
setCardStatus("active");

for (let i = 0; i < transactions.length; i++) {
await handleTransaction(i);
await Promise.all([
handleTransaction(i),
...(i < transactions.length - 1
? [new Promise((resolve) => setTimeout(resolve, 10_000))]
: []),
]);
}

setCardStatus("done");
Expand Down