diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7a2912b..06e6ba1 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -37,5 +37,5 @@ jobs: - name: Lint & Build run: | bun lint - # bun run test + bun run test bun run build \ No newline at end of file diff --git a/bun.lockb b/bun.lockb index 873c009..0218d2e 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/jest.config.cjs b/jest.config.cjs new file mode 100644 index 0000000..61cf8cd --- /dev/null +++ b/jest.config.cjs @@ -0,0 +1,9 @@ +/** @type {import('jest').Config} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1', + }, + extensionsToTreatAsEsm: ['.ts'], +} \ No newline at end of file diff --git a/package.json b/package.json index 67e60ae..b780417 100644 --- a/package.json +++ b/package.json @@ -6,17 +6,22 @@ "workspaces": [ "packages/*" ], + "type": "module", "scripts": { "build": "bun run --cwd packages/agent-sdk build", "lint": "prettier --check packages/**/* && eslint packages/", - "fmt": "prettier --write packages/**/* && eslint packages/ --fix" + "fmt": "prettier --write packages/**/* && eslint packages/ --fix", + "test": "jest" }, "devDependencies": { "@types/bun": "latest", + "@types/jest": "^29.5.14", "@typescript-eslint/eslint-plugin": "^8.16.0", "@typescript-eslint/parser": "^8.16.0", "eslint": "^9.15.0", + "jest": "^29.7.0", "prettier": "^3.4.1", + "ts-jest": "^29.2.5", "typescript": "^5.7.2" } } diff --git a/packages/agent-sdk/src/evm/erc20.ts b/packages/agent-sdk/src/evm/erc20.ts index 6527d17..b03a403 100644 --- a/packages/agent-sdk/src/evm/erc20.ts +++ b/packages/agent-sdk/src/evm/erc20.ts @@ -55,6 +55,20 @@ export async function erc20Approve(params: { }); } +export async function checkAllowance( + owner: Address, + token: Address, + spender: Address, + chainId: number, +): Promise { + return getClient(chainId).readContract({ + address: token, + abi: erc20Abi, + functionName: "allowance", + args: [owner, spender], + }); +} + export async function getTokenInfo( chainId: number, address: Address, diff --git a/packages/agent-sdk/src/evm/index.ts b/packages/agent-sdk/src/evm/index.ts index 7a9fc35..47a3fe3 100644 --- a/packages/agent-sdk/src/evm/index.ts +++ b/packages/agent-sdk/src/evm/index.ts @@ -4,6 +4,7 @@ import { getAddress, type Hex, zeroAddress, type Address } from "viem"; export * from "./types"; export * from "./erc20"; +export * from "./weth"; export function signRequestFor({ from, @@ -14,7 +15,6 @@ export function signRequestFor({ chainId: number; metaTransactions: MetaTransaction[]; }): SignRequestData { - console.log("metaTransactions", metaTransactions); return { method: "eth_sendTransaction", chainId, @@ -59,7 +59,6 @@ export async function validateRequest< safeSaltNonce: string, ): Promise { const metadataHeader = req.headers.get("mb-metadata"); - console.log("Request Metadata:", JSON.stringify(metadataHeader, null, 2)); const metadata = JSON.parse(metadataHeader ?? "{}"); const { accountId, evmAddress } = metadata; if (!accountId || !evmAddress) { @@ -78,7 +77,6 @@ export async function validateRequest< { status: 401 }, ) as TResponse; } - console.log(`Valid request for ${accountId} <-> ${evmAddress}`); return null; } diff --git a/packages/agent-sdk/src/evm/weth.ts b/packages/agent-sdk/src/evm/weth.ts new file mode 100644 index 0000000..df91376 --- /dev/null +++ b/packages/agent-sdk/src/evm/weth.ts @@ -0,0 +1,115 @@ +import { Network, type MetaTransaction, type SignRequestData } from "near-safe"; +import { + type Address, + encodeFunctionData, + getAddress, + parseAbi, + parseEther, + toHex, +} from "viem"; +import { signRequestFor } from "."; + +type NativeAsset = { + address: Address; + symbol: string; + scanUrl: string; + decimals: number; +}; + +export function validateWethInput(params: URLSearchParams): { + chainId: number; + amount: bigint; + nativeAsset: NativeAsset; +} { + const chainIdStr = params.get("chainId"); + const amountStr = params.get("amount"); + + // Ensure required fields + if (!chainIdStr) { + throw new Error("Missing required parameter: chainId"); + } + if (!amountStr) { + throw new Error("Missing required parameter: amount"); + } + + // Validate chainId + const chainId = parseInt(chainIdStr); + if (isNaN(chainId)) { + throw new Error("Invalid chainId, must be a number"); + } + + // Validate amount + const amount = parseFloat(amountStr); + if (isNaN(amount) || amount <= 0) { + throw new Error("Invalid amount, must be a positive float"); + } + + return { + chainId, + amount: parseEther(amount.toString()), + nativeAsset: getNativeAsset(chainId), + }; +} + +export function unwrapSignRequest( + chainId: number, + amount: bigint, +): SignRequestData { + return signRequestFor({ + chainId, + metaTransactions: [unwrapMetaTransaction(chainId, amount)], + }); +} + +export const unwrapMetaTransaction = ( + chainId: number, + amount: bigint, +): MetaTransaction => { + return { + to: getNativeAsset(chainId).address, + value: "0x0", + data: encodeFunctionData({ + abi: parseAbi(["function withdraw(uint wad)"]), + functionName: "withdraw", + args: [amount], + }), + }; +}; + +export function wrapSignRequest( + chainId: number, + amount: bigint, +): SignRequestData { + return signRequestFor({ + chainId, + metaTransactions: [wrapMetaTransaction(chainId, amount)], + }); +} + +export const wrapMetaTransaction = ( + chainId: number, + amount: bigint, +): MetaTransaction => { + return { + to: getNativeAsset(chainId).address, + value: toHex(amount), + // methodId for weth.deposit + data: "0xd0e30db0", + }; +}; + +export function getNativeAsset(chainId: number): NativeAsset { + const network = Network.fromChainId(chainId); + const wethAddress = network.nativeCurrency.wrappedAddress; + if (!wethAddress) { + throw new Error( + `Couldn't find wrapped address for Network ${network.name} (chainId=${chainId})`, + ); + } + return { + address: getAddress(wethAddress), + symbol: network.nativeCurrency.symbol, + scanUrl: `${network.scanUrl}/address/${wethAddress}`, + decimals: network.nativeCurrency.decimals, + }; +} diff --git a/packages/agent-sdk/src/index.ts b/packages/agent-sdk/src/index.ts index 24f18cc..73a1f49 100644 --- a/packages/agent-sdk/src/index.ts +++ b/packages/agent-sdk/src/index.ts @@ -1,57 +1,2 @@ -import { parseUnits, type Address } from "viem"; -import { erc20Transfer, getTokenDecimals } from "./evm"; -import { - addressField, - floatField, - numberField, - validateInput, - type FieldParser, -} from "./validate"; - export * from "./validate"; export * from "./evm"; - -interface Input { - chainId: number; - amount: number; - token: Address; - recipient: Address; -} - -const parsers: FieldParser = { - chainId: numberField, - // Note that this is a float (i.e. token units) - amount: floatField, - token: addressField, - recipient: addressField, -}; - -export async function GET(req: Request): Promise { - const url = new URL(req.url); - const search = url.searchParams; - console.log("erc20/", search); - try { - const { chainId, amount, token, recipient } = validateInput( - search, - parsers, - ); - const decimals = await getTokenDecimals(chainId, token); - return Response.json( - { - transaction: erc20Transfer({ - chainId, - token, - to: recipient, - amount: parseUnits(amount.toString(), decimals), - }), - }, - { status: 200 }, - ); - } catch (error: unknown) { - const message = - error instanceof Error - ? error.message - : `Unknown error occurred ${String(error)}`; - return Response.json({ ok: false, message }, { status: 400 }); - } -} diff --git a/packages/agent-sdk/tests/evm/erc20.spec.ts b/packages/agent-sdk/tests/evm/erc20.spec.ts new file mode 100644 index 0000000..59f1d0e --- /dev/null +++ b/packages/agent-sdk/tests/evm/erc20.spec.ts @@ -0,0 +1,185 @@ +import { type Address, erc20Abi } from "viem"; +import { getClient } from "near-safe"; +import { + erc20Transfer, + erc20Approve, + checkAllowance, + getTokenInfo, + getTokenDecimals, + getTokenSymbol, +} from "../../src"; + +// Mock the external dependencies +jest.mock("near-safe"); + +describe("ERC20 Utilities", () => { + const mockAddress = "0x1234567890123456789012345678901234567890" as Address; + const mockChainId = 1; + const mockAmount = 1000n; + + describe("erc20Transfer", () => { + it("creates correct transfer transaction", async () => { + const params = { + chainId: mockChainId, + token: mockAddress, + to: mockAddress, + amount: mockAmount, + }; + + const signRequest = await erc20Transfer(params); + + expect(signRequest).toEqual({ + chainId: 1, + method: "eth_sendTransaction", + params: [ + { + data: "0xa9059cbb000000000000000000000000123456789012345678901234567890123456789000000000000000000000000000000000000000000000000000000000000003e8", + from: "0x0000000000000000000000000000000000000000", + to: "0x1234567890123456789012345678901234567890", + value: "0x", + }, + ], + }); + }); + }); + + describe("erc20Approve", () => { + it("creates approval transaction with specific amount", async () => { + const params = { + chainId: mockChainId, + token: mockAddress, + spender: mockAddress, + amount: mockAmount, + }; + + const signRequest = await erc20Approve(params); + + expect(signRequest).toEqual({ + chainId: 1, + method: "eth_sendTransaction", + params: [ + { + data: "0x095ea7b3000000000000000000000000123456789012345678901234567890123456789000000000000000000000000000000000000000000000000000000000000003e8", + from: "0x0000000000000000000000000000000000000000", + to: "0x1234567890123456789012345678901234567890", + value: "0x", + }, + ], + }); + }); + + it("creates approval transaction with max amount when amount not specified", async () => { + const params = { + chainId: mockChainId, + token: mockAddress, + spender: mockAddress, + }; + + const signRequest = await erc20Approve(params); + + expect(signRequest).toEqual({ + chainId: 1, + method: "eth_sendTransaction", + params: [ + { + data: "0x095ea7b30000000000000000000000001234567890123456789012345678901234567890ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + from: "0x0000000000000000000000000000000000000000", + to: "0x1234567890123456789012345678901234567890", + value: "0x", + }, + ], + }); + }); + }); + + describe("checkAllowance", () => { + it("reads allowance correctly", async () => { + const mockClient = { + readContract: jest.fn().mockResolvedValue(BigInt(1000)), + }; + (getClient as jest.Mock).mockReturnValue(mockClient); + + const result = await checkAllowance( + mockAddress, + mockAddress, + mockAddress, + mockChainId, + ); + + expect(result).toBe(BigInt(1000)); + expect(mockClient.readContract).toHaveBeenCalledWith({ + address: mockAddress, + abi: erc20Abi, + functionName: "allowance", + args: [mockAddress, mockAddress], + }); + }); + }); + + describe("getTokenInfo", () => { + it("fetches token info correctly", async () => { + const mockClient = { + readContract: jest + .fn() + .mockResolvedValueOnce(18) // decimals + .mockResolvedValueOnce("TEST"), // symbol + }; + (getClient as jest.Mock).mockReturnValue(mockClient); + + const result = await getTokenInfo(mockChainId, mockAddress); + + expect(result).toEqual({ + decimals: 18, + symbol: "TEST", + }); + }); + }); + + describe("getTokenDecimals", () => { + it("fetches decimals correctly", async () => { + const mockClient = { + readContract: jest.fn().mockResolvedValue(18), + }; + (getClient as jest.Mock).mockReturnValue(mockClient); + + const result = await getTokenDecimals(mockChainId, mockAddress); + + expect(result).toBe(18); + }); + + it("handles errors appropriately", async () => { + const mockClient = { + readContract: jest.fn().mockRejectedValue(new Error("Test error")), + }; + (getClient as jest.Mock).mockReturnValue(mockClient); + + await expect(getTokenDecimals(mockChainId, mockAddress)).rejects.toThrow( + "Error fetching token decimals: Error: Test error", + ); + }); + }); + + describe("getTokenSymbol", () => { + it("fetches symbol correctly", async () => { + const mockClient = { + readContract: jest.fn().mockResolvedValue("TEST"), + }; + (getClient as jest.Mock).mockReturnValue(mockClient); + + const result = await getTokenSymbol(mockChainId, mockAddress); + + expect(result).toBe("TEST"); + }); + + it("handles errors appropriately", async () => { + const mockClient = { + readContract: jest.fn().mockRejectedValue(new Error("Test error")), + }; + (getClient as jest.Mock).mockReturnValue(mockClient); + + await expect(getTokenSymbol(mockChainId, mockAddress)).rejects.toThrow( + "Error fetching token decimals: Error: Test error", + ); + }); + }); +}); diff --git a/packages/agent-sdk/tests/evm/weth.spec.ts b/packages/agent-sdk/tests/evm/weth.spec.ts new file mode 100644 index 0000000..248d0fb --- /dev/null +++ b/packages/agent-sdk/tests/evm/weth.spec.ts @@ -0,0 +1,167 @@ +import { Network } from "near-safe"; +import { parseEther } from "viem"; +import { + validateWethInput, + unwrapSignRequest, + wrapSignRequest, + getNativeAsset, + unwrapMetaTransaction, + wrapMetaTransaction, +} from "../../src/evm/weth"; +import { signRequestFor } from "../../src"; + +// Mock the external dependencies +jest.mock("../../src", () => ({ + signRequestFor: jest.fn().mockImplementation((args) => args), +})); + +describe("evm/weth", () => { + // Existing tests + it("unwrapMetaTransaction", async () => { + expect(unwrapMetaTransaction(100, 25n)).toStrictEqual({ + to: "0xe91D153E0b41518A2Ce8Dd3D7944Fa863463a97d", + value: "0x0", + data: "0x2e1a7d4d0000000000000000000000000000000000000000000000000000000000000019", + }); + }); + + it("wrapMetaTransaction", async () => { + expect(wrapMetaTransaction(100, 25n)).toStrictEqual({ + to: "0xe91D153E0b41518A2Ce8Dd3D7944Fa863463a97d", + value: "0x19", + data: "0xd0e30db0", + }); + }); + + // New tests + describe("validateWethInput", () => { + it("validates correct input parameters", () => { + const params = new URLSearchParams({ + chainId: "100", + amount: "1.5", + }); + + const result = validateWethInput(params); + expect(result).toEqual({ + chainId: 100, + amount: parseEther("1.5"), + nativeAsset: expect.objectContaining({ + address: "0xe91D153E0b41518A2Ce8Dd3D7944Fa863463a97d", + symbol: "XDAI", + decimals: 18, + }), + }); + }); + + it("throws error for missing chainId", () => { + const params = new URLSearchParams({ + amount: "1.5", + }); + + expect(() => validateWethInput(params)).toThrow( + "Missing required parameter: chainId", + ); + }); + + it("throws error for missing amount", () => { + const params = new URLSearchParams({ + chainId: "100", + }); + + expect(() => validateWethInput(params)).toThrow( + "Missing required parameter: amount", + ); + }); + + it("throws error for invalid chainId", () => { + const params = new URLSearchParams({ + chainId: "invalid", + amount: "1.5", + }); + + expect(() => validateWethInput(params)).toThrow( + "Invalid chainId, must be a number", + ); + }); + + it("throws error for invalid amount", () => { + const params = new URLSearchParams({ + chainId: "100", + amount: "-1.5", + }); + + expect(() => validateWethInput(params)).toThrow( + "Invalid amount, must be a positive float", + ); + }); + }); + + describe("unwrapSignRequest", () => { + it("creates correct unwrap sign request", () => { + const signRequest = unwrapSignRequest(100, 25n); + expect(signRequest).toEqual({ + method: "eth_sendTransaction", + chainId: 100, + params: [ + { + from: "0x0000000000000000000000000000000000000000", + to: "0xe91D153E0b41518A2Ce8Dd3D7944Fa863463a97d", + value: "0x0", + data: "0x2e1a7d4d0000000000000000000000000000000000000000000000000000000000000019", + }, + ], + }); + }); + }); + + describe("wrapSignRequest", () => { + it("creates correct wrap sign request", () => { + const signRequest = wrapSignRequest(100, 25n); + expect(signRequest).toEqual({ + method: "eth_sendTransaction", + chainId: 100, + params: [ + { + from: "0x0000000000000000000000000000000000000000", + to: "0xe91D153E0b41518A2Ce8Dd3D7944Fa863463a97d", + value: "0x19", + data: "0xd0e30db0", + }, + ], + }); + }); + }); + + describe("getNativeAsset", () => { + it("returns correct native asset info for known chain", () => { + const result = getNativeAsset(100); + + expect(result).toEqual({ + address: "0xe91D153E0b41518A2Ce8Dd3D7944Fa863463a97d", + symbol: "XDAI", + scanUrl: + "https://gnosisscan.io/address/0xe91d153e0b41518a2ce8dd3d7944fa863463a97d", + decimals: 18, + }); + }); + + it("throws error for chain without wrapped address", () => { + // Mock a network without wrapped address + jest.spyOn(Network, "fromChainId").mockImplementationOnce( + () => + ({ + name: "TestNet", + nativeCurrency: { + wrappedAddress: null, + symbol: "TEST", + decimals: 18, + }, + }) as unknown as Network, + ); + + expect(() => getNativeAsset(999)).toThrow( + "Couldn't find wrapped address for Network TestNet (chainId=999)", + ); + }); + }); +}); diff --git a/packages/agent-sdk/tests/validate.spec.ts b/packages/agent-sdk/tests/validate.spec.ts new file mode 100644 index 0000000..beded1f --- /dev/null +++ b/packages/agent-sdk/tests/validate.spec.ts @@ -0,0 +1,59 @@ +import { + addressField, + type FieldParser, + floatField, + numberField, + validateInput, +} from "../src"; +import { type Address, zeroAddress } from "viem"; + +interface Input { + int: number; + float: number; + address: Address; +} + +const parsers: FieldParser = { + int: numberField, + float: floatField, + address: addressField, +}; + +describe("field validation", () => { + it("ERC20 Input success", async () => { + const search = new URLSearchParams( + `int=123&float=0.45&address=${zeroAddress}`, + ); + const input = validateInput(search, parsers); + expect(input).toStrictEqual({ + int: 123, + float: 0.45, + address: zeroAddress, + }); + }); + it("ERC20 Input fail", async () => { + const search = new URLSearchParams(`float=0.45&address=${zeroAddress}`); + expect(() => validateInput(search, parsers)).toThrow( + "Missing required field: 'int'", + ); + search.set("int", "poop"); + expect(() => validateInput(search, parsers)).toThrow( + "Invalid Integer field 'int': Not a number", + ); + search.set("int", "1"); + search.set("address", "0x12"); + expect(() => validateInput(search, parsers)).toThrow( + "Invalid Address field 'address': 0x12", + ); + + const search2 = new URLSearchParams( + "int=11155111&float=0.069&address=0xDcf56F5a8Cc380f63b6396Dbddd0aE9fa605BeeE", + ); + const input = validateInput(search2, parsers); + expect(input).toStrictEqual({ + int: 11155111, + float: 0.069, + address: "0xDcf56F5a8Cc380f63b6396Dbddd0aE9fa605BeeE", + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 238655f..5e04b15 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,11 +7,12 @@ "moduleDetection": "force", "jsx": "react-jsx", "allowJs": true, + "esModuleInterop": true, // Bundler mode "moduleResolution": "bundler", "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, + "verbatimModuleSyntax": false, "noEmit": true, // Best practices