+ {isLoading || !token ? (
+ <>
+
{label}
+
+
+ >
+ ) : (
+ <>
+
{label}
+ {tooltipContents && (
+
+ )}
+
+ {!!usdValue && (
+
+ {typeof usdValue === "string"
+ ? usdValue
+ : `$${usdValue.toFixed(2)}`}
+
+ )}
+
+ {!!amount && (
+
+ {`${amount.toLocaleString(undefined, {
+ maximumFractionDigits: decimals ?? 2,
+ })}`}
+
+ )}
+
+ {token.metadata.symbol}
+
+ >
+ )}
+
+ );
+ },
+);
diff --git a/packages/keychain/src/components/Total.tsx b/packages/keychain/src/components/Total.tsx
deleted file mode 100644
index 867906bf25..0000000000
--- a/packages/keychain/src/components/Total.tsx
+++ /dev/null
@@ -1,89 +0,0 @@
-import {
- Thumbnail,
- InfoIcon,
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
- Spinner,
-} from "@cartridge/ui";
-import { cn } from "@cartridge/ui/utils";
-import { ERC20 } from "./provider/tokens";
-
-export function Total({
- label,
- token,
- totalValue,
- decimals,
- usdValue,
- tooltipContents,
- isLoading,
- className,
-}: {
- label: string;
- token: ERC20 | undefined;
- totalValue: number;
- decimals?: number;
- usdValue?: number | string;
- tooltipContents?: React.ReactNode;
- isLoading?: boolean;
- className?: string;
-}) {
- return (
-
- {isLoading || !token ? (
-
- ) : (
- <>
-
-
{label}
- {tooltipContents &&
}
-
- {!!usdValue && (
-
- {typeof usdValue === "string"
- ? usdValue
- : `$${usdValue.toFixed(2)}`}
-
- )}
- {!!totalValue && (
-
- {`${totalValue.toLocaleString(undefined, {
- maximumFractionDigits: decimals ?? 2,
- })}`}
-
- )}
-
-
- >
- )}
-
- );
-}
-
-const TotalTooltip = ({ contents }: { contents: React.ReactNode }) => {
- return (
-
-
-
({
- name: args.name,
- amount: args.finalPrice.toFixed(fixedValue),
- }))}
- />
) : null}
diff --git a/packages/keychain/src/components/inventory/collection/collection-purchase.tsx b/packages/keychain/src/components/inventory/collection/collection-purchase.tsx
index 6e24407213..8872fde3a3 100644
--- a/packages/keychain/src/components/inventory/collection/collection-purchase.tsx
+++ b/packages/keychain/src/components/inventory/collection/collection-purchase.tsx
@@ -3,7 +3,7 @@ import {
LayoutContent,
Skeleton,
Empty,
- TagIcon,
+ ShopIcon,
Token,
Thumbnail,
ThumbnailCollectible,
@@ -38,7 +38,7 @@ import {
} from "@cartridge/arcade/marketplace/react";
import { StatusType } from "@cartridge/arcade";
import { ArcadeContext } from "@/context/arcade";
-import { CollectionFooter } from "./footer";
+import { usePurchaseFeesData } from "./footer";
export function CollectionPurchase() {
const { address: contractAddress, tokenId } = useParams();
@@ -322,6 +322,17 @@ export function CollectionPurchase() {
setAmount(newAmount);
}, [tokenOrders]);
+ const feesData = usePurchaseFeesData({
+ token,
+ fees,
+ totalPrice: floatPrice,
+ feeDecimals: fixedFeeValue,
+ orders: props.map((args) => ({
+ name: args.name,
+ amount: args.finalPrice.toFixed(fixedValue),
+ })),
+ });
+
return (
<>
{status === "loading" || !tokenContract ? (
@@ -333,17 +344,12 @@ export function CollectionPurchase() {
{buildTransactions ? (
- }
+ icon={
}
transactions={buildTransactions}
onSubmit={onSubmitPurchase}
onCancel={canGoBack ? goBack : closeModal}
buttonText="Confirm"
+ additionalFees={[feesData]}
>
-
-
({
- name: args.name,
- amount: args.finalPrice.toFixed(fixedValue),
- }))}
- />
) : null}
diff --git a/packages/keychain/src/components/inventory/collection/footer.tsx b/packages/keychain/src/components/inventory/collection/footer.tsx
index 0d090f15ec..b7818b9660 100644
--- a/packages/keychain/src/components/inventory/collection/footer.tsx
+++ b/packages/keychain/src/components/inventory/collection/footer.tsx
@@ -1,36 +1,31 @@
import { Token, Separator } from "@cartridge/ui";
-import { Total } from "@/components/Total";
-import { ERC20 } from "@/components/provider/tokens";
import { useMemo } from "react";
import { useCountervalue } from "@cartridge/ui/utils";
+import { FeesData } from "@/components/Fees";
-export function CollectionFooter({
+export function usePurchaseFeesData({
token,
orders,
fees,
totalPrice,
feeDecimals,
}: {
- token: Token;
+ token: Token | undefined;
orders: { name: string; amount: string }[];
fees: { label: string; amount: number; percentage: number }[];
totalPrice: number;
feeDecimals?: number;
-}) {
- const erc20 = {
- address: token.metadata.address,
- symbol: token.metadata.symbol,
- icon: token.metadata.image,
- } as ERC20;
-
+}): FeesData {
const tokenData = useMemo(
() => ({
- tokens: [
- {
- balance: totalPrice.toString(),
- address: token.metadata.address,
- },
- ],
+ tokens: token
+ ? [
+ {
+ balance: totalPrice.toString(),
+ address: token.metadata.address,
+ },
+ ]
+ : [],
}),
[totalPrice, token],
);
@@ -40,52 +35,55 @@ export function CollectionFooter({
[countervalues],
);
- return (
-
-
- {orders.map((order, i) => (
+ const tooltipContents = useMemo(
+ () => (
+
+
+ {orders.map((order, i) => (
+
+ {order.name}
+
+ {order.amount} {token?.metadata.symbol}
+
+
+ ))}
+
+
+
+ {fees
+ .filter((fee) => fee.amount > 0)
+ .map((fee) => (
-
{order.name}
-
- {order.amount} {token.metadata.symbol}
-
+ {fee.label}
+
+
+ {fee.amount.toLocaleString(undefined, {
+ maximumFractionDigits: feeDecimals,
+ })}
+
+ {token?.metadata.symbol}
+ ({fee.percentage.toFixed(2)}%)
+
))}
-
-
-
- {fees
- .filter((fee) => fee.amount > 0)
- .map((fee) => (
-
- {fee.label}
-
-
- {fee.amount.toLocaleString(undefined, {
- maximumFractionDigits: feeDecimals,
- })}
-
- {token.metadata.symbol}
- ({fee.percentage.toFixed(2)}%)
-
-
- ))}
-
- }
- />
+
+ ),
+ [orders, fees, token, feeDecimals],
);
+
+ return {
+ label: "Total",
+ contractAddress: token?.metadata.address ?? "0x0",
+ amount: totalPrice,
+ decimals: feeDecimals,
+ usdValue,
+ tooltipContents,
+ };
}
diff --git a/packages/keychain/src/components/session/MessageCard.tsx b/packages/keychain/src/components/session/MessageCard.tsx
index 12c904e25f..e0f64f2b0f 100644
--- a/packages/keychain/src/components/session/MessageCard.tsx
+++ b/packages/keychain/src/components/session/MessageCard.tsx
@@ -1,5 +1,4 @@
-import { useCreateSession } from "@/hooks/session";
-import type { SignMessagePolicy } from "@cartridge/presets";
+import { type PropsWithChildren, useState, useEffect } from "react";
import {
Accordion,
AccordionContent,
@@ -11,12 +10,13 @@ import {
Thumbnail,
} from "@cartridge/ui";
import { cn } from "@cartridge/ui/utils";
+import { useCreateSession } from "@/hooks/session";
import { ArrowTurnDownIcon, Badge } from "@cartridge/ui";
+import type { SignMessagePolicy } from "@cartridge/presets";
import type {
StarknetEnumType,
StarknetMerkleType,
} from "@starknet-io/types-js";
-import { type PropsWithChildren, useState, useEffect } from "react";
interface MessageCardProps {
messages: SignMessagePolicyWithEnabled[];
diff --git a/packages/keychain/src/components/swap/ConfirmSwap.tsx b/packages/keychain/src/components/swap/ConfirmSwap.tsx
new file mode 100644
index 0000000000..296f0ae8af
--- /dev/null
+++ b/packages/keychain/src/components/swap/ConfirmSwap.tsx
@@ -0,0 +1,121 @@
+import { useState } from "react";
+import {
+ Button,
+ GearIcon,
+ LayoutContent,
+ TokenCard,
+ TokenSummary,
+ TransferIcon,
+} from "@cartridge/ui";
+import { TransactionSummary } from "@/components/transaction/TransactionSummary";
+import { ControllerError } from "@/utils/connection";
+import { Call, FeeEstimate } from "starknet";
+import { ExecutionContainer } from "@/components/ExecutionContainer";
+import { useSwapTransactions } from "@/components/swap/swap";
+import { TokenSwapData, useTokenSwapData } from "@/hooks/token";
+import placeholder from "/placeholder.svg?url";
+
+interface ConfirmSwapProps {
+ onSubmit: (maxFee?: FeeEstimate) => Promise;
+ onError?: (error: ControllerError) => void;
+ transactions: Call[];
+ executionError?: ControllerError;
+ origin: string;
+}
+
+export function ConfirmSwap({
+ onSubmit,
+ onError,
+ transactions,
+ executionError,
+ origin,
+}: ConfirmSwapProps) {
+ const [advancedVisible, setAdvancedVisible] = useState(false);
+
+ const { isSwap, swapTransactions, additionalMethodCount } =
+ useSwapTransactions(transactions);
+ const { tokenSwapData: sellingSwapData } = useTokenSwapData(
+ swapTransactions.selling,
+ );
+ const { tokenSwapData: buyingSwapData } = useTokenSwapData(
+ swapTransactions.buying,
+ );
+
+ const formatAmount = (token: TokenSwapData) => {
+ return `${token.amount.toLocaleString(undefined, { maximumFractionDigits: 5 })} ${token.symbol}`;
+ };
+
+ const formatValue = (token: TokenSwapData) => {
+ return !token.value
+ ? "$0.00"
+ : token.value < 0.01
+ ? "<$0.01"
+ : `~$${token.value.toFixed(2)}`;
+ };
+
+ return (
+ }
+ title={"Review Swap"}
+ description={origin}
+ transactions={transactions}
+ executionError={executionError}
+ onSubmit={onSubmit}
+ onError={onError}
+ buttonText={`Swap ${additionalMethodCount > 0 ? `+ ${additionalMethodCount}` : ""}`}
+ right={
+ !advancedVisible ? (
+
+ ) : undefined
+ }
+ additionalFees={sellingSwapData.map((token) => ({
+ label: "Total",
+ contractAddress: token.address,
+ amount: token?.amount ?? 0,
+ usdValue: formatValue(token),
+ decimals: 2,
+ }))}
+ >
+
+ {!isSwap ? (
+
+ ) : (
+ <>
+
+ {sellingSwapData.map((token) => (
+
+ ))}
+ {buyingSwapData.map((token) => (
+
+ ))}
+
+ {advancedVisible && }
+ >
+ )}
+
+
+ );
+}
diff --git a/packages/keychain/src/components/swap/ekuboRouterAbi.json b/packages/keychain/src/components/swap/ekuboRouterAbi.json
new file mode 100644
index 0000000000..ac84ff3f06
--- /dev/null
+++ b/packages/keychain/src/components/swap/ekuboRouterAbi.json
@@ -0,0 +1,482 @@
+[
+ {
+ "type": "impl",
+ "name": "LockerImpl",
+ "interface_name": "ekubo::interfaces::core::ILocker"
+ },
+ {
+ "type": "struct",
+ "name": "core::array::Span::",
+ "members": [
+ {
+ "name": "snapshot",
+ "type": "@core::array::Array::"
+ }
+ ]
+ },
+ {
+ "type": "interface",
+ "name": "ekubo::interfaces::core::ILocker",
+ "items": [
+ {
+ "type": "function",
+ "name": "locked",
+ "inputs": [
+ {
+ "name": "id",
+ "type": "core::integer::u32"
+ },
+ {
+ "name": "data",
+ "type": "core::array::Span::"
+ }
+ ],
+ "outputs": [
+ {
+ "type": "core::array::Span::"
+ }
+ ],
+ "state_mutability": "external"
+ }
+ ]
+ },
+ {
+ "type": "impl",
+ "name": "RouterImpl",
+ "interface_name": "ekubo::router::IRouter"
+ },
+ {
+ "type": "struct",
+ "name": "ekubo::types::keys::PoolKey",
+ "members": [
+ {
+ "name": "token0",
+ "type": "core::starknet::contract_address::ContractAddress"
+ },
+ {
+ "name": "token1",
+ "type": "core::starknet::contract_address::ContractAddress"
+ },
+ {
+ "name": "fee",
+ "type": "core::integer::u128"
+ },
+ {
+ "name": "tick_spacing",
+ "type": "core::integer::u128"
+ },
+ {
+ "name": "extension",
+ "type": "core::starknet::contract_address::ContractAddress"
+ }
+ ]
+ },
+ {
+ "type": "struct",
+ "name": "core::integer::u256",
+ "members": [
+ {
+ "name": "low",
+ "type": "core::integer::u128"
+ },
+ {
+ "name": "high",
+ "type": "core::integer::u128"
+ }
+ ]
+ },
+ {
+ "type": "struct",
+ "name": "ekubo::router::RouteNode",
+ "members": [
+ {
+ "name": "pool_key",
+ "type": "ekubo::types::keys::PoolKey"
+ },
+ {
+ "name": "sqrt_ratio_limit",
+ "type": "core::integer::u256"
+ },
+ {
+ "name": "skip_ahead",
+ "type": "core::integer::u128"
+ }
+ ]
+ },
+ {
+ "type": "enum",
+ "name": "core::bool",
+ "variants": [
+ {
+ "name": "False",
+ "type": "()"
+ },
+ {
+ "name": "True",
+ "type": "()"
+ }
+ ]
+ },
+ {
+ "type": "struct",
+ "name": "ekubo::types::i129::i129",
+ "members": [
+ {
+ "name": "mag",
+ "type": "core::integer::u128"
+ },
+ {
+ "name": "sign",
+ "type": "core::bool"
+ }
+ ]
+ },
+ {
+ "type": "struct",
+ "name": "ekubo::router::TokenAmount",
+ "members": [
+ {
+ "name": "token",
+ "type": "core::starknet::contract_address::ContractAddress"
+ },
+ {
+ "name": "amount",
+ "type": "ekubo::types::i129::i129"
+ }
+ ]
+ },
+ {
+ "type": "struct",
+ "name": "ekubo::types::delta::Delta",
+ "members": [
+ {
+ "name": "amount0",
+ "type": "ekubo::types::i129::i129"
+ },
+ {
+ "name": "amount1",
+ "type": "ekubo::types::i129::i129"
+ }
+ ]
+ },
+ {
+ "type": "struct",
+ "name": "ekubo::router::Swap",
+ "members": [
+ {
+ "name": "route",
+ "type": "core::array::Array::"
+ },
+ {
+ "name": "token_amount",
+ "type": "ekubo::router::TokenAmount"
+ }
+ ]
+ },
+ {
+ "type": "struct",
+ "name": "ekubo::router::Depth",
+ "members": [
+ {
+ "name": "token0",
+ "type": "core::integer::u128"
+ },
+ {
+ "name": "token1",
+ "type": "core::integer::u128"
+ }
+ ]
+ },
+ {
+ "type": "interface",
+ "name": "ekubo::router::IRouter",
+ "items": [
+ {
+ "type": "function",
+ "name": "swap",
+ "inputs": [
+ {
+ "name": "node",
+ "type": "ekubo::router::RouteNode"
+ },
+ {
+ "name": "token_amount",
+ "type": "ekubo::router::TokenAmount"
+ }
+ ],
+ "outputs": [
+ {
+ "type": "ekubo::types::delta::Delta"
+ }
+ ],
+ "state_mutability": "external"
+ },
+ {
+ "type": "function",
+ "name": "multihop_swap",
+ "inputs": [
+ {
+ "name": "route",
+ "type": "core::array::Array::"
+ },
+ {
+ "name": "token_amount",
+ "type": "ekubo::router::TokenAmount"
+ }
+ ],
+ "outputs": [
+ {
+ "type": "core::array::Array::"
+ }
+ ],
+ "state_mutability": "external"
+ },
+ {
+ "type": "function",
+ "name": "multi_multihop_swap",
+ "inputs": [
+ {
+ "name": "swaps",
+ "type": "core::array::Array::"
+ }
+ ],
+ "outputs": [
+ {
+ "type": "core::array::Array::>"
+ }
+ ],
+ "state_mutability": "external"
+ },
+ {
+ "type": "function",
+ "name": "quote",
+ "inputs": [
+ {
+ "name": "swaps",
+ "type": "core::array::Array::"
+ }
+ ],
+ "outputs": [
+ {
+ "type": "core::array::Array::>"
+ }
+ ],
+ "state_mutability": "external"
+ },
+ {
+ "type": "function",
+ "name": "get_delta_to_sqrt_ratio",
+ "inputs": [
+ {
+ "name": "pool_key",
+ "type": "ekubo::types::keys::PoolKey"
+ },
+ {
+ "name": "sqrt_ratio",
+ "type": "core::integer::u256"
+ }
+ ],
+ "outputs": [
+ {
+ "type": "ekubo::types::delta::Delta"
+ }
+ ],
+ "state_mutability": "view"
+ },
+ {
+ "type": "function",
+ "name": "get_market_depth",
+ "inputs": [
+ {
+ "name": "pool_key",
+ "type": "ekubo::types::keys::PoolKey"
+ },
+ {
+ "name": "sqrt_percent",
+ "type": "core::integer::u128"
+ }
+ ],
+ "outputs": [
+ {
+ "type": "ekubo::router::Depth"
+ }
+ ],
+ "state_mutability": "view"
+ },
+ {
+ "type": "function",
+ "name": "get_market_depth_v2",
+ "inputs": [
+ {
+ "name": "pool_key",
+ "type": "ekubo::types::keys::PoolKey"
+ },
+ {
+ "name": "percent_64x64",
+ "type": "core::integer::u128"
+ }
+ ],
+ "outputs": [
+ {
+ "type": "ekubo::router::Depth"
+ }
+ ],
+ "state_mutability": "view"
+ },
+ {
+ "type": "function",
+ "name": "get_market_depth_at_sqrt_ratio",
+ "inputs": [
+ {
+ "name": "pool_key",
+ "type": "ekubo::types::keys::PoolKey"
+ },
+ {
+ "name": "sqrt_ratio",
+ "type": "core::integer::u256"
+ },
+ {
+ "name": "percent_64x64",
+ "type": "core::integer::u128"
+ }
+ ],
+ "outputs": [
+ {
+ "type": "ekubo::router::Depth"
+ }
+ ],
+ "state_mutability": "view"
+ }
+ ]
+ },
+ {
+ "type": "impl",
+ "name": "Clear",
+ "interface_name": "ekubo::components::clear::IClear"
+ },
+ {
+ "type": "struct",
+ "name": "ekubo::interfaces::erc20::IERC20Dispatcher",
+ "members": [
+ {
+ "name": "contract_address",
+ "type": "core::starknet::contract_address::ContractAddress"
+ }
+ ]
+ },
+ {
+ "type": "interface",
+ "name": "ekubo::components::clear::IClear",
+ "items": [
+ {
+ "type": "function",
+ "name": "clear",
+ "inputs": [
+ {
+ "name": "token",
+ "type": "ekubo::interfaces::erc20::IERC20Dispatcher"
+ }
+ ],
+ "outputs": [
+ {
+ "type": "core::integer::u256"
+ }
+ ],
+ "state_mutability": "view"
+ },
+ {
+ "type": "function",
+ "name": "clear_minimum",
+ "inputs": [
+ {
+ "name": "token",
+ "type": "ekubo::interfaces::erc20::IERC20Dispatcher"
+ },
+ {
+ "name": "minimum",
+ "type": "core::integer::u256"
+ }
+ ],
+ "outputs": [
+ {
+ "type": "core::integer::u256"
+ }
+ ],
+ "state_mutability": "view"
+ },
+ {
+ "type": "function",
+ "name": "clear_minimum_to_recipient",
+ "inputs": [
+ {
+ "name": "token",
+ "type": "ekubo::interfaces::erc20::IERC20Dispatcher"
+ },
+ {
+ "name": "minimum",
+ "type": "core::integer::u256"
+ },
+ {
+ "name": "recipient",
+ "type": "core::starknet::contract_address::ContractAddress"
+ }
+ ],
+ "outputs": [
+ {
+ "type": "core::integer::u256"
+ }
+ ],
+ "state_mutability": "view"
+ }
+ ]
+ },
+ {
+ "type": "impl",
+ "name": "Expires",
+ "interface_name": "ekubo::components::expires::IExpires"
+ },
+ {
+ "type": "interface",
+ "name": "ekubo::components::expires::IExpires",
+ "items": [
+ {
+ "type": "function",
+ "name": "expires",
+ "inputs": [
+ {
+ "name": "at",
+ "type": "core::integer::u64"
+ }
+ ],
+ "outputs": [],
+ "state_mutability": "view"
+ }
+ ]
+ },
+ {
+ "type": "struct",
+ "name": "ekubo::interfaces::core::ICoreDispatcher",
+ "members": [
+ {
+ "name": "contract_address",
+ "type": "core::starknet::contract_address::ContractAddress"
+ }
+ ]
+ },
+ {
+ "type": "constructor",
+ "name": "constructor",
+ "inputs": [
+ {
+ "name": "core",
+ "type": "ekubo::interfaces::core::ICoreDispatcher"
+ }
+ ]
+ },
+ {
+ "type": "event",
+ "name": "ekubo::router::Router::Event",
+ "kind": "enum",
+ "variants": []
+ }
+]
diff --git a/packages/keychain/src/components/swap/swap.test.ts b/packages/keychain/src/components/swap/swap.test.ts
new file mode 100644
index 0000000000..4cf92df8f7
--- /dev/null
+++ b/packages/keychain/src/components/swap/swap.test.ts
@@ -0,0 +1,470 @@
+import { describe, it, expect } from "vitest";
+import { renderHook } from "@testing-library/react";
+import { getChecksumAddress, type Call } from "starknet";
+import { useIsSwapTransaction, useSwapTransactions } from "./swap";
+
+// Transactions mirrored from examples/next/src/components/Swap.tsx
+const SWAP_SINGLE: Call[] = [
+ {
+ contractAddress:
+ "0x0124aeb495b947201f5faC96fD1138E326AD86195B98df6DEc9009158A533B49",
+ entrypoint: "transfer",
+ calldata: [
+ "0x04505a9f06f2bd639b6601f37a4dc0908bb70e8e0e0c34b1220827d64f4fc066",
+ "0x32a03ab37fef8ba51",
+ "0x0",
+ ],
+ },
+ {
+ contractAddress:
+ "0x04505a9f06f2bd639b6601f37a4dc0908bb70e8e0e0c34b1220827d64f4fc066",
+ entrypoint: "multihop_swap",
+ calldata: [
+ "0x2",
+ "0x016dea82a6588ca9fb7200125fa05631b1c1735a313e24afe9c90301e441a796",
+ "0x042dd777885ad2c116be96d4d634abc90a26a790ffb5871e037dd5ae7d2ec86b",
+ "17014118346046924117642026945517453312",
+ "0x56a4c",
+ "0x043e4f09c32d13d43a880e85f69f7de93ceda62d6cf2581a582c6db635548fdc",
+ "0x6f3528fe26840249f4b191ef6dff7928",
+ "0xfffffc080ed7b455",
+ 0,
+ "0x0124aeb495b947201f5fac96fd1138e326ad86195b98df6dec9009158a533b49",
+ "0x042dd777885ad2c116be96d4d634abc90a26a790ffb5871e037dd5ae7d2ec86b",
+ "3402823669209384634633746074317682114",
+ "0x56a4c",
+ "0x043e4f09c32d13d43a880e85f69f7de93ceda62d6cf2581a582c6db635548fdc",
+ "0x1000003f7f1380b75",
+ "0x0",
+ 0,
+ "0x016dea82a6588ca9fb7200125fa05631b1c1735a313e24afe9c90301e441a796",
+ "0xa688906bd8b00000",
+ "0x1",
+ ],
+ },
+ {
+ contractAddress:
+ "0x04505a9f06f2bd639b6601f37a4dc0908bb70e8e0e0c34b1220827d64f4fc066",
+ entrypoint: "clear_minimum",
+ calldata: [
+ "0x016dea82a6588ca9fb7200125fa05631b1c1735a313e24afe9c90301e441a796",
+ "0xa4de3d0e9ba40000",
+ "0x0",
+ ],
+ },
+ {
+ contractAddress:
+ "0x04505a9f06f2bd639b6601f37a4dc0908bb70e8e0e0c34b1220827d64f4fc066",
+ entrypoint: "clear",
+ calldata: [
+ "0x0124aeb495b947201f5faC96fD1138E326AD86195B98df6DEc9009158A533B49",
+ ],
+ },
+];
+
+const SWAP_MULTIPLE: Call[] = [
+ {
+ contractAddress:
+ "0x0124aeb495b947201f5faC96fD1138E326AD86195B98df6DEc9009158A533B49",
+ entrypoint: "transfer",
+ calldata: [
+ "0x04505a9f06f2bd639b6601f37a4dc0908bb70e8e0e0c34b1220827d64f4fc066",
+ "0x176e9649d99dd740a",
+ "0x0",
+ ],
+ },
+ {
+ contractAddress:
+ "0x04505a9f06f2bd639b6601f37a4dc0908bb70e8e0e0c34b1220827d64f4fc066",
+ entrypoint: "multihop_swap",
+ calldata: [
+ "0x2",
+ "0x01c3c8284d7eed443b42f47e764032a56eaf50a9079d67993b633930e3689814",
+ "0x075afe6402ad5a5c20dd25e10ec3b3986acaa647b77e4ae24b0cbc9a54a27a87",
+ "0",
+ "0x56a4c",
+ "0x005e470ff654d834983a46b8f29dfa99963d5044b993cb7b9c92243a69dab38f",
+ "0x6f3528fe26840249f4b191ef6dff7928",
+ "0xfffffc080ed7b455",
+ 0,
+ "0x0124aeb495b947201f5fac96fd1138e326ad86195b98df6dec9009158a533b49",
+ "0x075afe6402ad5a5c20dd25e10ec3b3986acaa647b77e4ae24b0cbc9a54a27a87",
+ "1020847100762815411640772995208708096",
+ "0x56a4c",
+ "0x043e4f09c32d13d43a880e85f69f7de93ceda62d6cf2581a582c6db635548fdc",
+ "0x1000003f7f1380b75",
+ "0x0",
+ 0,
+ "0x01c3c8284d7eed443b42f47e764032a56eaf50a9079d67993b633930e3689814",
+ "0xde0b6b3a7640000",
+ "0x1",
+ ],
+ },
+ {
+ contractAddress:
+ "0x04505a9f06f2bd639b6601f37a4dc0908bb70e8e0e0c34b1220827d64f4fc066",
+ entrypoint: "clear_minimum",
+ calldata: [
+ "0x01c3c8284d7eed443b42f47e764032a56eaf50a9079d67993b633930e3689814",
+ "0xdbd2fc137a30000",
+ "0x0",
+ ],
+ },
+ {
+ contractAddress:
+ "0x04505a9f06f2bd639b6601f37a4dc0908bb70e8e0e0c34b1220827d64f4fc066",
+ entrypoint: "clear",
+ calldata: [
+ "0x0124aeb495b947201f5faC96fD1138E326AD86195B98df6DEc9009158A533B49",
+ ],
+ },
+ {
+ contractAddress:
+ "0x0124aeb495b947201f5faC96fD1138E326AD86195B98df6DEc9009158A533B49",
+ entrypoint: "transfer",
+ calldata: [
+ "0x04505a9f06f2bd639b6601f37a4dc0908bb70e8e0e0c34b1220827d64f4fc066",
+ "0x4f1eba34861ddd0",
+ "0x0",
+ ],
+ },
+ {
+ contractAddress:
+ "0x04505a9f06f2bd639b6601f37a4dc0908bb70e8e0e0c34b1220827d64f4fc066",
+ entrypoint: "multihop_swap",
+ calldata: [
+ "0x2",
+ "0x0103eafe79f8631932530cc687dfcdeb013c883a82619ebf81be393e2953a87a",
+ "0x042dd777885ad2c116be96d4d634abc90a26a790ffb5871e037dd5ae7d2ec86b",
+ "17014118346046923173168730371588410572",
+ "0x56a4c",
+ "0x043e4f09c32d13d43a880e85f69f7de93ceda62d6cf2581a582c6db635548fdc",
+ "0x6f3528fe26840249f4b191ef6dff7928",
+ "0xfffffc080ed7b455",
+ 0,
+ "0x0124aeb495b947201f5fac96fd1138e326ad86195b98df6dec9009158a533b49",
+ "0x042dd777885ad2c116be96d4d634abc90a26a790ffb5871e037dd5ae7d2ec86b",
+ "17014118346046923173168730371588410572",
+ "0x1744e",
+ "0x0000000000000000000000000000000000000000000000000000000000000000",
+ "0xf7c31547edfb13af0071dfd6ffe",
+ "0x0",
+ 0,
+ "0x0103eafe79f8631932530cc687dfcdeb013c883a82619ebf81be393e2953a87a",
+ "0xde0b6b3a7640000",
+ "0x1",
+ ],
+ },
+ {
+ contractAddress:
+ "0x04505a9f06f2bd639b6601f37a4dc0908bb70e8e0e0c34b1220827d64f4fc066",
+ entrypoint: "clear_minimum",
+ calldata: [
+ "0x0103eafe79f8631932530cc687dfcdeb013c883a82619ebf81be393e2953a87a",
+ "0xdbd2fc137a30000",
+ "0x0",
+ ],
+ },
+ {
+ contractAddress:
+ "0x04505a9f06f2bd639b6601f37a4dc0908bb70e8e0e0c34b1220827d64f4fc066",
+ entrypoint: "clear",
+ calldata: [
+ "0x0124aeb495b947201f5faC96fD1138E326AD86195B98df6DEc9009158A533B49",
+ ],
+ },
+];
+
+const LS2_PURCHASE_GAME: Call[] = [
+ {
+ contractAddress:
+ "0x0124aeb495b947201f5fac96fd1138e326ad86195b98df6dec9009158a533b49",
+ entrypoint: "transfer",
+ calldata: [
+ "0x0199741822c2dc722f6f605204f35e56dbc23bceed54818168c4c49e4fb8737e",
+ "0x5bbb37da193af4ba9",
+ "0x0",
+ ],
+ },
+ {
+ contractAddress:
+ "0x0199741822c2dc722f6f605204f35e56dbc23bceed54818168c4c49e4fb8737e",
+ entrypoint: "multihop_swap",
+ calldata: [
+ "0x1",
+ "0x0124aeb495b947201f5fac96fd1138e326ad86195b98df6dec9009158a533b49",
+ "0x0452810188c4cb3aebd63711a3b445755bc0d6c4f27b923fdd99b1a118858136",
+ "0",
+ "0x56a4c",
+ "0x043e4f09c32d13d43a880e85f69f7de93ceda62d6cf2581a582c6db635548fdc",
+ "0x1000003f7f1380b75",
+ "0x0",
+ 0,
+ "0x0452810188C4Cb3AEbD63711a3b445755BC0D6C4f27B923fDd99B1A118858136",
+ "0xde0b6b3a7640000",
+ "0x1",
+ ],
+ },
+ {
+ contractAddress:
+ "0x0199741822c2dc722f6f605204f35e56dbc23bceed54818168c4c49e4fb8737e",
+ entrypoint: "clear_minimum",
+ calldata: [
+ "1955023220287003686908448593668771782622329060199208410425295899940041883958",
+ "1000000000000000000",
+ "0",
+ ],
+ },
+ {
+ contractAddress:
+ "0x0199741822c2dc722f6f605204f35e56dbc23bceed54818168c4c49e4fb8737e",
+ entrypoint: "clear",
+ calldata: [
+ "0x0124aeb495b947201f5fac96fd1138e326ad86195b98df6dec9009158a533b49",
+ ],
+ },
+ {
+ contractAddress:
+ "0x0452810188C4Cb3AEbD63711a3b445755BC0D6C4f27B923fDd99B1A118858136",
+ entrypoint: "approve",
+ calldata: [
+ "294172758298611957878874535440244936028848058202724233951972339591192112194",
+ "1000000000000000000",
+ "0",
+ ],
+ },
+ {
+ contractAddress:
+ "0x00a67ef20b61a9846e1c82b411175e6ab167ea9f8632bd6c2091823c3629ec42",
+ entrypoint: "buy_game",
+ calldata: [
+ "0",
+ "0",
+ "2017717448871504735845",
+ "2403140985568399978641699320335980224292375691718886561247325844102368719999",
+ "0",
+ ],
+ },
+];
+
+describe("useIsSwapTransaction", () => {
+ it("returns false for empty transactions", () => {
+ const { result } = renderHook(() => useIsSwapTransaction([]));
+ expect(result.current.isSwap).toBe(false);
+ });
+
+ it("returns false for fewer than 4 transactions", () => {
+ const { result } = renderHook(() =>
+ useIsSwapTransaction([
+ { contractAddress: "0x1", entrypoint: "transfer", calldata: [] },
+ { contractAddress: "0x1", entrypoint: "multihop_swap", calldata: [] },
+ { contractAddress: "0x1", entrypoint: "clear_minimum", calldata: [] },
+ ]),
+ );
+ expect(result.current.isSwap).toBe(false);
+ });
+
+ it("returns false when transfer entrypoint is missing", () => {
+ const { result } = renderHook(() =>
+ useIsSwapTransaction([
+ { contractAddress: "0x1", entrypoint: "approve", calldata: [] },
+ { contractAddress: "0x1", entrypoint: "multihop_swap", calldata: [] },
+ { contractAddress: "0x1", entrypoint: "clear_minimum", calldata: [] },
+ { contractAddress: "0x1", entrypoint: "clear", calldata: [] },
+ ]),
+ );
+ expect(result.current.isSwap).toBe(false);
+ });
+
+ it("returns false when multihop_swap entrypoint is missing", () => {
+ const { result } = renderHook(() =>
+ useIsSwapTransaction([
+ { contractAddress: "0x1", entrypoint: "transfer", calldata: [] },
+ { contractAddress: "0x1", entrypoint: "other", calldata: [] },
+ { contractAddress: "0x1", entrypoint: "clear_minimum", calldata: [] },
+ { contractAddress: "0x1", entrypoint: "clear", calldata: [] },
+ ]),
+ );
+ expect(result.current.isSwap).toBe(false);
+ });
+
+ it("returns false when clear_minimum entrypoint is missing", () => {
+ const { result } = renderHook(() =>
+ useIsSwapTransaction([
+ { contractAddress: "0x1", entrypoint: "transfer", calldata: [] },
+ { contractAddress: "0x1", entrypoint: "multihop_swap", calldata: [] },
+ { contractAddress: "0x1", entrypoint: "other", calldata: [] },
+ { contractAddress: "0x1", entrypoint: "clear", calldata: [] },
+ ]),
+ );
+ expect(result.current.isSwap).toBe(false);
+ });
+
+ it("returns false when clear entrypoint is missing", () => {
+ const { result } = renderHook(() =>
+ useIsSwapTransaction([
+ { contractAddress: "0x1", entrypoint: "transfer", calldata: [] },
+ { contractAddress: "0x1", entrypoint: "multihop_swap", calldata: [] },
+ { contractAddress: "0x1", entrypoint: "clear_minimum", calldata: [] },
+ { contractAddress: "0x1", entrypoint: "other", calldata: [] },
+ ]),
+ );
+ expect(result.current.isSwap).toBe(false);
+ });
+
+ it("returns true for SWAP_SINGLE", () => {
+ const { result } = renderHook(() => useIsSwapTransaction(SWAP_SINGLE));
+ expect(result.current.isSwap).toBe(true);
+ });
+
+ it("returns true for SWAP_MULTIPLE", () => {
+ const { result } = renderHook(() => useIsSwapTransaction(SWAP_MULTIPLE));
+ expect(result.current.isSwap).toBe(true);
+ });
+
+ it("returns true for LS2_PURCHASE_GAME (swap + additional calls)", () => {
+ const { result } = renderHook(() =>
+ useIsSwapTransaction(LS2_PURCHASE_GAME),
+ );
+ expect(result.current.isSwap).toBe(true);
+ });
+});
+
+describe("useSwapTransactions", () => {
+ it("returns correct defaults for empty transactions", () => {
+ const { result } = renderHook(() => useSwapTransactions([]));
+ expect(result.current.isSwap).toBe(false);
+ expect(result.current.swapMethodCount).toBe(0);
+ expect(result.current.additionalMethodCount).toBe(0);
+ expect(result.current.swapTransactions.selling).toHaveLength(0);
+ expect(result.current.swapTransactions.buying).toHaveLength(0);
+ });
+
+ describe("SWAP_SINGLE", () => {
+ it("is detected as a swap", () => {
+ const { result } = renderHook(() => useSwapTransactions(SWAP_SINGLE));
+ expect(result.current.isSwap).toBe(true);
+ });
+
+ it("counts 4 swap methods and 0 additional calls", () => {
+ const { result } = renderHook(() => useSwapTransactions(SWAP_SINGLE));
+ expect(result.current.swapMethodCount).toBe(4);
+ expect(result.current.additionalMethodCount).toBe(0);
+ });
+
+ it("has one selling token (the transferred token)", () => {
+ const { result } = renderHook(() => useSwapTransactions(SWAP_SINGLE));
+ expect(result.current.swapTransactions.selling).toHaveLength(1);
+ expect(result.current.swapTransactions.selling[0].address).toBe(
+ getChecksumAddress(
+ "0x0124aeb495b947201f5faC96fD1138E326AD86195B98df6DEc9009158A533B49",
+ ),
+ );
+ });
+
+ it("has one buying token (from clear_minimum)", () => {
+ const { result } = renderHook(() => useSwapTransactions(SWAP_SINGLE));
+ expect(result.current.swapTransactions.buying).toHaveLength(1);
+ expect(result.current.swapTransactions.buying[0].address).toBe(
+ getChecksumAddress(
+ "0x016dea82a6588ca9fb7200125fa05631b1c1735a313e24afe9c90301e441a796",
+ ),
+ );
+ });
+
+ it("decodes selling amount from transfer calldata", () => {
+ const { result } = renderHook(() => useSwapTransactions(SWAP_SINGLE));
+ // transfer calldata: [recipient, amount_low="0x32a03ab37fef8ba51", amount_high="0x0"]
+ expect(result.current.swapTransactions.selling[0].amount).toBe(
+ BigInt("0x32a03ab37fef8ba51"),
+ );
+ });
+
+ it("decodes buying minimum from clear_minimum calldata", () => {
+ const { result } = renderHook(() => useSwapTransactions(SWAP_SINGLE));
+ // clear_minimum calldata: [token, minimum_low="0xa4de3d0e9ba40000", minimum_high="0x0"]
+ expect(result.current.swapTransactions.buying[0].amount).toBe(
+ BigInt("0xa4de3d0e9ba40000"),
+ );
+ });
+ });
+
+ describe("SWAP_MULTIPLE", () => {
+ it("is detected as a swap", () => {
+ const { result } = renderHook(() => useSwapTransactions(SWAP_MULTIPLE));
+ expect(result.current.isSwap).toBe(true);
+ });
+
+ it("counts 8 swap methods and 0 additional calls", () => {
+ const { result } = renderHook(() => useSwapTransactions(SWAP_MULTIPLE));
+ // 2 transfers + 2 multihop_swaps + 2 clear_minimums + 2 clears = 8
+ expect(result.current.swapMethodCount).toBe(8);
+ expect(result.current.additionalMethodCount).toBe(0);
+ });
+
+ it("aggregates selling amounts for the same token across both swaps", () => {
+ const { result } = renderHook(() => useSwapTransactions(SWAP_MULTIPLE));
+ // Both transfers use the same contractAddress, so they're merged into one entry
+ expect(result.current.swapTransactions.selling).toHaveLength(1);
+ expect(result.current.swapTransactions.selling[0].address).toBe(
+ getChecksumAddress(
+ "0x0124aeb495b947201f5faC96fD1138E326AD86195B98df6DEc9009158A533B49",
+ ),
+ );
+ expect(result.current.swapTransactions.selling[0].amount).toBe(
+ BigInt("0x176e9649d99dd740a") + BigInt("0x4f1eba34861ddd0"),
+ );
+ });
+
+ it("has two distinct buying tokens from the two clear_minimums", () => {
+ const { result } = renderHook(() => useSwapTransactions(SWAP_MULTIPLE));
+ expect(result.current.swapTransactions.buying).toHaveLength(2);
+ expect(result.current.swapTransactions.buying[0].address).toBe(
+ getChecksumAddress(
+ "0x01c3c8284d7eed443b42f47e764032a56eaf50a9079d67993b633930e3689814",
+ ),
+ );
+ expect(result.current.swapTransactions.buying[1].address).toBe(
+ getChecksumAddress(
+ "0x0103eafe79f8631932530cc687dfcdeb013c883a82619ebf81be393e2953a87a",
+ ),
+ );
+ });
+ });
+
+ describe("LS2_PURCHASE_GAME", () => {
+ it("is detected as a swap", () => {
+ const { result } = renderHook(() =>
+ useSwapTransactions(LS2_PURCHASE_GAME),
+ );
+ expect(result.current.isSwap).toBe(true);
+ });
+
+ it("counts 4 swap methods and 2 additional calls (approve + buy_game)", () => {
+ const { result } = renderHook(() =>
+ useSwapTransactions(LS2_PURCHASE_GAME),
+ );
+ expect(result.current.swapMethodCount).toBe(4);
+ expect(result.current.additionalMethodCount).toBe(2);
+ });
+
+ it("has one selling token", () => {
+ const { result } = renderHook(() =>
+ useSwapTransactions(LS2_PURCHASE_GAME),
+ );
+ expect(result.current.swapTransactions.selling).toHaveLength(1);
+ expect(result.current.swapTransactions.selling[0].address).toBe(
+ getChecksumAddress(
+ "0x0124aeb495b947201f5fac96fd1138e326ad86195b98df6dec9009158a533b49",
+ ),
+ );
+ });
+
+ it("has one buying token", () => {
+ const { result } = renderHook(() =>
+ useSwapTransactions(LS2_PURCHASE_GAME),
+ );
+ expect(result.current.swapTransactions.buying).toHaveLength(1);
+ });
+ });
+});
diff --git a/packages/keychain/src/components/swap/swap.ts b/packages/keychain/src/components/swap/swap.ts
new file mode 100644
index 0000000000..1d9fe958be
--- /dev/null
+++ b/packages/keychain/src/components/swap/swap.ts
@@ -0,0 +1,107 @@
+import { useMemo } from "react";
+import { getChecksumAddress, type Call } from "starknet";
+import { useDecodeTransactionInputs } from "@/hooks/calldata-decode";
+import { TokenSwap } from "@/hooks/token";
+
+const findTransactions = (transactions: Call[], entrypoint: string) => {
+ return transactions.filter((t) => t.entrypoint === entrypoint);
+};
+
+// detect swap transaction
+// swap example in /examples/next/src/components/Profile.tsx
+export const useIsSwapTransaction = (
+ transactions: Call[],
+): {
+ isSwap: boolean;
+} => {
+ const isSwap = useMemo(
+ () =>
+ transactions.length >= 4 &&
+ findTransactions(transactions, "transfer").length > 0 &&
+ findTransactions(transactions, "multihop_swap").length > 0 &&
+ findTransactions(transactions, "clear_minimum").length > 0 &&
+ findTransactions(transactions, "clear").length > 0,
+ [transactions],
+ );
+ return { isSwap };
+};
+
+export type SwapTransactions = {
+ selling: TokenSwap[];
+ buying: TokenSwap[];
+};
+
+export const useSwapTransactions = (
+ transactions: Call[],
+): {
+ isSwap: boolean;
+ swapTransactions: SwapTransactions;
+ swapMethodCount: number;
+ additionalMethodCount: number;
+} => {
+ const { isSwap } = useIsSwapTransaction(transactions);
+
+ const { decodeTransferInputs, decodeClearMinimumInputs } =
+ useDecodeTransactionInputs();
+
+ const [swapTransactions, swapMethodCount] = useMemo(() => {
+ const swapTransactions: SwapTransactions = {
+ selling: [],
+ buying: [],
+ };
+ if (!isSwap) return [swapTransactions, 0];
+
+ const transfers = findTransactions(transactions, "transfer");
+ const multihop_swaps = findTransactions(transactions, "multihop_swap");
+ const clear_minimuns = findTransactions(transactions, "clear_minimum");
+ const clears = findTransactions(transactions, "clear");
+
+ const transferInputs = transfers.map((transfer) =>
+ decodeTransferInputs(transfer.calldata as string[]),
+ );
+ const clearInputs = clear_minimuns.map((clear_minimum) =>
+ decodeClearMinimumInputs(clear_minimum.calldata as string[]),
+ );
+
+ const addToken = (acc: TokenSwap[], address: string, amount: bigint) => {
+ const token = acc.find((t) => t.address === address);
+ if (token) {
+ token.amount += amount;
+ } else {
+ acc.push({ address, amount });
+ }
+ return acc;
+ };
+
+ swapTransactions.selling = transferInputs.reduce((acc, input, index) => {
+ return addToken(
+ acc,
+ getChecksumAddress(transfers[index].contractAddress),
+ input.amount,
+ );
+ }, [] as TokenSwap[]);
+
+ swapTransactions.buying = clearInputs.reduce((acc, input) => {
+ return addToken(
+ acc,
+ getChecksumAddress(input.token.contract_address),
+ input.minimum,
+ );
+ }, [] as TokenSwap[]);
+
+ const count =
+ transfers.length +
+ multihop_swaps.length +
+ clear_minimuns.length +
+ clears.length;
+
+ return [swapTransactions, count];
+ }, [isSwap, transactions, decodeClearMinimumInputs, decodeTransferInputs]);
+
+ return {
+ isSwap,
+ swapTransactions,
+ swapMethodCount,
+ additionalMethodCount: transactions.length - swapMethodCount,
+ };
+};
diff --git a/packages/keychain/src/components/transaction/CallCard.stories.tsx b/packages/keychain/src/components/transaction/CallCard.stories.tsx
index 9fd90592b6..69437a59c3 100644
--- a/packages/keychain/src/components/transaction/CallCard.stories.tsx
+++ b/packages/keychain/src/components/transaction/CallCard.stories.tsx
@@ -15,7 +15,6 @@ const meta: Meta = {
},
},
argTypes: {
- address: { control: "text" },
title: { control: "text" },
call: { control: "object" },
},
@@ -34,8 +33,6 @@ type Story = StoryObj;
export const SimpleCalldata: Story = {
args: {
- address:
- "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7",
title: "Transfer",
call: {
contractAddress:
@@ -53,8 +50,6 @@ export const SimpleCalldata: Story = {
export const ObjectCalldata: Story = {
args: {
- address:
- "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7",
title: "Swap",
call: {
contractAddress:
@@ -77,8 +72,6 @@ export const ObjectCalldata: Story = {
export const ComplexTypes: Story = {
args: {
- address:
- "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7",
title: "Complex Transaction",
call: {
contractAddress:
@@ -113,8 +106,6 @@ export const ComplexTypes: Story = {
export const WithUint256: Story = {
args: {
- address:
- "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7",
title: "With Uint256",
call: {
contractAddress:
@@ -132,8 +123,6 @@ export const WithUint256: Story = {
export const WithEnum: Story = {
args: {
- address:
- "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7",
title: "With Enum",
call: {
contractAddress:
@@ -150,8 +139,6 @@ export const WithEnum: Story = {
export const NestedObjects: Story = {
args: {
- address:
- "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7",
title: "Nested Objects",
call: {
contractAddress:
@@ -184,8 +171,6 @@ export const NestedObjects: Story = {
export const LongArrays: Story = {
args: {
- address:
- "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7",
title: "Long Arrays",
call: {
contractAddress:
diff --git a/packages/keychain/src/components/transaction/CallCard.tsx b/packages/keychain/src/components/transaction/CallCard.tsx
index 217ed22930..f141734ff9 100644
--- a/packages/keychain/src/components/transaction/CallCard.tsx
+++ b/packages/keychain/src/components/transaction/CallCard.tsx
@@ -1,4 +1,5 @@
-import { useConnection } from "@/hooks/connection";
+import { useState, useEffect } from "react";
+import { Call } from "starknet";
import {
Card,
CardContent,
@@ -8,13 +9,12 @@ import {
AccordionTrigger,
Badge,
Address,
+ cn,
} from "@cartridge/ui";
-import { useExplorer } from "@starknet-react/core";
-import { constants, Call } from "starknet";
-import { useState, useEffect } from "react";
+import { humanizeString } from "@cartridge/controller";
+import { ContractLink } from "@/components/ContractLink";
interface CallCardProps {
- address: string;
title: string;
call: Call;
icon?: React.ReactNode;
@@ -45,15 +45,20 @@ function copyToClipboard(text: string) {
// Component for clickable value that can be copied
function CopyableValue({
value,
+ className,
children,
}: {
value: string;
+ className?: string;
children: React.ReactNode;
}) {
return (
copyToClipboard(value)}
- className="cursor-pointer hover:opacity-80 relative group"
+ className={cn(
+ "cursor-pointer hover:opacity-80 relative group",
+ className,
+ )}
title="Click to copy"
>
{children}
@@ -223,13 +228,7 @@ function CalldataKeyValue({ keyName, value }: { keyName: string; value: any }) {
);
}
-export function CallCard({
- address,
- call,
- defaultExpanded = false,
-}: CallCardProps) {
- const { controller } = useConnection();
- const explorer = useExplorer();
+export function CallCard({ call, defaultExpanded = false }: CallCardProps) {
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
// Update expansion state when defaultExpanded prop changes
@@ -237,22 +236,6 @@ export function CallCard({
setIsExpanded(defaultExpanded);
}, [defaultExpanded]);
- const explorerLink = (
-
-
-
- );
-
return (
@@ -266,54 +249,13 @@ export function CallCard({
-
+
{humanizeString(call.entrypoint)}
-
-
-
- Contract
-
- {explorerLink}
-
-
-
-
- Entrypoint
-
-
-
- {call.entrypoint}
-
-
-
-
- {call.calldata && (
-
-
- Calldata
-
-
- {Array.isArray(call.calldata)
- ? call.calldata.map((data, i) => (
-
- ))
- : Object.entries(call.calldata).map(
- ([key, value], i) => (
-
- ),
- )}
-
-
- )}
-
+
@@ -322,10 +264,51 @@ export function CallCard({
);
}
-function humanizeString(str: string): string {
- return str
- .replace(/([a-z])([A-Z])/g, "$1 $2")
- .replace(/_/g, " ")
- .toLowerCase()
- .replace(/^\w/, (c) => c.toUpperCase());
+export function CallCardContents({
+ call,
+ className,
+}: {
+ call: Call;
+ className?: string;
+}) {
+ return (
+
+
+
+
+
Entrypoint
+
+
+ {call.entrypoint}
+
+
+
+
+ {call.calldata && (
+ <>
+
Calldata
+
+ {Array.isArray(call.calldata)
+ ? call.calldata.map((data, i) => (
+
+ ))
+ : Object.entries(call.calldata).map(([key, value], i) => (
+
+ ))}
+
+ >
+ )}
+
+ );
}
diff --git a/packages/keychain/src/components/transaction/ConfirmTransaction.test.tsx b/packages/keychain/src/components/transaction/ConfirmTransaction.test.tsx
index ca9a50870c..65d55370c1 100644
--- a/packages/keychain/src/components/transaction/ConfirmTransaction.test.tsx
+++ b/packages/keychain/src/components/transaction/ConfirmTransaction.test.tsx
@@ -80,6 +80,8 @@ describe("ConfirmTransaction", () => {
const estimateInvokeFee = vi.fn().mockResolvedValue({
suggestedMaxFee: BigInt(1000),
});
+ const address = vi.fn().mockResolvedValue("0x123456789abcdef");
+ const username = vi.fn().mockResolvedValue("testuser");
await act(async () => {
renderWithProviders(
@@ -93,6 +95,8 @@ describe("ConfirmTransaction", () => {
execute: mockExecute,
estimateInvokeFee,
isRequestedSession: vi.fn().mockResolvedValue(true),
+ address,
+ username,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
},
diff --git a/packages/keychain/src/components/transaction/ConfirmTransaction.tsx b/packages/keychain/src/components/transaction/ConfirmTransaction.tsx
index 7d31c1bb70..6dcd5b62e6 100644
--- a/packages/keychain/src/components/transaction/ConfirmTransaction.tsx
+++ b/packages/keychain/src/components/transaction/ConfirmTransaction.tsx
@@ -1,16 +1,18 @@
+import { useEffect, useState } from "react";
import { LayoutContent } from "@cartridge/ui";
import { useConnection } from "@/hooks/connection";
import { TransactionSummary } from "@/components/transaction/TransactionSummary";
import { ControllerError } from "@/utils/connection";
import { Call, FeeEstimate } from "starknet";
import { ExecutionContainer } from "@/components/ExecutionContainer";
-import { CreateSession } from "../connect";
import { executeCore } from "@/utils/connection/execute";
-import { useEffect, useState } from "react";
-import { PageLoading } from "../Loading";
+import { CreateSession } from "@/components/connect";
+import { PageLoading } from "@/components/Loading";
import { ErrorCode } from "@cartridge/controller-wasm";
import { useToast } from "@/context/toast";
import { humanizeString } from "@cartridge/controller";
+import { useIsSwapTransaction } from "@/components/swap/swap";
+import { ConfirmSwap } from "@/components/swap/ConfirmSwap";
interface ConfirmTransactionProps {
onComplete: (transaction_hash: string) => void;
@@ -89,10 +91,24 @@ export function ConfirmTransaction({
}
};
+ const { isSwap } = useIsSwapTransaction(transactions);
+
if (loading) {
return ;
}
+ if (isSwap) {
+ return (
+
+ );
+ }
+
// Show session refresh UI if SessionRefreshRequired error occurred
if (needsSessionRefresh && policies && !skipSession) {
return (
@@ -153,7 +169,7 @@ export function ConfirmTransaction({
onError={onError}
>
-
+
);
diff --git a/packages/keychain/src/components/transaction/TransactionSummary.tsx b/packages/keychain/src/components/transaction/TransactionSummary.tsx
index fdb01d8134..95dc1122cb 100644
--- a/packages/keychain/src/components/transaction/TransactionSummary.tsx
+++ b/packages/keychain/src/components/transaction/TransactionSummary.tsx
@@ -1,21 +1,134 @@
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+ CheckboxIcon,
+ GearIcon,
+ Thumbnail,
+} from "@cartridge/ui";
+import { cn } from "@cartridge/ui/utils";
import { Call } from "starknet";
-import { CallCard } from "./CallCard";
+import { humanizeString } from "@cartridge/controller";
+import { useState, PropsWithChildren } from "react";
+import { ContractLink } from "@/components/ContractLink";
+import { CallCardContents } from "../transaction/CallCard";
+
+interface TransactionSummaryProps {
+ calls: Call[];
+ isExpanded?: boolean;
+ className?: string;
+}
+
+export function TransactionSummary({
+ calls,
+ isExpanded = false,
+ className,
+}: TransactionSummaryProps) {
+ const [isOpened, setisOpened] = useState(isExpanded);
-export function TransactionSummary({ calls }: { calls: Call[] }) {
return (
-
- {calls.map((c, i) => {
- return (
-
setisOpened(value === "item")}
+ >
+
+
+ }
+ centered={true}
+ className={cn(
+ isOpened
+ ? "text-foreground-100"
+ : "text-foreground-300 bg-background-200",
+ )}
/>
- );
- })}
-
+ Advanced
+
+
+
+ {calls.map((call) => (
+
+
+ {/*
+
+
+
+
+ {humanizeString(call.entrypoint)}
+
+
+
+
+
*/}
+
+ ))}
+
+
+
+ );
+}
+
+interface CollapsibleTransactionProps extends PropsWithChildren {
+ transaction: Call;
+ enabled: boolean;
+}
+
+export function CollapsibleTransactionRow({
+ transaction,
+ children,
+}: CollapsibleTransactionProps) {
+ const [value, setValue] = useState("");
+
+ return (
+
+
+
+
+
+
+ {humanizeString(transaction.entrypoint)}
+
+
+
+
+
+
+ {children}
+
+
+
);
}
diff --git a/packages/keychain/src/hooks/calldata-decode.ts b/packages/keychain/src/hooks/calldata-decode.ts
new file mode 100644
index 0000000000..63352bd2ee
--- /dev/null
+++ b/packages/keychain/src/hooks/calldata-decode.ts
@@ -0,0 +1,131 @@
+import { useCallback } from "react";
+import { Abi, CallData, CallResult, FunctionAbi, InterfaceAbi } from "starknet";
+import { erc20Abi } from "viem";
+
+// ekubo abi from:
+// https://voyager.online/contract/0x04505a9f06f2bd639b6601f37a4dc0908bb70e8e0e0c34b1220827d64f4fc066#code
+import ekuboRouterAbi from "@/components/swap/ekuboRouterAbi.json" assert { type: "json" };
+
+const useDecodeCallbackInputs = () => {
+ const decodeInputs = useCallback(
+ (abi: Abi, interfaceName: string, method: string, args: string[]) => {
+ const interfaceAbi = abi.find(
+ (a) => a.name === interfaceName,
+ ) as InterfaceAbi;
+ const { inputs } = (interfaceAbi?.items ?? abi).find(
+ (a) => a.name === method,
+ ) as FunctionAbi;
+ const callData = new CallData(abi);
+ const decoded = callData.decodeParameters(
+ inputs.map((i) => i.type),
+ args,
+ );
+ const result = inputs.reduce(
+ (acc, input, index) => {
+ acc[input.name] = Array.isArray(decoded) ? decoded[index] : decoded;
+ return acc;
+ },
+ {} as { [key: string]: CallResult },
+ );
+ return result as T;
+ },
+ [],
+ );
+ return { decodeInputs };
+};
+
+export type TransferInputs = {
+ amount: bigint;
+ recipient: string;
+};
+
+export type MultihopSwapInputs = {
+ route: [
+ {
+ pool_key: {
+ token0: bigint;
+ token1: bigint;
+ fee: bigint;
+ tick_spacing: bigint;
+ extension: bigint;
+ };
+ sqrt_ratio_limit: bigint;
+ skip_ahead: bigint;
+ },
+ ];
+ token_amount: {
+ token: bigint;
+ amount: {
+ mag: bigint;
+ sign: boolean;
+ };
+ };
+};
+
+export interface ClearMinimumInputs {
+ minimum: bigint;
+ token: {
+ contract_address: bigint;
+ };
+}
+
+export interface ClearInputs {
+ minimum: bigint;
+ token: {
+ contract_address: bigint;
+ };
+}
+
+export const useDecodeTransactionInputs = () => {
+ const { decodeInputs } = useDecodeCallbackInputs();
+
+ const decodeTransferInputs = useCallback(
+ (args: string[]) => {
+ return decodeInputs(erc20Abi, "ERC20", "transfer", args);
+ },
+ [decodeInputs],
+ );
+
+ const decodeMultihopSwapInputs = useCallback(
+ (args: string[]) => {
+ return decodeInputs(
+ ekuboRouterAbi,
+ "ekubo::router::IRouter",
+ "multihop_swap",
+ args,
+ );
+ },
+ [decodeInputs],
+ );
+
+ const decodeClearMinimumInputs = useCallback(
+ (args: string[]) => {
+ return decodeInputs(
+ ekuboRouterAbi,
+ "ekubo::components::clear::IClear",
+ "clear_minimum",
+ args,
+ );
+ },
+ [decodeInputs],
+ );
+
+ const decodeClearInputs = useCallback(
+ (args: string[]) => {
+ return decodeInputs(
+ ekuboRouterAbi,
+ "ekubo::components::clear::IClear",
+ "clear",
+ args,
+ );
+ },
+ [decodeInputs],
+ );
+
+ return {
+ decodeTransferInputs,
+ decodeMultihopSwapInputs,
+ decodeClearMinimumInputs,
+ decodeClearInputs,
+ };
+};
diff --git a/packages/keychain/src/hooks/token.ts b/packages/keychain/src/hooks/token.ts
index 33e9b80041..076ac45624 100644
--- a/packages/keychain/src/hooks/token.ts
+++ b/packages/keychain/src/hooks/token.ts
@@ -8,6 +8,7 @@ import {
import {
useBalanceQuery,
useBalancesQuery,
+ usePriceByAddressesQuery,
} from "@cartridge/ui/utils/api/cartridge";
import makeBlockie from "ethereum-blockies-base64";
import { useAccount } from "./account";
@@ -390,3 +391,60 @@ export function useToken({
status,
};
}
+
+export type TokenSwap = {
+ address: string;
+ amount: bigint;
+};
+
+export type TokenSwapData = Metadata & {
+ amount: number;
+ value: number | null | undefined;
+};
+
+export type UseTokenSwapDataResponse = {
+ tokenSwapData: TokenSwapData[];
+ status: "success" | "error" | "idle" | "loading";
+};
+
+export function useTokenSwapData(
+ tokens: TokenSwap[],
+): UseTokenSwapDataResponse {
+ const { data: priceData, ...restPriceData } = usePriceByAddressesQuery({
+ addresses: tokens.map((token) => token.address),
+ });
+
+ const tokenSwapData = useMemo(() => {
+ return tokens.map((token) => {
+ const metadata = erc20Metadata.find(
+ (m) => BigInt(m.l2_token_address) === BigInt(token.address),
+ );
+ const price = priceData?.priceByAddresses.find(
+ (p) => BigInt(p.base) === BigInt(token.address),
+ );
+ const amount = Number(token.amount) / 10 ** (metadata?.decimals || 18);
+ const tokenData: TokenSwapData = {
+ amount,
+ name: metadata?.name || "Unknown",
+ symbol: metadata?.symbol || "UNKNOWN",
+ decimals: metadata?.decimals || 18,
+ address: getChecksumAddress(token.address),
+ image:
+ metadata?.logo_url || makeBlockie(getChecksumAddress(token.address)),
+ value: price
+ ? (Number(price.amount) / 10 ** (price.decimals || 18)) * amount
+ : undefined,
+ };
+ return tokenData;
+ });
+ }, [tokens, priceData]);
+
+ const status = useMemo(() => {
+ if (restPriceData.isLoading) return "loading";
+ if (restPriceData.isError) return "error";
+ if (restPriceData.isSuccess) return "success";
+ return "idle";
+ }, [restPriceData]);
+
+ return { tokenSwapData, status };
+}
diff --git a/packages/keychain/src/test/mocks/connection.tsx b/packages/keychain/src/test/mocks/connection.tsx
index cb847e5a36..4ea3c57b91 100644
--- a/packages/keychain/src/test/mocks/connection.tsx
+++ b/packages/keychain/src/test/mocks/connection.tsx
@@ -16,6 +16,8 @@ const defaultMockController: any = {
suggestedMaxFee: BigInt(1000),
})),
chainId: vi.fn().mockImplementation(() => constants.StarknetChainId.SN_MAIN),
+ address: vi.fn().mockImplementation(async () => "0x123456789abcdef"),
+ username: vi.fn().mockImplementation(async () => "testuser"),
} as const;
export const defaultMockConnection: ConnectionContextValue = {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index cab1b5034e..5229010135 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -162,8 +162,8 @@ importers:
.:
dependencies:
'@cartridge/presets':
- specifier: github:cartridge-gg/presets#c064e82
- version: https://codeload.github.com/cartridge-gg/presets/tar.gz/c064e82
+ specifier: github:cartridge-gg/presets#476cc4f
+ version: https://codeload.github.com/cartridge-gg/presets/tar.gz/476cc4f
'@cartridge/ui':
specifier: 'catalog:'
version: https://codeload.github.com/cartridge-gg/ui/tar.gz/b4646934(@types/react-dom@18.3.7(@types/react@18.3.20))(@types/react@18.3.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sonner@2.0.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(starknet@8.5.4)(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.11.24(@swc/helpers@0.5.17))(@types/node@16.18.11)(typescript@5.8.3)))(viem@2.28.1(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.24.4))
@@ -1261,12 +1261,12 @@ packages:
'@cartridge/penpal@6.2.4':
resolution: {integrity: sha512-tdpOnSJJBFMlgLZ1+z9Ho5e6cG5EgMAb1Cmmh1lGT2tmplogU/XPMjLE6CwvKAPDoe6a38iMnbH+ySTAWWIOKA==}
- '@cartridge/presets@https://codeload.github.com/cartridge-gg/presets/tar.gz/90a5fe0':
- resolution: {tarball: https://codeload.github.com/cartridge-gg/presets/tar.gz/90a5fe0}
+ '@cartridge/presets@https://codeload.github.com/cartridge-gg/presets/tar.gz/476cc4f':
+ resolution: {tarball: https://codeload.github.com/cartridge-gg/presets/tar.gz/476cc4f}
version: 0.0.1
- '@cartridge/presets@https://codeload.github.com/cartridge-gg/presets/tar.gz/c064e82':
- resolution: {tarball: https://codeload.github.com/cartridge-gg/presets/tar.gz/c064e82}
+ '@cartridge/presets@https://codeload.github.com/cartridge-gg/presets/tar.gz/90a5fe0':
+ resolution: {tarball: https://codeload.github.com/cartridge-gg/presets/tar.gz/90a5fe0}
version: 0.0.1
'@cartridge/ui@https://codeload.github.com/cartridge-gg/ui/tar.gz/b4646934':
@@ -11208,11 +11208,11 @@ snapshots:
'@cartridge/penpal@6.2.4': {}
- '@cartridge/presets@https://codeload.github.com/cartridge-gg/presets/tar.gz/90a5fe0':
+ '@cartridge/presets@https://codeload.github.com/cartridge-gg/presets/tar.gz/476cc4f':
dependencies:
'@starknet-io/types-js': 0.8.4
- '@cartridge/presets@https://codeload.github.com/cartridge-gg/presets/tar.gz/c064e82':
+ '@cartridge/presets@https://codeload.github.com/cartridge-gg/presets/tar.gz/90a5fe0':
dependencies:
'@starknet-io/types-js': 0.8.4