Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/nine-apes-attend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@happy.tech/iframe": minor
---

Adds a visual indicator in the wallet for user's paymaster gas budget.
2 changes: 2 additions & 0 deletions apps/iframe/src/components/interface/GlobalHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Link, useLocation } from "@tanstack/react-router"
import { useAtom } from "jotai"
import { appMessageBus } from "#src/services/eventBus"
import { secondaryMenuVisibilityAtom } from "#src/state/interfaceState"
import { UserGasBudgetIndicator } from "./home/UserGasBudgetIndicator"

function signalClosed() {
void appMessageBus.emit(Msgs.WalletVisibility, { isOpen: false })
Expand All @@ -27,6 +28,7 @@ const GlobalHeader = () => {
</span>

<div className="flex flex-row gap-1 items-center absolute end-2">
<UserGasBudgetIndicator />
Copy link
Contributor Author

Choose a reason for hiding this comment

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

there's a few changes in @not-reed's PR #781 that I tested with locally (didn't push here) that fix any alignment issues (not there are any observable ones)

<button
title={optionsLabel}
type="button"
Expand Down
Copy link
Contributor Author

@ultraviolet10 ultraviolet10 May 20, 2025

Choose a reason for hiding this comment

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

Appearances (compared between light and dark modes):

Light Mode Dark Mode
Light Mode Screenshot 1 Dark Mode Screenshot 1
Light Mode Screenshot 2 Dark Mode Screenshot 2
Light Mode Screenshot 3 Dark Mode Screenshot 3
Light Mode Screenshot 4 Dark Mode Screenshot 4
Screenshot 2025-05-20 at 6 02 55 PM Screenshot 2025-05-20 at 6 03 31 PM

Copy link
Contributor Author

Choose a reason for hiding this comment

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

on a second look with increasing my screen brightness, the warning states need some more brightness in light mode, it kinda disappears there 🔅

Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import {
BatteryFullIcon,
BatteryHighIcon,
BatteryLowIcon,
BatteryMediumIcon,
BatteryWarningIcon,
type Icon,
SpinnerIcon,
} from "@phosphor-icons/react"
import { cx } from "class-variance-authority"
import type { ClassValue } from "class-variance-authority/types"
import { useReadUserGasBudget } from "#src/hooks/useReadUserGasBudget.ts"
import { getUser } from "#src/state/user.ts"

const colorMap: Record<number, ClassValue> = {
0: "text-error animate-pulse",
1: "text-warning/60",
2: "text-warning",
3: "text-success/80",
4: "text-success",
}

/**
* Maps each battery health level to the corresponding Phosphor Icon component.
*/
const batteryIconStates: Record<number, Icon> = {
0: BatteryWarningIcon,
1: BatteryLowIcon,
2: BatteryMediumIcon,
3: BatteryHighIcon,
4: BatteryFullIcon,
}

export const UserGasBudgetIndicator = () => {
const user = getUser()
const {
data: { batteryHealth, batteryPct } = {},
isLoading,
} = useReadUserGasBudget(user?.address)

if (isLoading || batteryHealth === undefined)
return (
<SpinnerIcon
weight="bold"
className="text-lg mr-1 dark:opacity-60 motion-safe:animate-[spin_2s_linear_infinite]"
/>
)

const BatteryIcon = batteryIconStates[batteryHealth]
const colorClass = colorMap[batteryHealth]

return (
<button title={`${batteryPct}%`} type="button" aria-label={"gas budget"} className="dark:opacity-60">
<BatteryIcon weight="bold" className={cx(colorClass, "text-lg", "mr-1")} />
Copy link
Contributor Author

Choose a reason for hiding this comment

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

this gets fixed by the same changes (comment above)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

this is in ref to the mr-1, it looks a little weird without

</button>
)
}
1 change: 1 addition & 0 deletions apps/iframe/src/constants/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export const abis = getBoopAbis(chainId)
export const entryPointAbi = abis.EntryPoint
export const extensibleAccountAbi = abis.HappyAccountImpl
export const sessionKeyValidatorAbi = abis.SessionKeyValidator
export const happyPaymasterAbi = abis.HappyPaymaster

//== Paymaster Selectors ===========================================================================

Expand Down
70 changes: 70 additions & 0 deletions apps/iframe/src/hooks/useReadUserGasBudget.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import type { Address, UInt32 } from "@happy.tech/common"
import { type UseReadContractReturnType, useReadContract } from "wagmi"
import { happyPaymaster, happyPaymasterAbi } from "#src/constants/contracts"

/**
* Maximum cumulative gas budget per user as defined in the HappyPaymaster contract.
*/
const MAX_GAS_BUDGET: UInt32 = 1_000_000_000

/**
* Structured gas budget info for UI display:
* - `batteryHealth`: discrete level 0–4
* - `batteryPct`: percentage of MAX_GAS_BUDGET (0–100).
*/
export type UserGasBudgetInfo = {
batteryHealth: number
batteryPct: number
}

/**
* Wagmi return type for the `getBudget` read call, mapped to `UserGasBudgetInfo`.
*/
export type UseReadUserGasBudgetReturnType = UseReadContractReturnType<
typeof happyPaymasterAbi,
"getBudget",
[Address],
UserGasBudgetInfo
>

/**
* Hook to fetch and map a user's gas budget from the HappyPaymaster contract.
*
* @param userAddress - address of the user whose budget to read
* @returns Wagmi query object with:
* - `data`: `{ batteryHealth, batteryPct }`
* - `isLoading`, `isError`, `refetch`, etc. (cf. {@link UseReadUserGasBudgetReturnType})
* @throws `Error` if no userAddress is provided.
*/
export const useReadUserGasBudget = (userAddress?: Address): UseReadUserGasBudgetReturnType => {
if (!userAddress) throw new Error("No user found!")

const result = useReadContract({
address: happyPaymaster,
abi: happyPaymasterAbi,
functionName: "getBudget",
args: [userAddress],
query: {
Copy link
Contributor Author

@ultraviolet10 ultraviolet10 May 20, 2025

Choose a reason for hiding this comment

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

todo: query key invalidation plan?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

notes from convo w/ @norswap:

could call it (invalidateQuery) after markBoopAsSuccess(output) to trigger multiple invalidations at once and then the pertinent hooks would refetch the data based on the same

The problem is that functions lives in a part of the code related to storing the bop history, which is not an intuitive way to put this invalidation, I think it should be a the site where we get the receipt, but that means we would do the parsing there, and pass it along to the boop history.

enabled: Boolean(!!userAddress),
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The <GlobalHeader /> component is always visible across embed, send, permissions/xyz pages so didn't make sense to disable on page navigates

refetchInterval: 2000,
/**
* Maps the raw onchain `userGasBudget` to UI-friendly battery info:
* 1. Calculate `pct` as the ratio of `userGasBudget` to `MAX_GAS_BUDGET` (0.0 – 1.0).
* 2. Derive `batteryHealth` (0–4) by flooring `pct * 4` into four equal buckets and
* clamping between 0 and 4 to match discrete battery levels.
* 3. Compute `batteryPct` as a percentage string with two decimals (0 – 100).
*
* @param userGasBudget - raw uint32 gas budget from HappyPaymaster
*/
select(userGasBudget) {
const pct = Number(userGasBudget) / MAX_GAS_BUDGET
return {
batteryHealth: Math.min(4, Math.max(0, Math.floor(pct * 4))),
batteryPct: Number((pct * 100).toFixed(2)),
}
},
},
})

return result
}