diff --git a/examples/next/src/app/page.tsx b/examples/next/src/app/page.tsx index 7efd511f81..f047d8f196 100644 --- a/examples/next/src/app/page.tsx +++ b/examples/next/src/app/page.tsx @@ -12,6 +12,7 @@ import { PlayButton } from "components/PlayButton"; import { Profile } from "components/Profile"; import { SignMessage } from "components/SignMessage"; import { Transfer } from "components/Transfer"; +import { Swap } from "components/Swap"; import { Starterpack } from "components/Starterpack"; import { UpdateSession } from "components/UpdateSession"; import { ControllerToaster } from "@cartridge/ui"; @@ -29,6 +30,7 @@ const Home: FC = () => { + diff --git a/examples/next/src/components/Profile.tsx b/examples/next/src/components/Profile.tsx index bd47fbc4eb..5167484473 100644 --- a/examples/next/src/components/Profile.tsx +++ b/examples/next/src/components/Profile.tsx @@ -171,12 +171,29 @@ export function Profile() { ctrlConnector.controller.openProfileAt( // "account/bal7hazar/inventory/collection/0x046dA8955829ADF2bDa310099A0063451923f02E648cF25A1203aac6335CF0e4/token/0x000000000000000000000000000000000000000000000000000000000000c527?ps=arcade-main&preset=loot-survivor&address=0x027917d3084dC0dcd3C4ED5189733d14b0c4C13E762829BD3D1D761aa36201AB&purchaseView=true&tokenIds=0x000000000000000000000000000000000000000000000000000000000000c527", // "account/mataleone/inventory/collection/0x046dA8955829ADF2bDa310099A0063451923f02E648cF25A1203aac6335CF0e4/purchase?ps=arcade-main&preset=loot-survivor&orders=2674", - // "account/mataleone/inventory/collection/0x046dA8955829ADF2bDa310099A0063451923f02E648cF25A1203aac6335CF0e4/purchase?ps=arcade-main&preset=loot-survivor&orders=520", - "account/mataleone/inventory/collection/0x07aAa9866750A0db82a54bA8674c38620Fa2F967D2FBb31133DEF48E0527c87f/purchase?ps=arcade-main&preset=pistols&orders=2867", + "account/mataleone/inventory/collection/0x046dA8955829ADF2bDa310099A0063451923f02E648cF25A1203aac6335CF0e4/purchase?ps=arcade-main&preset=loot-survivor&orders=8772", ) } > - Open at Purchase + Purchase 1 + + + + + + + + + ); +} + +const SWAP_SINGLE = [ + { + 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 = [ + { + 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 = [ + { + 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", + ], + }, +]; + +const LS2_PURCHASE_GAME_ERROR = [ + { + contractAddress: + "0x0124aeb495b947201f5fac96fd1138e326ad86195b98df6dec9009158a533b49", + entrypoint: "transfer", + calldata: [ + "0x0199741822c2dc722f6f605204f35e56dbc23bceed54818168c4c49e4fb8737e", + "0x5bbb37da193af4ba90000000", + "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", + ], + }, +]; diff --git a/package.json b/package.json index 7defb07110..08f4e21fd5 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "prepare": "husky" }, "dependencies": { - "@cartridge/presets": "github:cartridge-gg/presets#c064e82", + "@cartridge/presets": "github:cartridge-gg/presets#476cc4f", "@cartridge/ui": "catalog:", "tailwindcss": "catalog:", "@graphql-codegen/cli": "^2.6.2", diff --git a/packages/keychain/src/components/ContractLink.tsx b/packages/keychain/src/components/ContractLink.tsx new file mode 100644 index 0000000000..bf3004ff07 --- /dev/null +++ b/packages/keychain/src/components/ContractLink.tsx @@ -0,0 +1,38 @@ +import { useConnection } from "@/hooks/connection"; +import { Address, cn } from "@cartridge/ui"; +import { useExplorer } from "@starknet-react/core"; +import { constants } from "starknet"; + +export function ContractLink({ + contractAddress, + className, +}: { + contractAddress: string; + className?: string; +}) { + const { controller } = useConnection(); + const explorer = useExplorer(); + return ( + +
+ + ); +} diff --git a/packages/keychain/src/components/ErrorAlert.tsx b/packages/keychain/src/components/ErrorAlert.tsx index a199c6ee03..6b7c3034cb 100644 --- a/packages/keychain/src/components/ErrorAlert.tsx +++ b/packages/keychain/src/components/ErrorAlert.tsx @@ -7,6 +7,7 @@ import { type GraphQLErrorDetails, type ErrorWithGraphQL, } from "@/utils/errors"; +import { humanizeString } from "@cartridge/controller"; import { ErrorCode } from "@cartridge/controller-wasm/controller"; import { Accordion, @@ -486,15 +487,3 @@ export function isControllerError( ): error is ControllerError { return !!(error as ControllerError).code; } - -export function humanizeString(str: string): string { - return ( - str - // Convert from camelCase or snake_case - .replace(/([a-z])([A-Z])/g, "$1 $2") // camelCase to spaces - .replace(/_/g, " ") // snake_case to spaces - .toLowerCase() - // Capitalize first letter - .replace(/^\w/, (c) => c.toUpperCase()) - ); -} diff --git a/packages/keychain/src/components/ExecutionContainer.test.tsx b/packages/keychain/src/components/ExecutionContainer.test.tsx index 9d1674cd0d..1b3b0fecaa 100644 --- a/packages/keychain/src/components/ExecutionContainer.test.tsx +++ b/packages/keychain/src/components/ExecutionContainer.test.tsx @@ -22,6 +22,12 @@ vi.mock("@/hooks/tokens", () => ({ convertTokenAmountToUSD: vi.fn(() => "$0.01"), })); +const estimateInvokeFee = vi.fn().mockImplementation(async () => ({ + suggestedMaxFee: BigInt(1000), +})); +const address = vi.fn().mockImplementation(async () => "0x123456789abcdef"); +const username = vi.fn().mockImplementation(async () => "testuser"); + describe("ExecutionContainer", () => { const defaultProps = { transactions: [], @@ -45,10 +51,6 @@ describe("ExecutionContainer", () => { }); it("estimates fees when transactions are provided", async () => { - const estimateInvokeFee = vi.fn().mockImplementation(async () => ({ - suggestedMaxFee: BigInt(1000), - })); - await act(async () => { renderWithProviders( { connection: { controller: { estimateInvokeFee, + address, + username, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any, }, @@ -80,10 +84,6 @@ describe("ExecutionContainer", () => { it("handles submit action correctly", async () => { const onSubmit = vi.fn().mockResolvedValue(undefined); - const estimateInvokeFee = vi.fn().mockImplementation(async () => ({ - suggestedMaxFee: BigInt(1000), - })); - await act(async () => { renderWithProviders( { connection: { controller: { estimateInvokeFee, + address, + username, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any, }, @@ -138,9 +140,6 @@ describe("ExecutionContainer", () => { code: 113, // ErrorCode.InsufficientBalance, message: "Insufficient balance", }); - const estimateInvokeFee = vi.fn().mockResolvedValue({ - suggestedMaxFee: BigInt(1000), - }); await act(async () => { renderWithProviders( @@ -160,6 +159,8 @@ describe("ExecutionContainer", () => { connection: { controller: { estimateInvokeFee, + address, + username, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any, }, @@ -197,7 +198,7 @@ describe("ExecutionContainer", () => { it("shows deploy view when controller is not deployed", async () => { await act(async () => { - renderWithProviders( + renderWithConnection( { it("shows funding view when balance is insufficient", async () => { await act(async () => { - renderWithConnection( + renderWithProviders( ) { @@ -157,10 +158,11 @@ export function ExecutionContainer({ description={description} icon={icon} right={right} - hideIcon + hideIcon={!icon} + className="pb-2" /> {children} - + {(() => { switch (ctrlError?.code) { case ErrorCode.CartridgeControllerNotDeployed: @@ -175,11 +177,14 @@ export function ExecutionContainer({ case ErrorCode.InsufficientBalance: return ( <> - {ctrlError ? ( - - ) : ( - - )} + + } + additionalFees={additionalFees} + /> ); @@ -232,7 +237,11 @@ export function ExecutionContainer({ // Paymaster not available, fallback to user pays flow return ( <> - + + ) : 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 ( +
+
+
Contract
+ +
+ +
+
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