From 05cc1a2e4df6056c99cb73e2e569ff4037927692 Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Thu, 5 Jun 2025 12:58:39 -0300 Subject: [PATCH 1/2] feat: add addLiquidity and Permit2 batch support - Add buildAddLiquidityCallData utility for creating add liquidity transactions - Implement preparePermit2BatchData for handling batch token approvals - Update SDK instance to support new functionality - Add comprehensive test coverage - Remove deprecated buildPermit2CallData - Update README with new examples --- README.md | 264 +++++++++--------- src/core/uniDevKitV4.ts | 68 ++++- src/test/core/uniDevKitV4.test.ts | 1 - src/test/helpers/sdkInstance.ts | 1 - .../utils/buildAddLiquidityCallData.test.ts | 95 +++++++ src/test/utils/getTokens.test.ts | 10 +- .../utils/preparePermit2BatchData.test.ts | 94 +++++++ src/types/core.ts | 2 - src/types/utils/buildAddLiquidityCallData.ts | 74 +++++ src/types/utils/buildPermit2CallData.ts | 84 ------ src/types/utils/getPosition.ts | 6 +- src/types/utils/preparePermit2BatchData.ts | 41 +++ src/utils/buildAddLiquidityCallData.ts | 191 +++++++++++++ src/utils/getTokens.ts | 18 +- src/utils/preparePermit2BatchData.ts | 163 +++++++++++ 15 files changed, 859 insertions(+), 253 deletions(-) create mode 100644 src/test/utils/buildAddLiquidityCallData.test.ts create mode 100644 src/test/utils/preparePermit2BatchData.test.ts create mode 100644 src/types/utils/buildAddLiquidityCallData.ts delete mode 100644 src/types/utils/buildPermit2CallData.ts create mode 100644 src/types/utils/preparePermit2BatchData.ts create mode 100644 src/utils/buildAddLiquidityCallData.ts create mode 100644 src/utils/preparePermit2BatchData.ts diff --git a/README.md b/README.md index 3e7e6ef..c70f9e6 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,4 @@ # uniswap-dev-kit - [![CI](https://github.com/BootNodeDev/uni-dev-kit/actions/workflows/ci.yml/badge.svg)](https://github.com/BootNodeDev/uni-dev-kit/actions/workflows/ci.yml) [![Release](https://github.com/BootNodeDev/uni-dev-kit/actions/workflows/release.yml/badge.svg)](https://github.com/BootNodeDev/uni-dev-kit/actions/workflows/release.yml) [![Docs](https://img.shields.io/badge/docs-typedoc-blue)](https://bootnodedev.github.io/uni-dev-kit) @@ -7,199 +6,206 @@ > Modern TypeScript SDK for integrating Uniswap V4 into your dapp. > **Early version:** API may change rapidly. +A developer-friendly library for interacting with Uniswap V4 contracts. This library provides a simple and flexible interface for common operations like adding liquidity, swapping tokens, and managing positions. + ## Features -- ๐Ÿš€ Full TypeScript support -- ๐Ÿ”„ Multi-chain support out of the box -- ๐Ÿ“ฆ Zero dependencies (except peer deps) -- ๐Ÿ” Comprehensive error handling -- ๐Ÿงช Fully tested -- ๐Ÿ“š Well documented +- ๐Ÿš€ Simple and intuitive API +- ๐Ÿ”„ Support for all major Uniswap V4 operations +- ๐Ÿ’ฐ Native token support +- ๐Ÿ”’ Permit2 integration for gasless approvals +- ๐Ÿ“Š Flexible liquidity management +- ๐Ÿ” Built-in quote simulation +- ๐Ÿ›  TypeScript support -## Install +## Installation ```bash -pnpm install uniswap-dev-kit -# or npm install uniswap-dev-kit +# or +yarn add uniswap-dev-kit ``` ## Quick Start -### 1. Configure and create SDK instances - ```ts -import { UniDevKitV4 } from "uniswap-dev-kit"; +import { UniDevKitV4 } from 'uniswap-dev-kit'; -// Create instance for Ethereum mainnet -const ethInstance = new UniDevKitV4({ +const uniDevKit = new UniDevKitV4({ chainId: 1, - rpcUrl: "https://eth.llamarpc.com", contracts: { poolManager: "0x...", - positionDescriptor: "0x...", positionManager: "0x...", + positionDescriptor: "0x...", quoter: "0x...", stateView: "0x...", - universalRouter: "0x...", - permit2: "0x..." + universalRouter: "0x..." } }); -// Create instance for another chain (e.g., Base) -const baseInstance = new UniDevKitV4({ - chainId: 8453, - rpcUrl: "https://mainnet.base.org", - contracts: { - // Base Uniswap V4 contract addresses... - } +const pool = await uniDevKit.getPool({ + tokens: ["0xTokenA", "0xTokenB"], + fee: 3000 +}); + +const quote = await uniDevKit.getQuote({ + pool, + amountIn: "1000000000000000000" }); ``` -### 2. Get a quote +## Documentation +Full API documentation with TypeDoc: [https://bootnodedev.github.io/uni-dev-kit](https://bootnodedev.github.io/uni-dev-kit) +## API Reference + +### Index +- [uniswap-dev-kit](#uniswap-dev-kit) + - [Features](#features) + - [Installation](#installation) + - [Quick Start](#quick-start) + - [Documentation](#documentation) + - [API Reference](#api-reference) + - [Index](#index) + - [`getPool`](#getpool) + - [`getQuote`](#getquote) + - [`getTokens`](#gettokens) + - [`getPosition`](#getposition) + - [`getPoolKeyFromPoolId`](#getpoolkeyfrompoolid) + - [`buildSwapCallData`](#buildswapcalldata) + - [`buildAddLiquidityCallData`](#buildaddliquiditycalldata) + - [`preparePermit2BatchCallData`](#preparepermit2batchcalldata) + - [Useful Links](#useful-links) + - [Development](#development) + - [Scripts](#scripts) + - [Contributing](#contributing) + - [Release](#release) + - [License](#license) + +### `getPool` +Retrieve a pool object from two tokens and a fee tier. ```ts -import { parseEther } from "viem"; - -const quote = await ethInstance.getQuote({ - tokens: [ - "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC - "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", // WETH - ], - feeTier: 3000, - amountIn: parseEther("1"), - zeroForOne: true +const pool = await uniDevKit.getPool({ + tokens: [tokenA, tokenB], + fee: 3000 }); -console.log(quote.amountOut); ``` -### 3. Get a pool - +### `getQuote` +Simulate a swap to get `amountOut` and `sqrtPriceLimitX96`. ```ts -const pool = await ethInstance.getPool({ - tokens: [ - "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", - "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" - ], - feeTier: 3000 +const quote = await uniDevKit.getQuote({ + pool, + amountIn: "1000000000000000000" }); -console.log(pool.liquidity.toString()); ``` -### 4. Get a position - +### `getTokens` +Retrieve token metadata. ```ts -const position = await ethInstance.getPosition({ - tokenId: "123" -}); -console.log({ - token0: position.token0.symbol, - token1: position.token1.symbol, - liquidity: position.position.liquidity.toString() +const tokens = await uniDevKit.getTokens({ + addresses: ["0x...", "0x..."] }); ``` -## Advanced Usage - -### Error Handling +### `getPosition` +Get details about a Uniswap V4 LP position. +```ts +const position = await uniDevKit.getPosition({ + tokenId: 123 +}); +``` -All SDK functions include comprehensive error handling: +### `getPoolKeyFromPoolId` +Retrieve the `PoolKey` object for a given pool ID. +```ts +const poolKey = await uniDevKit.getPoolKeyFromPoolId({ + poolId: "0x..." +}); +``` +### `buildSwapCallData` +Construct calldata and value for a Universal Router swap. ```ts -try { - const quote = await ethInstance.getQuote({ - tokens: [token0, token1], - feeTier: 3000, - amountIn: parseEther("1"), - zeroForOne: true - }); -} catch (error) { - // Handle specific error types - if (error.message.includes("insufficient liquidity")) { - // Handle liquidity error - } else if (error.message.includes("invalid pool")) { - // Handle pool error - } -} +const { calldata, value } = await uniDevKit.buildSwapCallData({ + tokenIn, + tokenOut, + amountIn: "1000000000000000000", + recipient, + slippageBips: 50 +}); ``` -### Using with React +### `buildAddLiquidityCallData` +Build calldata to add liquidity to a pool. +```ts +// Without permit +const { calldata, value } = await uniDevKit.buildAddLiquidityCallData({ + pool, + amount0: "100000000", + amount1: "50000000000000000", + recipient: "0x...", + slippageTolerance: 50 +}); -You can use the SDK with React Query for data fetching: +// With Permit2 batch approval +const permitData = await uniDevKit.preparePermit2BatchData({ + tokens: [pool.token0.address, pool.token1.address], + spender: uniDevKit.getContractAddress('positionManager'), + owner: userAddress +}); -```tsx -import { useQuery } from '@tanstack/react-query'; -import { UniDevKitV4 } from 'uniswap-dev-kit'; +const signature = await signer.signTypedData( + permitData.toSign.domain, + permitData.toSign.types, + permitData.toSign.values +); -// Create instance once -const sdk = new UniDevKitV4({ - chainId: 1, - rpcUrl: "https://eth.llamarpc.com", - contracts: { - // ... contract addresses - } +const permitWithSignature = permitData.buildPermit2BatchDataWithSignature(signature); + +const { calldata, value } = await uniDevKit.buildAddLiquidityCallData({ + pool, + amount0: parseUnits("100", 6), + recipient: "0x...", + permit2BatchSignature: permitWithSignature }); -// Simple hook for quotes -function useQuote() { - return useQuery({ - queryKey: ['quote'], - queryFn: () => sdk.getQuote({ - tokens: [ - "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", - "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" - ], - feeTier: 3000, - amountIn: parseEther("1"), - zeroForOne: true - }) - }); -} +const tx = await sendTransaction({ + to: uniDevKit.getContractAddress('positionManager'), + data: calldata, + value +}); ``` ---- - -## API Reference - -See [full TypeDoc documentation](https://bootnodedev.github.io/uni-dev-kit) for all methods, types, and advanced usage. +### `preparePermit2BatchCallData` +Construct a Permit2 batch approval for gasless interactions. +```ts +const permitData = await uniDevKit.preparePermit2BatchCallData({ + tokens: [tokenA.address, tokenB.address], + spender: uniDevKit.getContractAddress('positionManager'), + owner: userAddress +}); +``` ---- +## Useful Links +- [Uniswap V4 Docs](https://docs.uniswap.org/contracts/v4/overview) ## Development ### Scripts - - `pnpm build` โ€” Build the library - `pnpm test` โ€” Run all tests - `pnpm lint` โ€” Lint code with Biome - `pnpm format` โ€” Format code with Biome - `pnpm docs` โ€” Generate API docs with TypeDoc -### Contributing +## Contributing See [CONTRIBUTING.md](./CONTRIBUTING.md) for guidelines. -### Release +## Release - Releases are automated with [semantic-release](https://semantic-release.gitbook.io/semantic-release/). -- Versioning: [semver](https://semver.org/) - ---- - -## FAQ - -- **Does it work in Node and browser?** - Yes, works in both environments. -- **Can I use my own ABIs?** - Yes, but Uniswap V4 ABIs are included. - ---- ## License - MIT - ---- - -> Feedback, issues, and PRs welcome. -> [API Docs](https://bootnodedev.github.io/uni-dev-kit) โ€ข [Open an Issue](https://github.com/BootNodeDev/uni-dev-kit/issues) \ No newline at end of file diff --git a/src/core/uniDevKitV4.ts b/src/core/uniDevKitV4.ts index 20be7d7..cbc55e1 100644 --- a/src/core/uniDevKitV4.ts +++ b/src/core/uniDevKitV4.ts @@ -1,6 +1,10 @@ import { getChainById } from "@/constants/chains"; import type { BuildSwapCallDataParams } from "@/types"; import type { UniDevKitV4Config, UniDevKitV4Instance } from "@/types/core"; +import type { + BuildAddLiquidityCallDataResult, + BuildAddLiquidityParams, +} from "@/types/utils/buildAddLiquidityCallData"; import type { PoolParams } from "@/types/utils/getPool"; import type { GetPoolKeyFromPoolIdParams } from "@/types/utils/getPoolKeyFromPoolId"; import type { @@ -9,13 +13,19 @@ import type { } from "@/types/utils/getPosition"; import type { QuoteParams, QuoteResponse } from "@/types/utils/getQuote"; import type { GetTokensParams } from "@/types/utils/getTokens"; +import type { + PreparePermit2BatchDataParams, + PreparePermit2BatchDataResult, +} from "@/types/utils/preparePermit2BatchData"; +import { buildAddLiquidityCallData } from "@/utils/buildAddLiquidityCallData"; import { buildSwapCallData } from "@/utils/buildSwapCallData"; import { getPool } from "@/utils/getPool"; import { getPoolKeyFromPoolId } from "@/utils/getPoolKeyFromPoolId"; import { getPosition } from "@/utils/getPosition"; import { getQuote } from "@/utils/getQuote"; import { getTokens } from "@/utils/getTokens"; -import type { Token } from "@uniswap/sdk-core"; +import { preparePermit2BatchData } from "@/utils/preparePermit2BatchData"; +import type { Currency } from "@uniswap/sdk-core"; import type { Pool, PoolKey } from "@uniswap/v4-sdk"; import type { Abi, Address, Hex, PublicClient } from "viem"; import { http, createPublicClient } from "viem"; @@ -30,7 +40,7 @@ export class UniDevKitV4 { /** * Creates a new UniDevKitV4 instance. - * @param config - The complete configuration for the SDK, including chain ID, RPC URL, and contract addresses. + * @param config @type {UniDevKitV4Config} * @throws Will throw an error if the configuration is invalid. */ constructor(config: UniDevKitV4Config) { @@ -73,7 +83,7 @@ export class UniDevKitV4 { /** * Returns the address of a specific contract. - * @param name - The name of the contract (e.g., "quoter", "poolManager"). + * @param name @type {keyof UniDevKitV4Config["contracts"]} * @returns The address of the specified contract. * @throws Will throw an error if the contract address is not found. */ @@ -88,7 +98,7 @@ export class UniDevKitV4 { /** * Loads the ABI for a specific contract using dynamic imports. * This method is used internally to lazy load ABIs only when needed. - * @param name - The name of the contract to load the ABI for + * @param name @type {keyof UniDevKitV4Config["contracts"]} * @returns Promise resolving to the contract's ABI * @throws Will throw an error if the contract ABI is not found * @private @@ -100,7 +110,6 @@ export class UniDevKitV4 { keyof UniDevKitV4Config["contracts"], () => Promise | null > = { - permit2: () => import("@/constants/abis/Permit2").then((m) => m.default), poolManager: () => import("@/constants/abis/V4PoolManager").then((m) => m.default), positionManager: () => @@ -127,7 +136,7 @@ export class UniDevKitV4 { /** * Retrieves the ABI for a specific contract. * This method uses dynamic imports to load ABIs on demand, reducing the initial bundle size. - * @param name - The name of the contract (e.g., "poolManager", "quoter") + * @param name @type {keyof UniDevKitV4Config["contracts"]} * @returns Promise resolving to the contract's ABI * @throws Will throw an error if the contract ABI is not found * @example @@ -143,7 +152,7 @@ export class UniDevKitV4 { /** * Retrieves a Uniswap V4 pool instance for a given token pair. - * @param params Pool parameters including tokens, fee tier, tick spacing, and hooks configuration + * @param params @type {PoolParams} * @returns Promise resolving to pool data * @throws Error if pool data cannot be fetched */ @@ -153,17 +162,17 @@ export class UniDevKitV4 { /** * Retrieves token information for a given array of token addresses. - * @param params Parameters including token addresses + * @param params @type {GetTokensParams} * @returns Promise resolving to Token instances for each token address. * @throws Error if token data cannot be fetched */ - async getTokens(params: GetTokensParams): Promise { + async getTokens(params: GetTokensParams): Promise { return getTokens(params, this.instance); } /** * Retrieves a Uniswap V4 position information for a given token ID. - * @param params Position parameters including token ID + * @param params @type {GetPositionParams} * @returns Promise resolving to position data including pool, token0, token1, poolId, and tokenId * @throws Error if SDK instance is not found or if position data is invalid */ @@ -173,7 +182,7 @@ export class UniDevKitV4 { /** * Retrieves a Uniswap V4 quote for a given token pair and amount in. - * @param params Quote parameters including token pair and amount in + * @param params @type {QuoteParams} * @returns Promise resolving to quote data including amount out, estimated gas used, and timestamp * @throws Error if SDK instance is not found or if quote data is invalid */ @@ -181,13 +190,50 @@ export class UniDevKitV4 { return getQuote(params, this.instance); } + /** + * Retrieves a Uniswap V4 pool key from a given pool ID. + * @param params @type {GetPoolKeyFromPoolIdParams} + * @returns Promise resolving to pool key data including pool address, token0, token1, and fee + * @throws Error if SDK instance is not found or if pool key data is invalid + */ async getPoolKeyFromPoolId( params: GetPoolKeyFromPoolIdParams, ): Promise { return getPoolKeyFromPoolId(params, this.instance); } + /** + * Builds a swap call data for a given swap parameters. + * @param params @type {BuildSwapCallDataParams} + * @returns Promise resolving to swap call data including calldata and value + * @throws Error if SDK instance is not found or if swap call data is invalid + */ async buildSwapCallData(params: BuildSwapCallDataParams): Promise { return buildSwapCallData(params, this.instance); } + + /** + * Builds a add liquidity call data for a given add liquidity parameters. + * @param params @type {BuildAddLiquidityParams} + * @returns Promise resolving to add liquidity call data including calldata and value + * @throws Error if SDK instance is not found or if add liquidity call data is invalid + */ + async buildAddLiquidityCallData( + params: BuildAddLiquidityParams, + ): Promise { + return buildAddLiquidityCallData(params, this.instance); + } + + /** + * Prepares the permit2 batch data for multiple tokens. (Used to add liquidity) + * Use toSign.values to sign the permit2 batch data. + * @param params @type {PreparePermit2BatchDataParams} + * @returns Promise resolving to permit2 batch data + * @throws Error if SDK instance is not found or if permit2 batch data is invalid + */ + async preparePermit2BatchData( + params: PreparePermit2BatchDataParams, + ): Promise { + return preparePermit2BatchData(params, this.instance); + } } diff --git a/src/test/core/uniDevKitV4.test.ts b/src/test/core/uniDevKitV4.test.ts index d74d4a6..dce240b 100644 --- a/src/test/core/uniDevKitV4.test.ts +++ b/src/test/core/uniDevKitV4.test.ts @@ -17,7 +17,6 @@ describe("UniDevKitV4", () => { quoter: "0x1234567890123456789012345678901234567890", stateView: "0x1234567890123456789012345678901234567890", universalRouter: "0x1234567890123456789012345678901234567890", - permit2: "0x1234567890123456789012345678901234567890", }, }; sdk = new UniDevKitV4(config); diff --git a/src/test/helpers/sdkInstance.ts b/src/test/helpers/sdkInstance.ts index 3d9a4ca..84f8454 100644 --- a/src/test/helpers/sdkInstance.ts +++ b/src/test/helpers/sdkInstance.ts @@ -24,7 +24,6 @@ export const createMockSdkInstance = ( quoter: "0xb27308f9F90D607463bb33eA1BeBb41C27CE5AB6", stateView: "0x4eD4C8B7eF27d4d242c4D1267E1B1E39c14b9E73", universalRouter: "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", - permit2: "0x000000000022D473030F116dDEE9F6B43aC78BA3", }, ...overrides, }; diff --git a/src/test/utils/buildAddLiquidityCallData.test.ts b/src/test/utils/buildAddLiquidityCallData.test.ts new file mode 100644 index 0000000..a375015 --- /dev/null +++ b/src/test/utils/buildAddLiquidityCallData.test.ts @@ -0,0 +1,95 @@ +import { createMockSdkInstance } from "@/test/helpers/sdkInstance"; +import { buildAddLiquidityCallData } from "@/utils/buildAddLiquidityCallData"; +import { Token } from "@uniswap/sdk-core"; +import { Pool } from "@uniswap/v4-sdk"; +import { parseUnits } from "viem"; +import { describe, expect, it } from "vitest"; + +describe("buildAddLiquidityCallData", () => { + const instance = createMockSdkInstance(); + + const token0 = new Token(1, "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", 6); + const token1 = new Token(1, "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", 18); + + const pool = new Pool( + token0, + token1, + 3000, + 60, // tickSpacing + "0x1111111111111111111111111111111111111111", // hooks (dummy valid address) + "79228162514264337593543950336", // sqrtPriceX96 + "1000000", // liquidity + 0, // tick + ); + + it("should build add liquidity calldata with both amounts", async () => { + const params = { + pool, + amount0: parseUnits("100", 6).toString(), + amount1: parseUnits("0.04", 18).toString(), + recipient: "0x1234567890123456789012345678901234567890", + }; + + const result = await buildAddLiquidityCallData(params, instance); + + expect(result).toBeDefined(); + expect(result.calldata).toBeDefined(); + expect(result.value).toBeDefined(); + }); + + it("should build add liquidity calldata with only amount0", async () => { + const params = { + pool, + amount0: parseUnits("100", 6).toString(), + recipient: "0x1234567890123456789012345678901234567890", + }; + + const result = await buildAddLiquidityCallData(params, instance); + + expect(result).toBeDefined(); + expect(result.calldata).toBeDefined(); + expect(result.value).toBeDefined(); + }); + + it("should build add liquidity calldata with permit2 batch signature", async () => { + const params = { + pool, + amount0: parseUnits("100", 6).toString(), + recipient: "0x1234567890123456789012345678901234567890", + permit2BatchSignature: { + owner: "0x0987654321098765432109876543210987654321", + permitBatch: { + details: [ + { + token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + amount: "100000000", + expiration: Math.floor(Date.now() / 1000) + 1800, + nonce: 0, + }, + ], + spender: "0x1234567890123456789012345678901234567890", + sigDeadline: Math.floor(Date.now() / 1000) + 1800, + }, + signature: + "0x1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890", + }, + }; + + const result = await buildAddLiquidityCallData(params, instance); + + expect(result).toBeDefined(); + expect(result.calldata).toBeDefined(); + expect(result.value).toBeDefined(); + }); + + it("should throw error when neither amount0 nor amount1 is provided", async () => { + const params = { + pool, + recipient: "0x1234567890123456789012345678901234567890", + }; + + await expect(buildAddLiquidityCallData(params, instance)).rejects.toThrow( + "At least one of amount0 or amount1 must be provided.", + ); + }); +}); diff --git a/src/test/utils/getTokens.test.ts b/src/test/utils/getTokens.test.ts index d0a81d7..87045d0 100644 --- a/src/test/utils/getTokens.test.ts +++ b/src/test/utils/getTokens.test.ts @@ -1,14 +1,9 @@ import { createMockSdkInstance } from "@/test/helpers/sdkInstance"; import { getTokens } from "@/utils/getTokens"; -import { Token } from "@uniswap/sdk-core"; +import { Ether, Token } from "@uniswap/sdk-core"; import { type Address, zeroAddress } from "viem"; import { beforeEach, describe, expect, it, vi } from "vitest"; -// Mock the SDK instance -vi.mock("@/core/uniDevKitV4Factory", () => ({ - getInstance: vi.fn(), -})); - vi.mock("@/constants/chains", () => ({ getChainById: () => ({ nativeCurrency: { @@ -66,8 +61,7 @@ describe("getTokens", () => { expect(result).toHaveLength(2); expect(result[0]).toBeInstanceOf(Token); - expect(result[1]).toBeInstanceOf(Token); - expect(result[1].address).toBe(zeroAddress); + expect(result[1]).toBeInstanceOf(Ether); }); it("should return token instances for valid addresses", async () => { diff --git a/src/test/utils/preparePermit2BatchData.test.ts b/src/test/utils/preparePermit2BatchData.test.ts new file mode 100644 index 0000000..7993f28 --- /dev/null +++ b/src/test/utils/preparePermit2BatchData.test.ts @@ -0,0 +1,94 @@ +import { createMockSdkInstance } from "@/test/helpers/sdkInstance"; +import { preparePermit2BatchData } from "@/utils/preparePermit2BatchData"; +import { describe, expect, it, vi } from "vitest"; + +describe("preparePermit2BatchData", () => { + const instance = createMockSdkInstance(); + + // Mock multicall response + vi.spyOn(instance.client, "multicall").mockImplementation(async () => [ + { + amount: 0n, + expiration: 0n, + nonce: 0n, + }, + { + amount: 0n, + expiration: 0n, + nonce: 0n, + }, + ]); + + it("should prepare permit2 batch data correctly", async () => { + const params = { + tokens: [ + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + ], + spender: "0x1234567890123456789012345678901234567890", + owner: "0x0987654321098765432109876543210987654321", + }; + + const result = await preparePermit2BatchData(params, instance); + + expect(result).toBeDefined(); + expect(result.owner).toBe(params.owner); + expect(result.permitBatch.spender).toBe(params.spender); + expect(result.permitBatch.details).toHaveLength(2); + expect(result.toSign.domain).toBeDefined(); + expect(result.toSign.types).toBeDefined(); + expect(result.toSign.values).toBeDefined(); + }); + + it("should handle native token (zero address) correctly", async () => { + const params = { + tokens: [ + "0x0000000000000000000000000000000000000000", + "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + ], + spender: "0x1234567890123456789012345678901234567890", + owner: "0x0987654321098765432109876543210987654321", + }; + + const result = await preparePermit2BatchData(params, instance); + + expect(result.permitBatch.details).toHaveLength(1); + expect(result.permitBatch.details[0].token).toBe( + "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + ); + }); + + it("should build permit2 batch data with signature correctly", async () => { + const params = { + tokens: ["0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"], + spender: "0x1234567890123456789012345678901234567890", + owner: "0x0987654321098765432109876543210987654321", + }; + + const result = await preparePermit2BatchData(params, instance); + const signature = + "0x1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890"; + + const permitWithSignature = + result.buildPermit2BatchDataWithSignature(signature); + + expect(permitWithSignature).toBeDefined(); + expect(permitWithSignature.owner).toBe(params.owner); + expect(permitWithSignature.permitBatch).toBe(result.permitBatch); + expect(permitWithSignature.signature).toBe(signature); + }); + + it("should use provided sigDeadline if available", async () => { + const sigDeadline = Math.floor(Date.now() / 1000) + 3600; + const params = { + tokens: ["0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"], + spender: "0x1234567890123456789012345678901234567890", + owner: "0x0987654321098765432109876543210987654321", + sigDeadline, + }; + + const result = await preparePermit2BatchData(params, instance); + + expect(result.permitBatch.sigDeadline).toBe(sigDeadline); + }); +}); diff --git a/src/types/core.ts b/src/types/core.ts index 4beb1c1..e0aed96 100644 --- a/src/types/core.ts +++ b/src/types/core.ts @@ -17,8 +17,6 @@ export type V4Contracts = { stateView: Address; /** Address of the universal router contract */ universalRouter: Address; - /** Address of the permit2 contract */ - permit2: Address; }; /** diff --git a/src/types/utils/buildAddLiquidityCallData.ts b/src/types/utils/buildAddLiquidityCallData.ts new file mode 100644 index 0000000..6dea9fc --- /dev/null +++ b/src/types/utils/buildAddLiquidityCallData.ts @@ -0,0 +1,74 @@ +import type { BatchPermitOptions, Pool } from "@uniswap/v4-sdk"; + +/** + * Common base parameters for building add liquidity call data. + */ +type BaseAddLiquidityParams = { + /** + * The Uniswap V4 pool to add liquidity to. + */ + pool: Pool; + + /** + * Amount of token0 to add. + */ + amount0?: string; + + /** + * Amount of token1 to add. + */ + amount1?: string; + + /** + * Address that will receive the position (NFT). + */ + recipient: string; + + /** + * Lower tick boundary for the position. + * Defaults to nearest usable MIN_TICK. + */ + tickLower?: number; + + /** + * Upper tick boundary for the position. + * Defaults to nearest usable MAX_TICK. + */ + tickUpper?: number; + + /** + * Maximum acceptable slippage for the operation (in basis points). + * e.g. 50 = 0.5%. + * Defaults to 50. + */ + slippageTolerance?: number; + + /** + * Unix timestamp (in seconds) after which the transaction will revert. + * Defaults to current block timestamp + 1800 (30 minutes). + */ + deadline?: string; + + /** + * Optional Permit2 batch signature for token approvals. + */ + permit2BatchSignature?: BatchPermitOptions; +}; + +export type BuildAddLiquidityParams = BaseAddLiquidityParams; + +/** + * Result of building add liquidity call data. + */ +export interface BuildAddLiquidityCallDataResult { + /** + * Encoded calldata for the `mint` operation via V4PositionManager. + */ + calldata: string; + + /** + * Amount of native currency to send with the transaction (if needed). + * Stringified bigint. + */ + value: string; +} diff --git a/src/types/utils/buildPermit2CallData.ts b/src/types/utils/buildPermit2CallData.ts deleted file mode 100644 index 000e621..0000000 --- a/src/types/utils/buildPermit2CallData.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { Address } from "viem"; - -/** - * Token permissions structure for Permit2 SignatureTransfer - * @public - */ -export interface TokenPermissions { - /** The token contract address */ - token: Address; - /** The amount to be transferred */ - amount: bigint; -} - -/** - * Permit data structure for SignatureTransfer operations - * @public - */ -export interface PermitTransferFrom { - /** Token and amount permissions */ - permitted: TokenPermissions; - /** Unique nonce for replay protection */ - nonce: bigint; - /** Unix timestamp when the permit expires */ - deadline: bigint; -} - -/** - * EIP-712 typed data structure for PermitTransferFrom signatures - * @public - */ -export interface PermitTransferFromTypedData { - /** EIP-712 domain separator */ - domain: { - /** Domain name */ - name: string; - /** Domain version */ - version: string; - /** Chain ID */ - chainId: number; - /** Verifying contract address */ - verifyingContract: Address; - }; - /** EIP-712 type definitions */ - types: { - /** PermitTransferFrom type definition */ - PermitTransferFrom: readonly [ - { readonly name: "permitted"; readonly type: "TokenPermissions" }, - { readonly name: "spender"; readonly type: "address" }, - { readonly name: "nonce"; readonly type: "uint256" }, - { readonly name: "deadline"; readonly type: "uint256" }, - ]; - /** TokenPermissions type definition */ - TokenPermissions: readonly [ - { readonly name: "token"; readonly type: "address" }, - { readonly name: "amount"; readonly type: "uint256" }, - ]; - }; - /** Primary type for signing */ - primaryType: "PermitTransferFrom"; - /** Message data to be signed */ - message: PermitTransferFrom & { - /** Address authorized to execute the transfer */ - spender: Address; - }; -} - -/** - * Permit parameters for building typed data - * @public - */ -export interface PermitParams { - /** Token contract address */ - token: Address; - /** Amount to be permitted for transfer */ - amount: bigint; - /** Token owner address */ - owner: Address; - /** Address authorized to execute the transfer */ - spender: Address; - /** Optional unique nonce for replay protection (auto-fetched if not provided) */ - nonce?: bigint; - /** Optional deadline in seconds from now (defaults to 30 minutes) */ - deadlineSeconds?: number; -} diff --git a/src/types/utils/getPosition.ts b/src/types/utils/getPosition.ts index 25513c9..d1e4b70 100644 --- a/src/types/utils/getPosition.ts +++ b/src/types/utils/getPosition.ts @@ -1,4 +1,4 @@ -import type { Token } from "@uniswap/sdk-core"; +import type { Currency } from "@uniswap/sdk-core"; import type { Pool, Position } from "@uniswap/v4-sdk"; /** @@ -18,9 +18,9 @@ export interface GetPositionResponse { /** The pool instance associated with the position */ pool: Pool; /** The first token in the pool pair */ - token0: Token; + token0: Currency; /** The second token in the pool pair */ - token1: Token; + token1: Currency; /** The unique identifier of the pool */ poolId: `0x${string}`; /** The unique identifier of the position */ diff --git a/src/types/utils/preparePermit2BatchData.ts b/src/types/utils/preparePermit2BatchData.ts new file mode 100644 index 0000000..20a67ea --- /dev/null +++ b/src/types/utils/preparePermit2BatchData.ts @@ -0,0 +1,41 @@ +import type { PermitBatch } from "@uniswap/permit2-sdk"; +import type { BatchPermitOptions } from "@uniswap/v4-sdk"; +import type { TypedDataDomain, TypedDataField } from "ethers"; +import type { Address, Hex } from "viem"; + +/** + * Interface for the parameters required to generate a Permit2 batch signature + */ +export interface PreparePermit2BatchDataParams { + /** Array of token addresses to permit */ + tokens: (Address | string)[]; + /** Address that will be allowed to spend the tokens */ + spender: Address | string; + /** User's wallet address */ + owner: Address | string; + /** Signature deadline in seconds */ + sigDeadline?: number; +} + +/** + * Interface for the return value of the function + */ +export interface PreparePermit2BatchDataResult { + /** Function to build the permit2 batch data with a signature */ + buildPermit2BatchDataWithSignature: ( + signature: string | Hex, + ) => BatchPermitOptions; + /** User's wallet address */ + owner: Address | string; + /** Permit2 batch data */ + permitBatch: PermitBatch; + /** Data needed to sign the permit2 batch data */ + toSign: { + /** Domain of the permit2 batch data */ + domain: TypedDataDomain; + /** Types of the permit2 batch data */ + types: Record; + /** Values of the permit2 batch data */ + values: PermitBatch; + }; +} diff --git a/src/utils/buildAddLiquidityCallData.ts b/src/utils/buildAddLiquidityCallData.ts new file mode 100644 index 0000000..ab45685 --- /dev/null +++ b/src/utils/buildAddLiquidityCallData.ts @@ -0,0 +1,191 @@ +import type { UniDevKitV4Instance } from "@/types"; +import type { + BuildAddLiquidityCallDataResult, + BuildAddLiquidityParams, +} from "@/types/utils/buildAddLiquidityCallData"; +import { Percent } from "@uniswap/sdk-core"; +import { + TickMath, + encodeSqrtRatioX96, + nearestUsableTick, +} from "@uniswap/v3-sdk"; +import { Position, V4PositionManager } from "@uniswap/v4-sdk"; + +const DEFAULT_DEADLINE = 1800n; // 30 minutes +const DEFAULT_SLIPPAGE_TOLERANCE = 50; + +/** + * Builds the calldata and native value required to add liquidity to a Uniswap V4 pool. + * + * This function supports flexible input handling. The caller may specify: + * - Only `amount0` + * - Only `amount1` + * - Or both `amount0` and `amount1` + * + * The behavior depends on whether the pool has existing liquidity: + * + * - If the pool **already has liquidity**, only one of the amounts is required. + * The other will be computed internally using the current price of the pool. + * + * - If the pool **does not have liquidity** (i.e. is being created), + * both `amount0` and `amount1` are required in order to compute the initial price + * (`sqrtPriceX96`) using `encodeSqrtRatioX96(amount1, amount0)`. + * + * - The amounts must be matching the pool's token0 and token1. + * - The amounts must be in the same decimals as the pool's token0 and token1. + * + * The function also supports optional parameters for tick range, slippage tolerance, + * deadline, and Permit2 batch signature for token approvals. + * + * @param params - The full set of parameters for building the add liquidity calldata. + * @param instance - An instance of the UniDevKitV4 context, providing access to the connected RPC client. + * + * @returns An object containing: + * - `calldata`: The ABI-encoded calldata for the `mint` operation. + * - `value`: The native value (in wei, as string) to send with the transaction, if required. + * + * @throws If neither `amount0` nor `amount1` is provided. + * @throws If the pool has no liquidity and only one of the amounts is provided. + * @throws If tick bounds or permit2 data is invalid during calldata generation. + * @example + * ```typescript + * const params = { + * pool: pool, + * amount0: parseUnits("100", 6), // 100 USDC + * amount1: parseEther("0.04"), // 0.04 WETH + * recipient: "0x...", + * ... // other optional params + * }; + * + * const { calldata, value } = await buildAddLiquidityCallData(params, instance); + * + * // Send transaction + * const tx = await sendTransaction({ + * to: V4PositionManager.address, + * data: calldata, + * value, + * }); + * ``` + */ + +export async function buildAddLiquidityCallData( + params: BuildAddLiquidityParams, + instance: UniDevKitV4Instance, +): Promise { + const { + pool, + amount0, + amount1, + recipient, + tickLower: tickLowerParam, + tickUpper: tickUpperParam, + slippageTolerance = DEFAULT_SLIPPAGE_TOLERANCE, + deadline: deadlineParam, + permit2BatchSignature, + } = params; + + console.log("params", params); + + try { + const deadline = + deadlineParam ?? + ( + await instance.client + .getBlock() + .then((b) => b.timestamp + DEFAULT_DEADLINE) + ).toString(); + + const slippagePercent = new Percent(slippageTolerance, 10_000); + const createPool = pool.liquidity.toString() === "0"; + + const tickLower = + tickLowerParam ?? nearestUsableTick(TickMath.MIN_TICK, pool.tickSpacing); + const tickUpper = + tickUpperParam ?? nearestUsableTick(TickMath.MAX_TICK, pool.tickSpacing); + + // Validate input + if (!amount0 && !amount1) { + throw new Error("At least one of amount0 or amount1 must be provided."); + } + + let sqrtPriceX96: string; + if (createPool) { + if (!amount0 || !amount1) { + throw new Error( + "Both amount0 and amount1 are required when creating a new pool.", + ); + } + sqrtPriceX96 = encodeSqrtRatioX96(amount1, amount0).toString(); + } else { + sqrtPriceX96 = pool.sqrtRatioX96.toString(); + } + + // Build position + let position: Position; + if (amount0 && amount1) { + position = Position.fromAmounts({ + pool, + tickLower, + tickUpper, + amount0, + amount1, + useFullPrecision: true, + }); + } else if (amount0) { + position = Position.fromAmount0({ + pool, + tickLower, + tickUpper, + amount0, + useFullPrecision: true, + }); + } else if (amount1) { + position = Position.fromAmount1({ + pool, + tickLower, + tickUpper, + amount1, + }); + } else { + throw new Error( + "Invalid input: at least one of amount0 or amount1 must be defined.", + ); + } + + console.log("positionAmount0", position.amount0.toSignificant(6)); + console.log("positionAmount1", position.amount1.toSignificant(6)); + console.log("position", position); + + // Get native currency + const nativeCurrency = pool.token0.isNative + ? pool.token0 + : pool.token1.isNative + ? pool.token1 + : undefined; + + // Build calldata + const { calldata, value } = V4PositionManager.addCallParameters(position, { + recipient, + deadline, + slippageTolerance: slippagePercent, + createPool, + sqrtPriceX96, + useNative: nativeCurrency, + batchPermit: permit2BatchSignature + ? { + owner: permit2BatchSignature.owner, + permitBatch: permit2BatchSignature.permitBatch, + signature: permit2BatchSignature.signature, + } + : undefined, + }); + + return { + calldata, + value, + }; + } catch (error) { + console.error(error); + throw error; + } +} diff --git a/src/utils/getTokens.ts b/src/utils/getTokens.ts index b94b143..9e63593 100644 --- a/src/utils/getTokens.ts +++ b/src/utils/getTokens.ts @@ -1,6 +1,6 @@ import type { UniDevKitV4Instance } from "@/types/core"; import type { GetTokensParams } from "@/types/utils/getTokens"; -import { Token } from "@uniswap/sdk-core"; +import { type Currency, Ether, Token } from "@uniswap/sdk-core"; import { erc20Abi, zeroAddress } from "viem"; /** @@ -13,7 +13,7 @@ import { erc20Abi, zeroAddress } from "viem"; export async function getTokens( params: GetTokensParams, instance: UniDevKitV4Instance, -): Promise { +): Promise { const { addresses } = params; const { client, chain } = instance; @@ -31,22 +31,12 @@ export async function getTokens( allowFailure: false, }); - const tokens: Token[] = []; + const tokens: Currency[] = []; let resultIndex = 0; for (const address of addresses) { if (address === zeroAddress) { - // For native currency, use chain data from wagmi - const nativeCurrency = chain.nativeCurrency; - tokens.push( - new Token( - chain.id, - address, - nativeCurrency.decimals, - nativeCurrency.symbol, - nativeCurrency.name, - ), - ); + tokens.push(Ether.onChain(chain.id)); } else { // For ERC20 tokens, use multicall results const symbol = results[resultIndex++] as string; diff --git a/src/utils/preparePermit2BatchData.ts b/src/utils/preparePermit2BatchData.ts new file mode 100644 index 0000000..01023c6 --- /dev/null +++ b/src/utils/preparePermit2BatchData.ts @@ -0,0 +1,163 @@ +import type { UniDevKitV4Instance } from "@/types"; +import type { + PreparePermit2BatchDataParams, + PreparePermit2BatchDataResult, +} from "@/types/utils/preparePermit2BatchData"; +import { + AllowanceTransfer, + MaxUint160, + PERMIT2_ADDRESS, + type PermitBatch, +} from "@uniswap/permit2-sdk"; +import type { BatchPermitOptions } from "@uniswap/v4-sdk"; +import type { TypedDataDomain, TypedDataField } from "ethers"; +import { zeroAddress } from "viem"; +import type { Hex } from "viem/_types/types/misc"; + +/** + * Prepares the permit2 batch data for multiple tokens + * + * This function creates a batch permit that allows a spender to use multiple tokens + * on behalf of the user. It fetches current allowance details for each token and + * prepares the data needed for signing. + * + * The complete flow to use this function is: + * 1. Prepare the permit data: + * ```typescript + * const permitData = await preparePermit2BatchData({ + * tokens: [token0, token1], + * spender: positionManagerAddress, + * owner: userAddress + * }, instance) + * ``` + * + * 2. Sign the permit data using your signer: + * ```typescript + * const signature = await signer.signTypedData( + * permitData.toSign.domain, + * permitData.toSign.types, + * permitData.toSign.values + * ) + * ``` + * + * 3. Build the final permit data with signature: + * ```typescript + * const permitWithSignature = permitData.buildPermit2BatchDataWithSignature(signature) + * ``` + * + * 4. Use the permit in your transaction (e.g. with buildAddLiquidityCallData): + * ```typescript + * const { calldata } = await buildAddLiquidityCallData({ + * permit2BatchSignature: permitWithSignature, + * // ... other params + * }, instance) + * ``` + * + * @param params - Parameters for preparing the permit2 batch data + * @returns Promise resolving to the permit2 batch data and helper functions + * @throws Error if any required dependencies are missing + */ +export async function preparePermit2BatchData( + params: PreparePermit2BatchDataParams, + instance: UniDevKitV4Instance, +): Promise { + const { tokens, spender, owner, sigDeadline: sigDeadlineParam } = params; + + const chainId = instance.chain.id; + + // calculate sigDeadline if not provided + let sigDeadline = sigDeadlineParam; + if (!sigDeadline) { + const blockTimestamp = await instance.client + .getBlock() + .then((block) => block.timestamp); + + sigDeadline = Number(blockTimestamp + 60n * 60n); // 30 minutes from current block timestamp + } + + const noNativeTokens = tokens.filter( + (token) => token.toLowerCase() !== zeroAddress.toLowerCase(), + ); + + // Fetch allowance details for each token + const details = await instance.client.multicall({ + allowFailure: false, + contracts: noNativeTokens.map((token) => ({ + address: PERMIT2_ADDRESS as `0x${string}`, + abi: [ + { + name: "allowance", + type: "function", + stateMutability: "view", + inputs: [ + { name: "owner", type: "address" }, + { name: "token", type: "address" }, + { name: "spender", type: "address" }, + ], + outputs: [ + { + components: [ + { name: "amount", type: "uint160" }, + { name: "expiration", type: "uint48" }, + { name: "nonce", type: "uint48" }, + ], + name: "details", + type: "tuple", + }, + ], + }, + ] as const, + functionName: "allowance", + args: [owner, token, spender], + })), + }); + + const results = noNativeTokens.map((token, index) => { + const { expiration, nonce } = details[index]; + return { + token, + amount: MaxUint160.toString(), + expiration: Number(expiration), + nonce: Number(nonce), + }; + }); + + // Create the permit batch object + const permitBatch = { + details: results, + spender, + sigDeadline, + }; + + // Get the data needed for signing + const { domain, types, values } = AllowanceTransfer.getPermitData( + permitBatch, + PERMIT2_ADDRESS, + chainId, + ) as { + domain: TypedDataDomain; + types: Record; + values: PermitBatch; + }; + + const buildPermit2BatchDataWithSignature = ( + signature: string | Hex, + ): BatchPermitOptions => { + return { + owner, + permitBatch, + signature, + }; + }; + + return { + buildPermit2BatchDataWithSignature, + owner, + permitBatch, + toSign: { + domain, + types, + values, + }, + }; +} From c7db9bf1a6ea36bdd2e9c23e3b85dd517b2a7fef Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Thu, 5 Jun 2025 13:01:20 -0300 Subject: [PATCH 2/2] chore: update package version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index eba5751..2683061 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "uniswap-dev-kit", - "version": "1.0.8", + "version": "1.0.9", "description": "A modern TypeScript library for integrating Uniswap into your dapp.", "main": "dist/index.js", "types": "dist/index.d.ts",