diff --git a/src/shared/core/useIsOrbyEnabled.ts b/src/shared/core/useIsOrbyEnabled.ts
new file mode 100644
index 0000000000..d518290a00
--- /dev/null
+++ b/src/shared/core/useIsOrbyEnabled.ts
@@ -0,0 +1,15 @@
+import { useIsOrbySupportedChain } from '@orb-labs/orby-react';
+import { useMemo } from 'react';
+import _ from 'lodash';
+import { useIsOneClickTransactionsAndGasAbstractionEnabled } from './useIsOneClickTransactionsAndGasAbstractionEnabled';
+
+export function useIsOrbyEnabled(chainId?: bigint | undefined) {
+ const oneClickTransactionsAndGasAbstractionEnabled =
+ useIsOneClickTransactionsAndGasAbstractionEnabled();
+
+ const { isSupported } = useIsOrbySupportedChain(chainId);
+
+ return useMemo(() => {
+ return oneClickTransactionsAndGasAbstractionEnabled && isSupported;
+ }, [oneClickTransactionsAndGasAbstractionEnabled, isSupported]);
+}
diff --git a/src/ui/components/TokenStateSection/TokenStateSection.tsx b/src/ui/components/TokenStateSection/TokenStateSection.tsx
new file mode 100644
index 0000000000..5fc27198f6
--- /dev/null
+++ b/src/ui/components/TokenStateSection/TokenStateSection.tsx
@@ -0,0 +1,89 @@
+import React from 'react';
+import { Surface } from 'src/ui/ui-kit/Surface';
+import { UIText } from 'src/ui/ui-kit/UIText';
+import { VStack } from 'src/ui/ui-kit/VStack';
+import { HStack } from 'src/ui/ui-kit/HStack';
+import type { State } from '@orb-labs/orby-core';
+import { TokenIcon } from 'src/ui/ui-kit/TokenIcon';
+import { Media } from 'src/ui/ui-kit/Media';
+import { formatTokenValue } from 'src/shared/units/formatTokenValue';
+import { AssetQuantity } from 'src/ui/components/AssetQuantity';
+import { BigNumber } from 'bignumber.js';
+
+export function TokenStateSection({
+ title,
+ tokens,
+}: {
+ title: string;
+ tokens: State | undefined;
+}) {
+ const fungibleTokens = tokens?.getFungibleTokens();
+ if (!fungibleTokens) {
+ return null;
+ }
+
+ if (fungibleTokens.length === 0) {
+ return (
+
+
+
+ {title}
+
+
+ No tokens moving
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ {title}
+
+ {fungibleTokens.map((tokenAmount, index) => {
+ // Convert raw amount to decimal based on token decimals
+ const rawAmount = BigNumber(tokenAmount.toRawAmount().toString());
+ const decimals = tokenAmount.token.currency().decimals;
+ const decimalAmount = rawAmount.dividedBy(
+ BigNumber(10).pow(decimals)
+ );
+
+ return (
+
+ }
+ text={
+
+
+
+ {tokenAmount.token.currency().symbol}
+
+
+ }
+ detailText={
+
+ {formatTokenValue(
+ decimalAmount,
+ tokenAmount.token.currency().symbol
+ )}
+
+ }
+ />
+ );
+ })}
+
+
+ );
+}
diff --git a/src/ui/components/TokenStateSection/index.ts b/src/ui/components/TokenStateSection/index.ts
new file mode 100644
index 0000000000..a5930eb528
--- /dev/null
+++ b/src/ui/components/TokenStateSection/index.ts
@@ -0,0 +1 @@
+export { TokenStateSection } from './TokenStateSection';
diff --git a/src/ui/pages/SendTransaction/NetworkFee/NetworkFee.tsx b/src/ui/pages/SendTransaction/NetworkFee/NetworkFee.tsx
index 6dcb7427e3..281bdb4078 100644
--- a/src/ui/pages/SendTransaction/NetworkFee/NetworkFee.tsx
+++ b/src/ui/pages/SendTransaction/NetworkFee/NetworkFee.tsx
@@ -15,9 +15,26 @@ import type { IncomingTransactionWithFrom } from 'src/modules/ethereum/types/Inc
import { useNetworks } from 'src/modules/networks/useNetworks';
import { useCurrency } from 'src/modules/currency/useCurrency';
import { formatCurrencyValueExtra } from 'src/shared/units/formatCurrencyValue';
+import type { StandardizedBalance } from '@orb-labs/orby-core';
+import { TokenIcon } from 'src/ui/ui-kit/TokenIcon';
+import { BottomSheetDialog } from 'src/ui/ui-kit/ModalDialogs/BottomSheetDialog';
+import { DialogTitle } from 'src/ui/ui-kit/ModalDialogs/DialogTitle';
+import { SurfaceList } from 'src/ui/ui-kit/SurfaceList';
+import { VStack } from 'src/ui/ui-kit/VStack';
+import { createPortal } from 'react-dom';
+import { getRootDomNode } from 'src/ui/shared/getRootDomNode';
+import DownIcon from 'jsx:src/ui/assets/chevron-down.svg';
+import { useIsOrbyEnabled } from 'src/shared/core/useIsOrbyEnabled';
import type { TransactionFee } from '../TransactionConfiguration/useTransactionFee';
-import { NetworkFeeDialog } from './NetworkFeeDialog';
import { NETWORK_SPEED_TO_TITLE } from './constants';
+import { NetworkFeeDialog } from './NetworkFeeDialog';
+
+export interface GasTokenInput {
+ name: string;
+ standardizedTokenId?: string;
+ isDefault: boolean;
+ url?: string;
+}
function getFeeTypeTitle(
type: Exclude | undefined
@@ -32,6 +49,128 @@ function getFeeTypeTitle(
return labels[type];
}
+export function GasTokenSelector({
+ selectedGasToken,
+ setSelectedGasToken,
+ fungibleTokens,
+}: {
+ selectedGasToken?: GasTokenInput;
+ setSelectedGasToken?: (gasToken?: GasTokenInput) => void;
+ fungibleTokens?: StandardizedBalance[] | undefined;
+}) {
+ const dialogRef = useRef(null);
+ const rootNode = getRootDomNode();
+
+ const handleSelectGasToken = (token: StandardizedBalance) => {
+ const gasToken: GasTokenInput = {
+ name: token.total.currency.symbol,
+ standardizedTokenId: token.standardizedTokenId,
+ isDefault: false,
+ url: token.total.currency.logoUrl,
+ };
+ setSelectedGasToken?.(gasToken);
+ dialogRef.current?.close();
+ };
+
+ const handleSelectDefault = () => {
+ setSelectedGasToken?.({
+ name: 'Native Token',
+ standardizedTokenId: undefined,
+ isDefault: true,
+ });
+ dialogRef.current?.close();
+ };
+
+ const items = [
+ {
+ key: 'default',
+ component: (
+
+
+ No Gas Abstraction
+
+ ),
+ onClick: handleSelectDefault,
+ isInteractive: true,
+ },
+ ...(fungibleTokens
+ ? fungibleTokens.map((token, index) => ({
+ key: token.standardizedTokenId || `token-${index}`,
+ component: (
+
+
+ {token.total.currency.symbol}
+
+ ),
+ onClick: () => handleSelectGasToken(token),
+ isInteractive: true,
+ }))
+ : []),
+ ];
+
+ return (
+ <>
+ {createPortal(
+
+ Select Gas Token}
+ alignTitle="start"
+ />
+
+
+
+ ,
+ rootNode
+ )}
+
+ {
+ dialogRef.current?.showModal();
+ }}
+ >
+
+
+
+ {selectedGasToken?.name || 'Select Token'}
+
+
+
+
+ >
+ );
+}
+
export function NetworkFee({
transaction,
transactionFee,
@@ -43,6 +182,9 @@ export function NetworkFee({
label,
renderDisplayValue,
displayValueEnd,
+ selectedGasToken,
+ setSelectedGasToken,
+ fungibleTokens,
}: {
transaction: IncomingTransactionWithFrom;
transactionFee: TransactionFee;
@@ -57,9 +199,15 @@ export function NetworkFee({
displayValue: string;
}) => React.ReactNode;
displayValueEnd?: React.ReactNode;
+ selectedGasToken?: GasTokenInput;
+ setSelectedGasToken?: (gasToken?: GasTokenInput) => void;
+ fungibleTokens?: StandardizedBalance[] | undefined;
}) {
const { networks } = useNetworks();
const { currency } = useCurrency();
+ const isOrbyEnabled = useIsOrbyEnabled(
+ transaction.chainId ? BigInt(transaction.chainId) : undefined
+ );
const dialogRef = useRef(null);
const { time, feeEstimation, feeEstimationQuery, costs, costsQuery } =
transactionFee;
@@ -138,6 +286,13 @@ export function NetworkFee({
) : (
Network Fee
)}
+ {selectedGasToken && isOrbyEnabled ? (
+
+ ) : null}
{isLoading ? (
) : displayValue ? (
diff --git a/src/ui/pages/SendTransaction/SendTransaction.tsx b/src/ui/pages/SendTransaction/SendTransaction.tsx
index e49fa4b72e..a01fa600a2 100644
--- a/src/ui/pages/SendTransaction/SendTransaction.tsx
+++ b/src/ui/pages/SendTransaction/SendTransaction.tsx
@@ -99,6 +99,8 @@ import ScrollIcon from 'jsx:src/ui/assets/scroll.svg';
import ArrowDownIcon from 'jsx:src/ui/assets/caret-down-filled.svg';
import { SiteFaviconImg } from 'src/ui/components/SiteFaviconImg';
import { NetworkId } from 'src/modules/networks/NetworkId';
+import { useIsOrbyEnabled } from 'src/shared/core/useIsOrbyEnabled';
+import { useOrbyGetOperationsToSignTransactionOrSignTypedData } from 'src/ui/shared/hooks/useOrbyGetOperationsToSignTransactionOrSignTypedData';
import type { PopoverToastHandle } from '../Settings/PopoverToast';
import { PopoverToast } from '../Settings/PopoverToast';
import { TransactionConfiguration } from './TransactionConfiguration';
@@ -110,6 +112,7 @@ import { TransactionAdvancedView } from './TransactionAdvancedView';
import { TransactionWarnings } from './TransactionWarnings';
import { txErrorToMessage } from './shared/transactionErrorToMessage';
import { AddressActionNetworkFee } from './TransactionConfiguration/TransactionConfiguration';
+import type { GasTokenInput } from './NetworkFee/NetworkFee';
async function configureTransactionToSign(
transaction: T,
@@ -226,7 +229,7 @@ function usePreparedTx(transaction: IncomingTransaction, origin: string) {
queryFn: async () =>
withChainId ? resolveGasAndFee(withChainId, { source }) : null,
enabled: Boolean(withChainId),
- useErrorBoundary: true,
+ // useErrorBoundary: true,
suspense: false,
});
return {
@@ -303,6 +306,8 @@ function TransactionDefaultView({
paymasterWaiting,
onOpenAdvancedView,
onFeeValueCommonReady,
+ selectedGasToken,
+ setSelectedGasToken,
}: {
networks: Networks;
chain: Chain;
@@ -324,6 +329,8 @@ function TransactionDefaultView({
paymasterWaiting: boolean;
onOpenAdvancedView: () => void;
onFeeValueCommonReady: (value: string) => void;
+ selectedGasToken?: GasTokenInput;
+ setSelectedGasToken?: (gasToken?: GasTokenInput) => void;
}) {
const { singleAddress } = useAddressParams();
const [params] = useSearchParams();
@@ -497,6 +504,8 @@ function TransactionDefaultView({
: null
}
listViewTransitions={true}
+ selectedGasToken={selectedGasToken}
+ setSelectedGasToken={setSelectedGasToken}
/>
) : null}
@@ -576,6 +585,30 @@ function SendTransactionContent({
const network = networks.getByNetworkId(chain) || null;
const source = preferences?.testnetMode?.on ? 'testnet' : 'mainnet';
+ const isOrbyEnabled = useIsOrbyEnabled(BigInt(populatedTransaction?.chainId));
+ const [selectedGasToken, setSelectedGasToken] = useState({
+ name: 'Native Token',
+ standardizedTokenId: undefined,
+ isDefault: true,
+ });
+
+ const selectGasToken = useCallback(
+ (gasToken?: GasTokenInput) => {
+ if (gasToken) {
+ setSelectedGasToken(gasToken);
+ }
+ },
+ [setSelectedGasToken]
+ );
+
+ const { operationSet, operationSetError, operationSetLoading } =
+ useOrbyGetOperationsToSignTransactionOrSignTypedData(
+ populatedTransaction,
+ wallet,
+ isOrbyEnabled,
+ selectedGasToken
+ );
+
const paymasterPossible =
USE_PAYMASTER_FEATURE && Boolean(network?.supports_sponsored_transactions);
@@ -790,6 +823,8 @@ function SendTransactionContent({
paymasterWaiting={paymasterWaiting}
onOpenAdvancedView={openAdvancedView}
onFeeValueCommonReady={handleFeeValueCommonReady}
+ selectedGasToken={selectedGasToken}
+ setSelectedGasToken={selectGasToken}
/>
) : null}
toastRef.current?.showToast()}
+ operationSet={operationSet}
/>
>
)}
@@ -832,6 +868,11 @@ function SendTransactionContent({
{txErrorToMessage(sendTransactionMutation.error)}
) : null}
+ {operationSetError ? (
+
+ {operationSetError}
+
+ ) : null}
{view === View.customAllowance ? (
) : (
@@ -859,8 +900,14 @@ function SendTransactionContent({
wallet={wallet}
ref={sendTxBtnRef}
onClick={() => sendTransaction()}
- isLoading={sendTransactionMutation.isLoading}
- disabled={sendTransactionMutation.isLoading}
+ isLoading={
+ sendTransactionMutation.isLoading || operationSetLoading
+ }
+ disabled={
+ sendTransactionMutation.isLoading ||
+ operationSetLoading ||
+ !!operationSetError
+ }
buttonKind={
interpretationHasCriticalWarning ? 'danger' : 'primary'
}
@@ -1077,6 +1124,7 @@ function SolDefaultView({
transaction={{ solana: rawTransaction }}
addressAction={addressAction}
onCopyData={() => toastRef.current?.showToast()}
+ operationSet={undefined}
/>
>
)}
diff --git a/src/ui/pages/SendTransaction/TransactionAdvancedView/TransactionAdvancedView.tsx b/src/ui/pages/SendTransaction/TransactionAdvancedView/TransactionAdvancedView.tsx
index c0d9e7b739..c717c02ecf 100644
--- a/src/ui/pages/SendTransaction/TransactionAdvancedView/TransactionAdvancedView.tsx
+++ b/src/ui/pages/SendTransaction/TransactionAdvancedView/TransactionAdvancedView.tsx
@@ -28,6 +28,8 @@ import { DialogButtonValue } from 'src/ui/ui-kit/ModalDialogs/DialogTitle';
import { Spacer } from 'src/ui/ui-kit/Spacer';
import { PageBottom } from 'src/ui/components/PageBottom';
import type { MultichainTransaction } from 'src/shared/types/MultichainTransaction';
+import type { OperationSet } from '@orb-labs/orby-core';
+import { TokenStateSection } from 'src/ui/components/TokenStateSection';
function maybeHexValue(value?: BigNumberish | null): string | null {
return value ? valueToHex(value) : null;
@@ -205,6 +207,7 @@ export function TransactionAdvancedView({
interpretation,
addressAction,
onCopyData,
+ operationSet,
}: {
networks: Networks;
chain: Chain;
@@ -212,6 +215,7 @@ export function TransactionAdvancedView({
interpretation?: InterpretResponse | null;
addressAction: AnyAddressAction;
onCopyData?: () => void;
+ operationSet?: OperationSet;
}) {
const transactionFormatted = useMemo(() => {
if (transaction.evm) {
@@ -240,6 +244,18 @@ export function TransactionAdvancedView({
chain={chain}
networks={networks}
/>
+ {operationSet && (
+ <>
+
+
+ >
+ )}
{transaction.evm ? (
void;
+ fungibleTokens: StandardizedBalance[];
}) {
const { data: chainGasPrices = null } = useGasPrices(chain, {
suspense: true,
@@ -234,6 +244,9 @@ function NetworkFeeLine({
chainGasPrices={chainGasPrices}
networkFeeConfiguration={configuration.networkFee}
label={Network Fee}
+ selectedGasToken={selectedGasToken}
+ setSelectedGasToken={setSelectedGasToken}
+ fungibleTokens={fungibleTokens}
displayValueEnd={
paymasterPossible && !paymasterEligible ? (
<>
@@ -278,6 +291,8 @@ export function TransactionConfiguration({
keepPreviousData = false,
gasback: gasbackData,
listViewTransitions = false,
+ selectedGasToken,
+ setSelectedGasToken,
}: {
transaction: IncomingTransaction;
from: string;
@@ -292,6 +307,8 @@ export function TransactionConfiguration({
gasback: GasbackData | null;
/** Hacky, experimental and only needed on SendTransaction View because list is stuck to the bottom */
listViewTransitions?: boolean;
+ selectedGasToken?: GasTokenInput;
+ setSelectedGasToken?: (gasToken?: GasTokenInput) => void;
}) {
const { preferences } = usePreferences();
const transactionWithFrom = useMemo(
@@ -302,6 +319,11 @@ export function TransactionConfiguration({
const [gasback, setGasback] = useState(gasbackValueOriginal);
const hasGasbackOnFirstRenderRef = useRef(gasback);
+ const isOrbyEnabled = useIsOrbyEnabled(
+ incomingTransaction.chainId
+ ? BigInt(incomingTransaction.chainId)
+ : undefined
+ );
const { data: loyaltyEnabled } = useRemoteConfigValue(
'extension_loyalty_enabled'
);
@@ -329,6 +351,13 @@ export function TransactionConfiguration({
// viewTransitionNames need to be unique when this component is used more than once on the same view
const id = usePlainId();
+ const { fungibleTokens } = useGetFungibleTokenPortfolio(
+ undefined,
+ transactionWithFrom.chainId && isOrbyEnabled
+ ? BigInt(transactionWithFrom.chainId)
+ : undefined
+ );
+
return (
{paymasterWaiting ? (
@@ -370,6 +399,9 @@ export function TransactionConfiguration({
onFeeValueCommonReady={onFeeValueCommonReady}
paymasterPossible={paymasterPossible}
paymasterEligible={paymasterEligible}
+ selectedGasToken={selectedGasToken}
+ setSelectedGasToken={setSelectedGasToken}
+ fungibleTokens={fungibleTokens}
/>
)}
diff --git a/src/ui/shared/hooks/useOperationSetError.ts b/src/ui/shared/hooks/useOperationSetError.ts
new file mode 100644
index 0000000000..e5e4ea5048
--- /dev/null
+++ b/src/ui/shared/hooks/useOperationSetError.ts
@@ -0,0 +1,47 @@
+import { useEffect, useState } from 'react';
+import { CreateOperationsStatus } from '@orb-labs/orby-core';
+import type { OperationSet } from '@orb-labs/orby-core';
+
+/**
+ * Custom hook that monitors operation set status and returns appropriate error messages
+ * @param operationSet - The operation set to monitor
+ * @param isOrbyEnabled - Whether Orby is enabled for the current chain
+ * @returns The error message string or null if no error
+ */
+export function useOperationSetError(
+ operationSet: OperationSet | undefined,
+ isOrbyEnabled: boolean | undefined
+): string | null {
+ const [errorMessage, setErrorMessage] = useState(null);
+
+ useEffect(() => {
+ if (operationSet?.status && isOrbyEnabled) {
+ let newErrorMessage: string | null = null;
+
+ if (operationSet.status === CreateOperationsStatus.INSUFFICIENT_FUNDS) {
+ newErrorMessage = 'Insufficient funds';
+ } else if (
+ operationSet.status === CreateOperationsStatus.NO_EXECUTION_PATH
+ ) {
+ newErrorMessage = 'No execution path';
+ } else if (
+ operationSet.status ===
+ CreateOperationsStatus.INSUFFICIENT_FUNDS_FOR_GAS
+ ) {
+ newErrorMessage = 'Insufficient funds for gas. Choose another token';
+ } else if (operationSet.status === CreateOperationsStatus.INTERNAL) {
+ newErrorMessage = 'Internal error';
+ } else if (
+ operationSet.status === CreateOperationsStatus.INVALID_ARGUMENT
+ ) {
+ newErrorMessage = 'Invalid argument';
+ }
+
+ setErrorMessage(newErrorMessage);
+ } else {
+ setErrorMessage(null);
+ }
+ }, [operationSet?.status, isOrbyEnabled]);
+
+ return errorMessage;
+}
diff --git a/src/ui/shared/hooks/useOrbyGetOperationsToSignTransactionOrSignTypedData.ts b/src/ui/shared/hooks/useOrbyGetOperationsToSignTransactionOrSignTypedData.ts
new file mode 100644
index 0000000000..bedb22f422
--- /dev/null
+++ b/src/ui/shared/hooks/useOrbyGetOperationsToSignTransactionOrSignTypedData.ts
@@ -0,0 +1,37 @@
+import { useMemo } from 'react';
+import type { IncomingTransactionWithChainId } from 'src/modules/ethereum/types/IncomingTransaction';
+import { useGetOperationsToSignTransactionOrSignTypedData } from '@orb-labs/orby-react';
+import type { ExternallyOwnedAccount } from 'src/shared/types/ExternallyOwnedAccount';
+import type { GasTokenInput } from 'src/ui/pages/SendTransaction/NetworkFee/NetworkFee';
+import { useOperationSetError } from './useOperationSetError';
+
+export function useOrbyGetOperationsToSignTransactionOrSignTypedData(
+ populatedTransaction: IncomingTransactionWithChainId | undefined,
+ wallet: ExternallyOwnedAccount,
+ isOrbyEnabled: boolean | undefined,
+ selectedGasToken: GasTokenInput | undefined
+) {
+ const gasToken = useMemo(() => {
+ return selectedGasToken?.standardizedTokenId
+ ? { standardizedTokenId: selectedGasToken.standardizedTokenId }
+ : undefined;
+ }, [selectedGasToken]);
+
+ const { operationSet, isLoading: operationSetLoading } =
+ useGetOperationsToSignTransactionOrSignTypedData(
+ populatedTransaction?.data as string,
+ populatedTransaction?.to as string,
+ populatedTransaction?.value
+ ? BigInt(populatedTransaction?.value.toString())
+ : undefined,
+ isOrbyEnabled ? (wallet?.address as string) : undefined,
+ isOrbyEnabled && populatedTransaction?.chainId
+ ? BigInt(populatedTransaction?.chainId as number)
+ : undefined,
+ gasToken
+ );
+
+ const operationSetError = useOperationSetError(operationSet, isOrbyEnabled);
+
+ return { operationSet, operationSetError, operationSetLoading };
+}