From 9b4092ba5f53db86747a096bf5c2155c7b9dbe71 Mon Sep 17 00:00:00 2001 From: Dirk Page Date: Fri, 14 Mar 2025 14:33:14 -0500 Subject: [PATCH 01/22] Added optional intentExecutionTypes parameter to request quotes for an intent method, fixed run order when running check-types task --- apps/sdk-demo/src/views/demo/demo-view.tsx | 2 +- packages/sdk/src/constants/index.ts | 7 +++++ packages/sdk/src/index.ts | 6 ++-- packages/sdk/src/quotes/OpenQuotingClient.ts | 10 ++++--- packages/sdk/src/quotes/types.ts | 8 ++++++ packages/sdk/test/e2e/publishAndFund.test.ts | 2 +- .../integration/OpenQuotingClient.test.ts | 28 +++++++++---------- turbo.json | 6 ++++ 8 files changed, 46 insertions(+), 23 deletions(-) diff --git a/apps/sdk-demo/src/views/demo/demo-view.tsx b/apps/sdk-demo/src/views/demo/demo-view.tsx index f7b063f..6de9060 100644 --- a/apps/sdk-demo/src/views/demo/demo-view.tsx +++ b/apps/sdk-demo/src/views/demo/demo-view.tsx @@ -20,7 +20,7 @@ export default function DemoView() { useEffect(() => { if (intent) { - openQuotingClient.requestQuotesForIntent(intent).then((quotes) => { + openQuotingClient.requestQuotesForIntent({ intent }).then((quotes) => { setQuotes(quotes) }).catch((error) => { alert('Could not fetch quotes: ' + error.message) diff --git a/packages/sdk/src/constants/index.ts b/packages/sdk/src/constants/index.ts index d8542e8..7f9080e 100644 --- a/packages/sdk/src/constants/index.ts +++ b/packages/sdk/src/constants/index.ts @@ -1,5 +1,11 @@ import { Hex } from "viem"; +export const INTENT_EXECUTION_TYPES = [ + "GASLESS", + "SELF_PUBLISH" +] as const; +export type IntentExecutionType = typeof INTENT_EXECUTION_TYPES[number] + export const chainIds = [ 1, // ETH Mainnet 10, // Optimism @@ -47,3 +53,4 @@ export const stableAddresses: Record { + async requestQuotesForIntent({ intent, intentExecutionTypes = ['SELF_PUBLISH', 'GASLESS'] }: RequestQuotesForIntentParams): Promise { + if (intentExecutionTypes.length === 0) { + throw new Error("intentExecutionTypes must not be empty"); + } const payload: OpenQuotingAPI.Quotes.Request = { dAppID: this.dAppID, + intentExecutionTypes, intentData: { routeData: { originChainID: intent.route.source.toString(), diff --git a/packages/sdk/src/quotes/types.ts b/packages/sdk/src/quotes/types.ts index de28fba..7e3c110 100644 --- a/packages/sdk/src/quotes/types.ts +++ b/packages/sdk/src/quotes/types.ts @@ -1,4 +1,6 @@ +import { IntentType } from "@eco-foundation/routes-ts"; import { Hex } from "viem"; +import { IntentExecutionType } from "../constants"; export namespace OpenQuotingAPI { export enum Endpoints { Quotes = '/api/v1/quotes' @@ -7,6 +9,7 @@ export namespace OpenQuotingAPI { export namespace Quotes { export interface Request { dAppID: string; + intentExecutionTypes: string[]; intentData: { routeData: { originChainID: string @@ -40,6 +43,11 @@ export namespace OpenQuotingAPI { } } +export type RequestQuotesForIntentParams = { + intent: IntentType + intentExecutionTypes?: IntentExecutionType[] +} + export type SolverQuote = { quoteData: QuoteData } diff --git a/packages/sdk/test/e2e/publishAndFund.test.ts b/packages/sdk/test/e2e/publishAndFund.test.ts index bcf32e7..7310cbb 100644 --- a/packages/sdk/test/e2e/publishAndFund.test.ts +++ b/packages/sdk/test/e2e/publishAndFund.test.ts @@ -50,7 +50,7 @@ describe("publishAndFund", () => { }) // request quotes - const quotes = await openQuotingClient.requestQuotesForIntent(intent) + const quotes = await openQuotingClient.requestQuotesForIntent({ intent }) const selectedQuote = selectCheapestQuote(quotes) // setup the intent for publishing diff --git a/packages/sdk/test/integration/OpenQuotingClient.test.ts b/packages/sdk/test/integration/OpenQuotingClient.test.ts index 509ad35..890a6d8 100644 --- a/packages/sdk/test/integration/OpenQuotingClient.test.ts +++ b/packages/sdk/test/integration/OpenQuotingClient.test.ts @@ -36,7 +36,7 @@ describe("OpenQuotingClient", () => { describe("requestQuotesForIntent", () => { test("valid", async () => { - const quotes = await openQuotingClient.requestQuotesForIntent(validIntent); + const quotes = await openQuotingClient.requestQuotesForIntent({ intent: validIntent }); expect(quotes).toBeDefined(); expect(quotes.length).toBeGreaterThan(0); @@ -58,7 +58,7 @@ describe("OpenQuotingClient", () => { test("valid:creator==zeroAddress", async () => { validIntent.reward.creator = zeroAddress; - const quotes = await openQuotingClient.requestQuotesForIntent(validIntent); + const quotes = await openQuotingClient.requestQuotesForIntent({ intent: validIntent }); expect(quotes).toBeDefined(); expect(quotes.length).toBeGreaterThan(0); @@ -96,77 +96,77 @@ describe("OpenQuotingClient", () => { } } - await expect(openQuotingClient.requestQuotesForIntent(emptyIntent)).rejects.toThrow("Request failed with status code 400"); + await expect(openQuotingClient.requestQuotesForIntent({ intent: emptyIntent })).rejects.toThrow("Request failed with status code 400"); }) test("invalid:route.source", async () => { const invalidIntent = validIntent; invalidIntent.route.source = BigInt(0); - await expect(openQuotingClient.requestQuotesForIntent(invalidIntent)).rejects.toThrow("Request failed with status code 400"); + await expect(openQuotingClient.requestQuotesForIntent({ intent: invalidIntent })).rejects.toThrow("Request failed with status code 400"); }) test("invalid:route.destination", async () => { const invalidIntent = validIntent; invalidIntent.route.destination = BigInt(0); - await expect(openQuotingClient.requestQuotesForIntent(invalidIntent)).rejects.toThrow("Request failed with status code 400"); + await expect(openQuotingClient.requestQuotesForIntent({ intent: invalidIntent })).rejects.toThrow("Request failed with status code 400"); }) test("invalid:route.inbox", async () => { const invalidIntent = validIntent; invalidIntent.route.inbox = "0x0"; - await expect(openQuotingClient.requestQuotesForIntent(invalidIntent)).rejects.toThrow("Request failed with status code 400"); + await expect(openQuotingClient.requestQuotesForIntent({ intent: invalidIntent })).rejects.toThrow("Request failed with status code 400"); }) test("invalid:route.calls", async () => { const invalidIntent = validIntent; invalidIntent.route.calls = [{ target: "0x0", data: zeroHash, value: BigInt(0) }]; - await expect(openQuotingClient.requestQuotesForIntent(invalidIntent)).rejects.toThrow("Request failed with status code 400"); + await expect(openQuotingClient.requestQuotesForIntent({ intent: invalidIntent })).rejects.toThrow("Request failed with status code 400"); invalidIntent.route.calls = [{ target: RoutesService.getStableAddress(10, "USDC"), data: zeroHash, value: BigInt(-1) }]; - await expect(openQuotingClient.requestQuotesForIntent(invalidIntent)).rejects.toThrow("Request failed with status code 400"); + await expect(openQuotingClient.requestQuotesForIntent({ intent: invalidIntent })).rejects.toThrow("Request failed with status code 400"); }) test("invalid:reward.creator", async () => { const invalidIntent = validIntent; invalidIntent.reward.creator = "0x0"; - await expect(openQuotingClient.requestQuotesForIntent(invalidIntent)).rejects.toThrow("Request failed with status code 400"); + await expect(openQuotingClient.requestQuotesForIntent({ intent: invalidIntent })).rejects.toThrow("Request failed with status code 400"); }) test("invalid:reward.prover", async () => { const invalidIntent = validIntent; invalidIntent.reward.prover = "0x0"; - await expect(openQuotingClient.requestQuotesForIntent(invalidIntent)).rejects.toThrow("Request failed with status code 400"); + await expect(openQuotingClient.requestQuotesForIntent({ intent: invalidIntent })).rejects.toThrow("Request failed with status code 400"); }) test("invalid:reward.deadline", async () => { const invalidIntent = validIntent; invalidIntent.reward.deadline = dateToTimestamp(getSecondsFromNow(50)); // must be 60 seconds in the future or more - await expect(openQuotingClient.requestQuotesForIntent(invalidIntent)).rejects.toThrow("Request failed with status code 400"); + await expect(openQuotingClient.requestQuotesForIntent({ intent: invalidIntent })).rejects.toThrow("Request failed with status code 400"); }) test("invalid:reward.nativeValue", async () => { const invalidIntent = validIntent; invalidIntent.reward.nativeValue = BigInt(-1); - await expect(openQuotingClient.requestQuotesForIntent(invalidIntent)).rejects.toThrow("Request failed with status code 400"); + await expect(openQuotingClient.requestQuotesForIntent({ intent: invalidIntent })).rejects.toThrow("Request failed with status code 400"); }) test("invalid:reward.tokens", async () => { const invalidIntent = validIntent; invalidIntent.reward.tokens = [{ token: "0x0", amount: BigInt(1000000) }]; - await expect(openQuotingClient.requestQuotesForIntent(invalidIntent)).rejects.toThrow("Request failed with status code 400"); + await expect(openQuotingClient.requestQuotesForIntent({ intent: invalidIntent })).rejects.toThrow("Request failed with status code 400"); invalidIntent.reward.tokens = [{ token: RoutesService.getStableAddress(10, "USDC"), amount: BigInt(-1) }]; - await expect(openQuotingClient.requestQuotesForIntent(invalidIntent)).rejects.toThrow("Request failed with status code 400"); + await expect(openQuotingClient.requestQuotesForIntent({ intent: invalidIntent })).rejects.toThrow("Request failed with status code 400"); }) }); }, 60_000); diff --git a/turbo.json b/turbo.json index 928e953..01e0cfe 100644 --- a/turbo.json +++ b/turbo.json @@ -45,6 +45,12 @@ "^check-types" ] }, + "sdk-demo#check-types": { + "dependsOn": [ + "@eco-foundation/routes-sdk#build", + "^check-types" + ] + }, "dev": { "cache": false, "persistent": true From 1cfcf7434621ee4d8f5df0eea723e7f5e3e3788e Mon Sep 17 00:00:00 2001 From: Dirk Page Date: Thu, 27 Mar 2025 18:05:06 -0500 Subject: [PATCH 02/22] Changes to quote response, open quoting client now parses quote responses --- packages/sdk/src/quotes/OpenQuotingClient.ts | 24 ++++- packages/sdk/src/quotes/types.ts | 94 ++++++++++++++------ 2 files changed, 88 insertions(+), 30 deletions(-) diff --git a/packages/sdk/src/quotes/OpenQuotingClient.ts b/packages/sdk/src/quotes/OpenQuotingClient.ts index 0554c3c..ca4bf33 100644 --- a/packages/sdk/src/quotes/OpenQuotingClient.ts +++ b/packages/sdk/src/quotes/OpenQuotingClient.ts @@ -62,6 +62,28 @@ export class OpenQuotingClient { } const response = await this.axiosInstance.post(OpenQuotingAPI.Endpoints.Quotes, payload); - return response.data.data; + + return this.parseQuotesResponse(response.data); + } + + private parseQuotesResponse(response: OpenQuotingAPI.Quotes.Response): SolverQuote[] { + return response.data.map((quote) => ({ + solverID: quote.solverID, + quoteData: { + quoteEntries: quote.quoteData.quoteEntries.map((entry) => ({ + intentExecutionType: entry.intentExecutionType, + tokens: entry.tokens.map((token) => ({ + token: token.token, + amount: BigInt(token.amount) + })), + expiryTime: BigInt(entry.expiryTime) + })) + } + })); + } + + // TODO: add method for submitting quoted intent gaslessly + async submitGaslessIntent({ }: { intent: any, quote: any }): Promise { + throw new Error("Method not implemented"); } } diff --git a/packages/sdk/src/quotes/types.ts b/packages/sdk/src/quotes/types.ts index 7e3c110..d349f90 100644 --- a/packages/sdk/src/quotes/types.ts +++ b/packages/sdk/src/quotes/types.ts @@ -3,42 +3,74 @@ import { Hex } from "viem"; import { IntentExecutionType } from "../constants"; export namespace OpenQuotingAPI { export enum Endpoints { - Quotes = '/api/v1/quotes' + Quotes = '/api/v1/quotes', + InitiateGaslessIntent = '/api/v1/quotes/initiateGaslessIntent', } export namespace Quotes { + export type IntentData = { + routeData: { + originChainID: string + destinationChainID: string + inboxContract: Hex + tokens: { + token: Hex + amount: string + }[] + calls: { + target: Hex + data: Hex + value: string + }[] + }, + rewardData: { + creator: Hex + proverContract: Hex + deadline: string + nativeValue: string + tokens: { + token: Hex + amount: string + }[] + } + } export interface Request { dAppID: string; - intentExecutionTypes: string[]; - intentData: { - routeData: { - originChainID: string - destinationChainID: string - inboxContract: Hex - tokens: { - token: Hex - amount: string - }[] - calls: { - target: Hex - data: Hex - value: string - }[] - }, - rewardData: { - creator: Hex - proverContract: Hex - deadline: string - nativeValue: string - tokens: { - token: Hex - amount: string + intentExecutionTypes: IntentExecutionType[]; + intentData: IntentData + } + export interface Response { + data: { + solverID: string + quoteData: { + quoteEntries: { + intentExecutionType: IntentExecutionType + tokens: { + token: Hex + amount: string + }[] + expiryTime: string }[] } + }[] + } + } + + export namespace InitiateGaslessIntent { + export interface Request { + dAppID: string; + solverID: string; + intentData: Quotes.IntentData & { + gaslessIntentData: { + funder: Hex + permitContract: Hex + allowPartial?: boolean + // TODO: add in optional permit2 signature data + } } } export interface Response { - data: SolverQuote[] + data: {} // TODO: get response format } } } @@ -49,13 +81,17 @@ export type RequestQuotesForIntentParams = { } export type SolverQuote = { - quoteData: QuoteData + solverID: string + quoteData: { + quoteEntries: QuoteData[] + } } export type QuoteData = { + intentExecutionType: IntentExecutionType tokens: { token: Hex, - amount: string + amount: bigint }[] - expiryTime: string // seconds since epoch + expiryTime: bigint // seconds since epoch } From 832e881806137036eea79faca155facf622801fe Mon Sep 17 00:00:00 2001 From: Dirk Page Date: Wed, 2 Apr 2025 22:25:09 -0500 Subject: [PATCH 03/22] Removing applyQuoteToIntent method from RoutesService --- packages/sdk/src/routes/RoutesService.ts | 25 +----- packages/sdk/src/routes/types.ts | 7 -- packages/sdk/test/unit/RoutesService.test.ts | 88 +------------------- 3 files changed, 4 insertions(+), 116 deletions(-) diff --git a/packages/sdk/src/routes/RoutesService.ts b/packages/sdk/src/routes/RoutesService.ts index a847c7d..0b8f824 100644 --- a/packages/sdk/src/routes/RoutesService.ts +++ b/packages/sdk/src/routes/RoutesService.ts @@ -1,7 +1,7 @@ import { encodeFunctionData, erc20Abi, Hex, isAddress } from "viem"; import { dateToTimestamp, generateRandomHex, getSecondsFromNow, isAmountInvalid } from "../utils"; import { stableAddresses, RoutesSupportedChainId, RoutesSupportedStable } from "../constants"; -import { CreateIntentParams, CreateSimpleIntentParams, ApplyQuoteToIntentParams } from "./types"; +import { CreateIntentParams, CreateSimpleIntentParams } from "./types"; import { EcoChainIds, EcoProtocolAddresses, IntentType } from "@eco-foundation/routes-ts"; import { ECO_SDK_CONFIG } from "../config"; @@ -154,29 +154,6 @@ export class RoutesService { } } - /** - * Applies a quote to an intent, modifying the reward tokens. - * - * @param {ApplyQuoteToIntentParams} params - The parameters for applying the quote to the intent. - * - * @returns {IntentType} The intent with the quote applied. - * - * @throws {Error} If the quote is invalid. - */ - applyQuoteToIntent({ intent, quote }: ApplyQuoteToIntentParams): IntentType { - if (!quote.quoteData.tokens.length) { - throw new Error("Invalid quoteData: tokens array must have length greater than 0") - } - - // only thing affected by the quote is the reward tokens - intent.reward.tokens = quote.quoteData.tokens.map(({ token, amount }) => ({ - token: token, - amount: BigInt(amount) - })) - - return intent; - } - /** * Returns the EcoChainId for a given chainId, appending "-pre" if the environment is pre-production. * diff --git a/packages/sdk/src/routes/types.ts b/packages/sdk/src/routes/types.ts index dc67299..7aa8a70 100644 --- a/packages/sdk/src/routes/types.ts +++ b/packages/sdk/src/routes/types.ts @@ -1,7 +1,5 @@ import { Hex } from "viem" import { RoutesSupportedChainId } from "../constants" -import { SolverQuote } from "../quotes/types" -import { IntentType } from "@eco-foundation/routes-ts" export type CreateSimpleIntentParams = { creator: Hex @@ -27,11 +25,6 @@ export type CreateIntentParams = { expiryTime?: Date } -export type ApplyQuoteToIntentParams = { - intent: IntentType - quote: SolverQuote -} - type IntentCall = { target: Hex data: Hex diff --git a/packages/sdk/test/unit/RoutesService.test.ts b/packages/sdk/test/unit/RoutesService.test.ts index dd14746..c8e4325 100644 --- a/packages/sdk/test/unit/RoutesService.test.ts +++ b/packages/sdk/test/unit/RoutesService.test.ts @@ -1,10 +1,9 @@ -import { describe, test, expect, beforeAll, beforeEach } from "vitest"; +import { describe, test, expect, beforeAll } from "vitest"; import { encodeFunctionData, erc20Abi, Hex, isAddress, zeroAddress } from "viem"; import { privateKeyToAccount } from "viem/accounts"; -import { IntentType } from "@eco-foundation/routes-ts"; -import { RoutesService, SolverQuote } from "../../src"; -import { dateToTimestamp, getSecondsFromNow } from "../../src/utils"; +import { RoutesService } from "../../src"; +import { getSecondsFromNow } from "../../src/utils"; const account = privateKeyToAccount(process.env.VITE_TESTING_PK as Hex) @@ -631,85 +630,4 @@ describe("RoutesService", () => { })).toThrow("No default prover found for this chain"); }) }) - - describe("applyQuoteToIntent", () => { - let validIntent: IntentType; - let validQuote: SolverQuote; - - beforeAll(() => { - validIntent = routesService.createSimpleIntent({ - creator, - originChainID: 10, - destinationChainID: 8453, - spendingToken: RoutesService.getStableAddress(10, "USDC"), - spendingTokenLimit: BigInt(10000000), - receivingToken: RoutesService.getStableAddress(8453, "USDC"), - amount: BigInt(1000000), - prover: 'HyperProver', - - }); - }) - - beforeEach(() => { - validQuote = { - quoteData: { - tokens: [{ - token: RoutesService.getStableAddress(10, "USDC"), - amount: "1000000", - }], - expiryTime: dateToTimestamp(getSecondsFromNow(60)).toString() - } - }; - }); - - test("valid", () => { - const intent = routesService.applyQuoteToIntent({ intent: validIntent, quote: validQuote }); - - expect(intent).toBeDefined(); - expect(intent).toBeDefined(); - expect(intent.route).toBeDefined(); - expect(intent.route.salt).toBeDefined(); - expect(intent.route.source).toBeDefined(); - expect(intent.route.destination).toBeDefined(); - expect(intent.route.inbox).toBeDefined(); - expect(isAddress(intent.route.inbox, { strict: false })).toBe(true); - expect(intent.route.calls).toBeDefined(); - expect(intent.route.calls.length).toBeGreaterThan(0); - for (const call of intent.route.calls) { - expect(call.target).toBeDefined(); - expect(isAddress(call.target, { strict: false })).toBe(true); - expect(call.data).toBeDefined(); - expect(call.value).toBeDefined(); - } - expect(intent.reward).toBeDefined(); - expect(intent.reward.creator).toBeDefined(); - expect(isAddress(intent.reward.creator, { strict: false })).toBe(true); - expect(intent.reward.prover).toBeDefined(); - expect(isAddress(intent.reward.prover, { strict: false })).toBe(true); - expect(intent.reward.deadline).toBeDefined(); - expect(intent.reward.nativeValue).toBeDefined(); - expect(intent.reward.tokens).toBeDefined(); - expect(intent.reward.tokens.length).toBeGreaterThan(0); - - for (const token of intent.reward.tokens) { - expect(token.token).toBeDefined(); - expect(isAddress(token.token, { strict: false })).toBe(true); - expect(token.amount).toBeDefined(); - expect(token.amount).toBeGreaterThan(0); - } - }); - - test("invalid quote data", () => { - const intent = validIntent; - const quote: SolverQuote = { - ...validQuote, - quoteData: { - ...validQuote.quoteData, - tokens: [], - } - }; - - expect(() => routesService.applyQuoteToIntent({ intent, quote })).toThrow("Invalid quoteData: tokens array must have length greater than 0"); - }); - }); }) From dc817dc7562c0858d3903137ec51a7168897733b Mon Sep 17 00:00:00 2001 From: Dirk Page Date: Wed, 2 Apr 2025 23:17:35 -0500 Subject: [PATCH 04/22] Separating out formatting and parsing functions, added initiateGaslessIntent implementation, added requestReverseQuotesForIntent method and updated OpenQuotingClient tests --- packages/sdk/src/quotes/OpenQuotingClient.ts | 218 +++++++++++++++--- packages/sdk/src/quotes/types.ts | 124 +++++++++- .../integration/OpenQuotingClient.test.ts | 202 ++++++++++++---- packages/sdk/test/utils.ts | 101 ++++++++ 4 files changed, 555 insertions(+), 90 deletions(-) create mode 100644 packages/sdk/test/utils.ts diff --git a/packages/sdk/src/quotes/OpenQuotingClient.ts b/packages/sdk/src/quotes/OpenQuotingClient.ts index ca4bf33..997b8d5 100644 --- a/packages/sdk/src/quotes/OpenQuotingClient.ts +++ b/packages/sdk/src/quotes/OpenQuotingClient.ts @@ -1,7 +1,9 @@ import axios, { AxiosInstance } from "axios"; import axiosRetry from "axios-retry"; -import { OpenQuotingAPI, RequestQuotesForIntentParams, SolverQuote } from "./types"; +import { InitiateGaslessIntentResponse, OpenQuotingAPI, PermitData, RequestQuotesForIntentParams, SolverQuote, SubmitGaslessIntentParams } from "./types"; import { ECO_SDK_CONFIG } from "../config"; +import { IntentType } from "@eco-foundation/routes-ts"; +import { decodeFunctionData, erc20Abi } from "viem"; export class OpenQuotingClient { private readonly MAX_RETRIES = 5; @@ -20,11 +22,13 @@ export class OpenQuotingClient { * Requests quotes for a given intent. * * @param intent - The intent for which quotes are being requested. + * @param intentExecutionTypes - The types of intent execution for which quotes are being requested. * @returns A promise that resolves to an `OpenQuotingClient_ApiResponse_Quotes` object containing the quotes. * @throws An error if multiple requests fail. * * @remarks * This method sends a POST request to the `/api/v1/quotes` endpoint with the provided intent information. + * This will return quotes with the fee added to the reward tokens. */ async requestQuotesForIntent({ intent, intentExecutionTypes = ['SELF_PUBLISH', 'GASLESS'] }: RequestQuotesForIntentParams): Promise { if (intentExecutionTypes.length === 0) { @@ -33,32 +37,45 @@ export class OpenQuotingClient { const payload: OpenQuotingAPI.Quotes.Request = { dAppID: this.dAppID, intentExecutionTypes, - intentData: { - routeData: { - originChainID: intent.route.source.toString(), - destinationChainID: intent.route.destination.toString(), - inboxContract: intent.route.inbox, - tokens: intent.route.tokens.map((token) => ({ - token: token.token, - amount: token.amount.toString() - })), - calls: intent.route.calls.map((call) => ({ - target: call.target, - data: call.data, - value: call.value.toString() - })) - }, - rewardData: { - creator: intent.reward.creator, - proverContract: intent.reward.prover, - deadline: intent.reward.deadline.toString(), - nativeValue: intent.reward.nativeValue.toString(), - tokens: intent.reward.tokens.map((token) => ({ - token: token.token, - amount: token.amount.toString() - })) - } + intentData: this.formatIntentData(intent) + } + + const response = await this.axiosInstance.post(OpenQuotingAPI.Endpoints.Quotes, payload); + + return this.parseQuotesResponse(response.data); + } + + /** + * Requests reverse quotes for a given intent. + * + * @param intent - The intent for which quotes are being requested. + * @param intentExecutionTypes - The types of intent execution for which quotes are being requested. + * @returns A promise that resolves to an array of `SolverQuote` objects containing the quotes. + * @throws An error if multiple requests fail. + * + * @remarks + * This method sends a POST request to the `/api/v1/quotes` endpoint with the provided intent information. + * This will return quotes with the fee subtracted from the route tokens and calls + */ + async requestReverseQuotesForIntent({ intent, intentExecutionTypes = ['SELF_PUBLISH', 'GASLESS'] }: RequestQuotesForIntentParams): Promise { + if (intentExecutionTypes.length === 0) { + throw new Error("intentExecutionTypes must not be empty"); + } + if (intent.route.calls.some((call) => { + try { + const result = decodeFunctionData({ data: call.data, abi: erc20Abi }); + return result.functionName !== "transfer"; + } + catch { + return true; } + })) { + throw new Error("Reverse quote calls must be ERC20 transfer calls"); + } + const payload: OpenQuotingAPI.Quotes.Request = { + dAppID: this.dAppID, + intentExecutionTypes, + intentData: this.formatIntentData(intent) } const response = await this.axiosInstance.post(OpenQuotingAPI.Endpoints.Quotes, payload); @@ -66,24 +83,159 @@ export class OpenQuotingClient { return this.parseQuotesResponse(response.data); } + private formatIntentData(intent: IntentType): OpenQuotingAPI.Quotes.IntentData { + return { + routeData: { + salt: intent.route.salt, + originChainID: intent.route.source.toString(), + destinationChainID: intent.route.destination.toString(), + inboxContract: intent.route.inbox, + tokens: intent.route.tokens.map((token) => ({ + token: token.token, + amount: token.amount.toString() + })), + calls: intent.route.calls.map((call) => ({ + target: call.target, + data: call.data, + value: call.value.toString() + })) + }, + rewardData: { + creator: intent.reward.creator, + proverContract: intent.reward.prover, + deadline: intent.reward.deadline.toString(), + nativeValue: intent.reward.nativeValue.toString(), + tokens: intent.reward.tokens.map((token) => ({ + token: token.token, + amount: token.amount.toString() + })) + } + } + } + private parseQuotesResponse(response: OpenQuotingAPI.Quotes.Response): SolverQuote[] { return response.data.map((quote) => ({ solverID: quote.solverID, quoteData: { quoteEntries: quote.quoteData.quoteEntries.map((entry) => ({ intentExecutionType: entry.intentExecutionType, - tokens: entry.tokens.map((token) => ({ - token: token.token, - amount: BigInt(token.amount) - })), + intentData: { + route: { + salt: entry.intentData.routeData.salt, + source: BigInt(entry.intentData.routeData.originChainID), + destination: BigInt(entry.intentData.routeData.destinationChainID), + inbox: entry.intentData.routeData.inboxContract, + tokens: entry.intentData.routeData.tokens.map((token) => ({ + token: token.token, + amount: BigInt(token.amount) + })), + calls: entry.intentData.routeData.calls.map((call) => ({ + target: call.target, + data: call.data, + value: BigInt(call.value) + })) + }, + reward: { + creator: entry.intentData.rewardData.creator, + prover: entry.intentData.rewardData.proverContract, + deadline: BigInt(entry.intentData.rewardData.deadline), + nativeValue: BigInt(entry.intentData.rewardData.nativeValue), + tokens: entry.intentData.rewardData.tokens.map((token) => ({ + token: token.token, + amount: BigInt(token.amount) + })) + } + }, expiryTime: BigInt(entry.expiryTime) })) } })); } - // TODO: add method for submitting quoted intent gaslessly - async submitGaslessIntent({ }: { intent: any, quote: any }): Promise { - throw new Error("Method not implemented"); + /** + * Submits a gasless intent to the Open Quoting service. + * + * @param intent - The intent for which quotes are being requested. + * @param solverID - The ID of the solver that is submitting the intent. + * @returns A promise that resolves to the response from the Open Quoting service. + * + * @remarks + * This method sends a POST request to the `/api/v1/quotes/initiateGaslessIntent` endpoint with the provided intent information. + */ + async submitGaslessIntent({ funder, intent, solverID, permitData }: SubmitGaslessIntentParams): Promise { + const payload: OpenQuotingAPI.InitiateGaslessIntent.Request = { + dAppID: this.dAppID, + solverID, + intentData: { + ...this.formatIntentData(intent), + gaslessIntentData: { + funder, + permitData: this.formatPermitData(permitData), + } + } + } + + const response = await this.axiosInstance.post(OpenQuotingAPI.Endpoints.InitiateGaslessIntent, payload, { + 'axios-retry': { + retries: 0 + } + }); + + return response.data.data; + } + + private formatPermitData(permit: PermitData): OpenQuotingAPI.InitiateGaslessIntent.Request["intentData"]["gaslessIntentData"]["permitData"] { + if (permit.permit) { + const permitData: Pick = permit; + return { + permit: permitData.permit.map((permit1) => ({ + token: permit1.token, + data: { + signature: permit1.data.signature, + deadline: permit1.data.deadline.toString(), + } + })) + } + } + else { + const permitData: Pick = permit; + return { + permit2: { + permitContract: permitData.permit2.permitContract, + permitData: permitData.permit2.permitData.singlePermitData ? { + singlePermitData: { + typedData: { + details: { + name: permitData.permit2.permitData.singlePermitData.typedData.details.name, + version: permitData.permit2.permitData.singlePermitData.typedData.details.version, + chainId: permitData.permit2.permitData.singlePermitData.typedData.details.chainId, + verifyingContract: permitData.permit2.permitData.singlePermitData.typedData.details.verifyingContract, + nonce: permitData.permit2.permitData.singlePermitData.typedData.details.nonce.toString(), + deadline: permitData.permit2.permitData.singlePermitData.typedData.details.deadline.toString(), + }, + spender: permitData.permit2.permitData.singlePermitData.typedData.spender, + sigDeadline: permitData.permit2.permitData.singlePermitData.typedData.sigDeadline.toString(), + } + } + } : { + batchPermitData: { + typedData: { + details: permitData.permit2.permitData.batchPermitData.typedData.details.map((detail) => ({ + name: detail.name, + version: detail.version, + chainId: detail.chainId, + verifyingContract: detail.verifyingContract, + nonce: detail.nonce.toString(), + deadline: detail.deadline.toString(), + })), + spender: permitData.permit2.permitData.batchPermitData.typedData.spender, + sigDeadline: permitData.permit2.permitData.batchPermitData.typedData.sigDeadline.toString(), + } + } + }, + signature: permitData.permit2.signature, + } + } + } } } diff --git a/packages/sdk/src/quotes/types.ts b/packages/sdk/src/quotes/types.ts index d349f90..da35816 100644 --- a/packages/sdk/src/quotes/types.ts +++ b/packages/sdk/src/quotes/types.ts @@ -10,6 +10,7 @@ export namespace OpenQuotingAPI { export namespace Quotes { export type IntentData = { routeData: { + salt: Hex originChainID: string destinationChainID: string inboxContract: Hex @@ -45,10 +46,7 @@ export namespace OpenQuotingAPI { quoteData: { quoteEntries: { intentExecutionType: IntentExecutionType - tokens: { - token: Hex - amount: string - }[] + intentData: IntentData expiryTime: string }[] } @@ -63,14 +61,60 @@ export namespace OpenQuotingAPI { intentData: Quotes.IntentData & { gaslessIntentData: { funder: Hex - permitContract: Hex + permitData: { + permit: { + token: Hex + data: { + signature: Hex + deadline: string + } + }[] + } | { + permit2: { + permitContract: Hex + permitData: { + singlePermitData: { + typedData: { + details: { + name: string + version: string + chainId: number + verifyingContract: Hex + nonce: string + deadline: string + } + spender: Hex + sigDeadline: string + } + } + } | { + batchPermitData: { + typedData: { + details: { + name: string + version: string + chainId: number + verifyingContract: Hex + nonce: string + deadline: string + }[] + spender: Hex + sigDeadline: string + } + } + } + signature: Hex + } + } allowPartial?: boolean - // TODO: add in optional permit2 signature data } } } export interface Response { - data: {} // TODO: get response format + data: { + // TODO: return acutal structure when it is decided + transactionHash: Hex + } } } } @@ -80,6 +124,13 @@ export type RequestQuotesForIntentParams = { intentExecutionTypes?: IntentExecutionType[] } +export type SubmitGaslessIntentParams = { + funder: Hex + intent: IntentType + solverID: string + permitData: PermitData +} + export type SolverQuote = { solverID: string quoteData: { @@ -89,9 +140,60 @@ export type SolverQuote = { export type QuoteData = { intentExecutionType: IntentExecutionType - tokens: { - token: Hex, - amount: bigint - }[] + intentData: IntentType expiryTime: bigint // seconds since epoch } + +export type PermitData = Permit1 & Permit2 + +export type Permit1 = { + permit: { + token: Hex + data: { + signature: Hex + deadline: bigint + } + }[] +} + +export type Permit2 = { + permit2: { + permitContract: Hex + permitData: SinglePermit2Data & BatchPermit2Data + signature: Hex + } +} + +export type SinglePermit2Data = { + singlePermitData: { + typedData: { + details: Permit2DataDetails + spender: Hex + sigDeadline: bigint + } + } +} + +export type BatchPermit2Data = { + batchPermitData: { + typedData: { + details: Permit2DataDetails[] + spender: Hex + sigDeadline: bigint + } + } +} + +export type Permit2DataDetails = { + name: string + version: string + chainId: number + verifyingContract: Hex + nonce: bigint + deadline: bigint +} + +// TODO: return acutal structure when it is decided +export type InitiateGaslessIntentResponse = { + transactionHash: Hex +} \ No newline at end of file diff --git a/packages/sdk/test/integration/OpenQuotingClient.test.ts b/packages/sdk/test/integration/OpenQuotingClient.test.ts index 890a6d8..58e81bc 100644 --- a/packages/sdk/test/integration/OpenQuotingClient.test.ts +++ b/packages/sdk/test/integration/OpenQuotingClient.test.ts @@ -1,19 +1,20 @@ import { describe, test, expect, beforeAll, beforeEach } from "vitest"; -import { Hex, zeroAddress, zeroHash } from "viem"; +import { encodeFunctionData, erc20Abi, Hex, zeroAddress, zeroHash } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import { IntentType } from "@eco-foundation/routes-ts"; import { RoutesService, OpenQuotingClient } from "../../src"; import { dateToTimestamp, getSecondsFromNow } from "../../src/utils"; +import { validateSolverQuoteResponse } from "../utils"; -const account = privateKeyToAccount(process.env.VITE_TESTING_PK as Hex) +const account = privateKeyToAccount(process.env.VITE_TESTING_PK as Hex); describe("OpenQuotingClient", () => { let routesService: RoutesService; let openQuotingClient: OpenQuotingClient; let validIntent: IntentType; - const creator = account.address + const creator = account.address; beforeAll(() => { routesService = new RoutesService(); @@ -32,7 +33,7 @@ describe("OpenQuotingClient", () => { prover: 'HyperProver', recipient: creator, }); - }) + }); describe("requestQuotesForIntent", () => { test("valid", async () => { @@ -41,17 +42,8 @@ describe("OpenQuotingClient", () => { expect(quotes).toBeDefined(); expect(quotes.length).toBeGreaterThan(0); - for (const quote of quotes) { - expect(quote.quoteData).toBeDefined(); - expect(quote.quoteData.expiryTime).toBeDefined(); - expect(quote.quoteData.tokens).toBeDefined(); - expect(quote.quoteData.tokens.length).toBeGreaterThan(0); - for (const token of quote.quoteData.tokens) { - expect(token).toBeDefined(); - expect(token.amount).toBeDefined(); - expect(BigInt(token.amount)).toBeGreaterThan(0); - expect(token.token).toBeDefined(); - } + for (const solverQuoteResponse of quotes) { + validateSolverQuoteResponse(solverQuoteResponse, validIntent, false); } }); @@ -63,17 +55,8 @@ describe("OpenQuotingClient", () => { expect(quotes).toBeDefined(); expect(quotes.length).toBeGreaterThan(0); - for (const quote of quotes) { - expect(quote.quoteData).toBeDefined(); - expect(quote.quoteData.expiryTime).toBeDefined(); - expect(quote.quoteData.tokens).toBeDefined(); - expect(quote.quoteData.tokens.length).toBeGreaterThan(0); - for (const token of quote.quoteData.tokens) { - expect(token).toBeDefined(); - expect(token.amount).toBeDefined(); - expect(BigInt(token.amount)).toBeGreaterThan(0); - expect(token.token).toBeDefined(); - } + for (const solverQuoteResponse of quotes) { + validateSolverQuoteResponse(solverQuoteResponse, validIntent, false); } }); @@ -94,33 +77,37 @@ describe("OpenQuotingClient", () => { nativeValue: BigInt(0), tokens: [] } - } + }; await expect(openQuotingClient.requestQuotesForIntent({ intent: emptyIntent })).rejects.toThrow("Request failed with status code 400"); - }) + }); + + test("invalid:intentExecutionTypes", async () => { + await expect(openQuotingClient.requestQuotesForIntent({ intent: validIntent, intentExecutionTypes: [] })).rejects.toThrow("intentExecutionTypes must not be empty"); + }); - test("invalid:route.source", async () => { + test("invalid:intent.route.source", async () => { const invalidIntent = validIntent; invalidIntent.route.source = BigInt(0); await expect(openQuotingClient.requestQuotesForIntent({ intent: invalidIntent })).rejects.toThrow("Request failed with status code 400"); - }) + }); - test("invalid:route.destination", async () => { + test("invalid:intent.route.destination", async () => { const invalidIntent = validIntent; invalidIntent.route.destination = BigInt(0); await expect(openQuotingClient.requestQuotesForIntent({ intent: invalidIntent })).rejects.toThrow("Request failed with status code 400"); - }) + }); - test("invalid:route.inbox", async () => { + test("invalid:intent.route.inbox", async () => { const invalidIntent = validIntent; invalidIntent.route.inbox = "0x0"; await expect(openQuotingClient.requestQuotesForIntent({ intent: invalidIntent })).rejects.toThrow("Request failed with status code 400"); - }) + }); - test("invalid:route.calls", async () => { + test("invalid:intent.route.calls", async () => { const invalidIntent = validIntent; invalidIntent.route.calls = [{ target: "0x0", data: zeroHash, value: BigInt(0) }]; @@ -129,37 +116,37 @@ describe("OpenQuotingClient", () => { invalidIntent.route.calls = [{ target: RoutesService.getStableAddress(10, "USDC"), data: zeroHash, value: BigInt(-1) }]; await expect(openQuotingClient.requestQuotesForIntent({ intent: invalidIntent })).rejects.toThrow("Request failed with status code 400"); - }) + }); - test("invalid:reward.creator", async () => { + test("invalid:intent.reward.creator", async () => { const invalidIntent = validIntent; invalidIntent.reward.creator = "0x0"; await expect(openQuotingClient.requestQuotesForIntent({ intent: invalidIntent })).rejects.toThrow("Request failed with status code 400"); - }) + }); - test("invalid:reward.prover", async () => { + test("invalid:intent.reward.prover", async () => { const invalidIntent = validIntent; invalidIntent.reward.prover = "0x0"; await expect(openQuotingClient.requestQuotesForIntent({ intent: invalidIntent })).rejects.toThrow("Request failed with status code 400"); - }) + }); - test("invalid:reward.deadline", async () => { + test("invalid:intent.reward.deadline", async () => { const invalidIntent = validIntent; invalidIntent.reward.deadline = dateToTimestamp(getSecondsFromNow(50)); // must be 60 seconds in the future or more await expect(openQuotingClient.requestQuotesForIntent({ intent: invalidIntent })).rejects.toThrow("Request failed with status code 400"); - }) + }); - test("invalid:reward.nativeValue", async () => { + test("invalid:intent.reward.nativeValue", async () => { const invalidIntent = validIntent; invalidIntent.reward.nativeValue = BigInt(-1); await expect(openQuotingClient.requestQuotesForIntent({ intent: invalidIntent })).rejects.toThrow("Request failed with status code 400"); - }) + }); - test("invalid:reward.tokens", async () => { + test("invalid:intent.reward.tokens", async () => { const invalidIntent = validIntent; invalidIntent.reward.tokens = [{ token: "0x0", amount: BigInt(1000000) }]; @@ -167,6 +154,129 @@ describe("OpenQuotingClient", () => { invalidIntent.reward.tokens = [{ token: RoutesService.getStableAddress(10, "USDC"), amount: BigInt(-1) }]; await expect(openQuotingClient.requestQuotesForIntent({ intent: invalidIntent })).rejects.toThrow("Request failed with status code 400"); - }) + }); + }); + + describe("requestReverseQuotesForIntent", () => { + test("valid", async () => { + const quotes = await openQuotingClient.requestReverseQuotesForIntent({ intent: validIntent }); + + expect(quotes).toBeDefined(); + expect(quotes.length).toBeGreaterThan(0); + + for (const solverQuoteResponse of quotes) { + validateSolverQuoteResponse(solverQuoteResponse, validIntent, true); + } + }); + + test("valid:creator==zeroAddress", async () => { + validIntent.reward.creator = zeroAddress; + + const quotes = await openQuotingClient.requestReverseQuotesForIntent({ intent: validIntent }); + + expect(quotes).toBeDefined(); + expect(quotes.length).toBeGreaterThan(0); + + for (const solverQuoteResponse of quotes) { + validateSolverQuoteResponse(solverQuoteResponse, validIntent, false); + } + }); + + test("empty", async () => { + const emptyIntent: IntentType = { + route: { + salt: "0x", + source: BigInt(10), + destination: BigInt(8453), + inbox: "0x", + calls: [], + tokens: [] + }, + reward: { + creator: "0x0", + prover: "0x0", + deadline: BigInt(0), + nativeValue: BigInt(0), + tokens: [] + } + }; + + await expect(openQuotingClient.requestReverseQuotesForIntent({ intent: emptyIntent })).rejects.toThrow("Request failed with status code 400"); + }); + + test("invalid:intentExecutionTypes", async () => { + await expect(openQuotingClient.requestReverseQuotesForIntent({ intent: validIntent, intentExecutionTypes: [] })).rejects.toThrow("intentExecutionTypes must not be empty"); + }); + + test("invalid:intent.route.source", async () => { + const invalidIntent = validIntent; + invalidIntent.route.source = BigInt(0); + + await expect(openQuotingClient.requestReverseQuotesForIntent({ intent: invalidIntent })).rejects.toThrow("Request failed with status code 400"); + }); + + test("invalid:intent.route.destination", async () => { + const invalidIntent = validIntent; + invalidIntent.route.destination = BigInt(0); + + await expect(openQuotingClient.requestReverseQuotesForIntent({ intent: invalidIntent })).rejects.toThrow("Request failed with status code 400"); + }); + + test("invalid:intent.route.inbox", async () => { + const invalidIntent = validIntent; + invalidIntent.route.inbox = "0x0"; + + await expect(openQuotingClient.requestReverseQuotesForIntent({ intent: invalidIntent })).rejects.toThrow("Request failed with status code 400"); + }); + + test("invalid:intent.route.calls", async () => { + const invalidIntent = validIntent; + invalidIntent.route.calls = [{ target: RoutesService.getStableAddress(10, "USDC"), data: encodeFunctionData({ abi: erc20Abi, functionName: "approve", args: [zeroAddress, BigInt(10000)] }), value: BigInt(0) }]; + await expect(openQuotingClient.requestReverseQuotesForIntent({ intent: invalidIntent })).rejects.toThrow("Reverse quote calls must be ERC20 transfer calls"); + + invalidIntent.route.calls = [{ target: "0x0", data: zeroHash, value: BigInt(0) }]; + await expect(openQuotingClient.requestReverseQuotesForIntent({ intent: invalidIntent })).rejects.toThrow("Request failed with status code 400"); + + invalidIntent.route.calls = [{ target: RoutesService.getStableAddress(10, "USDC"), data: zeroHash, value: BigInt(-1) }]; + await expect(openQuotingClient.requestReverseQuotesForIntent({ intent: invalidIntent })).rejects.toThrow("Request failed with status code 400"); + }); + + test("invalid:intent.reward.creator", async () => { + const invalidIntent = validIntent; + invalidIntent.reward.creator = "0x0"; + + await expect(openQuotingClient.requestReverseQuotesForIntent({ intent: invalidIntent })).rejects.toThrow("Request failed with status code 400"); + }); + + test("invalid:intent.reward.prover", async () => { + const invalidIntent = validIntent; + invalidIntent.reward.prover = "0x0"; + + await expect(openQuotingClient.requestReverseQuotesForIntent({ intent: invalidIntent })).rejects.toThrow("Request failed with status code 400"); + }); + + test("invalid:intent.reward.deadline", async () => { + const invalidIntent = validIntent; + invalidIntent.reward.deadline = dateToTimestamp(getSecondsFromNow(50)); // must be 60 seconds in the future or more + + await expect(openQuotingClient.requestReverseQuotesForIntent({ intent: invalidIntent })).rejects.toThrow("Request failed with status code 400"); + }); + + test("invalid:intent.reward.nativeValue", async () => { + const invalidIntent = validIntent; + invalidIntent.reward.nativeValue = BigInt(-1); + + await expect(openQuotingClient.requestReverseQuotesForIntent({ intent: invalidIntent })).rejects.toThrow("Request failed with status code 400"); + }); + + test("invalid:intent.reward.tokens", async () => { + const invalidIntent = validIntent; + invalidIntent.reward.tokens = [{ token: "0x0", amount: BigInt(1000000) }]; + await expect(openQuotingClient.requestReverseQuotesForIntent({ intent: invalidIntent })).rejects.toThrow("Request failed with status code 400"); + + invalidIntent.reward.tokens = [{ token: RoutesService.getStableAddress(10, "USDC"), amount: BigInt(-1) }]; + await expect(openQuotingClient.requestReverseQuotesForIntent({ intent: invalidIntent })).rejects.toThrow("Request failed with status code 400"); + }); }); + }, 60_000); diff --git a/packages/sdk/test/utils.ts b/packages/sdk/test/utils.ts new file mode 100644 index 0000000..83dc28e --- /dev/null +++ b/packages/sdk/test/utils.ts @@ -0,0 +1,101 @@ + +import { expect } from "vitest"; +import { INTENT_EXECUTION_TYPES, SolverQuote } from "../src"; +import { IntentType } from "@eco-foundation/routes-ts"; +import { decodeFunctionData, erc20Abi } from "viem"; +import { sum } from "../src/utils"; + +export function validateSolverQuoteResponse(quoteResponse: SolverQuote, originalIntent: IntentType, isReverseQuote: boolean) { + expect(quoteResponse.solverID).toBeDefined(); + expect(quoteResponse.quoteData).toBeDefined(); + expect(quoteResponse.quoteData.quoteEntries).toBeDefined(); + expect(quoteResponse.quoteData.quoteEntries.length).toBeGreaterThan(0); + + for (const quote of quoteResponse.quoteData.quoteEntries) { + expect(quote).toBeDefined(); + expect(quote.intentExecutionType).toBeDefined(); + expect(quote.intentExecutionType).toBeOneOf([...INTENT_EXECUTION_TYPES]); + expect(quote.expiryTime).toBeDefined(); + expect(quote.intentData).toBeDefined(); + + expect(quote.intentData.reward).toBeDefined(); + expect(quote.intentData.reward.creator).toBeDefined(); + expect(quote.intentData.reward.prover).toBeDefined(); + expect(quote.intentData.reward.deadline).toBeDefined(); + expect(quote.intentData.reward.nativeValue).toBeDefined(); + expect(quote.intentData.reward.tokens).toBeDefined(); + expect(quote.intentData.reward.tokens.length).toBeGreaterThan(0); + for (const token of quote.intentData.reward.tokens) { + expect(token).toBeDefined(); + expect(token.amount).toBeDefined(); + expect(token.amount).toBeGreaterThan(0n); + expect(token.token).toBeDefined(); + } + + expect(quote.intentData.route).toBeDefined(); + expect(quote.intentData.route.salt).toBeDefined(); + expect(quote.intentData.route.source).toBeDefined(); + expect(quote.intentData.route.destination).toBeDefined(); + expect(quote.intentData.route.inbox).toBeDefined(); + expect(quote.intentData.route.tokens).toBeDefined(); + expect(quote.intentData.route.tokens.length).toBeGreaterThan(0); + for (const token of quote.intentData.route.tokens) { + expect(token).toBeDefined(); + expect(token.amount).toBeDefined(); + expect(token.amount).toBeGreaterThan(0n); + expect(token.token).toBeDefined(); + } + expect(quote.intentData.route.calls).toBeDefined(); + expect(quote.intentData.route.calls.length).toBeGreaterThan(0); + for (const call of quote.intentData.route.calls) { + expect(call).toBeDefined(); + expect(call.target).toBeDefined(); + expect(call.data).toBeDefined(); + expect(call.value).toBeDefined(); + } + + // Validate that quotes are applied correctly based on the quote method + if (isReverseQuote) { + // For reverse quotes: + // 1. Validate all calls are ERC20 transfers and the overall amount is less than the original intent + const quoteCallsSum = sum(quote.intentData.route.calls.map(call => { + const decodedCall = decodeFunctionData({ data: call.data, abi: erc20Abi }); + expect(decodedCall.functionName).toBe("transfer"); + return BigInt(decodedCall.args[1]!); + })); + const intentCallsSum = sum(originalIntent.route.calls.map(call => { + const decodedCall = decodeFunctionData({ data: call.data, abi: erc20Abi }); + expect(decodedCall.functionName).toBe("transfer"); + return BigInt(decodedCall.args[1]!); + })); + expect(quoteCallsSum, "Reverse quote should reduce route.calls sum").toBeLessThanOrEqual(intentCallsSum); + + // 2. Verify quote is applied to route tokens + const quoteTokensSum = sum(quote.intentData.route.tokens.map(token => token.amount)); + const intentTokensSum = sum(originalIntent.route.tokens.map(token => token.amount)); + expect(quoteTokensSum, "Reverse quote should reduce route.tokens sum").toBeLessThanOrEqual(intentTokensSum); + + // 3. Reward tokens should remain unchanged + const quoteRewardTokensSum = sum(quote.intentData.reward.tokens.map(token => token.amount)); + const intentRewardTokensSum = sum(originalIntent.reward.tokens.map(token => token.amount)); + expect(quoteRewardTokensSum, "Reverse quote should not change reward.tokens sum").toBe( + intentRewardTokensSum, + ); + } else { + // For standard quotes: + // 1. Verify quote is applied to reward tokens (fee is added to reward) + const quoteRewardTokensSum = sum(quote.intentData.reward.tokens.map(token => token.amount)); + const intentRewardTokensSum = sum(originalIntent.reward.tokens.map(token => token.amount)); + expect(quoteRewardTokensSum, "Standard quote should increase reward.tokens sum").toBeGreaterThanOrEqual( + intentRewardTokensSum, + ); + + // 2. Route token amounts should remain unchanged + const quoteTokensSum = sum(quote.intentData.route.tokens.map(token => token.amount)); + const intentTokensSum = sum(originalIntent.route.tokens.map(token => token.amount)); + expect(quoteTokensSum, "Standard quote should not change route.tokens sum").toBe( + intentTokensSum, + ); + } + } +} From e6518282106120e1a6dead9afd7febf9a925a1bc Mon Sep 17 00:00:00 2001 From: Dirk Page Date: Wed, 2 Apr 2025 23:20:29 -0500 Subject: [PATCH 05/22] Updated exported types --- packages/sdk/src/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 5f446a3..efc7f65 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -2,8 +2,8 @@ export { INTENT_EXECUTION_TYPES, chainIds, stables, stableAddresses } from "./co export type { IntentExecutionType, RoutesSupportedChainId, RoutesSupportedStable } from "./constants"; export { RoutesService } from "./routes/RoutesService"; -export type { CreateSimpleIntentParams, CreateIntentParams, ApplyQuoteToIntentParams } from "./routes/types"; +export type { CreateSimpleIntentParams, CreateIntentParams } from "./routes/types"; export { OpenQuotingClient } from "./quotes/OpenQuotingClient"; -export type { RequestQuotesForIntentParams, SolverQuote, QuoteData } from "./quotes/types"; +export type { RequestQuotesForIntentParams, SolverQuote, QuoteData, PermitData, Permit1, Permit2, SinglePermit2Data, BatchPermit2Data, Permit2DataDetails, InitiateGaslessIntentResponse } from "./quotes/types"; export { selectCheapestQuote } from "./quotes/quoteSelectors"; From 3cb7575d8209d069b29f5e685c441751938d1ac4 Mon Sep 17 00:00:00 2001 From: Dirk Page Date: Thu, 3 Apr 2025 15:52:54 -0500 Subject: [PATCH 06/22] Refactored provided selector function --- .../sdk/src/quotes/quoteSelectors/index.ts | 59 ++++++++++++++++--- 1 file changed, 51 insertions(+), 8 deletions(-) diff --git a/packages/sdk/src/quotes/quoteSelectors/index.ts b/packages/sdk/src/quotes/quoteSelectors/index.ts index c668c44..cc0326d 100644 --- a/packages/sdk/src/quotes/quoteSelectors/index.ts +++ b/packages/sdk/src/quotes/quoteSelectors/index.ts @@ -1,10 +1,53 @@ +import { INTENT_EXECUTION_TYPES, IntentExecutionType } from "../../constants"; import { sum } from "../../utils"; -import { SolverQuote } from "../types"; - -export function selectCheapestQuote(quotes: SolverQuote[]): SolverQuote { - return quotes.reduce((cheapest, quote) => { - const cheapestSum = cheapest ? sum(cheapest.quoteData.tokens.map(({ amount }) => amount)) : BigInt(Infinity); - const quoteSum = sum(quote.quoteData.tokens.map(({ amount }) => amount)); - return quoteSum < cheapestSum ? quote : cheapest; - }); +import { QuoteData, SolverQuote } from "../types"; + +type QuoteSelectorResult = { + solverID: string; + quoteData: QuoteData; } + +export function selectCheapestQuote(solverQuotes: SolverQuote[], isReverse: boolean = false, allowedIntentExecutionTypes: IntentExecutionType[] = [...INTENT_EXECUTION_TYPES]): QuoteSelectorResult { + return solverQuotes.reduce(({ solverID: cheapestSolverID, quoteData: cheapestQuoteData }, solverQuoteResponse) => { + const quotes = solverQuoteResponse.quoteData.quoteEntries; + for (const quoteData of quotes) { + if (allowedIntentExecutionTypes.includes(quoteData.intentExecutionType)) { + let cheapestTokens = cheapestQuoteData.intentData.reward.tokens; + let quoteTokens = quoteData.intentData.reward.tokens; + const defaultSum = BigInt(isReverse ? 0 : Infinity); + if (isReverse) { + cheapestTokens = cheapestQuoteData.intentData.route.tokens; + quoteTokens = quoteData.intentData.route.tokens; + } + + const cheapestSum = cheapestQuoteData ? sum(cheapestTokens.map(({ amount }) => amount)) : defaultSum; + const quoteSum = sum(quoteTokens.map(({ amount }) => amount)); + + if (isReverse) { + // want to set the quote with the highest route tokens sum (most received on destination chain) + return quoteSum > cheapestSum ? { + solverID: solverQuoteResponse.solverID, + quoteData + } : { + solverID: cheapestSolverID, + quoteData: cheapestQuoteData + }; + } + else { + // want to set the quote with the lowest reward tokens sum (least spent on origin chain) + return quoteSum < cheapestSum ? { + solverID: solverQuoteResponse.solverID, + quoteData + } : { + solverID: cheapestSolverID, + quoteData: cheapestQuoteData + } as QuoteSelectorResult; + } + } + } + return { + solverID: cheapestSolverID, + quoteData: cheapestQuoteData + } + }, {} as QuoteSelectorResult); +} \ No newline at end of file From 85b452ffe602b75a2e86f6e510ef81bf6bbc3e90 Mon Sep 17 00:00:00 2001 From: Dirk Page Date: Thu, 3 Apr 2025 22:01:36 -0500 Subject: [PATCH 07/22] Refactoring selectCheapestQuote --- .../sdk/src/quotes/quoteSelectors/index.ts | 78 +++++++++++++------ 1 file changed, 55 insertions(+), 23 deletions(-) diff --git a/packages/sdk/src/quotes/quoteSelectors/index.ts b/packages/sdk/src/quotes/quoteSelectors/index.ts index cc0326d..2391533 100644 --- a/packages/sdk/src/quotes/quoteSelectors/index.ts +++ b/packages/sdk/src/quotes/quoteSelectors/index.ts @@ -1,4 +1,4 @@ -import { INTENT_EXECUTION_TYPES, IntentExecutionType } from "../../constants"; +import { IntentExecutionType } from "../../constants"; import { sum } from "../../utils"; import { QuoteData, SolverQuote } from "../types"; @@ -7,47 +7,79 @@ type QuoteSelectorResult = { quoteData: QuoteData; } -export function selectCheapestQuote(solverQuotes: SolverQuote[], isReverse: boolean = false, allowedIntentExecutionTypes: IntentExecutionType[] = [...INTENT_EXECUTION_TYPES]): QuoteSelectorResult { +export function selectCheapestQuote(solverQuotes: SolverQuote[], isReverse: boolean = false, allowedIntentExecutionTypes: IntentExecutionType[] = ["SELF_PUBLISH"]): QuoteSelectorResult { return solverQuotes.reduce(({ solverID: cheapestSolverID, quoteData: cheapestQuoteData }, solverQuoteResponse) => { const quotes = solverQuoteResponse.quoteData.quoteEntries; + let localCheapestQuoteData = cheapestQuoteData; + let localCheapestSolverID = cheapestSolverID; + const defaultSum = BigInt(isReverse ? 0 : Infinity); + for (const quoteData of quotes) { if (allowedIntentExecutionTypes.includes(quoteData.intentExecutionType)) { - let cheapestTokens = cheapestQuoteData.intentData.reward.tokens; + let localCheapestTokens = localCheapestQuoteData?.intentData?.reward.tokens; let quoteTokens = quoteData.intentData.reward.tokens; - const defaultSum = BigInt(isReverse ? 0 : Infinity); + if (isReverse) { - cheapestTokens = cheapestQuoteData.intentData.route.tokens; + localCheapestTokens = localCheapestQuoteData?.intentData?.route.tokens; quoteTokens = quoteData.intentData.route.tokens; } - const cheapestSum = cheapestQuoteData ? sum(cheapestTokens.map(({ amount }) => amount)) : defaultSum; + const localCheapestSum = localCheapestQuoteData ? sum(localCheapestTokens.map(({ amount }) => amount)) : defaultSum; const quoteSum = sum(quoteTokens.map(({ amount }) => amount)); if (isReverse) { // want to set the quote with the highest route tokens sum (most received on destination chain) - return quoteSum > cheapestSum ? { - solverID: solverQuoteResponse.solverID, - quoteData - } : { - solverID: cheapestSolverID, - quoteData: cheapestQuoteData - }; + if (quoteSum > localCheapestSum) { + localCheapestSolverID = solverQuoteResponse.solverID; + localCheapestQuoteData = quoteData; + } } else { // want to set the quote with the lowest reward tokens sum (least spent on origin chain) - return quoteSum < cheapestSum ? { - solverID: solverQuoteResponse.solverID, - quoteData - } : { - solverID: cheapestSolverID, - quoteData: cheapestQuoteData - } as QuoteSelectorResult; + if (quoteSum < localCheapestSum) { + localCheapestSolverID = solverQuoteResponse.solverID; + localCheapestQuoteData = quoteData; + } } } } - return { - solverID: cheapestSolverID, - quoteData: cheapestQuoteData + + // After iterating through all quotes for this solver, compare the local cheapest with the global cheapest + if (!cheapestQuoteData) { + return { + solverID: localCheapestSolverID, + quoteData: localCheapestQuoteData + }; + } + + if (isReverse) { + const globalTokens = cheapestQuoteData.intentData.route.tokens; + const localTokens = localCheapestQuoteData?.intentData?.route.tokens; + + const globalSum = sum(globalTokens.map(({ amount }) => amount)); + const localSum = localCheapestQuoteData ? sum(localTokens.map(({ amount }) => amount)) : defaultSum; + + return localSum > globalSum ? { + solverID: localCheapestSolverID, + quoteData: localCheapestQuoteData + } : { + solverID: cheapestSolverID, + quoteData: cheapestQuoteData + }; + } else { + const globalTokens = cheapestQuoteData.intentData.reward.tokens; + const localTokens = localCheapestQuoteData?.intentData?.reward.tokens; + + const globalSum = sum(globalTokens.map(({ amount }) => amount)); + const localSum = localCheapestQuoteData ? sum(localTokens.map(({ amount }) => amount)) : defaultSum; + + return localSum < globalSum ? { + solverID: localCheapestSolverID, + quoteData: localCheapestQuoteData + } : { + solverID: cheapestSolverID, + quoteData: cheapestQuoteData + }; } }, {} as QuoteSelectorResult); } \ No newline at end of file From 6bd854175e8d0e9db99edeb65c4550bd6220e083 Mon Sep 17 00:00:00 2001 From: Dirk Page Date: Fri, 4 Apr 2025 12:03:11 -0500 Subject: [PATCH 08/22] Adding salts in quote responses --- packages/sdk/src/quotes/OpenQuotingClient.ts | 4 ++-- packages/sdk/src/quotes/types.ts | 1 - packages/sdk/test/e2e/publishAndFund.test.ts | 13 +++---------- 3 files changed, 5 insertions(+), 13 deletions(-) diff --git a/packages/sdk/src/quotes/OpenQuotingClient.ts b/packages/sdk/src/quotes/OpenQuotingClient.ts index 997b8d5..6cbd555 100644 --- a/packages/sdk/src/quotes/OpenQuotingClient.ts +++ b/packages/sdk/src/quotes/OpenQuotingClient.ts @@ -4,6 +4,7 @@ import { InitiateGaslessIntentResponse, OpenQuotingAPI, PermitData, RequestQuote import { ECO_SDK_CONFIG } from "../config"; import { IntentType } from "@eco-foundation/routes-ts"; import { decodeFunctionData, erc20Abi } from "viem"; +import { generateRandomHex } from "../utils"; export class OpenQuotingClient { private readonly MAX_RETRIES = 5; @@ -86,7 +87,6 @@ export class OpenQuotingClient { private formatIntentData(intent: IntentType): OpenQuotingAPI.Quotes.IntentData { return { routeData: { - salt: intent.route.salt, originChainID: intent.route.source.toString(), destinationChainID: intent.route.destination.toString(), inboxContract: intent.route.inbox, @@ -121,7 +121,7 @@ export class OpenQuotingClient { intentExecutionType: entry.intentExecutionType, intentData: { route: { - salt: entry.intentData.routeData.salt, + salt: generateRandomHex(), source: BigInt(entry.intentData.routeData.originChainID), destination: BigInt(entry.intentData.routeData.destinationChainID), inbox: entry.intentData.routeData.inboxContract, diff --git a/packages/sdk/src/quotes/types.ts b/packages/sdk/src/quotes/types.ts index da35816..8a8f122 100644 --- a/packages/sdk/src/quotes/types.ts +++ b/packages/sdk/src/quotes/types.ts @@ -10,7 +10,6 @@ export namespace OpenQuotingAPI { export namespace Quotes { export type IntentData = { routeData: { - salt: Hex originChainID: string destinationChainID: string inboxContract: Hex diff --git a/packages/sdk/test/e2e/publishAndFund.test.ts b/packages/sdk/test/e2e/publishAndFund.test.ts index 7310cbb..54eea3a 100644 --- a/packages/sdk/test/e2e/publishAndFund.test.ts +++ b/packages/sdk/test/e2e/publishAndFund.test.ts @@ -51,17 +51,10 @@ describe("publishAndFund", () => { // request quotes const quotes = await openQuotingClient.requestQuotesForIntent({ intent }) - const selectedQuote = selectCheapestQuote(quotes) - - // setup the intent for publishing - const quotedIntent = routesService.applyQuoteToIntent({ - intent, - quote: selectedQuote - }) - expect(quotedIntent).toBeDefined() + const { quoteData } = selectCheapestQuote(quotes, false, ["SELF_PUBLISH"]) // approve - await Promise.all(quotedIntent.reward.tokens.map(async ({ token, amount }) => { + await Promise.all(quoteData.intentData.reward.tokens.map(async ({ token, amount }) => { const hash = await baseWalletClient.writeContract({ abi: erc20Abi, address: token, @@ -78,7 +71,7 @@ describe("publishAndFund", () => { abi: IntentSourceAbi, address: intentSourceContract, functionName: 'publishAndFund', - args: [quotedIntent, false], + args: [quoteData.intentData, false], chain: originChain, account }) From 7e12e5785104068fd70f502cd1bbfbbeb1d316c8 Mon Sep 17 00:00:00 2001 From: Dirk Page Date: Tue, 15 Apr 2025 18:36:26 -0500 Subject: [PATCH 09/22] Fixes from preprod testing --- packages/sdk/src/quotes/OpenQuotingClient.ts | 33 ++++++++++--------- .../sdk/src/quotes/quoteSelectors/index.ts | 2 +- packages/sdk/src/quotes/types.ts | 18 +++++++++- packages/sdk/src/routes/RoutesService.ts | 2 +- .../integration/OpenQuotingClient.test.ts | 13 ++++++-- packages/sdk/test/utils.ts | 19 +++++++---- 6 files changed, 59 insertions(+), 28 deletions(-) diff --git a/packages/sdk/src/quotes/OpenQuotingClient.ts b/packages/sdk/src/quotes/OpenQuotingClient.ts index 6cbd555..b195196 100644 --- a/packages/sdk/src/quotes/OpenQuotingClient.ts +++ b/packages/sdk/src/quotes/OpenQuotingClient.ts @@ -4,7 +4,6 @@ import { InitiateGaslessIntentResponse, OpenQuotingAPI, PermitData, RequestQuote import { ECO_SDK_CONFIG } from "../config"; import { IntentType } from "@eco-foundation/routes-ts"; import { decodeFunctionData, erc20Abi } from "viem"; -import { generateRandomHex } from "../utils"; export class OpenQuotingClient { private readonly MAX_RETRIES = 5; @@ -43,7 +42,7 @@ export class OpenQuotingClient { const response = await this.axiosInstance.post(OpenQuotingAPI.Endpoints.Quotes, payload); - return this.parseQuotesResponse(response.data); + return this.parseQuotesResponse(intent, response.data); } /** @@ -79,14 +78,15 @@ export class OpenQuotingClient { intentData: this.formatIntentData(intent) } - const response = await this.axiosInstance.post(OpenQuotingAPI.Endpoints.Quotes, payload); + const response = await this.axiosInstance.post(OpenQuotingAPI.Endpoints.ReverseQuotes, payload); - return this.parseQuotesResponse(response.data); + return this.parseQuotesResponse(intent, response.data); } private formatIntentData(intent: IntentType): OpenQuotingAPI.Quotes.IntentData { return { routeData: { + salt: "0x0", originChainID: intent.route.source.toString(), destinationChainID: intent.route.destination.toString(), inboxContract: intent.route.inbox, @@ -113,34 +113,35 @@ export class OpenQuotingClient { } } - private parseQuotesResponse(response: OpenQuotingAPI.Quotes.Response): SolverQuote[] { + private parseQuotesResponse(intent: IntentType, response: OpenQuotingAPI.Quotes.Response): SolverQuote[] { return response.data.map((quote) => ({ + quoteID: quote.quoteID, solverID: quote.solverID, quoteData: { quoteEntries: quote.quoteData.quoteEntries.map((entry) => ({ intentExecutionType: entry.intentExecutionType, intentData: { route: { - salt: generateRandomHex(), - source: BigInt(entry.intentData.routeData.originChainID), - destination: BigInt(entry.intentData.routeData.destinationChainID), - inbox: entry.intentData.routeData.inboxContract, - tokens: entry.intentData.routeData.tokens.map((token) => ({ + salt: intent.route.salt, + source: intent.route.source, + destination: intent.route.destination, + inbox: intent.route.inbox, + tokens: entry.routeTokens.map((token) => ({ token: token.token, amount: BigInt(token.amount) })), - calls: entry.intentData.routeData.calls.map((call) => ({ + calls: entry.routeCalls.map((call) => ({ target: call.target, data: call.data, value: BigInt(call.value) })) }, reward: { - creator: entry.intentData.rewardData.creator, - prover: entry.intentData.rewardData.proverContract, - deadline: BigInt(entry.intentData.rewardData.deadline), - nativeValue: BigInt(entry.intentData.rewardData.nativeValue), - tokens: entry.intentData.rewardData.tokens.map((token) => ({ + creator: intent.reward.creator, + prover: intent.reward.prover, + deadline: intent.reward.deadline, + nativeValue: intent.reward.nativeValue, + tokens: entry.rewardTokens.map((token) => ({ token: token.token, amount: BigInt(token.amount) })) diff --git a/packages/sdk/src/quotes/quoteSelectors/index.ts b/packages/sdk/src/quotes/quoteSelectors/index.ts index 2391533..9d77369 100644 --- a/packages/sdk/src/quotes/quoteSelectors/index.ts +++ b/packages/sdk/src/quotes/quoteSelectors/index.ts @@ -12,7 +12,7 @@ export function selectCheapestQuote(solverQuotes: SolverQuote[], isReverse: bool const quotes = solverQuoteResponse.quoteData.quoteEntries; let localCheapestQuoteData = cheapestQuoteData; let localCheapestSolverID = cheapestSolverID; - const defaultSum = BigInt(isReverse ? 0 : Infinity); + const defaultSum = BigInt(isReverse ? 0 : Number.MAX_SAFE_INTEGER); for (const quoteData of quotes) { if (allowedIntentExecutionTypes.includes(quoteData.intentExecutionType)) { diff --git a/packages/sdk/src/quotes/types.ts b/packages/sdk/src/quotes/types.ts index 8a8f122..2267053 100644 --- a/packages/sdk/src/quotes/types.ts +++ b/packages/sdk/src/quotes/types.ts @@ -4,12 +4,14 @@ import { IntentExecutionType } from "../constants"; export namespace OpenQuotingAPI { export enum Endpoints { Quotes = '/api/v1/quotes', + ReverseQuotes = '/api/v1/quotes/reverse', InitiateGaslessIntent = '/api/v1/quotes/initiateGaslessIntent', } export namespace Quotes { export type IntentData = { routeData: { + salt: Hex originChainID: string destinationChainID: string inboxContract: Hex @@ -41,11 +43,24 @@ export namespace OpenQuotingAPI { } export interface Response { data: { + quoteID: string solverID: string quoteData: { quoteEntries: { intentExecutionType: IntentExecutionType - intentData: IntentData + routeTokens: { + token: Hex + amount: string + }[] + routeCalls: { + target: Hex + data: Hex + value: string + }[] + rewardTokens: { + token: Hex + amount: string + }[] expiryTime: string }[] } @@ -131,6 +146,7 @@ export type SubmitGaslessIntentParams = { } export type SolverQuote = { + quoteID: string solverID: string quoteData: { quoteEntries: QuoteData[] diff --git a/packages/sdk/src/routes/RoutesService.ts b/packages/sdk/src/routes/RoutesService.ts index 0b8f824..efa5d83 100644 --- a/packages/sdk/src/routes/RoutesService.ts +++ b/packages/sdk/src/routes/RoutesService.ts @@ -99,7 +99,7 @@ export class RoutesService { /** * Creates an intent. * - * @param {CreateRouteParams} params - The parameters for creating the intent. + * @param {CreateIntentParams} params - The parameters for creating the intent. * * @returns {IntentType} The created intent. * diff --git a/packages/sdk/test/integration/OpenQuotingClient.test.ts b/packages/sdk/test/integration/OpenQuotingClient.test.ts index 58e81bc..bd19d7a 100644 --- a/packages/sdk/test/integration/OpenQuotingClient.test.ts +++ b/packages/sdk/test/integration/OpenQuotingClient.test.ts @@ -178,7 +178,7 @@ describe("OpenQuotingClient", () => { expect(quotes.length).toBeGreaterThan(0); for (const solverQuoteResponse of quotes) { - validateSolverQuoteResponse(solverQuoteResponse, validIntent, false); + validateSolverQuoteResponse(solverQuoteResponse, validIntent, true); } }); @@ -231,13 +231,20 @@ describe("OpenQuotingClient", () => { test("invalid:intent.route.calls", async () => { const invalidIntent = validIntent; + // invalid function invalidIntent.route.calls = [{ target: RoutesService.getStableAddress(10, "USDC"), data: encodeFunctionData({ abi: erc20Abi, functionName: "approve", args: [zeroAddress, BigInt(10000)] }), value: BigInt(0) }]; await expect(openQuotingClient.requestReverseQuotesForIntent({ intent: invalidIntent })).rejects.toThrow("Reverse quote calls must be ERC20 transfer calls"); - invalidIntent.route.calls = [{ target: "0x0", data: zeroHash, value: BigInt(0) }]; + // no data valid length + invalidIntent.route.calls = [{ target: RoutesService.getStableAddress(10, "USDC"), data: zeroHash, value: BigInt(0) }]; + await expect(openQuotingClient.requestReverseQuotesForIntent({ intent: invalidIntent })).rejects.toThrow("Reverse quote calls must be ERC20 transfer calls"); + + // invalid target + invalidIntent.route.calls = [{ target: "0x0", data: encodeFunctionData({ abi: erc20Abi, functionName: "transfer", args: [zeroAddress, BigInt(10000)] }), value: BigInt(0) }]; await expect(openQuotingClient.requestReverseQuotesForIntent({ intent: invalidIntent })).rejects.toThrow("Request failed with status code 400"); - invalidIntent.route.calls = [{ target: RoutesService.getStableAddress(10, "USDC"), data: zeroHash, value: BigInt(-1) }]; + // invalid value + invalidIntent.route.calls = [{ target: RoutesService.getStableAddress(10, "USDC"), data: encodeFunctionData({ abi: erc20Abi, functionName: "transfer", args: [zeroAddress, BigInt(10000)] }), value: BigInt(-1) }]; await expect(openQuotingClient.requestReverseQuotesForIntent({ intent: invalidIntent })).rejects.toThrow("Request failed with status code 400"); }); diff --git a/packages/sdk/test/utils.ts b/packages/sdk/test/utils.ts index 83dc28e..ac2a1c6 100644 --- a/packages/sdk/test/utils.ts +++ b/packages/sdk/test/utils.ts @@ -83,19 +83,26 @@ export function validateSolverQuoteResponse(quoteResponse: SolverQuote, original ); } else { // For standard quotes: - // 1. Verify quote is applied to reward tokens (fee is added to reward) + // 1. Verify quote reduces asked reward tokens or keeps it the same const quoteRewardTokensSum = sum(quote.intentData.reward.tokens.map(token => token.amount)); const intentRewardTokensSum = sum(originalIntent.reward.tokens.map(token => token.amount)); - expect(quoteRewardTokensSum, "Standard quote should increase reward.tokens sum").toBeGreaterThanOrEqual( + expect(quoteRewardTokensSum, "Standard quote should reducs reward.tokens sum").toBeLessThanOrEqual( intentRewardTokensSum, ); // 2. Route token amounts should remain unchanged - const quoteTokensSum = sum(quote.intentData.route.tokens.map(token => token.amount)); - const intentTokensSum = sum(originalIntent.route.tokens.map(token => token.amount)); - expect(quoteTokensSum, "Standard quote should not change route.tokens sum").toBe( - intentTokensSum, + const quoteRouteTokensSum = sum(quote.intentData.route.tokens.map(token => token.amount)); + const intentRouteTokensSum = sum(originalIntent.route.tokens.map(token => token.amount)); + expect(quoteRouteTokensSum, "Standard quote should not change route.tokens sum").toBe( + intentRouteTokensSum, ); } + + // Verify that the quote reward tokens sum is equal to or greater than the route tokens sum + const quoteRewardTokensSum = sum(quote.intentData.reward.tokens.map(token => token.amount)); + const quoteRouteTokensSum = sum(quote.intentData.route.tokens.map(token => token.amount)); + expect(quoteRewardTokensSum, "Quote reward tokens sum should be greater than or equal to route tokens sum").toBeGreaterThanOrEqual( + quoteRouteTokensSum, + ); } } From 109ce6f38ca5c01768956125078110f02da8b330 Mon Sep 17 00:00:00 2001 From: Dirk Page Date: Fri, 18 Apr 2025 13:28:01 -0500 Subject: [PATCH 10/22] Fixing SDK for gasless initiation with permit and permit2 --- packages/sdk/src/quotes/OpenQuotingClient.ts | 103 +- .../sdk/src/quotes/quoteSelectors/index.ts | 11 +- packages/sdk/src/quotes/types.ts | 46 +- packages/sdk/test/Permit2Abi.ts | 901 +++++++++++++++++ packages/sdk/test/PermitAbi.ts | 907 ++++++++++++++++++ .../test/e2e/initiateGaslessIntent.test.ts | 348 +++++++ packages/sdk/test/e2e/publishAndFund.test.ts | 4 + .../integration/OpenQuotingClient.test.ts | 1 + packages/sdk/test/permit.ts | 155 +++ packages/sdk/test/utils.ts | 4 +- 10 files changed, 2407 insertions(+), 73 deletions(-) create mode 100644 packages/sdk/test/Permit2Abi.ts create mode 100644 packages/sdk/test/PermitAbi.ts create mode 100644 packages/sdk/test/e2e/initiateGaslessIntent.test.ts create mode 100644 packages/sdk/test/permit.ts diff --git a/packages/sdk/src/quotes/OpenQuotingClient.ts b/packages/sdk/src/quotes/OpenQuotingClient.ts index b195196..1235980 100644 --- a/packages/sdk/src/quotes/OpenQuotingClient.ts +++ b/packages/sdk/src/quotes/OpenQuotingClient.ts @@ -1,6 +1,6 @@ import axios, { AxiosInstance } from "axios"; import axiosRetry from "axios-retry"; -import { InitiateGaslessIntentResponse, OpenQuotingAPI, PermitData, RequestQuotesForIntentParams, SolverQuote, SubmitGaslessIntentParams } from "./types"; +import { BatchPermit2Data, InitiateGaslessIntentResponse, OpenQuotingAPI, Permit1, Permit2, PermitData, RequestQuotesForIntentParams, SinglePermit2Data, SolverQuote, SubmitGaslessIntentParams } from "./types"; import { ECO_SDK_CONFIG } from "../config"; import { IntentType } from "@eco-foundation/routes-ts"; import { decodeFunctionData, erc20Abi } from "viem"; @@ -39,6 +39,7 @@ export class OpenQuotingClient { intentExecutionTypes, intentData: this.formatIntentData(intent) } + payload.intentData.routeData.salt = "0x0"; const response = await this.axiosInstance.post(OpenQuotingAPI.Endpoints.Quotes, payload); @@ -77,6 +78,7 @@ export class OpenQuotingClient { intentExecutionTypes, intentData: this.formatIntentData(intent) } + payload.intentData.routeData.salt = "0x0"; const response = await this.axiosInstance.post(OpenQuotingAPI.Endpoints.ReverseQuotes, payload); @@ -86,7 +88,7 @@ export class OpenQuotingClient { private formatIntentData(intent: IntentType): OpenQuotingAPI.Quotes.IntentData { return { routeData: { - salt: "0x0", + salt: intent.route.salt, originChainID: intent.route.source.toString(), destinationChainID: intent.route.destination.toString(), inboxContract: intent.route.inbox, @@ -163,15 +165,17 @@ export class OpenQuotingClient { * @remarks * This method sends a POST request to the `/api/v1/quotes/initiateGaslessIntent` endpoint with the provided intent information. */ - async submitGaslessIntent({ funder, intent, solverID, permitData }: SubmitGaslessIntentParams): Promise { + async submitGaslessIntent({ funder, intent, quoteID, solverID, vaultAddress, permitData }: SubmitGaslessIntentParams): Promise { const payload: OpenQuotingAPI.InitiateGaslessIntent.Request = { dAppID: this.dAppID, + quoteID, solverID, intentData: { ...this.formatIntentData(intent), gaslessIntentData: { funder, - permitData: this.formatPermitData(permitData), + vaultAddress, + permitData: permitData ? this.formatPermitData(permitData) : undefined, } } } @@ -186,57 +190,68 @@ export class OpenQuotingClient { } private formatPermitData(permit: PermitData): OpenQuotingAPI.InitiateGaslessIntent.Request["intentData"]["gaslessIntentData"]["permitData"] { - if (permit.permit) { - const permitData: Pick = permit; + if ((permit as Permit1).permit) { + // regular permit + const permit1 = permit as Permit1; return { - permit: permitData.permit.map((permit1) => ({ - token: permit1.token, + permit: permit1.permit.map((p) => ({ + token: p.token, data: { - signature: permit1.data.signature, - deadline: permit1.data.deadline.toString(), + signature: p.data.signature, + deadline: p.data.deadline.toString(), } })) } } else { - const permitData: Pick = permit; - return { - permit2: { - permitContract: permitData.permit2.permitContract, - permitData: permitData.permit2.permitData.singlePermitData ? { - singlePermitData: { - typedData: { - details: { - name: permitData.permit2.permitData.singlePermitData.typedData.details.name, - version: permitData.permit2.permitData.singlePermitData.typedData.details.version, - chainId: permitData.permit2.permitData.singlePermitData.typedData.details.chainId, - verifyingContract: permitData.permit2.permitData.singlePermitData.typedData.details.verifyingContract, - nonce: permitData.permit2.permitData.singlePermitData.typedData.details.nonce.toString(), - deadline: permitData.permit2.permitData.singlePermitData.typedData.details.deadline.toString(), - }, - spender: permitData.permit2.permitData.singlePermitData.typedData.spender, - sigDeadline: permitData.permit2.permitData.singlePermitData.typedData.sigDeadline.toString(), + const permit2 = permit as Permit2; + if ((permit2.permit2.permitData as SinglePermit2Data).singlePermitData) { + const permitData = permit2.permit2.permitData as SinglePermit2Data; + return { + permit2: { + permitContract: permit2.permit2.permitContract, + permitData: { + singlePermitData: { + typedData: { + details: { + token: permitData.singlePermitData.typedData.details.token, + amount: permitData.singlePermitData.typedData.details.amount.toString(), + expiration: permitData.singlePermitData.typedData.details.expiration.toString(), + nonce: permitData.singlePermitData.typedData.details.nonce.toString(), + }, + spender: permitData.singlePermitData.typedData.spender, + sigDeadline: permitData.singlePermitData.typedData.sigDeadline.toString(), + } } - } - } : { - batchPermitData: { - typedData: { - details: permitData.permit2.permitData.batchPermitData.typedData.details.map((detail) => ({ - name: detail.name, - version: detail.version, - chainId: detail.chainId, - verifyingContract: detail.verifyingContract, - nonce: detail.nonce.toString(), - deadline: detail.deadline.toString(), - })), - spender: permitData.permit2.permitData.batchPermitData.typedData.spender, - sigDeadline: permitData.permit2.permitData.batchPermitData.typedData.sigDeadline.toString(), + }, + signature: permit2.permit2.signature, + } + } + } + else { + const permitData = permit2.permit2.permitData as BatchPermit2Data; + return { + permit2: { + permitContract: permit2.permit2.permitContract, + permitData: { + batchPermitData: { + typedData: { + details: permitData.batchPermitData.typedData.details.map((detail) => ({ + token: detail.token, + amount: detail.amount.toString(), + expiration: detail.expiration.toString(), + nonce: detail.nonce.toString(), + })), + spender: permitData.batchPermitData.typedData.spender, + sigDeadline: permitData.batchPermitData.typedData.sigDeadline.toString(), + } } - } - }, - signature: permitData.permit2.signature, + }, + signature: permit2.permit2.signature, + } } } + } } } diff --git a/packages/sdk/src/quotes/quoteSelectors/index.ts b/packages/sdk/src/quotes/quoteSelectors/index.ts index 9d77369..34d63fc 100644 --- a/packages/sdk/src/quotes/quoteSelectors/index.ts +++ b/packages/sdk/src/quotes/quoteSelectors/index.ts @@ -3,15 +3,17 @@ import { sum } from "../../utils"; import { QuoteData, SolverQuote } from "../types"; type QuoteSelectorResult = { + quoteID: string; solverID: string; quoteData: QuoteData; } export function selectCheapestQuote(solverQuotes: SolverQuote[], isReverse: boolean = false, allowedIntentExecutionTypes: IntentExecutionType[] = ["SELF_PUBLISH"]): QuoteSelectorResult { - return solverQuotes.reduce(({ solverID: cheapestSolverID, quoteData: cheapestQuoteData }, solverQuoteResponse) => { + return solverQuotes.reduce(({ solverID: cheapestSolverID, quoteID: cheapestQuoteID, quoteData: cheapestQuoteData }, solverQuoteResponse) => { const quotes = solverQuoteResponse.quoteData.quoteEntries; let localCheapestQuoteData = cheapestQuoteData; let localCheapestSolverID = cheapestSolverID; + let localCheapestQuoteID = cheapestQuoteID; const defaultSum = BigInt(isReverse ? 0 : Number.MAX_SAFE_INTEGER); for (const quoteData of quotes) { @@ -31,6 +33,7 @@ export function selectCheapestQuote(solverQuotes: SolverQuote[], isReverse: bool // want to set the quote with the highest route tokens sum (most received on destination chain) if (quoteSum > localCheapestSum) { localCheapestSolverID = solverQuoteResponse.solverID; + localCheapestQuoteID = solverQuoteResponse.quoteID; localCheapestQuoteData = quoteData; } } @@ -38,6 +41,7 @@ export function selectCheapestQuote(solverQuotes: SolverQuote[], isReverse: bool // want to set the quote with the lowest reward tokens sum (least spent on origin chain) if (quoteSum < localCheapestSum) { localCheapestSolverID = solverQuoteResponse.solverID; + localCheapestQuoteID = solverQuoteResponse.quoteID; localCheapestQuoteData = quoteData; } } @@ -48,6 +52,7 @@ export function selectCheapestQuote(solverQuotes: SolverQuote[], isReverse: bool if (!cheapestQuoteData) { return { solverID: localCheapestSolverID, + quoteID: localCheapestQuoteID, quoteData: localCheapestQuoteData }; } @@ -61,9 +66,11 @@ export function selectCheapestQuote(solverQuotes: SolverQuote[], isReverse: bool return localSum > globalSum ? { solverID: localCheapestSolverID, + quoteID: localCheapestQuoteID, quoteData: localCheapestQuoteData } : { solverID: cheapestSolverID, + quoteID: cheapestQuoteID, quoteData: cheapestQuoteData }; } else { @@ -75,9 +82,11 @@ export function selectCheapestQuote(solverQuotes: SolverQuote[], isReverse: bool return localSum < globalSum ? { solverID: localCheapestSolverID, + quoteID: localCheapestQuoteID, quoteData: localCheapestQuoteData } : { solverID: cheapestSolverID, + quoteID: cheapestQuoteID, quoteData: cheapestQuoteData }; } diff --git a/packages/sdk/src/quotes/types.ts b/packages/sdk/src/quotes/types.ts index 2267053..5f49916 100644 --- a/packages/sdk/src/quotes/types.ts +++ b/packages/sdk/src/quotes/types.ts @@ -1,5 +1,5 @@ import { IntentType } from "@eco-foundation/routes-ts"; -import { Hex } from "viem"; +import { Hex, TransactionReceipt } from "viem"; import { IntentExecutionType } from "../constants"; export namespace OpenQuotingAPI { export enum Endpoints { @@ -70,12 +70,15 @@ export namespace OpenQuotingAPI { export namespace InitiateGaslessIntent { export interface Request { + quoteID: string; dAppID: string; solverID: string; intentData: Quotes.IntentData & { gaslessIntentData: { funder: Hex - permitData: { + vaultAddress: Hex + allowPartial?: boolean + permitData?: { permit: { token: Hex data: { @@ -90,12 +93,10 @@ export namespace OpenQuotingAPI { singlePermitData: { typedData: { details: { - name: string - version: string - chainId: number - verifyingContract: Hex + token: Hex + amount: string + expiration: string nonce: string - deadline: string } spender: Hex sigDeadline: string @@ -105,12 +106,10 @@ export namespace OpenQuotingAPI { batchPermitData: { typedData: { details: { - name: string - version: string - chainId: number - verifyingContract: Hex + token: Hex + amount: string + expiration: string nonce: string - deadline: string }[] spender: Hex sigDeadline: string @@ -120,15 +119,11 @@ export namespace OpenQuotingAPI { signature: Hex } } - allowPartial?: boolean } } } export interface Response { - data: { - // TODO: return acutal structure when it is decided - transactionHash: Hex - } + data: TransactionReceipt } } } @@ -141,8 +136,10 @@ export type RequestQuotesForIntentParams = { export type SubmitGaslessIntentParams = { funder: Hex intent: IntentType + vaultAddress: Hex + quoteID: string solverID: string - permitData: PermitData + permitData?: PermitData } export type SolverQuote = { @@ -159,7 +156,7 @@ export type QuoteData = { expiryTime: bigint // seconds since epoch } -export type PermitData = Permit1 & Permit2 +export type PermitData = Permit1 | Permit2 export type Permit1 = { permit: { @@ -174,7 +171,7 @@ export type Permit1 = { export type Permit2 = { permit2: { permitContract: Hex - permitData: SinglePermit2Data & BatchPermit2Data + permitData: SinglePermit2Data | BatchPermit2Data signature: Hex } } @@ -200,15 +197,12 @@ export type BatchPermit2Data = { } export type Permit2DataDetails = { - name: string - version: string - chainId: number - verifyingContract: Hex + token: Hex + amount: bigint + expiration: bigint nonce: bigint - deadline: bigint } -// TODO: return acutal structure when it is decided export type InitiateGaslessIntentResponse = { transactionHash: Hex } \ No newline at end of file diff --git a/packages/sdk/test/Permit2Abi.ts b/packages/sdk/test/Permit2Abi.ts new file mode 100644 index 0000000..179fe55 --- /dev/null +++ b/packages/sdk/test/Permit2Abi.ts @@ -0,0 +1,901 @@ +export const Permit2Abi = [ + { + inputs: [ + { + internalType: 'uint256', + name: 'deadline', + type: 'uint256', + }, + ], + name: 'AllowanceExpired', + type: 'error', + }, + { + inputs: [], + name: 'ExcessiveInvalidation', + type: 'error', + }, + { + inputs: [ + { + internalType: 'uint256', + name: 'amount', + type: 'uint256', + }, + ], + name: 'InsufficientAllowance', + type: 'error', + }, + { + inputs: [ + { + internalType: 'uint256', + name: 'maxAmount', + type: 'uint256', + }, + ], + name: 'InvalidAmount', + type: 'error', + }, + { + inputs: [], + name: 'InvalidContractSignature', + type: 'error', + }, + { + inputs: [], + name: 'InvalidNonce', + type: 'error', + }, + { + inputs: [], + name: 'InvalidSignature', + type: 'error', + }, + { + inputs: [], + name: 'InvalidSignatureLength', + type: 'error', + }, + { + inputs: [], + name: 'InvalidSigner', + type: 'error', + }, + { + inputs: [], + name: 'LengthMismatch', + type: 'error', + }, + { + inputs: [ + { + internalType: 'uint256', + name: 'signatureDeadline', + type: 'uint256', + }, + ], + name: 'SignatureExpired', + type: 'error', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'owner', + type: 'address', + }, + { + indexed: true, + internalType: 'address', + name: 'token', + type: 'address', + }, + { + indexed: true, + internalType: 'address', + name: 'spender', + type: 'address', + }, + { + indexed: false, + internalType: 'uint160', + name: 'amount', + type: 'uint160', + }, + { + indexed: false, + internalType: 'uint48', + name: 'expiration', + type: 'uint48', + }, + ], + name: 'Approval', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'owner', + type: 'address', + }, + { + indexed: false, + internalType: 'address', + name: 'token', + type: 'address', + }, + { + indexed: false, + internalType: 'address', + name: 'spender', + type: 'address', + }, + ], + name: 'Lockdown', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'owner', + type: 'address', + }, + { + indexed: true, + internalType: 'address', + name: 'token', + type: 'address', + }, + { + indexed: true, + internalType: 'address', + name: 'spender', + type: 'address', + }, + { + indexed: false, + internalType: 'uint48', + name: 'newNonce', + type: 'uint48', + }, + { + indexed: false, + internalType: 'uint48', + name: 'oldNonce', + type: 'uint48', + }, + ], + name: 'NonceInvalidation', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'owner', + type: 'address', + }, + { + indexed: true, + internalType: 'address', + name: 'token', + type: 'address', + }, + { + indexed: true, + internalType: 'address', + name: 'spender', + type: 'address', + }, + { + indexed: false, + internalType: 'uint160', + name: 'amount', + type: 'uint160', + }, + { + indexed: false, + internalType: 'uint48', + name: 'expiration', + type: 'uint48', + }, + { + indexed: false, + internalType: 'uint48', + name: 'nonce', + type: 'uint48', + }, + ], + name: 'Permit', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'owner', + type: 'address', + }, + { + indexed: false, + internalType: 'uint256', + name: 'word', + type: 'uint256', + }, + { + indexed: false, + internalType: 'uint256', + name: 'mask', + type: 'uint256', + }, + ], + name: 'UnorderedNonceInvalidation', + type: 'event', + }, + { + inputs: [], + name: 'DOMAIN_SEPARATOR', + outputs: [ + { + internalType: 'bytes32', + name: '', + type: 'bytes32', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: '', + type: 'address', + }, + { + internalType: 'address', + name: '', + type: 'address', + }, + { + internalType: 'address', + name: '', + type: 'address', + }, + ], + name: 'allowance', + outputs: [ + { + internalType: 'uint160', + name: 'amount', + type: 'uint160', + }, + { + internalType: 'uint48', + name: 'expiration', + type: 'uint48', + }, + { + internalType: 'uint48', + name: 'nonce', + type: 'uint48', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: 'token', + type: 'address', + }, + { + internalType: 'address', + name: 'spender', + type: 'address', + }, + { + internalType: 'uint160', + name: 'amount', + type: 'uint160', + }, + { + internalType: 'uint48', + name: 'expiration', + type: 'uint48', + }, + ], + name: 'approve', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: 'token', + type: 'address', + }, + { + internalType: 'address', + name: 'spender', + type: 'address', + }, + { + internalType: 'uint48', + name: 'newNonce', + type: 'uint48', + }, + ], + name: 'invalidateNonces', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + internalType: 'uint256', + name: 'wordPos', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'mask', + type: 'uint256', + }, + ], + name: 'invalidateUnorderedNonces', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + components: [ + { + internalType: 'address', + name: 'token', + type: 'address', + }, + { + internalType: 'address', + name: 'spender', + type: 'address', + }, + ], + internalType: 'struct IAllowanceTransfer.TokenSpenderPair[]', + name: 'approvals', + type: 'tuple[]', + }, + ], + name: 'lockdown', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: '', + type: 'address', + }, + { + internalType: 'uint256', + name: '', + type: 'uint256', + }, + ], + name: 'nonceBitmap', + outputs: [ + { + internalType: 'uint256', + name: '', + type: 'uint256', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: 'owner', + type: 'address', + }, + { + components: [ + { + components: [ + { + internalType: 'address', + name: 'token', + type: 'address', + }, + { + internalType: 'uint160', + name: 'amount', + type: 'uint160', + }, + { + internalType: 'uint48', + name: 'expiration', + type: 'uint48', + }, + { + internalType: 'uint48', + name: 'nonce', + type: 'uint48', + }, + ], + internalType: 'struct IAllowanceTransfer.PermitDetails[]', + name: 'details', + type: 'tuple[]', + }, + { + internalType: 'address', + name: 'spender', + type: 'address', + }, + { + internalType: 'uint256', + name: 'sigDeadline', + type: 'uint256', + }, + ], + internalType: 'struct IAllowanceTransfer.PermitBatch', + name: 'permitBatch', + type: 'tuple', + }, + { + internalType: 'bytes', + name: 'signature', + type: 'bytes', + }, + ], + name: 'permit', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: 'owner', + type: 'address', + }, + { + components: [ + { + components: [ + { + internalType: 'address', + name: 'token', + type: 'address', + }, + { + internalType: 'uint160', + name: 'amount', + type: 'uint160', + }, + { + internalType: 'uint48', + name: 'expiration', + type: 'uint48', + }, + { + internalType: 'uint48', + name: 'nonce', + type: 'uint48', + }, + ], + internalType: 'struct IAllowanceTransfer.PermitDetails', + name: 'details', + type: 'tuple', + }, + { + internalType: 'address', + name: 'spender', + type: 'address', + }, + { + internalType: 'uint256', + name: 'sigDeadline', + type: 'uint256', + }, + ], + internalType: 'struct IAllowanceTransfer.PermitSingle', + name: 'permitSingle', + type: 'tuple', + }, + { + internalType: 'bytes', + name: 'signature', + type: 'bytes', + }, + ], + name: 'permit', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + components: [ + { + components: [ + { + internalType: 'address', + name: 'token', + type: 'address', + }, + { + internalType: 'uint256', + name: 'amount', + type: 'uint256', + }, + ], + internalType: 'struct ISignatureTransfer.TokenPermissions', + name: 'permitted', + type: 'tuple', + }, + { + internalType: 'uint256', + name: 'nonce', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'deadline', + type: 'uint256', + }, + ], + internalType: 'struct ISignatureTransfer.PermitTransferFrom', + name: 'permit', + type: 'tuple', + }, + { + components: [ + { + internalType: 'address', + name: 'to', + type: 'address', + }, + { + internalType: 'uint256', + name: 'requestedAmount', + type: 'uint256', + }, + ], + internalType: 'struct ISignatureTransfer.SignatureTransferDetails', + name: 'transferDetails', + type: 'tuple', + }, + { + internalType: 'address', + name: 'owner', + type: 'address', + }, + { + internalType: 'bytes', + name: 'signature', + type: 'bytes', + }, + ], + name: 'permitTransferFrom', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + components: [ + { + components: [ + { + internalType: 'address', + name: 'token', + type: 'address', + }, + { + internalType: 'uint256', + name: 'amount', + type: 'uint256', + }, + ], + internalType: 'struct ISignatureTransfer.TokenPermissions[]', + name: 'permitted', + type: 'tuple[]', + }, + { + internalType: 'uint256', + name: 'nonce', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'deadline', + type: 'uint256', + }, + ], + internalType: 'struct ISignatureTransfer.PermitBatchTransferFrom', + name: 'permit', + type: 'tuple', + }, + { + components: [ + { + internalType: 'address', + name: 'to', + type: 'address', + }, + { + internalType: 'uint256', + name: 'requestedAmount', + type: 'uint256', + }, + ], + internalType: 'struct ISignatureTransfer.SignatureTransferDetails[]', + name: 'transferDetails', + type: 'tuple[]', + }, + { + internalType: 'address', + name: 'owner', + type: 'address', + }, + { + internalType: 'bytes', + name: 'signature', + type: 'bytes', + }, + ], + name: 'permitTransferFrom', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + components: [ + { + components: [ + { + internalType: 'address', + name: 'token', + type: 'address', + }, + { + internalType: 'uint256', + name: 'amount', + type: 'uint256', + }, + ], + internalType: 'struct ISignatureTransfer.TokenPermissions', + name: 'permitted', + type: 'tuple', + }, + { + internalType: 'uint256', + name: 'nonce', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'deadline', + type: 'uint256', + }, + ], + internalType: 'struct ISignatureTransfer.PermitTransferFrom', + name: 'permit', + type: 'tuple', + }, + { + components: [ + { + internalType: 'address', + name: 'to', + type: 'address', + }, + { + internalType: 'uint256', + name: 'requestedAmount', + type: 'uint256', + }, + ], + internalType: 'struct ISignatureTransfer.SignatureTransferDetails', + name: 'transferDetails', + type: 'tuple', + }, + { + internalType: 'address', + name: 'owner', + type: 'address', + }, + { + internalType: 'bytes32', + name: 'witness', + type: 'bytes32', + }, + { + internalType: 'string', + name: 'witnessTypeString', + type: 'string', + }, + { + internalType: 'bytes', + name: 'signature', + type: 'bytes', + }, + ], + name: 'permitWitnessTransferFrom', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + components: [ + { + components: [ + { + internalType: 'address', + name: 'token', + type: 'address', + }, + { + internalType: 'uint256', + name: 'amount', + type: 'uint256', + }, + ], + internalType: 'struct ISignatureTransfer.TokenPermissions[]', + name: 'permitted', + type: 'tuple[]', + }, + { + internalType: 'uint256', + name: 'nonce', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'deadline', + type: 'uint256', + }, + ], + internalType: 'struct ISignatureTransfer.PermitBatchTransferFrom', + name: 'permit', + type: 'tuple', + }, + { + components: [ + { + internalType: 'address', + name: 'to', + type: 'address', + }, + { + internalType: 'uint256', + name: 'requestedAmount', + type: 'uint256', + }, + ], + internalType: 'struct ISignatureTransfer.SignatureTransferDetails[]', + name: 'transferDetails', + type: 'tuple[]', + }, + { + internalType: 'address', + name: 'owner', + type: 'address', + }, + { + internalType: 'bytes32', + name: 'witness', + type: 'bytes32', + }, + { + internalType: 'string', + name: 'witnessTypeString', + type: 'string', + }, + { + internalType: 'bytes', + name: 'signature', + type: 'bytes', + }, + ], + name: 'permitWitnessTransferFrom', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + components: [ + { + internalType: 'address', + name: 'from', + type: 'address', + }, + { + internalType: 'address', + name: 'to', + type: 'address', + }, + { + internalType: 'uint160', + name: 'amount', + type: 'uint160', + }, + { + internalType: 'address', + name: 'token', + type: 'address', + }, + ], + internalType: 'struct IAllowanceTransfer.AllowanceTransferDetails[]', + name: 'transferDetails', + type: 'tuple[]', + }, + ], + name: 'transferFrom', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: 'from', + type: 'address', + }, + { + internalType: 'address', + name: 'to', + type: 'address', + }, + { + internalType: 'uint160', + name: 'amount', + type: 'uint160', + }, + { + internalType: 'address', + name: 'token', + type: 'address', + }, + ], + name: 'transferFrom', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, +] as const diff --git a/packages/sdk/test/PermitAbi.ts b/packages/sdk/test/PermitAbi.ts new file mode 100644 index 0000000..fc3dfb3 --- /dev/null +++ b/packages/sdk/test/PermitAbi.ts @@ -0,0 +1,907 @@ +export const PermitAbi = [ + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'owner', + type: 'address', + }, + { + indexed: true, + internalType: 'address', + name: 'spender', + type: 'address', + }, + { + indexed: false, + internalType: 'uint256', + name: 'value', + type: 'uint256', + }, + ], + name: 'Approval', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'authorizer', + type: 'address', + }, + { + indexed: true, + internalType: 'bytes32', + name: 'nonce', + type: 'bytes32', + }, + ], + name: 'AuthorizationCanceled', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'authorizer', + type: 'address', + }, + { + indexed: true, + internalType: 'bytes32', + name: 'nonce', + type: 'bytes32', + }, + ], + name: 'AuthorizationUsed', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: '_account', + type: 'address', + }, + ], + name: 'Blacklisted', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'newBlacklister', + type: 'address', + }, + ], + name: 'BlacklisterChanged', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'burner', + type: 'address', + }, + { + indexed: false, + internalType: 'uint256', + name: 'amount', + type: 'uint256', + }, + ], + name: 'Burn', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'newMasterMinter', + type: 'address', + }, + ], + name: 'MasterMinterChanged', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'minter', + type: 'address', + }, + { + indexed: true, + internalType: 'address', + name: 'to', + type: 'address', + }, + { + indexed: false, + internalType: 'uint256', + name: 'amount', + type: 'uint256', + }, + ], + name: 'Mint', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'minter', + type: 'address', + }, + { + indexed: false, + internalType: 'uint256', + name: 'minterAllowedAmount', + type: 'uint256', + }, + ], + name: 'MinterConfigured', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'oldMinter', + type: 'address', + }, + ], + name: 'MinterRemoved', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: false, + internalType: 'address', + name: 'previousOwner', + type: 'address', + }, + { + indexed: false, + internalType: 'address', + name: 'newOwner', + type: 'address', + }, + ], + name: 'OwnershipTransferred', + type: 'event', + }, + { anonymous: false, inputs: [], name: 'Pause', type: 'event' }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'newAddress', + type: 'address', + }, + ], + name: 'PauserChanged', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'newRescuer', + type: 'address', + }, + ], + name: 'RescuerChanged', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: 'address', name: 'from', type: 'address' }, + { + indexed: true, + internalType: 'address', + name: 'to', + type: 'address', + }, + { + indexed: false, + internalType: 'uint256', + name: 'value', + type: 'uint256', + }, + ], + name: 'Transfer', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: '_account', + type: 'address', + }, + ], + name: 'UnBlacklisted', + type: 'event', + }, + { anonymous: false, inputs: [], name: 'Unpause', type: 'event' }, + { + inputs: [], + name: 'CANCEL_AUTHORIZATION_TYPEHASH', + outputs: [{ internalType: 'bytes32', name: '', type: 'bytes32' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'DOMAIN_SEPARATOR', + outputs: [{ internalType: 'bytes32', name: '', type: 'bytes32' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'PERMIT_TYPEHASH', + outputs: [{ internalType: 'bytes32', name: '', type: 'bytes32' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'RECEIVE_WITH_AUTHORIZATION_TYPEHASH', + outputs: [{ internalType: 'bytes32', name: '', type: 'bytes32' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'TRANSFER_WITH_AUTHORIZATION_TYPEHASH', + outputs: [{ internalType: 'bytes32', name: '', type: 'bytes32' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: 'owner', type: 'address' }, + { + internalType: 'address', + name: 'spender', + type: 'address', + }, + ], + name: 'allowance', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: 'spender', type: 'address' }, + { + internalType: 'uint256', + name: 'value', + type: 'uint256', + }, + ], + name: 'approve', + outputs: [{ internalType: 'bool', name: '', type: 'bool' }], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: 'authorizer', type: 'address' }, + { + internalType: 'bytes32', + name: 'nonce', + type: 'bytes32', + }, + ], + name: 'authorizationState', + outputs: [{ internalType: 'bool', name: '', type: 'bool' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: 'account', type: 'address' }], + name: 'balanceOf', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: '_account', type: 'address' }], + name: 'blacklist', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [], + name: 'blacklister', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'uint256', name: '_amount', type: 'uint256' }], + name: 'burn', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: 'authorizer', type: 'address' }, + { + internalType: 'bytes32', + name: 'nonce', + type: 'bytes32', + }, + { internalType: 'uint8', name: 'v', type: 'uint8' }, + { + internalType: 'bytes32', + name: 'r', + type: 'bytes32', + }, + { internalType: 'bytes32', name: 's', type: 'bytes32' }, + ], + name: 'cancelAuthorization', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: 'authorizer', type: 'address' }, + { + internalType: 'bytes32', + name: 'nonce', + type: 'bytes32', + }, + { internalType: 'bytes', name: 'signature', type: 'bytes' }, + ], + name: 'cancelAuthorization', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: 'minter', type: 'address' }, + { + internalType: 'uint256', + name: 'minterAllowedAmount', + type: 'uint256', + }, + ], + name: 'configureMinter', + outputs: [{ internalType: 'bool', name: '', type: 'bool' }], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [], + name: 'currency', + outputs: [{ internalType: 'string', name: '', type: 'string' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'decimals', + outputs: [{ internalType: 'uint8', name: '', type: 'uint8' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: 'spender', type: 'address' }, + { + internalType: 'uint256', + name: 'decrement', + type: 'uint256', + }, + ], + name: 'decreaseAllowance', + outputs: [{ internalType: 'bool', name: '', type: 'bool' }], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: 'spender', type: 'address' }, + { + internalType: 'uint256', + name: 'increment', + type: 'uint256', + }, + ], + name: 'increaseAllowance', + outputs: [{ internalType: 'bool', name: '', type: 'bool' }], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'string', name: 'tokenName', type: 'string' }, + { + internalType: 'string', + name: 'tokenSymbol', + type: 'string', + }, + { internalType: 'string', name: 'tokenCurrency', type: 'string' }, + { + internalType: 'uint8', + name: 'tokenDecimals', + type: 'uint8', + }, + { internalType: 'address', name: 'newMasterMinter', type: 'address' }, + { + internalType: 'address', + name: 'newPauser', + type: 'address', + }, + { internalType: 'address', name: 'newBlacklister', type: 'address' }, + { + internalType: 'address', + name: 'newOwner', + type: 'address', + }, + ], + name: 'initialize', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'string', name: 'newName', type: 'string' }], + name: 'initializeV2', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: 'lostAndFound', type: 'address' }, + ], + name: 'initializeV2_1', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address[]', + name: 'accountsToBlacklist', + type: 'address[]', + }, + { internalType: 'string', name: 'newSymbol', type: 'string' }, + ], + name: 'initializeV2_2', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: '_account', type: 'address' }], + name: 'isBlacklisted', + outputs: [{ internalType: 'bool', name: '', type: 'bool' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: 'account', type: 'address' }], + name: 'isMinter', + outputs: [{ internalType: 'bool', name: '', type: 'bool' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'masterMinter', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: '_to', type: 'address' }, + { + internalType: 'uint256', + name: '_amount', + type: 'uint256', + }, + ], + name: 'mint', + outputs: [{ internalType: 'bool', name: '', type: 'bool' }], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: 'minter', type: 'address' }], + name: 'minterAllowance', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'name', + outputs: [{ internalType: 'string', name: '', type: 'string' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: 'owner', type: 'address' }], + name: 'nonces', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'owner', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'pause', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [], + name: 'paused', + outputs: [{ internalType: 'bool', name: '', type: 'bool' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'pauser', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: 'owner', type: 'address' }, + { + internalType: 'address', + name: 'spender', + type: 'address', + }, + { internalType: 'uint256', name: 'value', type: 'uint256' }, + { + internalType: 'uint256', + name: 'deadline', + type: 'uint256', + }, + { internalType: 'bytes', name: 'signature', type: 'bytes' }, + ], + name: 'permit', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: 'owner', type: 'address' }, + { + internalType: 'address', + name: 'spender', + type: 'address', + }, + { internalType: 'uint256', name: 'value', type: 'uint256' }, + { + internalType: 'uint256', + name: 'deadline', + type: 'uint256', + }, + { internalType: 'uint8', name: 'v', type: 'uint8' }, + { + internalType: 'bytes32', + name: 'r', + type: 'bytes32', + }, + { internalType: 'bytes32', name: 's', type: 'bytes32' }, + ], + name: 'permit', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: 'from', type: 'address' }, + { + internalType: 'address', + name: 'to', + type: 'address', + }, + { internalType: 'uint256', name: 'value', type: 'uint256' }, + { + internalType: 'uint256', + name: 'validAfter', + type: 'uint256', + }, + { internalType: 'uint256', name: 'validBefore', type: 'uint256' }, + { + internalType: 'bytes32', + name: 'nonce', + type: 'bytes32', + }, + { internalType: 'bytes', name: 'signature', type: 'bytes' }, + ], + name: 'receiveWithAuthorization', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: 'from', type: 'address' }, + { + internalType: 'address', + name: 'to', + type: 'address', + }, + { internalType: 'uint256', name: 'value', type: 'uint256' }, + { + internalType: 'uint256', + name: 'validAfter', + type: 'uint256', + }, + { internalType: 'uint256', name: 'validBefore', type: 'uint256' }, + { + internalType: 'bytes32', + name: 'nonce', + type: 'bytes32', + }, + { internalType: 'uint8', name: 'v', type: 'uint8' }, + { + internalType: 'bytes32', + name: 'r', + type: 'bytes32', + }, + { internalType: 'bytes32', name: 's', type: 'bytes32' }, + ], + name: 'receiveWithAuthorization', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: 'minter', type: 'address' }], + name: 'removeMinter', + outputs: [{ internalType: 'bool', name: '', type: 'bool' }], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + internalType: 'contract IERC20', + name: 'tokenContract', + type: 'address', + }, + { internalType: 'address', name: 'to', type: 'address' }, + { + internalType: 'uint256', + name: 'amount', + type: 'uint256', + }, + ], + name: 'rescueERC20', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [], + name: 'rescuer', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'symbol', + outputs: [{ internalType: 'string', name: '', type: 'string' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'totalSupply', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: 'to', type: 'address' }, + { + internalType: 'uint256', + name: 'value', + type: 'uint256', + }, + ], + name: 'transfer', + outputs: [{ internalType: 'bool', name: '', type: 'bool' }], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: 'from', type: 'address' }, + { + internalType: 'address', + name: 'to', + type: 'address', + }, + { internalType: 'uint256', name: 'value', type: 'uint256' }, + ], + name: 'transferFrom', + outputs: [{ internalType: 'bool', name: '', type: 'bool' }], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: 'newOwner', type: 'address' }], + name: 'transferOwnership', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: 'from', type: 'address' }, + { + internalType: 'address', + name: 'to', + type: 'address', + }, + { internalType: 'uint256', name: 'value', type: 'uint256' }, + { + internalType: 'uint256', + name: 'validAfter', + type: 'uint256', + }, + { internalType: 'uint256', name: 'validBefore', type: 'uint256' }, + { + internalType: 'bytes32', + name: 'nonce', + type: 'bytes32', + }, + { internalType: 'bytes', name: 'signature', type: 'bytes' }, + ], + name: 'transferWithAuthorization', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: 'from', type: 'address' }, + { + internalType: 'address', + name: 'to', + type: 'address', + }, + { internalType: 'uint256', name: 'value', type: 'uint256' }, + { + internalType: 'uint256', + name: 'validAfter', + type: 'uint256', + }, + { internalType: 'uint256', name: 'validBefore', type: 'uint256' }, + { + internalType: 'bytes32', + name: 'nonce', + type: 'bytes32', + }, + { internalType: 'uint8', name: 'v', type: 'uint8' }, + { + internalType: 'bytes32', + name: 'r', + type: 'bytes32', + }, + { internalType: 'bytes32', name: 's', type: 'bytes32' }, + ], + name: 'transferWithAuthorization', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: '_account', type: 'address' }], + name: 'unBlacklist', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [], + name: 'unpause', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: '_newBlacklister', type: 'address' }, + ], + name: 'updateBlacklister', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: '_newMasterMinter', type: 'address' }, + ], + name: 'updateMasterMinter', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: '_newPauser', type: 'address' }], + name: 'updatePauser', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: 'newRescuer', type: 'address' }], + name: 'updateRescuer', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [], + name: 'version', + outputs: [{ internalType: 'string', name: '', type: 'string' }], + stateMutability: 'pure', + type: 'function', + }, +] as const diff --git a/packages/sdk/test/e2e/initiateGaslessIntent.test.ts b/packages/sdk/test/e2e/initiateGaslessIntent.test.ts new file mode 100644 index 0000000..81b6255 --- /dev/null +++ b/packages/sdk/test/e2e/initiateGaslessIntent.test.ts @@ -0,0 +1,348 @@ +import { describe, test, beforeAll, expect } from "vitest"; +import { createWalletClient, Hex, webSocket, WalletClient, erc20Abi, createPublicClient } from "viem"; +import { base, optimism } from "viem/chains"; +import { privateKeyToAccount } from "viem/accounts"; + +import { EcoProtocolAddresses, IntentSourceAbi } from "@eco-foundation/routes-ts"; + +import { RoutesService, OpenQuotingClient, selectCheapestQuote, Permit1, Permit2, Permit2DataDetails } from "../../src"; +import { PERMIT2_ADDRESS, signPermit, signPermit2 } from "../permit"; +import { getSecondsFromNow } from "../../src/utils"; +import { PermitAbi } from "../PermitAbi"; +import { Permit2Abi } from "../Permit2Abi"; + +const account = privateKeyToAccount(process.env.VITE_TESTING_PK as Hex) + +describe("initiateGaslessIntent", () => { + let baseWalletClient: WalletClient + let routesService: RoutesService + let openQuotingClient: OpenQuotingClient + + const publicClient = createPublicClient({ + chain: base, + transport: webSocket(process.env.VITE_BASE_RPC_URL!) + }) + + const amount = BigInt(10000) // 1 cent + const balance = BigInt(1000000000) // 1000 USDC + const originChain = base + const destinationChain = optimism + const receivingToken = RoutesService.getStableAddress(destinationChain.id, "USDC") + const spendingToken = RoutesService.getStableAddress(originChain.id, "USDC") + + beforeAll(() => { + routesService = new RoutesService() + openQuotingClient = new OpenQuotingClient({ dAppID: "test" }) + + baseWalletClient = createWalletClient({ + account, + transport: webSocket(process.env.VITE_BASE_RPC_URL!) + }) + }) + + test("gasless initiation with quote", async () => { + const intent = routesService.createSimpleIntent({ + creator: account.address, + originChainID: originChain.id, + destinationChainID: destinationChain.id, + receivingToken, + spendingToken, + spendingTokenLimit: balance, + amount, + recipient: account.address + }) + + const quotes = await openQuotingClient.requestQuotesForIntent({ intent, intentExecutionTypes: ["GASLESS"] }) + const { quoteID, solverID, quoteData } = selectCheapestQuote(quotes, false, ["GASLESS"]); + + const intentSource = EcoProtocolAddresses[routesService.getEcoChainId(originChain.id)].IntentSource + // initiate gasless intent + const vaultAddress = await publicClient.readContract({ + abi: IntentSourceAbi, + address: intentSource, + functionName: "intentVaultAddress", + args: [quoteData.intentData] + }) + + // approve + await Promise.all(quoteData.intentData.reward.tokens.map(async ({ token, amount }) => { + const hash = await baseWalletClient.writeContract({ + abi: erc20Abi, + address: token, + functionName: "approve", + args: [vaultAddress, amount], + chain: originChain, + account + }) + + await publicClient.waitForTransactionReceipt({ hash }) + })); + + // initiate gasless intent + const response = await openQuotingClient.submitGaslessIntent({ + funder: account.address, + intent: quoteData.intentData, + solverID, + quoteID, + vaultAddress, + }) + + expect(response).toBeDefined() + expect(response.transactionHash).toBeDefined() + }, 20_000); + + test("gasless initiation with reverse quote", async () => { + const intent = routesService.createSimpleIntent({ + creator: account.address, + originChainID: originChain.id, + destinationChainID: destinationChain.id, + receivingToken, + spendingToken, + spendingTokenLimit: balance, + amount, + recipient: account.address + }) + + const quotes = await openQuotingClient.requestReverseQuotesForIntent({ intent, intentExecutionTypes: ["GASLESS"] }) + const { quoteID, solverID, quoteData } = selectCheapestQuote(quotes, true, ["GASLESS"]); + + const intentSource = EcoProtocolAddresses[routesService.getEcoChainId(originChain.id)].IntentSource + // initiate gasless intent + const vaultAddress = await publicClient.readContract({ + abi: IntentSourceAbi, + address: intentSource, + functionName: "intentVaultAddress", + args: [quoteData.intentData] + }) + + // approve + await Promise.all(quoteData.intentData.reward.tokens.map(async ({ token, amount }) => { + const hash = await baseWalletClient.writeContract({ + abi: erc20Abi, + address: token, + functionName: "approve", + args: [vaultAddress, amount], + chain: originChain, + account + }) + + await publicClient.waitForTransactionReceipt({ hash }) + })); + + // initiate gasless intent + const response = await openQuotingClient.submitGaslessIntent({ + funder: account.address, + intent: quoteData.intentData, + solverID, + quoteID, + vaultAddress, + }) + + expect(response).toBeDefined() + expect(response.transactionHash).toBeDefined() + }, 20_000); + + test("gasless initiation with permit", async () => { + const intent = routesService.createSimpleIntent({ + creator: account.address, + originChainID: originChain.id, + destinationChainID: destinationChain.id, + receivingToken, + spendingToken, + spendingTokenLimit: balance, + amount, + recipient: account.address + }) + + const quotes = await openQuotingClient.requestQuotesForIntent({ intent, intentExecutionTypes: ["GASLESS"] }) + const { quoteID, solverID, quoteData } = selectCheapestQuote(quotes, true, ["GASLESS"]); + + const intentSource = EcoProtocolAddresses[routesService.getEcoChainId(originChain.id)].IntentSource + // initiate gasless intent + const vaultAddress = await publicClient.readContract({ + abi: IntentSourceAbi, + address: intentSource, + functionName: "intentVaultAddress", + args: [quoteData.intentData] + }) + + // sign approval using USDC permit + const deadline = Math.round(getSecondsFromNow(60 * 30).getTime() / 1000) // 30 minutes from now in UNIX seconds since epoch + + // for each reward token, generate a permit signature + const permitData: Permit1 = { + permit: await Promise.all(quoteData.intentData.reward.tokens.map(async ({ token, amount }) => { + const tokenContract = { + address: token, + abi: PermitAbi, + } as const + const responses = await publicClient.multicall({ + contracts: [ + { + ...tokenContract, + functionName: 'nonces', + args: [account.address], + }, + { + ...tokenContract, + functionName: 'version', + }, + { + ...tokenContract, + functionName: 'name', + }, + ], + }) + + const [nonce, version, name] = [ + responses[0].result, + responses[1].result, + responses[2].result, + ] + + const signature = await signPermit(baseWalletClient, { + chainId: originChain.id, + contractAddress: token, + deadline: BigInt(deadline), + erc20Name: name!, + nonce: nonce || BigInt(0), + ownerAddress: account.address, + permitVersion: version, + spenderAddress: vaultAddress, + value: amount, + }) + + return { + token, + data: { + signature, + deadline: BigInt(deadline), + nonce: nonce || BigInt(0), + } + } + })) + } + + // now initiate gaslessly with all the data + const response = await openQuotingClient.submitGaslessIntent({ + funder: account.address, + intent: quoteData.intentData, + solverID, + quoteID, + vaultAddress, + permitData, + }) + + expect(response).toBeDefined() + expect(response.transactionHash).toBeDefined() + }, 20_000); + + test("gasless initiation with permit2", async () => { + const intent = routesService.createSimpleIntent({ + creator: account.address, + originChainID: originChain.id, + destinationChainID: destinationChain.id, + receivingToken, + spendingToken, + spendingTokenLimit: balance, + amount, + recipient: account.address + }) + + const quotes = await openQuotingClient.requestQuotesForIntent({ intent, intentExecutionTypes: ["GASLESS"] }) + const { quoteID, solverID, quoteData } = selectCheapestQuote(quotes, true, ["GASLESS"]); + + const intentSource = EcoProtocolAddresses[routesService.getEcoChainId(originChain.id)].IntentSource + // initiate gasless intent + const vaultAddress = await publicClient.readContract({ + abi: IntentSourceAbi, + address: intentSource, + functionName: "intentVaultAddress", + args: [quoteData.intentData] + }) + + // sign approval using permit2 contract + + const deadline = Math.round(getSecondsFromNow(60 * 30).getTime() / 1000) // 30 minutes from now in UNIX seconds since epoch + + // for each reward token perform initial approval to the permit2 contract + await Promise.all(quoteData.intentData.reward.tokens.map(async ({ token, amount }) => { + const approvalTxHash = await baseWalletClient.writeContract({ + abi: erc20Abi, + address: token, + functionName: "approve", + args: [PERMIT2_ADDRESS, amount], + chain: originChain, + account + }); + + await publicClient.waitForTransactionReceipt({ hash: approvalTxHash }) + })); + + // now create the permit2 data to pass to the initiate gasless endpoint + + const details: Permit2DataDetails[] = await Promise.all(quoteData.intentData.reward.tokens.map(async ({ token, amount }) => { + // get nonce + + const currentAllowance = await publicClient.readContract({ + abi: Permit2Abi, + address: PERMIT2_ADDRESS, + functionName: "allowance", + args: [account.address, token, vaultAddress] + }) + + const currentNonce = BigInt(currentAllowance[2]) + + return { + token, + amount, + expiration: BigInt(deadline), + nonce: currentNonce, + } + })); + + const signature = await signPermit2(baseWalletClient, { + chainId: originChain.id, + expiration: BigInt(deadline), + spender: vaultAddress, + details, + }) + + const permitData: Permit2 = { + permit2: { + permitContract: PERMIT2_ADDRESS, + permitData: details.length > 1 ? { + batchPermitData: { + typedData: { + details, + spender: vaultAddress, + sigDeadline: BigInt(deadline), + } + } + } : { + singlePermitData: { + typedData: { + details: details[0]!, + spender: vaultAddress, + sigDeadline: BigInt(deadline), + } + } + }, + signature, + } + } + + // now initate gaslessly + const response = await openQuotingClient.submitGaslessIntent({ + funder: account.address, + intent: quoteData.intentData, + solverID, + quoteID, + vaultAddress, + permitData, + }) + + expect(response).toBeDefined() + expect(response.transactionHash).toBeDefined() + }, 45_000); +}); \ No newline at end of file diff --git a/packages/sdk/test/e2e/publishAndFund.test.ts b/packages/sdk/test/e2e/publishAndFund.test.ts index 54eea3a..a02bc3a 100644 --- a/packages/sdk/test/e2e/publishAndFund.test.ts +++ b/packages/sdk/test/e2e/publishAndFund.test.ts @@ -79,6 +79,10 @@ describe("publishAndFund", () => { await publicClient.waitForTransactionReceipt({ hash: publishTxHash }) }, 20_000) + test.skip("onchain with reverse quote", async () => { + // TODO: implement this + }); + test("onchain without quote", async () => { const intent = routesService.createSimpleIntent({ creator: account.address, diff --git a/packages/sdk/test/integration/OpenQuotingClient.test.ts b/packages/sdk/test/integration/OpenQuotingClient.test.ts index bd19d7a..f9cdc81 100644 --- a/packages/sdk/test/integration/OpenQuotingClient.test.ts +++ b/packages/sdk/test/integration/OpenQuotingClient.test.ts @@ -286,4 +286,5 @@ describe("OpenQuotingClient", () => { }); }); + // TODO: add validity tests for initiateGaslessIntent }, 60_000); diff --git a/packages/sdk/test/permit.ts b/packages/sdk/test/permit.ts new file mode 100644 index 0000000..7765576 --- /dev/null +++ b/packages/sdk/test/permit.ts @@ -0,0 +1,155 @@ +import { hexToNumber, slice } from 'viem' +import type { Hex, WalletClient } from 'viem' +import { Permit2DataDetails } from '../src' + +export type SignPermitProps = { + /** Address of the token to approve */ + contractAddress: Hex + /** Name of the token to approve. + * Corresponds to the `name` method on the ERC-20 contract. Please note this must match exactly byte-for-byte */ + erc20Name: string + /** Owner of the tokens. Usually the currently connected address. */ + ownerAddress: Hex + /** Address to grant allowance to */ + spenderAddress: Hex + /** Expiration of this approval, in SECONDS */ + deadline: bigint + /** Numerical chainId of the token contract */ + chainId: number + /** Defaults to 1. Some tokens need a different version, check the [PERMIT INFORMATION](https://github.com/vacekj/wagmi-permit/blob/main/PERMIT.md) for more information */ + permitVersion?: string + /** Permit nonce for the specific address and token contract. You can get the nonce from the `nonces` method on the token contract. */ + nonce: bigint + /** Amount to approve */ + value: bigint +} + +export type SignPermit2Props = { + chainId: number + expiration: bigint + spender: Hex + details: Permit2DataDetails[] +} + +/** + * Signs a permit for a given ERC-2612 ERC20 token using the specified parameters. + * + * @param {WalletClient} walletClient - Wallet client to invoke for signing the permit message + * @param {SignPermitProps} props - The properties required to sign the permit. + * @param {string} props.contractAddress - The address of the ERC20 token contract. + * @param {string} props.erc20Name - The name of the ERC20 token. + * @param {number} props.value - The amount of the ERC20 to approve. + * @param {string} props.ownerAddress - The address of the token holder. + * @param {string} props.spenderAddress - The address of the token spender. + * @param {number} props.deadline - The permit expiration timestamp in seconds. + * @param {number} props.nonce - The nonce of the address on the specified ERC20. + * @param {number} props.chainId - The chain ID for which the permit will be valid. + * @param {number} props.permitVersion - The version of the permit (optional, defaults to "1"). + */ +export const signPermit = async ( + walletClient: WalletClient, + { + contractAddress, + erc20Name, + ownerAddress, + spenderAddress, + value, + deadline, + nonce, + chainId, + permitVersion, + }: SignPermitProps, +): Promise => { + const types = { + Permit: [ + { name: 'owner', type: 'address' }, + { name: 'spender', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'nonce', type: 'uint256' }, + { name: 'deadline', type: 'uint256' }, + ], + } + + const domainData = { + name: erc20Name, + /** We assume 1 if permit version is not specified */ + version: permitVersion ?? '1', + chainId: chainId, + verifyingContract: contractAddress, + } + + const message = { + owner: ownerAddress, + spender: spenderAddress, + value, + nonce, + deadline, + } + const response = await walletClient.account!.signTypedData!({ + message, + domain: domainData, + primaryType: 'Permit', + types, + }) + + return response +} + +export const PERMIT2_ADDRESS: Hex = '0x000000000022D473030F116dDEE9F6B43aC78BA3' + +export async function signPermit2( + walletClient: WalletClient, + { + chainId, + expiration, + spender, + details, + }: SignPermit2Props, +): Promise { + const types = { + PermitDetails: [ + { name: 'token', type: 'address' }, + { name: 'amount', type: 'uint160' }, + { name: 'expiration', type: 'uint48' }, + { name: 'nonce', type: 'uint48' }, + ], + PermitSingle: [ + { name: 'details', type: 'PermitDetails' }, + { name: 'spender', type: 'address' }, + { name: 'sigDeadline', type: 'uint256' }, + ], + PermitBatch: [ + { name: 'details', type: 'PermitDetails[]' }, + { name: 'spender', type: 'address' }, + { name: 'sigDeadline', type: 'uint256' } + ], + } + + const domainData = { + name: "Permit2", + chainId: chainId, + verifyingContract: PERMIT2_ADDRESS, + } + + const message = { + details: details.length > 1 ? details : details[0], + spender, + sigDeadline: expiration, + } + + return walletClient.account!.signTypedData!({ + message, + domain: domainData, + primaryType: details.length > 1 ? 'PermitBatch' : 'PermitSingle', + types, + }) +} + +export function splitSignature(signature: Hex) { + const [r, s, v] = [ + slice(signature, 0, 32), + slice(signature, 32, 64), + slice(signature, 64, 65), + ] + return { r, s, v: hexToNumber(v) } +} diff --git a/packages/sdk/test/utils.ts b/packages/sdk/test/utils.ts index ac2a1c6..d583dfe 100644 --- a/packages/sdk/test/utils.ts +++ b/packages/sdk/test/utils.ts @@ -1,8 +1,8 @@ import { expect } from "vitest"; import { INTENT_EXECUTION_TYPES, SolverQuote } from "../src"; -import { IntentType } from "@eco-foundation/routes-ts"; -import { decodeFunctionData, erc20Abi } from "viem"; +import { hashIntent, hashRoute, IntentType } from "@eco-foundation/routes-ts"; +import { decodeFunctionData, erc20Abi, Hex, encodePacked } from "viem"; import { sum } from "../src/utils"; export function validateSolverQuoteResponse(quoteResponse: SolverQuote, originalIntent: IntentType, isReverseQuote: boolean) { From bfbcea5c656e04422135200e3930a2edf850995c Mon Sep 17 00:00:00 2001 From: Dirk Page Date: Mon, 21 Apr 2025 11:54:48 -0500 Subject: [PATCH 11/22] Fixes, refactoring submitGaslessIntent to initiateGaslessIntent --- packages/sdk/src/index.ts | 2 +- packages/sdk/src/quotes/OpenQuotingClient.ts | 6 +-- packages/sdk/src/quotes/types.ts | 2 +- .../test/e2e/initiateGaslessIntent.test.ts | 8 ++-- packages/sdk/test/e2e/publishAndFund.test.ts | 46 +++++++++++++++++-- .../integration/OpenQuotingClient.test.ts | 2 - packages/sdk/test/utils.ts | 4 +- 7 files changed, 54 insertions(+), 16 deletions(-) diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index efc7f65..f716321 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -5,5 +5,5 @@ export { RoutesService } from "./routes/RoutesService"; export type { CreateSimpleIntentParams, CreateIntentParams } from "./routes/types"; export { OpenQuotingClient } from "./quotes/OpenQuotingClient"; -export type { RequestQuotesForIntentParams, SolverQuote, QuoteData, PermitData, Permit1, Permit2, SinglePermit2Data, BatchPermit2Data, Permit2DataDetails, InitiateGaslessIntentResponse } from "./quotes/types"; +export type { RequestQuotesForIntentParams, InitiateGaslessIntentParams, PermitData, Permit1, Permit2, SinglePermit2Data, BatchPermit2Data, Permit2DataDetails, SolverQuote, QuoteData, InitiateGaslessIntentResponse } from "./quotes/types"; export { selectCheapestQuote } from "./quotes/quoteSelectors"; diff --git a/packages/sdk/src/quotes/OpenQuotingClient.ts b/packages/sdk/src/quotes/OpenQuotingClient.ts index 1235980..1d8a8b8 100644 --- a/packages/sdk/src/quotes/OpenQuotingClient.ts +++ b/packages/sdk/src/quotes/OpenQuotingClient.ts @@ -1,6 +1,6 @@ import axios, { AxiosInstance } from "axios"; import axiosRetry from "axios-retry"; -import { BatchPermit2Data, InitiateGaslessIntentResponse, OpenQuotingAPI, Permit1, Permit2, PermitData, RequestQuotesForIntentParams, SinglePermit2Data, SolverQuote, SubmitGaslessIntentParams } from "./types"; +import { BatchPermit2Data, InitiateGaslessIntentResponse, OpenQuotingAPI, Permit1, Permit2, PermitData, RequestQuotesForIntentParams, SinglePermit2Data, SolverQuote, InitiateGaslessIntentParams } from "./types"; import { ECO_SDK_CONFIG } from "../config"; import { IntentType } from "@eco-foundation/routes-ts"; import { decodeFunctionData, erc20Abi } from "viem"; @@ -156,7 +156,7 @@ export class OpenQuotingClient { } /** - * Submits a gasless intent to the Open Quoting service. + * Initiates a gasless intent via the Open Quoting service. * * @param intent - The intent for which quotes are being requested. * @param solverID - The ID of the solver that is submitting the intent. @@ -165,7 +165,7 @@ export class OpenQuotingClient { * @remarks * This method sends a POST request to the `/api/v1/quotes/initiateGaslessIntent` endpoint with the provided intent information. */ - async submitGaslessIntent({ funder, intent, quoteID, solverID, vaultAddress, permitData }: SubmitGaslessIntentParams): Promise { + async initiateGaslessIntent({ funder, intent, quoteID, solverID, vaultAddress, permitData }: InitiateGaslessIntentParams): Promise { const payload: OpenQuotingAPI.InitiateGaslessIntent.Request = { dAppID: this.dAppID, quoteID, diff --git a/packages/sdk/src/quotes/types.ts b/packages/sdk/src/quotes/types.ts index 5f49916..c9f5de7 100644 --- a/packages/sdk/src/quotes/types.ts +++ b/packages/sdk/src/quotes/types.ts @@ -133,7 +133,7 @@ export type RequestQuotesForIntentParams = { intentExecutionTypes?: IntentExecutionType[] } -export type SubmitGaslessIntentParams = { +export type InitiateGaslessIntentParams = { funder: Hex intent: IntentType vaultAddress: Hex diff --git a/packages/sdk/test/e2e/initiateGaslessIntent.test.ts b/packages/sdk/test/e2e/initiateGaslessIntent.test.ts index 81b6255..e249c74 100644 --- a/packages/sdk/test/e2e/initiateGaslessIntent.test.ts +++ b/packages/sdk/test/e2e/initiateGaslessIntent.test.ts @@ -79,7 +79,7 @@ describe("initiateGaslessIntent", () => { })); // initiate gasless intent - const response = await openQuotingClient.submitGaslessIntent({ + const response = await openQuotingClient.initiateGaslessIntent({ funder: account.address, intent: quoteData.intentData, solverID, @@ -130,7 +130,7 @@ describe("initiateGaslessIntent", () => { })); // initiate gasless intent - const response = await openQuotingClient.submitGaslessIntent({ + const response = await openQuotingClient.initiateGaslessIntent({ funder: account.address, intent: quoteData.intentData, solverID, @@ -224,7 +224,7 @@ describe("initiateGaslessIntent", () => { } // now initiate gaslessly with all the data - const response = await openQuotingClient.submitGaslessIntent({ + const response = await openQuotingClient.initiateGaslessIntent({ funder: account.address, intent: quoteData.intentData, solverID, @@ -333,7 +333,7 @@ describe("initiateGaslessIntent", () => { } // now initate gaslessly - const response = await openQuotingClient.submitGaslessIntent({ + const response = await openQuotingClient.initiateGaslessIntent({ funder: account.address, intent: quoteData.intentData, solverID, diff --git a/packages/sdk/test/e2e/publishAndFund.test.ts b/packages/sdk/test/e2e/publishAndFund.test.ts index a02bc3a..aafbbda 100644 --- a/packages/sdk/test/e2e/publishAndFund.test.ts +++ b/packages/sdk/test/e2e/publishAndFund.test.ts @@ -79,9 +79,49 @@ describe("publishAndFund", () => { await publicClient.waitForTransactionReceipt({ hash: publishTxHash }) }, 20_000) - test.skip("onchain with reverse quote", async () => { - // TODO: implement this - }); + test("onchain with reverse quote", async () => { + const intentSourceContract = EcoProtocolAddresses[routesService.getEcoChainId(originChain.id)].IntentSource + + const intent = routesService.createSimpleIntent({ + creator: account.address, + originChainID: originChain.id, + destinationChainID: destinationChain.id, + receivingToken, + spendingToken, + spendingTokenLimit: balance, + amount, + recipient: account.address + }) + + // request quotes + const quotes = await openQuotingClient.requestReverseQuotesForIntent({ intent }) + const { quoteData } = selectCheapestQuote(quotes, false, ["SELF_PUBLISH"]) + + // approve + await Promise.all(quoteData.intentData.reward.tokens.map(async ({ token, amount }) => { + const hash = await baseWalletClient.writeContract({ + abi: erc20Abi, + address: token, + functionName: 'approve', + args: [intentSourceContract, amount], + chain: originChain, + account + }) + await publicClient.waitForTransactionReceipt({ hash }) + })) + + // publish intent onchain + const publishTxHash = await baseWalletClient.writeContract({ + abi: IntentSourceAbi, + address: intentSourceContract, + functionName: 'publishAndFund', + args: [quoteData.intentData, false], + chain: originChain, + account + }) + + await publicClient.waitForTransactionReceipt({ hash: publishTxHash }) + }, 20_000); test("onchain without quote", async () => { const intent = routesService.createSimpleIntent({ diff --git a/packages/sdk/test/integration/OpenQuotingClient.test.ts b/packages/sdk/test/integration/OpenQuotingClient.test.ts index f9cdc81..d41e868 100644 --- a/packages/sdk/test/integration/OpenQuotingClient.test.ts +++ b/packages/sdk/test/integration/OpenQuotingClient.test.ts @@ -285,6 +285,4 @@ describe("OpenQuotingClient", () => { await expect(openQuotingClient.requestReverseQuotesForIntent({ intent: invalidIntent })).rejects.toThrow("Request failed with status code 400"); }); }); - - // TODO: add validity tests for initiateGaslessIntent }, 60_000); diff --git a/packages/sdk/test/utils.ts b/packages/sdk/test/utils.ts index d583dfe..ac2a1c6 100644 --- a/packages/sdk/test/utils.ts +++ b/packages/sdk/test/utils.ts @@ -1,8 +1,8 @@ import { expect } from "vitest"; import { INTENT_EXECUTION_TYPES, SolverQuote } from "../src"; -import { hashIntent, hashRoute, IntentType } from "@eco-foundation/routes-ts"; -import { decodeFunctionData, erc20Abi, Hex, encodePacked } from "viem"; +import { IntentType } from "@eco-foundation/routes-ts"; +import { decodeFunctionData, erc20Abi } from "viem"; import { sum } from "../src/utils"; export function validateSolverQuoteResponse(quoteResponse: SolverQuote, originalIntent: IntentType, isReverseQuote: boolean) { From 58e072bf57cc40e6a37844861d439123c189db01 Mon Sep 17 00:00:00 2001 From: Dirk Page Date: Mon, 21 Apr 2025 13:35:30 -0500 Subject: [PATCH 12/22] Method comment improvements --- packages/sdk/src/quotes/OpenQuotingClient.ts | 36 ++++++++++------- packages/sdk/src/routes/RoutesService.ts | 42 +++++++++++++++++--- 2 files changed, 58 insertions(+), 20 deletions(-) diff --git a/packages/sdk/src/quotes/OpenQuotingClient.ts b/packages/sdk/src/quotes/OpenQuotingClient.ts index 1d8a8b8..77257b0 100644 --- a/packages/sdk/src/quotes/OpenQuotingClient.ts +++ b/packages/sdk/src/quotes/OpenQuotingClient.ts @@ -21,14 +21,15 @@ export class OpenQuotingClient { /** * Requests quotes for a given intent. * - * @param intent - The intent for which quotes are being requested. - * @param intentExecutionTypes - The types of intent execution for which quotes are being requested. - * @returns A promise that resolves to an `OpenQuotingClient_ApiResponse_Quotes` object containing the quotes. - * @throws An error if multiple requests fail. + * @param {RequestQuotesForIntentParams} params - The parameters for requesting quotes. + * @param {IntentType} params.intent - The intent for which quotes are being requested. + * @param {string[]} params.intentExecutionTypes - The types of intent execution for which quotes are being requested. + * @returns {Promise} A promise that resolves to an array of SolverQuote objects containing the quotes. + * @throws {Error} If intentExecutionTypes is empty or if the request fails after multiple retries. * * @remarks * This method sends a POST request to the `/api/v1/quotes` endpoint with the provided intent information. - * This will return quotes with the fee added to the reward tokens. + * The intentData returned in each quote will have the fee added to the reward tokens. */ async requestQuotesForIntent({ intent, intentExecutionTypes = ['SELF_PUBLISH', 'GASLESS'] }: RequestQuotesForIntentParams): Promise { if (intentExecutionTypes.length === 0) { @@ -49,14 +50,15 @@ export class OpenQuotingClient { /** * Requests reverse quotes for a given intent. * - * @param intent - The intent for which quotes are being requested. - * @param intentExecutionTypes - The types of intent execution for which quotes are being requested. - * @returns A promise that resolves to an array of `SolverQuote` objects containing the quotes. - * @throws An error if multiple requests fail. + * @param {RequestQuotesForIntentParams} params - The parameters for requesting reverse quotes. + * @param {IntentType} params.intent - The intent for which quotes are being requested. + * @param {string[]} params.intentExecutionTypes - The types of intent execution for which quotes are being requested. + * @returns {Promise} A promise that resolves to an array of SolverQuote objects containing the quotes. + * @throws {Error} If intentExecutionTypes is empty, if the calls aren't ERC20.transfer calls, or if the request fails after multiple retries. * * @remarks - * This method sends a POST request to the `/api/v1/quotes` endpoint with the provided intent information. - * This will return quotes with the fee subtracted from the route tokens and calls + * This method sends a POST request to the `/api/v1/quotes/reverse` endpoint with the provided intent information. + * This intentData returned in each quote will have the fee subtracted from the route tokens and calls. */ async requestReverseQuotesForIntent({ intent, intentExecutionTypes = ['SELF_PUBLISH', 'GASLESS'] }: RequestQuotesForIntentParams): Promise { if (intentExecutionTypes.length === 0) { @@ -158,9 +160,15 @@ export class OpenQuotingClient { /** * Initiates a gasless intent via the Open Quoting service. * - * @param intent - The intent for which quotes are being requested. - * @param solverID - The ID of the solver that is submitting the intent. - * @returns A promise that resolves to the response from the Open Quoting service. + * @param {InitiateGaslessIntentParams} params - The parameters for initiating a gasless intent. + * @param {string} params.funder - The address of the entity funding the intent execution. + * @param {IntentType} params.intent - The intent to be executed gaslessly. + * @param {string} params.quoteID - The ID of the quote selected for execution. + * @param {string} params.solverID - The ID of the solver that is executing the intent. + * @param {string} params.vaultAddress - The address of the vault to use for the gasless intent. + * @param {PermitData} [params.permitData] - Optional permit data for token approvals. + * @returns {Promise} A promise that resolves to the response from the Open Quoting service. + * @throws {Error} If the request fails (no retries are attempted for this endpoint). * * @remarks * This method sends a POST request to the `/api/v1/quotes/initiateGaslessIntent` endpoint with the provided intent information. diff --git a/packages/sdk/src/routes/RoutesService.ts b/packages/sdk/src/routes/RoutesService.ts index efa5d83..30716ab 100644 --- a/packages/sdk/src/routes/RoutesService.ts +++ b/packages/sdk/src/routes/RoutesService.ts @@ -17,9 +17,17 @@ export class RoutesService { * Creates a simple intent. * * @param {CreateSimpleIntentParams} params - The parameters for creating the simple intent. - * + * @param {Hex} params.creator - The address of the intent creator. + * @param {RoutesSupportedChainId} params.originChainID - The chain ID where the intent originates. + * @param {RoutesSupportedChainId} params.destinationChainID - The chain ID where the intent is fulfilled. + * @param {Hex} params.receivingToken - The token address to be received on the destination chain. + * @param {Hex} params.spendingToken - The token address to be spent on the origin chain. + * @param {bigint} params.spendingTokenLimit - The maximum amount of spending token to use. + * @param {bigint} params.amount - The amount of receiving token to transfer. + * @param {string} [params.prover="HyperProver"] - The type of prover to use. + * @param {Hex} [params.recipient] - The recipient address (defaults to creator). + * @param {Date} [params.expiryTime] - The expiry time for the intent (defaults to 90 minutes from now). * @returns {IntentType} The created intent. - * * @throws {Error} If the creator address is invalid, the origin and destination chain are the same, the amount is invalid, or the expiry time is in the past. Or if there is no prover for the specified configuration. */ createSimpleIntent({ @@ -100,9 +108,15 @@ export class RoutesService { * Creates an intent. * * @param {CreateIntentParams} params - The parameters for creating the intent. - * + * @param {Hex} params.creator - The address of the intent creator. + * @param {RoutesSupportedChainId} params.originChainID - The chain ID where the intent originates. + * @param {RoutesSupportedChainId} params.destinationChainID - The chain ID where the intent is fulfilled. + * @param {IntentCall[]} params.calls - The calls to be executed on the destination chain. + * @param {IntentToken[]} params.callTokens - The tokens required for the calls on the destination chain. + * @param {IntentToken[]} params.tokens - The tokens to be used for rewards on the origin chain. + * @param {string|Hex} [params.prover="HyperProver"] - The type of prover or custom prover address to use. + * @param {Date} [params.expiryTime] - The expiry time for the intent (defaults to 90 minutes from now). * @returns {IntentType} The created intent. - * * @throws {Error} If the creator address is invalid, the origin and destination chain are the same, the calls or tokens are invalid, or the expiry time is in the past. */ createIntent({ @@ -157,8 +171,8 @@ export class RoutesService { /** * Returns the EcoChainId for a given chainId, appending "-pre" if the environment is pre-production. * - * @param chainId - The chain ID to be converted to an EcoChainId. - * @returns The EcoChainId, with "-pre" appended if the environment is pre-production. + * @param {RoutesSupportedChainId} chainId - The chain ID to be converted to an EcoChainId. + * @returns {EcoChainIds} The EcoChainId, with "-pre" appended if the environment is pre-production. */ getEcoChainId(chainId: RoutesSupportedChainId): EcoChainIds { return `${chainId}${this.isPreprod ? "-pre" : ""}` @@ -187,6 +201,14 @@ export class RoutesService { return proverContract; } + /** + * Gets the address of a stable token on a specific chain. + * + * @param {RoutesSupportedChainId} chainID - The chain ID where the stable token is deployed. + * @param {RoutesSupportedStable} stable - The type of stable token. + * @returns {Hex} The hexadecimal address of the stable token. + * @throws {Error} If the stable token is not found on the specified chain. + */ static getStableAddress(chainID: RoutesSupportedChainId, stable: RoutesSupportedStable): Hex { const stableAddress = stableAddresses[chainID][stable]; if (!stableAddress) { @@ -195,6 +217,14 @@ export class RoutesService { return stableAddress; } + /** + * Gets the stable token type from its address on a specific chain. + * + * @param {RoutesSupportedChainId} chainID - The chain ID where the stable token is deployed. + * @param {Hex} address - The hexadecimal address of the stable token. + * @returns {RoutesSupportedStable | undefined} The type of stable token. + * @throws {Error} If no stable token is found for the given address on the specified chain. + */ static getStableFromAddress(chainID: RoutesSupportedChainId, address: Hex): RoutesSupportedStable | undefined { for (const stable in stableAddresses[chainID]) { if (stableAddresses[chainID][stable as RoutesSupportedStable]?.toLowerCase() === address.toLowerCase()) { From 6f4325edbd700dbef6ae1637151d85543cf7675b Mon Sep 17 00:00:00 2001 From: Dirk Page Date: Tue, 22 Apr 2025 10:36:20 -0500 Subject: [PATCH 13/22] Adding reverse quote functionality, initial implementation of gasless initiation --- apps/sdk-demo/src/components/edit-config.tsx | 2 +- apps/sdk-demo/src/utils/abis.ts | 41 ++ apps/sdk-demo/src/utils/index.ts | 12 + apps/sdk-demo/src/utils/permit.ts | 144 +++++ .../views/demo/components/create-intent.tsx | 41 +- .../views/demo/components/publish-intent.tsx | 495 ++++++++++++++---- .../views/demo/components/select-quote.tsx | 74 ++- apps/sdk-demo/src/views/demo/demo-view.tsx | 36 +- 8 files changed, 719 insertions(+), 126 deletions(-) create mode 100644 apps/sdk-demo/src/utils/abis.ts create mode 100644 apps/sdk-demo/src/utils/permit.ts diff --git a/apps/sdk-demo/src/components/edit-config.tsx b/apps/sdk-demo/src/components/edit-config.tsx index 9298247..f0100bc 100644 --- a/apps/sdk-demo/src/components/edit-config.tsx +++ b/apps/sdk-demo/src/components/edit-config.tsx @@ -21,7 +21,7 @@ export default function EditConfig() { return (
- Edit Config + Configure:
diff --git a/apps/sdk-demo/src/utils/abis.ts b/apps/sdk-demo/src/utils/abis.ts new file mode 100644 index 0000000..2a01390 --- /dev/null +++ b/apps/sdk-demo/src/utils/abis.ts @@ -0,0 +1,41 @@ +export const PermitAbi = [ + { + inputs: [{ internalType: 'address', name: 'owner', type: 'address' }], + name: 'nonces', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'version', + outputs: [{ internalType: 'string', name: '', type: 'string' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'name', + outputs: [{ internalType: 'string', name: '', type: 'string' }], + stateMutability: 'view', + type: 'function', + }, +]; + +export const Permit2Abi = [ + { + inputs: [ + { internalType: 'address', name: '', type: 'address' }, + { internalType: 'address', name: '', type: 'address' }, + { internalType: 'address', name: '', type: 'address' }, + ], + name: 'allowance', + outputs: [ + { internalType: 'uint160', name: 'amount', type: 'uint160' }, + { internalType: 'uint48', name: 'expiration', type: 'uint48' }, + { internalType: 'uint48', name: 'nonce', type: 'uint48' }, + ], + stateMutability: 'view', + type: 'function', + }, +] as const; \ No newline at end of file diff --git a/apps/sdk-demo/src/utils/index.ts b/apps/sdk-demo/src/utils/index.ts index 0e104a3..857cd58 100644 --- a/apps/sdk-demo/src/utils/index.ts +++ b/apps/sdk-demo/src/utils/index.ts @@ -11,3 +11,15 @@ export function getAvailableStables(chain: RoutesSupportedChainId): MyTokenConfi export function findTokenByAddress(chain: RoutesSupportedChainId, address: string): MyTokenConfig | undefined { return getAvailableStables(chain).find((token) => token.address === address) } + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const replaceBigIntsWithStrings = (obj: any): any => { + if (typeof obj === "bigint") return obj.toString(); + if (Array.isArray(obj)) + return obj.map((x) => replaceBigIntsWithStrings(x)); + if (obj && typeof obj === "object") + return Object.fromEntries( + Object.entries(obj).map(([k, v]) => [k, replaceBigIntsWithStrings(v)]), + ); + return obj; +}; diff --git a/apps/sdk-demo/src/utils/permit.ts b/apps/sdk-demo/src/utils/permit.ts new file mode 100644 index 0000000..89acb75 --- /dev/null +++ b/apps/sdk-demo/src/utils/permit.ts @@ -0,0 +1,144 @@ +import { Hex } from "viem"; +import { signTypedData } from "@wagmi/core"; +import { config } from "../wagmi"; +import { Permit2DataDetails } from "@eco-foundation/routes-sdk"; +import { stableAddresses } from "@eco-foundation/routes-sdk"; + +// Global permit2 address (same across all chains) +export const PERMIT2_ADDRESS: Hex = "0x000000000022D473030F116dDEE9F6B43aC78BA3"; + +/** + * Checks if a token is USDC + * @param address Token address + * @returns Boolean indicating if token is USDC + */ +export function isUSDC(address: Hex): boolean { + // Check against known USDC addresses by lowercase comparison + const usdcAddresses = Object.values(stableAddresses) + .map(tokens => tokens.USDC) + .filter(Boolean) + .map(addr => addr?.toLowerCase()); + + return usdcAddresses.includes(address.toLowerCase()); +} + +/** + * Signs a permit for a given ERC-2612 ERC20 token using wagmi's signTypedData + */ +export type SignPermitProps = { + /** Address of the token to approve */ + contractAddress: Hex + /** Name of the token to approve. + * Corresponds to the `name` method on the ERC-20 contract. Please note this must match exactly byte-for-byte */ + erc20Name: string + /** Owner of the tokens. Usually the currently connected address. */ + ownerAddress: Hex + /** Address to grant allowance to */ + spenderAddress: Hex + /** Expiration of this approval, in SECONDS */ + deadline: bigint + /** Numerical chainId of the token contract */ + chainId: number + /** Defaults to 1. Some tokens need a different version, check the [PERMIT INFORMATION](https://github.com/vacekj/wagmi-permit/blob/main/PERMIT.md) for more information */ + permitVersion?: string + /** Permit nonce for the specific address and token contract. You can get the nonce from the `nonces` method on the token contract. */ + nonce: bigint + /** Amount to approve */ + value: bigint +} + +export async function signPermit({ + contractAddress, + erc20Name, + ownerAddress, + spenderAddress, + value, + deadline, + nonce, + chainId, + permitVersion = "1", +}: SignPermitProps): Promise { + const types = { + Permit: [ + { name: 'owner', type: 'address' }, + { name: 'spender', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'nonce', type: 'uint256' }, + { name: 'deadline', type: 'uint256' }, + ], + }; + + const domainData = { + name: erc20Name, + version: permitVersion, + chainId: chainId, + verifyingContract: contractAddress, + }; + + const message = { + owner: ownerAddress, + spender: spenderAddress, + value, + nonce, + deadline, + }; + + return signTypedData(config, { + domain: domainData, + message, + primaryType: 'Permit', + types, + }); +} + +export type SignPermit2Props = { + chainId: number + expiration: bigint + spender: Hex + details: Permit2DataDetails[] +} + +export async function signPermit2({ + chainId, + expiration, + spender, + details, +}: SignPermit2Props): Promise { + const types = { + PermitDetails: [ + { name: 'token', type: 'address' }, + { name: 'amount', type: 'uint160' }, + { name: 'expiration', type: 'uint48' }, + { name: 'nonce', type: 'uint48' }, + ], + PermitSingle: [ + { name: 'details', type: 'PermitDetails' }, + { name: 'spender', type: 'address' }, + { name: 'sigDeadline', type: 'uint256' }, + ], + PermitBatch: [ + { name: 'details', type: 'PermitDetails[]' }, + { name: 'spender', type: 'address' }, + { name: 'sigDeadline', type: 'uint256' } + ], + }; + + const domainData = { + name: "Permit2", + chainId: chainId, + verifyingContract: PERMIT2_ADDRESS, + }; + + const message = { + details: details.length > 1 ? details : details[0], + spender, + sigDeadline: expiration, + }; + + return signTypedData(config, { + domain: domainData, + message, + primaryType: details.length > 1 ? 'PermitBatch' : 'PermitSingle', + types, + }); +} diff --git a/apps/sdk-demo/src/views/demo/components/create-intent.tsx b/apps/sdk-demo/src/views/demo/components/create-intent.tsx index 5c1c45f..6cf8d7c 100644 --- a/apps/sdk-demo/src/views/demo/components/create-intent.tsx +++ b/apps/sdk-demo/src/views/demo/components/create-intent.tsx @@ -8,12 +8,16 @@ import { chains } from "../../../config"; type Props = { routesService: RoutesService, - onNewIntent: (intent: IntentType) => void + onNewIntent: (intent: IntentType) => void, + quoteType: "receive" | "spend", + setQuoteType: (type: "receive" | "spend") => void } export default function CreateIntent({ routesService, - onNewIntent + onNewIntent, + quoteType, + setQuoteType }: Props) { const { address } = useAccount(); @@ -50,14 +54,15 @@ export default function CreateIntent({ isAddress(destinationToken, { strict: false }) && isAddress(recipient, { strict: false }) && !isNaN(Number(amount)) && - Number(amount) > 0 + Number(amount) > 0 && + Number(balance) >= 0 ) { try { const intent = routesService.createSimpleIntent({ creator: address, originChainID: originChain, spendingToken: originToken, - spendingTokenLimit: balance, + spendingTokenLimit: quoteType === "receive" ? balance : BigInt(amount), destinationChainID: destinationChain, receivingToken: destinationToken, amount: BigInt(amount), @@ -76,7 +81,7 @@ export default function CreateIntent({ return () => { setIsIntentValid(false) } - }, [balance, address, originChain, originToken, destinationChain, destinationToken, amount, recipient, prover, onNewIntent, routesService]); + }, [balance, address, originChain, originToken, destinationChain, destinationToken, amount, recipient, prover, onNewIntent, routesService, quoteType]); const originTokensAvailable = useMemo(() => originChain ? getAvailableStables(originChain) : [], [originChain]); const destinationTokensAvailable = useMemo(() => destinationChain ? getAvailableStables(destinationChain) : [], [destinationChain]); @@ -159,11 +164,33 @@ export default function CreateIntent({ ))}
-
- Desired Amount + Amount setAmount(e.target.value)} /> {amount && decimals && ({formatUnits(BigInt(amount), decimals)})} +
+ + | + +
diff --git a/apps/sdk-demo/src/views/demo/components/publish-intent.tsx b/apps/sdk-demo/src/views/demo/components/publish-intent.tsx index d4c9057..b6a647a 100644 --- a/apps/sdk-demo/src/views/demo/components/publish-intent.tsx +++ b/apps/sdk-demo/src/views/demo/components/publish-intent.tsx @@ -1,115 +1,374 @@ -import { RoutesService, RoutesSupportedChainId, SolverQuote } from "@eco-foundation/routes-sdk" -import { IntentType, IntentSourceAbi, InboxAbi, EcoProtocolAddresses } from "@eco-foundation/routes-ts" -import { useCallback, useState } from "react" -import { useAccount, useSwitchChain, useWriteContract } from "wagmi" -import { getBlockNumber, waitForTransactionReceipt, watchContractEvent } from "@wagmi/core" +import { RoutesService, RoutesSupportedChainId, SolverQuote, IntentExecutionType, OpenQuotingClient, Permit1, Permit2, Permit2DataDetails } from "@eco-foundation/routes-sdk" +import { IntentSourceAbi, InboxAbi, EcoProtocolAddresses } from "@eco-foundation/routes-ts" +import { useCallback, useState, useMemo } from "react" +import { useAccount, useSwitchChain, useWriteContract, useReadContract, usePublicClient } from "wagmi" +import { getBlockNumber, waitForTransactionReceipt, watchContractEvent, readContract } from "@wagmi/core" import { erc20Abi, Hex, parseEventLogs } from "viem" import { config } from "../../../wagmi" import { chains } from "../../../config" +import { PermitAbi, Permit2Abi } from "../../../utils/abis" +import { isUSDC, signPermit, signPermit2, PERMIT2_ADDRESS } from "../../../utils/permit" type Props = { routesService: RoutesService, - intent: IntentType | undefined, quotes: SolverQuote[] | undefined, - quote: SolverQuote | undefined + quote: SolverQuote | undefined, + openQuotingClient: OpenQuotingClient } -export default function PublishIntent({ routesService, intent, quotes, quote }: Props) { - const { chainId } = useAccount(); +export default function PublishIntent({ routesService, quotes, quote, openQuotingClient }: Props) { + const { address, chainId } = useAccount(); const { switchChain } = useSwitchChain(); + const publicClient = usePublicClient(); - const { writeContractAsync } = useWriteContract() - const [isPublishing, setIsPublishing] = useState(false) - const [isPublished, setIsPublished] = useState(false) + const { writeContractAsync } = useWriteContract(); + const [isPublishing, setIsPublishing] = useState(false); + const [isPublished, setIsPublished] = useState(false); + const [selectedExecutionType, setSelectedExecutionType] = useState("SELF_PUBLISH"); - const [approvalTxHashes, setApprovalTxHashes] = useState([]) - const [publishTxHash, setPublishTxHash] = useState() - const [fulfillmentTxHash, setFulfillmentTxHash] = useState() + const [approvalTxHashes, setApprovalTxHashes] = useState([]); + const [publishTxHash, setPublishTxHash] = useState(); + const [fulfillmentTxHash, setFulfillmentTxHash] = useState(); + + const handleExecutionTypeChange = (event: React.ChangeEvent) => { + setSelectedExecutionType(event.target.value as IntentExecutionType); + }; + + // TODO: use getVaultAddress asynchronously because it's not needed for self publish, only gasless + // TODO: fix self publish and gasless code snippets to actually look like the actual code not some pseduocode + + // Get the selected quote entry and its intentData + const selectedQuoteEntry = useMemo(() => { + if (!quote) return null; + return quote.quoteData.quoteEntries.find(entry => entry.intentExecutionType === selectedExecutionType); + }, [quote, selectedExecutionType]); + + // Extract source chain ID from intentData in the selected quote entry + const sourceChainID = useMemo(() => { + if (!selectedQuoteEntry) return undefined; + return Number(selectedQuoteEntry.intentData.route.source); + }, [selectedQuoteEntry]); + + // Get vault address for gasless execution + const { data: vaultAddress } = useReadContract({ + chainId: sourceChainID, + abi: IntentSourceAbi, + address: sourceChainID ? EcoProtocolAddresses[routesService.getEcoChainId(sourceChainID as RoutesSupportedChainId)].IntentSource : undefined, + functionName: 'intentVaultAddress', + query: { enabled: Boolean(sourceChainID && quote && selectedExecutionType === "GASLESS") } + }); + + // Extract available execution types and corresponding quote entries + const availableExecutionTypes = useMemo(() => { + if (!quote) return []; + return quote.quoteData.quoteEntries.map(entry => entry.intentExecutionType); + }, [quote]); + + const waitForFulfillment = async (intentHash: Hex, destinationChainId: number, inbox: Hex) => { + const blockNumber = await getBlockNumber(config, { chainId: destinationChainId as RoutesSupportedChainId }); + + return new Promise((resolve, reject) => { + const unwatch = watchContractEvent(config, { + fromBlock: blockNumber - BigInt(10), + chainId: destinationChainId as RoutesSupportedChainId, + abi: InboxAbi, + eventName: 'Fulfillment', + address: inbox, + args: { + _hash: intentHash + }, + onLogs(logs) { + if (logs && logs.length > 0) { + const fulfillmentTxHash = logs[0]!.transactionHash; + unwatch(); + resolve(fulfillmentTxHash); + } + }, + onError(error) { + unwatch(); + reject(error); + } + }); + }); + }; + + // Extract destination chain ID and inbox from intentData in the selected quote entry + const destinationChainID = useMemo(() => { + if (!selectedQuoteEntry) return undefined; + return Number(selectedQuoteEntry.intentData.route.destination); + }, [selectedQuoteEntry]); + + const inboxAddress = useMemo(() => { + if (!selectedQuoteEntry) return undefined; + return selectedQuoteEntry.intentData.route.inbox as Hex; + }, [selectedQuoteEntry]); const publishIntent = useCallback(async () => { - if (!intent || !quote) return + if (!quote || !address || !selectedQuoteEntry || !publicClient || !sourceChainID) return; try { - const quotedIntent = routesService.applyQuoteToIntent({ intent, quote }) + setIsPublishing(true); - console.log("Quoted Intent:", quotedIntent) + // Get the intentData from the selected quote entry + const intentData = selectedQuoteEntry.intentData; - setIsPublishing(true) + // Get the intentSource contract address + const intentSourceContract = EcoProtocolAddresses[routesService.getEcoChainId(sourceChainID as RoutesSupportedChainId)].IntentSource; - const intentSourceContract = EcoProtocolAddresses[routesService.getEcoChainId(Number(quotedIntent.route.source) as RoutesSupportedChainId)].IntentSource + let intentHash: Hex | undefined; - // approve the amount for the intent source contract, then publish the intent + // Handle based on execution type + if (selectedExecutionType === "SELF_PUBLISH") { + // Approve tokens for the IntentSource contract + const approveTxHashes = await Promise.all(intentData.reward.tokens.map((rewardToken) => writeContractAsync({ + chainId: sourceChainID, + abi: erc20Abi, + functionName: 'approve', + address: rewardToken.token, + args: [intentSourceContract, rewardToken.amount] + }))); - const approveTxHashes = await Promise.all(quotedIntent.reward.tokens.map((rewardToken) => writeContractAsync({ - chainId: Number(quotedIntent.route.source), - abi: erc20Abi, - functionName: 'approve', - address: rewardToken.token, - args: [intentSourceContract, rewardToken.amount] - }))) - await Promise.all(approveTxHashes.map((txHash) => waitForTransactionReceipt(config, { hash: txHash }))) + await Promise.all(approveTxHashes.map((txHash) => waitForTransactionReceipt(config, { hash: txHash }))); + setApprovalTxHashes(approveTxHashes); - setApprovalTxHashes(approveTxHashes) + // Publish and fund the intent + const publishTxHash = await writeContractAsync({ + chainId: sourceChainID, + abi: IntentSourceAbi, + functionName: 'publishAndFund', + address: intentSourceContract, + args: [intentData, false] + }); - const publishTxHash = await writeContractAsync({ - chainId: Number(quotedIntent.route.source), - abi: IntentSourceAbi, - functionName: 'publishAndFund', - address: intentSourceContract, - args: [quotedIntent, false] - }) + const receipt = await waitForTransactionReceipt(config, { hash: publishTxHash }); + const logs = parseEventLogs({ + abi: IntentSourceAbi, + logs: receipt.logs + }); - const receipt = await waitForTransactionReceipt(config, { hash: publishTxHash }) - const logs = parseEventLogs({ - abi: IntentSourceAbi, - logs: receipt.logs - }) - const intentCreatedEvent = logs.find((log) => log.eventName === 'IntentCreated') + const intentCreatedEvent = logs.find((log) => log.eventName === 'IntentCreated'); + if (!intentCreatedEvent) { + throw new Error('IntentCreated event not found in logs'); + } - if (!intentCreatedEvent) { - throw new Error('IntentCreated event not found in logs') + intentHash = intentCreatedEvent.args.hash as Hex; + setPublishTxHash(publishTxHash); } + else if (selectedExecutionType === "GASLESS") { + // Check if we have the vault address + if (!vaultAddress) { + throw new Error("Vault address not available"); + } - setPublishTxHash(publishTxHash) - - const blockNumber = await getBlockNumber(config, { chainId: Number(quotedIntent.route.destination) as RoutesSupportedChainId }) - - const fulfillmentTxHash = await new Promise((resolve, reject) => { - const unwatch = watchContractEvent(config, { - fromBlock: blockNumber - BigInt(10), - chainId: Number(quotedIntent.route.destination) as RoutesSupportedChainId, - abi: InboxAbi, - eventName: 'Fulfillment', - address: quotedIntent.route.inbox, - args: { - _hash: intentCreatedEvent.args.hash - }, - onLogs(logs) { - if (logs && logs.length > 0) { - const fulfillmentTxHash = logs[0]!.transactionHash - unwatch() - resolve(fulfillmentTxHash) + // Process for gasless execution with permit or permit2 + const deadline = BigInt(Math.round(new Date(Date.now() + 60 * 1000).getTime() / 1000)); // 30 minutes from now in UNIX seconds + let permitData: Permit1 | Permit2 | undefined; + const approveTxHashes: Hex[] = []; + + // Group tokens by whether they support permit or need permit2 + const tokens = intentData.reward.tokens; + const usdcTokens = tokens.filter(token => isUSDC(token.token)); + const nonUsdcTokens = tokens.filter(token => !isUSDC(token.token)); + + // Process USDC tokens with Permit (EIP-2612) + if (usdcTokens.length > 0) { + const permitSignatures = await Promise.all(usdcTokens.map(async ({ token, amount }) => { + // Fetch token details for permit signing + const tokenContract = { + address: token, + abi: PermitAbi, + } as const; + + const [nonceResult, versionResult, nameResult] = await Promise.all([ + readContract(config, { + ...tokenContract, + functionName: 'nonces', + args: [address], + chainId: sourceChainID as RoutesSupportedChainId, + }) as Promise, + readContract(config, { + ...tokenContract, + functionName: 'version', + chainId: sourceChainID as RoutesSupportedChainId, + }) as Promise, + readContract(config, { + ...tokenContract, + functionName: 'name', + chainId: sourceChainID as RoutesSupportedChainId, + }) as Promise, + ]); + + // Sign the permit + const signature = await signPermit({ + contractAddress: token, + erc20Name: nameResult, + ownerAddress: address, + spenderAddress: vaultAddress as Hex, + value: BigInt(amount), + deadline, + nonce: nonceResult || BigInt(0), + chainId: sourceChainID, + permitVersion: versionResult, + }); + + return { + token, + data: { + signature, + deadline, + } + }; + })); + + // Create Permit1 data + permitData = { + permit: permitSignatures + } as Permit1; + } + + // Process non-USDC tokens with Permit2 + if (nonUsdcTokens.length > 0) { + // Approve tokens for the Permit2 contract + for (const { token, amount } of nonUsdcTokens) { + // Check current allowance + const currentAllowance = await readContract(config, { + chainId: sourceChainID as RoutesSupportedChainId, + abi: erc20Abi, + functionName: 'allowance', + address: token, + args: [address, PERMIT2_ADDRESS] + }) as bigint; + + // Only approve if current allowance is less than the required amount + if (currentAllowance < BigInt(amount)) { + // Approve max uint256 value + const maxUint256 = BigInt('0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'); + const approvalTxHash = await writeContractAsync({ + chainId: sourceChainID, + abi: erc20Abi, + functionName: 'approve', + address: token, + args: [PERMIT2_ADDRESS, maxUint256] + }); + + await waitForTransactionReceipt(config, { hash: approvalTxHash }); + approveTxHashes.push(approvalTxHash); } - }, - onError(error) { - unwatch() - reject(error) } - }) - }) - setFulfillmentTxHash(fulfillmentTxHash) - setIsPublished(true) + // Get current nonces from Permit2 contract + const details: Permit2DataDetails[] = await Promise.all(nonUsdcTokens.map(async ({ token, amount }) => { + const currentAllowance = await readContract(config, { + abi: Permit2Abi, + address: PERMIT2_ADDRESS, + functionName: "allowance", + args: [address, token, vaultAddress as Hex], + chainId: sourceChainID as RoutesSupportedChainId, + }); + + const currentNonce = BigInt(currentAllowance[2]); + + return { + token, + amount: BigInt(amount), + expiration: deadline, + nonce: currentNonce, + }; + })); + + // Sign the permit2 data + const signature = await signPermit2({ + chainId: sourceChainID, + expiration: deadline, + spender: vaultAddress as Hex, + details, + }); + + // Create Permit2 data + permitData = { + permit2: { + permitContract: PERMIT2_ADDRESS, + permitData: details.length > 1 + ? { + batchPermitData: { + typedData: { + details, + spender: vaultAddress as Hex, + sigDeadline: deadline, + } + } + } + : { + singlePermitData: { + typedData: { + details: details[0]!, + spender: vaultAddress as Hex, + sigDeadline: deadline, + } + } + }, + signature, + } + } as Permit2; + } + + // Update approval transaction hashes + if (approveTxHashes.length > 0) { + setApprovalTxHashes(approveTxHashes); + } + + // Initiate the gasless intent with or without permit data + const gaslessResponse = await openQuotingClient.initiateGaslessIntent({ + funder: address, + intent: intentData, + quoteID: quote.quoteID, + solverID: quote.solverID, + vaultAddress: vaultAddress as Hex, + permitData, + }); + + setPublishTxHash(gaslessResponse.transactionHash as Hex); + + // Wait for transaction receipt to get the IntentFunded event + const receipt = await waitForTransactionReceipt(config, { hash: gaslessResponse.transactionHash as Hex }); + + // Parse logs to find the IntentFunded event and get the intent hash + const logs = parseEventLogs({ + abi: IntentSourceAbi, + logs: receipt.logs + }); + + const intentFundedEvent = logs.find((log) => log.eventName === 'IntentFunded'); + if (!intentFundedEvent) { + throw new Error('IntentFunded event not found in logs'); + } + + intentHash = intentFundedEvent.args.intentHash as Hex; + } else { + throw new Error(`Execution type ${selectedExecutionType} not supported`); + } + + // Wait for fulfillment on destination chain + if (intentHash && destinationChainID && inboxAddress) { + const fulfillmentTxHash = await waitForFulfillment( + intentHash, + destinationChainID, + inboxAddress + ); + + setFulfillmentTxHash(fulfillmentTxHash); + setIsPublished(true); + } } catch (error) { - alert('Could not publish intent: ' + (error as Error).message) - console.error(error) + alert('Could not publish intent: ' + (error as Error).message); + console.error(error); } finally { - setIsPublishing(false) + setIsPublishing(false); } - }, [intent, quote, writeContractAsync, routesService]) + }, [quote, writeContractAsync, routesService, selectedExecutionType, selectedQuoteEntry, sourceChainID, destinationChainID, inboxAddress, vaultAddress, address, publicClient, openQuotingClient]); - if (!intent || !quote) return null + if (!quote) return null; return (
@@ -132,10 +391,11 @@ export default function PublishIntent({ routesService, intent, quotes, quote }: {publishTxHash ? (
- Intent Published: + Intent {selectedExecutionType === "GASLESS" ? "Initiated" : "Published"}: {publishTxHash}
- ) : Publishing..} + ) : {selectedExecutionType === "GASLESS" ? "Initiating" : "Publishing"}..} + {fulfillmentTxHash ? (
Intent fulfilled: @@ -145,25 +405,80 @@ export default function PublishIntent({ routesService, intent, quotes, quote }: {isPublished && (
- Intent published and fulfilled! + Intent {selectedExecutionType === "GASLESS" ? "initiated" : "published"} and fulfilled!
)}
) : (<> - {chainId !== Number(intent.route.source) ? - : ( - - )} + {sourceChainID && chainId !== sourceChainID ? ( + + ) : ( +
+
+ + +
+ + +
+ )} )}
-
-            {`${quotes && quote ? `const selectedQuote = quotes[${quotes.indexOf(quote)}];
-const quotedIntent = routesService.applyQuoteToIntent({ intent, quote: selectedQuote });
-              ` : undefined}`}
+          
+            {`${quotes && quote ? `// ${selectedExecutionType} execution
+${selectedExecutionType === "GASLESS"
+                ? `const vaultAddress = await contract.intentVaultAddress();
+const openQuotingClient = routesService.getOpenQuotingClient();
+const selectedQuote = quotes[${quotes.indexOf(quote)}];
+const quoteEntry = selectedQuote.quoteData.quoteEntries.find(
+  entry => entry.intentExecutionType === "GASLESS"
+);
+const intentData = quoteEntry?.intentData;
+
+const result = await openQuotingClient.initiateGaslessIntent({
+  funder: "${address}",
+  intent: intentData,
+  quoteID: "${quote.quoteID}",
+  solverID: "${quote.solverID}",
+  vaultAddress
+});`
+                : `const selectedQuote = quotes[${quotes.indexOf(quote)}];
+const quoteEntry = selectedQuote.quoteData.quoteEntries.find(
+  entry => entry.intentExecutionType === "SELF_PUBLISH"
+);
+const intentData = quoteEntry?.intentData;
+
+// Publish and fund using the intentData from the selected quote
+const publishTxHash = await writeContractAsync({
+  chainId: Number(intentData.route.source),
+  abi: IntentSourceAbi,
+  functionName: 'publishAndFund',
+  address: intentSourceContract,
+  args: [intentData, false]
+});`}` : undefined}`}
           
diff --git a/apps/sdk-demo/src/views/demo/components/select-quote.tsx b/apps/sdk-demo/src/views/demo/components/select-quote.tsx index 22bc323..edcc39d 100644 --- a/apps/sdk-demo/src/views/demo/components/select-quote.tsx +++ b/apps/sdk-demo/src/views/demo/components/select-quote.tsx @@ -1,15 +1,16 @@ import { RoutesSupportedChainId, SolverQuote } from "@eco-foundation/routes-sdk" import { IntentType } from "@eco-foundation/routes-ts" import { formatUnits } from "viem" -import { findTokenByAddress } from "../../../utils" +import { findTokenByAddress, replaceBigIntsWithStrings } from "../../../utils" type Props = { intent: IntentType | undefined, quotes: SolverQuote[] | undefined, - onQuoteSelected: (quote: SolverQuote) => void + onQuoteSelected: (quote: SolverQuote) => void, + quoteType: "receive" | "spend" } -export default function SelectQuote({ intent, quotes, onQuoteSelected }: Props) { +export default function SelectQuote({ intent, quotes, onQuoteSelected, quoteType }: Props) { if (!intent || !quotes) return null return ( @@ -18,29 +19,70 @@ export default function SelectQuote({ intent, quotes, onQuoteSelected }: Props)
{quotes.map((quote, index) => ( -
- Amounts requested by solver on the origin chain: -
    - {quote.quoteData.tokens.map((token) => ( -
  • {formatUnits(BigInt(token.amount), 6)} {findTokenByAddress(Number(intent.route.source) as RoutesSupportedChainId, token.token)?.id}
  • - ))} -
- Quote expires at: {new Date(Number(quote.quoteData.expiryTime) * 1000).toISOString()} - +
+
+
Quote ID: {quote.quoteID}
+
Solver ID: {quote.solverID}
+
+ + {quote.quoteData.quoteEntries.map((entry, entryIndex) => ( +
+

Quote Entry {entryIndex + 1}

+ +
+ Execution Type: {entry.intentExecutionType} +
+ + {quoteType === "receive" ? ( +
+
Origin Chain Tokens (Requested):
+
    + {entry.intentData.reward.tokens.map((token, tokenIndex) => ( +
  • + {formatUnits(token.amount, 6)} {findTokenByAddress(Number(intent.route.source) as RoutesSupportedChainId, token.token)?.id} +
  • + ))} +
+
+ ) : ( +
+
Destination Chain Tokens (Determined):
+
    + {entry.intentData.route.tokens.map((token, tokenIndex) => ( +
  • + {formatUnits(BigInt(token.amount), 6)} {findTokenByAddress(Number(intent.route.destination) as RoutesSupportedChainId, token.token)?.id} +
  • + ))} +
+
+ )} + +
+ Expires: {new Date(Number(entry.expiryTime) * 1000).toLocaleString()} +
+
+ ))} + +
))}
-
+          
             {
-              `const quotes = await openQuotingClient.requestQuotesForIntent(intent);
+              `const quotes = await openQuotingClient.${quoteType === 'receive' ? 'requestQuotesForIntent' : 'requestReverseQuotesForIntent'}(intent);
           
 console.log(quotes);
-${JSON.stringify(quotes, null, 2)}`}
+${JSON.stringify(replaceBigIntsWithStrings(quotes), null, 2)}
+`}
           
-
) } \ No newline at end of file diff --git a/apps/sdk-demo/src/views/demo/demo-view.tsx b/apps/sdk-demo/src/views/demo/demo-view.tsx index 6de9060..d14bcc5 100644 --- a/apps/sdk-demo/src/views/demo/demo-view.tsx +++ b/apps/sdk-demo/src/views/demo/demo-view.tsx @@ -17,28 +17,40 @@ export default function DemoView() { const [intent, setIntent] = useState(); const [quotes, setQuotes] = useState(); const [selectedQuote, setSelectedQuote] = useState(); + const [quoteType, setQuoteType] = useState<"receive" | "spend">("receive"); useEffect(() => { if (intent) { - openQuotingClient.requestQuotesForIntent({ intent }).then((quotes) => { - setQuotes(quotes) - }).catch((error) => { - alert('Could not fetch quotes: ' + error.message) - console.error(error) - }) + const fetchQuotes = async () => { + try { + let quotesResult: SolverQuote[]; + if (quoteType === "receive") { + quotesResult = await openQuotingClient.requestQuotesForIntent({ intent }); + } else { + quotesResult = await openQuotingClient.requestReverseQuotesForIntent({ intent }); + } + setQuotes(quotesResult); + } catch (error) { + alert('Could not fetch quotes: ' + (error as Error).message); + console.error(error); + } + }; + + fetchQuotes(); } + return () => { - setSelectedQuote(undefined) - setQuotes(undefined) + setSelectedQuote(undefined); + setQuotes(undefined); } - }, [intent, openQuotingClient]); + }, [intent, quoteType, openQuotingClient]); return (
- - - + + +
); } \ No newline at end of file From 53bdd8ef0f94fb4a3e5ac3cc31f3bc2cc5094d8a Mon Sep 17 00:00:00 2001 From: Dirk Page Date: Wed, 23 Apr 2025 11:40:32 -0500 Subject: [PATCH 14/22] Updating SDK to v1 --- package-lock.json | 2 +- packages/sdk/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1f2e6d1..94282fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11959,7 +11959,7 @@ }, "packages/sdk": { "name": "@eco-foundation/routes-sdk", - "version": "0.12.1", + "version": "1.0.0", "license": "MIT", "dependencies": { "axios": "^1.7.9", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index f288bd8..f662d7e 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@eco-foundation/routes-sdk", - "version": "0.12.1", + "version": "1.0.0", "description": "Eco Routes SDK", "main": "./dist/index.cjs", "module": "./dist/index.js", From fbba8a12510402423ab7bb1b3bf05ad452cf5b5a Mon Sep 17 00:00:00 2001 From: Dirk Page Date: Wed, 23 Apr 2025 11:43:48 -0500 Subject: [PATCH 15/22] Publishing alpha for testing --- package-lock.json | 2 +- packages/sdk/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index ea0e044..ed24bac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11960,7 +11960,7 @@ }, "packages/sdk": { "name": "@eco-foundation/routes-sdk", - "version": "1.0.0", + "version": "1.0.0-alpha.0", "license": "MIT", "dependencies": { "axios": "^1.7.9", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 9478286..ba7b022 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@eco-foundation/routes-sdk", - "version": "1.0.0", + "version": "1.0.0-alpha.0", "description": "Eco Routes SDK", "main": "./dist/index.cjs", "module": "./dist/index.js", From 362b79b2782b52330f0bedc91152d9cd062e3e4e Mon Sep 17 00:00:00 2001 From: Dirk Page Date: Wed, 23 Apr 2025 14:32:09 -0500 Subject: [PATCH 16/22] Added publish-beta and publish-alpha scripts --- package.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 291e1af..7a7d63a 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,9 @@ "format": "prettier --write \"**/*.{ts,tsx,md}\"", "changeset": "changeset", "update-versions": "changeset version", - "publish-packages": "turbo run build lint check-types && changeset version && changeset publish && git push --follow-tags" + "publish-packages": "turbo run build lint check-types && changeset version && changeset publish && git push --follow-tags", + "publish-beta": "turbo run build lint check-types && changeset version && changeset publish --tag beta", + "publish-alpha": "turbo run build lint check-types && changeset version && changeset publish --tag alpha" }, "devDependencies": { "@changesets/cli": "^2.27.11", @@ -28,4 +30,4 @@ "apps/*", "packages/*" ] -} +} \ No newline at end of file From 733572f5c2c68d175057319c03f921483d5ea487 Mon Sep 17 00:00:00 2001 From: Dirk Page Date: Mon, 28 Apr 2025 15:08:27 -0500 Subject: [PATCH 17/22] Exporting quote selector result from SDK --- packages/sdk/src/index.ts | 2 +- packages/sdk/src/quotes/OpenQuotingClient.ts | 2 +- .../sdk/src/quotes/quoteSelectors/index.ts | 22 +++++------- packages/sdk/src/quotes/types.ts | 6 ++++ .../test/e2e/initiateGaslessIntent.test.ts | 34 +++++++++---------- packages/sdk/test/e2e/publishAndFund.test.ts | 12 +++---- 6 files changed, 39 insertions(+), 39 deletions(-) diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index f716321..f9396f0 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -5,5 +5,5 @@ export { RoutesService } from "./routes/RoutesService"; export type { CreateSimpleIntentParams, CreateIntentParams } from "./routes/types"; export { OpenQuotingClient } from "./quotes/OpenQuotingClient"; -export type { RequestQuotesForIntentParams, InitiateGaslessIntentParams, PermitData, Permit1, Permit2, SinglePermit2Data, BatchPermit2Data, Permit2DataDetails, SolverQuote, QuoteData, InitiateGaslessIntentResponse } from "./quotes/types"; +export type { RequestQuotesForIntentParams, InitiateGaslessIntentParams, SolverQuote, QuoteData, QuoteSelectorResult, PermitData, Permit1, Permit2, SinglePermit2Data, BatchPermit2Data, Permit2DataDetails, InitiateGaslessIntentResponse } from "./quotes/types"; export { selectCheapestQuote } from "./quotes/quoteSelectors"; diff --git a/packages/sdk/src/quotes/OpenQuotingClient.ts b/packages/sdk/src/quotes/OpenQuotingClient.ts index 77257b0..1e17dc2 100644 --- a/packages/sdk/src/quotes/OpenQuotingClient.ts +++ b/packages/sdk/src/quotes/OpenQuotingClient.ts @@ -31,7 +31,7 @@ export class OpenQuotingClient { * This method sends a POST request to the `/api/v1/quotes` endpoint with the provided intent information. * The intentData returned in each quote will have the fee added to the reward tokens. */ - async requestQuotesForIntent({ intent, intentExecutionTypes = ['SELF_PUBLISH', 'GASLESS'] }: RequestQuotesForIntentParams): Promise { + async requestQuotesForIntent({ intent, intentExecutionTypes = ['SELF_PUBLISH'] }: RequestQuotesForIntentParams): Promise { if (intentExecutionTypes.length === 0) { throw new Error("intentExecutionTypes must not be empty"); } diff --git a/packages/sdk/src/quotes/quoteSelectors/index.ts b/packages/sdk/src/quotes/quoteSelectors/index.ts index 34d63fc..296f674 100644 --- a/packages/sdk/src/quotes/quoteSelectors/index.ts +++ b/packages/sdk/src/quotes/quoteSelectors/index.ts @@ -1,15 +1,9 @@ import { IntentExecutionType } from "../../constants"; import { sum } from "../../utils"; -import { QuoteData, SolverQuote } from "../types"; - -type QuoteSelectorResult = { - quoteID: string; - solverID: string; - quoteData: QuoteData; -} +import { QuoteSelectorResult, SolverQuote } from "../types"; export function selectCheapestQuote(solverQuotes: SolverQuote[], isReverse: boolean = false, allowedIntentExecutionTypes: IntentExecutionType[] = ["SELF_PUBLISH"]): QuoteSelectorResult { - return solverQuotes.reduce(({ solverID: cheapestSolverID, quoteID: cheapestQuoteID, quoteData: cheapestQuoteData }, solverQuoteResponse) => { + return solverQuotes.reduce(({ solverID: cheapestSolverID, quoteID: cheapestQuoteID, quote: cheapestQuoteData }, solverQuoteResponse) => { const quotes = solverQuoteResponse.quoteData.quoteEntries; let localCheapestQuoteData = cheapestQuoteData; let localCheapestSolverID = cheapestSolverID; @@ -53,7 +47,7 @@ export function selectCheapestQuote(solverQuotes: SolverQuote[], isReverse: bool return { solverID: localCheapestSolverID, quoteID: localCheapestQuoteID, - quoteData: localCheapestQuoteData + quote: localCheapestQuoteData }; } @@ -67,11 +61,11 @@ export function selectCheapestQuote(solverQuotes: SolverQuote[], isReverse: bool return localSum > globalSum ? { solverID: localCheapestSolverID, quoteID: localCheapestQuoteID, - quoteData: localCheapestQuoteData + quote: localCheapestQuoteData } : { solverID: cheapestSolverID, quoteID: cheapestQuoteID, - quoteData: cheapestQuoteData + quote: cheapestQuoteData }; } else { const globalTokens = cheapestQuoteData.intentData.reward.tokens; @@ -83,12 +77,12 @@ export function selectCheapestQuote(solverQuotes: SolverQuote[], isReverse: bool return localSum < globalSum ? { solverID: localCheapestSolverID, quoteID: localCheapestQuoteID, - quoteData: localCheapestQuoteData + quote: localCheapestQuoteData } : { solverID: cheapestSolverID, quoteID: cheapestQuoteID, - quoteData: cheapestQuoteData + quote: cheapestQuoteData }; } }, {} as QuoteSelectorResult); -} \ No newline at end of file +} diff --git a/packages/sdk/src/quotes/types.ts b/packages/sdk/src/quotes/types.ts index c9f5de7..4a6890b 100644 --- a/packages/sdk/src/quotes/types.ts +++ b/packages/sdk/src/quotes/types.ts @@ -156,6 +156,12 @@ export type QuoteData = { expiryTime: bigint // seconds since epoch } +export type QuoteSelectorResult = { + quoteID: string; + solverID: string; + quote: QuoteData; +} + export type PermitData = Permit1 | Permit2 export type Permit1 = { diff --git a/packages/sdk/test/e2e/initiateGaslessIntent.test.ts b/packages/sdk/test/e2e/initiateGaslessIntent.test.ts index e249c74..50b406d 100644 --- a/packages/sdk/test/e2e/initiateGaslessIntent.test.ts +++ b/packages/sdk/test/e2e/initiateGaslessIntent.test.ts @@ -53,7 +53,7 @@ describe("initiateGaslessIntent", () => { }) const quotes = await openQuotingClient.requestQuotesForIntent({ intent, intentExecutionTypes: ["GASLESS"] }) - const { quoteID, solverID, quoteData } = selectCheapestQuote(quotes, false, ["GASLESS"]); + const { quoteID, solverID, quote } = selectCheapestQuote(quotes, false, ["GASLESS"]); const intentSource = EcoProtocolAddresses[routesService.getEcoChainId(originChain.id)].IntentSource // initiate gasless intent @@ -61,11 +61,11 @@ describe("initiateGaslessIntent", () => { abi: IntentSourceAbi, address: intentSource, functionName: "intentVaultAddress", - args: [quoteData.intentData] + args: [quote.intentData] }) // approve - await Promise.all(quoteData.intentData.reward.tokens.map(async ({ token, amount }) => { + await Promise.all(quote.intentData.reward.tokens.map(async ({ token, amount }) => { const hash = await baseWalletClient.writeContract({ abi: erc20Abi, address: token, @@ -81,7 +81,7 @@ describe("initiateGaslessIntent", () => { // initiate gasless intent const response = await openQuotingClient.initiateGaslessIntent({ funder: account.address, - intent: quoteData.intentData, + intent: quote.intentData, solverID, quoteID, vaultAddress, @@ -104,7 +104,7 @@ describe("initiateGaslessIntent", () => { }) const quotes = await openQuotingClient.requestReverseQuotesForIntent({ intent, intentExecutionTypes: ["GASLESS"] }) - const { quoteID, solverID, quoteData } = selectCheapestQuote(quotes, true, ["GASLESS"]); + const { quoteID, solverID, quote } = selectCheapestQuote(quotes, true, ["GASLESS"]); const intentSource = EcoProtocolAddresses[routesService.getEcoChainId(originChain.id)].IntentSource // initiate gasless intent @@ -112,11 +112,11 @@ describe("initiateGaslessIntent", () => { abi: IntentSourceAbi, address: intentSource, functionName: "intentVaultAddress", - args: [quoteData.intentData] + args: [quote.intentData] }) // approve - await Promise.all(quoteData.intentData.reward.tokens.map(async ({ token, amount }) => { + await Promise.all(quote.intentData.reward.tokens.map(async ({ token, amount }) => { const hash = await baseWalletClient.writeContract({ abi: erc20Abi, address: token, @@ -132,7 +132,7 @@ describe("initiateGaslessIntent", () => { // initiate gasless intent const response = await openQuotingClient.initiateGaslessIntent({ funder: account.address, - intent: quoteData.intentData, + intent: quote.intentData, solverID, quoteID, vaultAddress, @@ -155,7 +155,7 @@ describe("initiateGaslessIntent", () => { }) const quotes = await openQuotingClient.requestQuotesForIntent({ intent, intentExecutionTypes: ["GASLESS"] }) - const { quoteID, solverID, quoteData } = selectCheapestQuote(quotes, true, ["GASLESS"]); + const { quoteID, solverID, quote } = selectCheapestQuote(quotes, true, ["GASLESS"]); const intentSource = EcoProtocolAddresses[routesService.getEcoChainId(originChain.id)].IntentSource // initiate gasless intent @@ -163,7 +163,7 @@ describe("initiateGaslessIntent", () => { abi: IntentSourceAbi, address: intentSource, functionName: "intentVaultAddress", - args: [quoteData.intentData] + args: [quote.intentData] }) // sign approval using USDC permit @@ -171,7 +171,7 @@ describe("initiateGaslessIntent", () => { // for each reward token, generate a permit signature const permitData: Permit1 = { - permit: await Promise.all(quoteData.intentData.reward.tokens.map(async ({ token, amount }) => { + permit: await Promise.all(quote.intentData.reward.tokens.map(async ({ token, amount }) => { const tokenContract = { address: token, abi: PermitAbi, @@ -226,7 +226,7 @@ describe("initiateGaslessIntent", () => { // now initiate gaslessly with all the data const response = await openQuotingClient.initiateGaslessIntent({ funder: account.address, - intent: quoteData.intentData, + intent: quote.intentData, solverID, quoteID, vaultAddress, @@ -250,7 +250,7 @@ describe("initiateGaslessIntent", () => { }) const quotes = await openQuotingClient.requestQuotesForIntent({ intent, intentExecutionTypes: ["GASLESS"] }) - const { quoteID, solverID, quoteData } = selectCheapestQuote(quotes, true, ["GASLESS"]); + const { quoteID, solverID, quote } = selectCheapestQuote(quotes, true, ["GASLESS"]); const intentSource = EcoProtocolAddresses[routesService.getEcoChainId(originChain.id)].IntentSource // initiate gasless intent @@ -258,7 +258,7 @@ describe("initiateGaslessIntent", () => { abi: IntentSourceAbi, address: intentSource, functionName: "intentVaultAddress", - args: [quoteData.intentData] + args: [quote.intentData] }) // sign approval using permit2 contract @@ -266,7 +266,7 @@ describe("initiateGaslessIntent", () => { const deadline = Math.round(getSecondsFromNow(60 * 30).getTime() / 1000) // 30 minutes from now in UNIX seconds since epoch // for each reward token perform initial approval to the permit2 contract - await Promise.all(quoteData.intentData.reward.tokens.map(async ({ token, amount }) => { + await Promise.all(quote.intentData.reward.tokens.map(async ({ token, amount }) => { const approvalTxHash = await baseWalletClient.writeContract({ abi: erc20Abi, address: token, @@ -281,7 +281,7 @@ describe("initiateGaslessIntent", () => { // now create the permit2 data to pass to the initiate gasless endpoint - const details: Permit2DataDetails[] = await Promise.all(quoteData.intentData.reward.tokens.map(async ({ token, amount }) => { + const details: Permit2DataDetails[] = await Promise.all(quote.intentData.reward.tokens.map(async ({ token, amount }) => { // get nonce const currentAllowance = await publicClient.readContract({ @@ -335,7 +335,7 @@ describe("initiateGaslessIntent", () => { // now initate gaslessly const response = await openQuotingClient.initiateGaslessIntent({ funder: account.address, - intent: quoteData.intentData, + intent: quote.intentData, solverID, quoteID, vaultAddress, diff --git a/packages/sdk/test/e2e/publishAndFund.test.ts b/packages/sdk/test/e2e/publishAndFund.test.ts index aafbbda..4cac03a 100644 --- a/packages/sdk/test/e2e/publishAndFund.test.ts +++ b/packages/sdk/test/e2e/publishAndFund.test.ts @@ -51,10 +51,10 @@ describe("publishAndFund", () => { // request quotes const quotes = await openQuotingClient.requestQuotesForIntent({ intent }) - const { quoteData } = selectCheapestQuote(quotes, false, ["SELF_PUBLISH"]) + const { quote } = selectCheapestQuote(quotes, false, ["SELF_PUBLISH"]) // approve - await Promise.all(quoteData.intentData.reward.tokens.map(async ({ token, amount }) => { + await Promise.all(quote.intentData.reward.tokens.map(async ({ token, amount }) => { const hash = await baseWalletClient.writeContract({ abi: erc20Abi, address: token, @@ -71,7 +71,7 @@ describe("publishAndFund", () => { abi: IntentSourceAbi, address: intentSourceContract, functionName: 'publishAndFund', - args: [quoteData.intentData, false], + args: [quote.intentData, false], chain: originChain, account }) @@ -95,10 +95,10 @@ describe("publishAndFund", () => { // request quotes const quotes = await openQuotingClient.requestReverseQuotesForIntent({ intent }) - const { quoteData } = selectCheapestQuote(quotes, false, ["SELF_PUBLISH"]) + const { quote } = selectCheapestQuote(quotes, false, ["SELF_PUBLISH"]) // approve - await Promise.all(quoteData.intentData.reward.tokens.map(async ({ token, amount }) => { + await Promise.all(quote.intentData.reward.tokens.map(async ({ token, amount }) => { const hash = await baseWalletClient.writeContract({ abi: erc20Abi, address: token, @@ -115,7 +115,7 @@ describe("publishAndFund", () => { abi: IntentSourceAbi, address: intentSourceContract, functionName: 'publishAndFund', - args: [quoteData.intentData, false], + args: [quote.intentData, false], chain: originChain, account }) From 0609484a33ce25ef5f22c151bcec7da1482050f8 Mon Sep 17 00:00:00 2001 From: Dirk Page Date: Mon, 28 Apr 2025 15:46:43 -0500 Subject: [PATCH 18/22] Updating README quick start and adding gasless initiation walkthrough --- packages/sdk/README.md | 223 +++++++++++++++++++++++++++++++++++------ 1 file changed, 190 insertions(+), 33 deletions(-) diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 9880c46..03ebaaa 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -63,18 +63,18 @@ npm install @eco-foundation/routes-sdk@latest ### Create a simple intent To create a simple stable send intent, create an instance of the `RoutesService` and call `createSimpleIntent` with the required parameters: ``` ts -import { RoutesService } from '@eco-foundation/routes-sdk'; +import { RoutesService } from '@eco-foundation/routes-sdk' -const address = '0x1234567890123456789012345678901234567890'; -const originChainID = 10; -const spendingToken = RoutesService.getStableAddress(originChainID, 'USDC'); -const spendingTokenLimit = BigInt(10000000); // 10 USDC -const destinationChainID = 8453; -const receivingToken = RoutesService.getStableAddress(destinationChainID, 'USDC'); +const address = '0x1234567890123456789012345678901234567890' +const originChainID = 10 +const spendingToken = RoutesService.getStableAddress(originChainID, 'USDC') +const spendingTokenLimit = BigInt(10000000) // 10 USDC +const destinationChainID = 8453 +const receivingToken = RoutesService.getStableAddress(destinationChainID, 'USDC') -const amount = BigInt(1000000); // 1 USDC +const amount = BigInt(1000000) // 1 USDC -const routesService = new RoutesService(); +const routesService = new RoutesService() // create a simple stable transfer from my wallet on the origin chain to my wallet on the destination chain (bridge) const intent = routesService.createSimpleIntent({ @@ -89,26 +89,57 @@ const intent = routesService.createSimpleIntent({ }) ``` -### Request quotes for an intent and select a quote (recommended) +### Request quotes for an intent and select a quote To request quotes for an intent and select the cheapest quote, use the `OpenQuotingClient` and `selectCheapestQuote` functions. -Then, you can apply the quote by calling `applyQuoteToIntent` on the `RoutesService` instance: +Each quote includes a modified intent that is adjusted to account for the fees that the solver will charge. ``` ts -import { OpenQuotingClient, selectCheapestQuote } from '@eco-foundation/routes-sdk'; +import { OpenQuotingClient, selectCheapestQuote } from '@eco-foundation/routes-sdk' -const openQuotingClient = new OpenQuotingClient({ dAppID: 'my-dapp' }); +const openQuotingClient = new OpenQuotingClient({ dAppID: 'my-dapp' }) try { - const quotes = await openQuotingClient.requestQuotesForIntent(intent); + const quotes = await openQuotingClient.requestQuotesForIntent({ intent }) // select quote - const selectedQuote = selectCheapestQuote(quotes); + const selectedQuote = selectCheapestQuote(quotes) - // apply quote to intent - const intentWithQuote = routesService.applyQuoteToIntent({ intent, quote: selectedQuote }); + const quotedIntent = selectedQuote.quote.intentData } catch (error) { - console.error('Quotes not available', error); + console.error('No quotes available for intent', error) +} +``` + +### \**NEW*\* Requesting a reverse quote + +Primarily our quoting system is designed to add fees to the source amounts. The intent is that you are asking for a certain destination operation to be done and that operation has a fixed cost. However most crypto bridges today allow users to specify a source amount and will then calculate the destination amount you will receive. Because this was such a widely used pattern we have added a new quoting option that we refer to as *reverse quoting*. + +Requesting a reverse quote is slightly different in the way you create the intent, request quotes, and select a quote. + +#### Creating an intent for a reverse quote + +When you are creating an intent for a reverse quote, your `spendingTokenLimit` is the immutable amount you want to send on the source chain, rather than the maximum amount you are willing to pay for the destination operation. Similarily, the `amount` you pass in is the maximum destination amount you want to receive on the destination chain, which will be reduced based on any fees applied. + +#### Requesting quotes for a reverse quote + +If the source amount you provide results in a destination amount that is 0 or less, your request for a quote will throw an error. + +``` ts +import { OpenQuotingClient, selectCheapestQuote } from '@eco-foundation/routes-sdk' + +const openQuotingClient = new OpenQuotingClient({ dAppID: 'my-dapp' }) + +try { + const quotes = await openQuotingClient.requestReverseQuotesForIntent({ intent }) + + // select quote + const selectedQuote = selectCheapestQuote(quotes, true) + + const quotedIntent = selectedQuote.quote.intentData +} +catch (error) { + console.error('No quotes available for intent', error) } ``` @@ -116,31 +147,32 @@ catch (error) { Depending on your use case, you might want to select some quote based on some other criteria, not just the cheapest. You can create a custom selector function to do this. ``` ts -import { SolverQuote } from '@eco-foundation/routes-sdk'; +import { SolverQuote, IntentExecutionType, QuoteSelectorResult } from '@eco-foundation/routes-sdk' // custom selector fn using SolverQuote type -export function selectMostExpensiveQuote(quotes: SolverQuote[]): SolverQuote { - return quotes.reduce((mostExpensive, quote) => { - const mostExpensiveSum = mostExpensive ? sum(mostExpensive.quoteData.tokens.map(({ balance }) => balance)) : BigInt(-1); - const quoteSum = sum(quote.quoteData.tokens.map(({ balance }) => balance)); - return quoteSum > mostExpensiveSum ? quote : mostExpensive; - }); +function selectMyFavoriteQuote(solverQuotes: SolverQuote[], isReverse: boolean = false, allowedIntentExecutionTypes: IntentExecutionType[] = ['SELF_PUBLISH']): QuoteSelectorResult { + // your custom logic here + return { + intentData, + solverID, + quoteID, + } } ``` #### Implications of not requesting a quote If you do not request a quote for your intent and you continue with publishing it, you risk the possibility of your intent not being fulfilled by any solvers (because of an insufficient token limit) or losing any surplus amount from your `spendingTokenLimit` that the solver didn't need to fulfill your intent. This is why requesting a quote is **strongly recommended**. -### Publishing the intent +### Publishing the intent onchain The SDK gives you what you need so that you can publish the intent to the origin chain with whatever web3 library you choose, here is an example of how to publish our quoted intent using `viem`! ``` ts -import { createWalletClient, privateKeyToAccount, webSocket, http, erc20Abi } from 'viem'; -import { optimism } from 'viem/chains'; -import { IntentSourceAbi, EcoProtocolAddresses } from '@eco-foundation/routes-ts'; +import { createWalletClient, createPublicClient, privateKeyToAccount, webSocket, http, erc20Abi } from 'viem' +import { optimism } from 'viem/chains' +import { IntentSourceAbi, EcoProtocolAddresses } from '@eco-foundation/routes-ts' const account = privateKeyToAccount('YOUR PRIVATE KEY HERE') -const originChain = optimism; +const originChain = optimism const rpcUrl = 'YOUR RPC URL' const originWalletClient = createWalletClient({ @@ -152,11 +184,11 @@ const originPublicClient = createPublicClient({ transport: webSocket(rpcUrl) // OR http(rpcUrl) }) -const intentSourceContract = EcoProtocolAddresses[routesService.getEcoChainId(originChain.id)].IntentSource; +const intentSourceContract = EcoProtocolAddresses[routesService.getEcoChainId(originChain.id)].IntentSource try { // approve the quoted amount to account for fees - await Promise.all(intentWithQuote.reward.tokens.map(async ({ token, amount }) => { + await Promise.all(quotedIntent.reward.tokens.map(async ({ token, amount }) => { const approveTxHash = await originWalletClient.writeContract({ abi: erc20Abi, address: token, @@ -173,7 +205,7 @@ try { abi: IntentSourceAbi, address: intentSourceContract, functionName: 'publishAndFund', - args: [intentWithQuote, false], + args: [quotedIntent, false], chain: originChain, account }) @@ -187,6 +219,131 @@ catch (error) { [See more from viem's docs](https://viem.sh/) +## Initiate the intent gaslessly +Eco's solver provides the option to initiate an intent gaslessly using permit or permit2. By signing your approvals for source tokens, and then passing your intent to our open quoting API. Here is an example of how to do this: + + +As a preliminary step for permit2 you need to ensure that the funder of the intent has permitted for the Permit2 contract to spend token on their behalf. This is done by calling `approve` on the tokens that you are spending on the source chain. This is the only operation that requires a transaction to be sent to the source chain. + +``` ts +import { createWalletClient, createPublicClient, privateKeyToAccount, webSocket, http, erc20Abi } from 'viem' +import { optimism } from 'viem/chains' +import { IntentSourceAbi, EcoProtocolAddresses } from '@eco-foundation/routes-ts' + +const account = privateKeyToAccount('YOUR PRIVATE KEY HERE') +const originChain = optimism + +const rpcUrl = 'YOUR RPC URL' +const originWalletClient = createWalletClient({ + account, + transport: webSocket(rpcUrl) // OR http(rpcUrl) +}) +const originPublicClient = createPublicClient({ + chain: originChain, + transport: webSocket(rpcUrl) // OR http(rpcUrl) +}) + +const intentSourceContract = EcoProtocolAddresses[routesService.getEcoChainId(originChain.id)].IntentSource + +const PERMIT2_ADDRESS = '0x000000000022D473030F116dDEE9F6B43aC78BA3' + +// initial permit2 approvals +await Promise.all(quotedIntent.reward.tokens.map(async ({ token, amount }) => { + const approveTxHash = await originWalletClient.writeContract({ + abi: erc20Abi, + address: token, + functionName: 'approve', + args: [PERMIT2_ADDRESS, amount], + chain: originChain, + account + }) + + await originPublicClient.waitForTransactionReceipt({ hash: approveTxHash }) +})) +``` + +Once the permit2 contract has been approved to spend the tokens, you can create a permit2 signature for the tokens to be spent. Unlike when publishing directly, the spender will be a vault address. Each intent has a unique vault address that is created to fund the intent. First we get the address via the IntentSource contract by calling `getIntentVaultAddress` with the intent. Then we can create a permit2 signature for the tokens to be spent. + +``` ts +import { Permit2, Permit2Abi, Permit2DataDetails } from '@eco-foundation/routes-sdk' + +// get vault address from IntentSource contract +const vaultAddress = await originPublicClient.readContract({ + abi: IntentSourceAbi, + address: intentSourceContract, + functionName: 'intentVaultAddress', + args: [quotedIntent] +}) + +// 30 minutes from now in UNIX seconds since epoch +const deadline = Math.round((Date.now() + 30 * 60 * 1000) / 1000) + +// create permit2 details +const details: Permit2DataDetails[] = await Promise.all(quotedIntent.reward.tokens.map(async ({ token, amount }) => { + // get nonce from Permit2 contract + const currentAllowance = await originPublicClient.readContract({ + abi: Permit2Abi, + address: PERMIT2_ADDRESS, + functionName: 'allowance', + args: [account.address, token, vaultAddress] + }) + + const currentNonce = BigInt(currentAllowance[2]) + + return { + token, + amount, + expiration: BigInt(deadline), + nonce: currentNonce, + } +})) + +// Supplement this with your own permit2 signing function +const signature = await signPermit2(...) + +// now setup permit2 data to pass to the API +const permitData: Permit2 = { + permit2: { + permitContract: PERMIT2_ADDRESS, + permitData: details.length > 1 ? { + batchPermitData: { + typedData: { + details, + spender: vaultAddress, + sigDeadline: BigInt(deadline), + } + } + } : { + singlePermitData: { + typedData: { + details: details[0], + spender: vaultAddress, + sigDeadline: BigInt(deadline), + } + } + }, + signature, + } +} +``` + +Now, we can pass the permit2 data and quoted intent to the `initiateGaslessIntent` function. This will return a transaction hash that can be used to track the intent until it is fulfilled. + +``` ts +const { initiateTxHash } = await openQuotingClient.initiateGaslessIntent({ + funder: account.address, + intent: quotedIntent, + solverID: selectedQuote.solverID, + quoteID: selectedQuote.quoteID, + vaultAddress, + permitData +}) + +await originPublicClient.waitForTransactionReceipt({ hash: initiateTxHash }) +``` + +[See more from Uniswap's Permit2 docs](https://blog.uniswap.org/permit2-integration-guide#how-to-construct-permit2-signatures-on-the-frontend) + # Full Demo For a full example of creating an intent and tracking it until it's fulfilled, see the [Eco Routes SDK Demo](https://github.com/eco/toolkit/tree/main/apps/sdk-demo). From 764a4fea2dd2cbe4726551fe866daa8d57852aa2 Mon Sep 17 00:00:00 2001 From: Dirk Page Date: Tue, 29 Apr 2025 09:40:58 -0500 Subject: [PATCH 19/22] Updating to use v2 quotes endpoints and publishing alpha 2 --- package-lock.json | 2 +- packages/sdk/package.json | 2 +- packages/sdk/src/quotes/OpenQuotingClient.ts | 6 +++--- packages/sdk/src/quotes/types.ts | 6 +++--- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index ed24bac..4510645 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11960,7 +11960,7 @@ }, "packages/sdk": { "name": "@eco-foundation/routes-sdk", - "version": "1.0.0-alpha.0", + "version": "1.0.0-alpha.1", "license": "MIT", "dependencies": { "axios": "^1.7.9", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index ba7b022..e1de8fd 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@eco-foundation/routes-sdk", - "version": "1.0.0-alpha.0", + "version": "1.0.0-alpha.1", "description": "Eco Routes SDK", "main": "./dist/index.cjs", "module": "./dist/index.js", diff --git a/packages/sdk/src/quotes/OpenQuotingClient.ts b/packages/sdk/src/quotes/OpenQuotingClient.ts index 1e17dc2..aedc07d 100644 --- a/packages/sdk/src/quotes/OpenQuotingClient.ts +++ b/packages/sdk/src/quotes/OpenQuotingClient.ts @@ -28,7 +28,7 @@ export class OpenQuotingClient { * @throws {Error} If intentExecutionTypes is empty or if the request fails after multiple retries. * * @remarks - * This method sends a POST request to the `/api/v1/quotes` endpoint with the provided intent information. + * This method sends a POST request to the `/api/v2/quotes` endpoint with the provided intent information. * The intentData returned in each quote will have the fee added to the reward tokens. */ async requestQuotesForIntent({ intent, intentExecutionTypes = ['SELF_PUBLISH'] }: RequestQuotesForIntentParams): Promise { @@ -57,7 +57,7 @@ export class OpenQuotingClient { * @throws {Error} If intentExecutionTypes is empty, if the calls aren't ERC20.transfer calls, or if the request fails after multiple retries. * * @remarks - * This method sends a POST request to the `/api/v1/quotes/reverse` endpoint with the provided intent information. + * This method sends a POST request to the `/api/v2/quotes/reverse` endpoint with the provided intent information. * This intentData returned in each quote will have the fee subtracted from the route tokens and calls. */ async requestReverseQuotesForIntent({ intent, intentExecutionTypes = ['SELF_PUBLISH', 'GASLESS'] }: RequestQuotesForIntentParams): Promise { @@ -171,7 +171,7 @@ export class OpenQuotingClient { * @throws {Error} If the request fails (no retries are attempted for this endpoint). * * @remarks - * This method sends a POST request to the `/api/v1/quotes/initiateGaslessIntent` endpoint with the provided intent information. + * This method sends a POST request to the `/api/v2/quotes/initiateGaslessIntent` endpoint with the provided intent information. */ async initiateGaslessIntent({ funder, intent, quoteID, solverID, vaultAddress, permitData }: InitiateGaslessIntentParams): Promise { const payload: OpenQuotingAPI.InitiateGaslessIntent.Request = { diff --git a/packages/sdk/src/quotes/types.ts b/packages/sdk/src/quotes/types.ts index 4a6890b..ab6ac96 100644 --- a/packages/sdk/src/quotes/types.ts +++ b/packages/sdk/src/quotes/types.ts @@ -3,9 +3,9 @@ import { Hex, TransactionReceipt } from "viem"; import { IntentExecutionType } from "../constants"; export namespace OpenQuotingAPI { export enum Endpoints { - Quotes = '/api/v1/quotes', - ReverseQuotes = '/api/v1/quotes/reverse', - InitiateGaslessIntent = '/api/v1/quotes/initiateGaslessIntent', + Quotes = '/api/v2/quotes', + ReverseQuotes = '/api/v2/quotes/reverse', + InitiateGaslessIntent = '/api/v2/quotes/initiateGaslessIntent', } export namespace Quotes { From 97dd10063a1c7722cbf20fec62e95f18d0493b8c Mon Sep 17 00:00:00 2001 From: Dirk Page Date: Tue, 29 Apr 2025 10:26:32 -0500 Subject: [PATCH 20/22] Adding getFee, reducing sdk index.ts files --- .../sdk/src/{config/index.ts => config.ts} | 0 .../src/{constants/index.ts => constants.ts} | 0 packages/sdk/src/index.ts | 1 + .../index.ts => quoteSelectors.ts} | 6 +++--- packages/sdk/src/routes/getFee.ts | 19 +++++++++++++++++++ packages/sdk/src/{utils/index.ts => utils.ts} | 0 6 files changed, 23 insertions(+), 3 deletions(-) rename packages/sdk/src/{config/index.ts => config.ts} (100%) rename packages/sdk/src/{constants/index.ts => constants.ts} (100%) rename packages/sdk/src/quotes/{quoteSelectors/index.ts => quoteSelectors.ts} (95%) create mode 100644 packages/sdk/src/routes/getFee.ts rename packages/sdk/src/{utils/index.ts => utils.ts} (100%) diff --git a/packages/sdk/src/config/index.ts b/packages/sdk/src/config.ts similarity index 100% rename from packages/sdk/src/config/index.ts rename to packages/sdk/src/config.ts diff --git a/packages/sdk/src/constants/index.ts b/packages/sdk/src/constants.ts similarity index 100% rename from packages/sdk/src/constants/index.ts rename to packages/sdk/src/constants.ts diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index f9396f0..7d43e8c 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -3,6 +3,7 @@ export type { IntentExecutionType, RoutesSupportedChainId, RoutesSupportedStable export { RoutesService } from "./routes/RoutesService"; export type { CreateSimpleIntentParams, CreateIntentParams } from "./routes/types"; +export { getFee } from "./routes/getFee"; export { OpenQuotingClient } from "./quotes/OpenQuotingClient"; export type { RequestQuotesForIntentParams, InitiateGaslessIntentParams, SolverQuote, QuoteData, QuoteSelectorResult, PermitData, Permit1, Permit2, SinglePermit2Data, BatchPermit2Data, Permit2DataDetails, InitiateGaslessIntentResponse } from "./quotes/types"; diff --git a/packages/sdk/src/quotes/quoteSelectors/index.ts b/packages/sdk/src/quotes/quoteSelectors.ts similarity index 95% rename from packages/sdk/src/quotes/quoteSelectors/index.ts rename to packages/sdk/src/quotes/quoteSelectors.ts index 296f674..e8f95cc 100644 --- a/packages/sdk/src/quotes/quoteSelectors/index.ts +++ b/packages/sdk/src/quotes/quoteSelectors.ts @@ -1,6 +1,6 @@ -import { IntentExecutionType } from "../../constants"; -import { sum } from "../../utils"; -import { QuoteSelectorResult, SolverQuote } from "../types"; +import { IntentExecutionType } from "../constants"; +import { sum } from "../utils"; +import { QuoteSelectorResult, SolverQuote } from "./types"; export function selectCheapestQuote(solverQuotes: SolverQuote[], isReverse: boolean = false, allowedIntentExecutionTypes: IntentExecutionType[] = ["SELF_PUBLISH"]): QuoteSelectorResult { return solverQuotes.reduce(({ solverID: cheapestSolverID, quoteID: cheapestQuoteID, quote: cheapestQuoteData }, solverQuoteResponse) => { diff --git a/packages/sdk/src/routes/getFee.ts b/packages/sdk/src/routes/getFee.ts new file mode 100644 index 0000000..f6f74f0 --- /dev/null +++ b/packages/sdk/src/routes/getFee.ts @@ -0,0 +1,19 @@ +import { IntentType } from "@eco-foundation/routes-ts"; +import { formatUnits } from "viem"; + +/** + * Get the fee for an intent + * + * @param intent - The intent object containing route and reward tokens + * @returns a string of the fee in formatted dollars + * @throws Error if the reward tokens sum is strictly less than the route tokens sum + */ +export function getFee(intent: IntentType): string { + // fee calculated as sum of reward tokens sub sum of route tokens + const routeTokensSum = intent.route.tokens.reduce((acc, token) => acc + BigInt(token.amount), BigInt(0)); + const rewardTokensSum = intent.reward.tokens.reduce((acc, token) => acc + BigInt(token.amount), BigInt(0)); + if (rewardTokensSum < routeTokensSum) { + throw new Error("Reward tokens sum should never be less than route tokens sum"); + } + return formatUnits(rewardTokensSum - routeTokensSum, 6); +} \ No newline at end of file diff --git a/packages/sdk/src/utils/index.ts b/packages/sdk/src/utils.ts similarity index 100% rename from packages/sdk/src/utils/index.ts rename to packages/sdk/src/utils.ts From 96662c3edb843c7c966029880a2edb1221d23d39 Mon Sep 17 00:00:00 2001 From: Dirk Page Date: Fri, 16 May 2025 14:45:48 -0500 Subject: [PATCH 21/22] Removing getFee --- packages/sdk/src/index.ts | 1 - packages/sdk/src/routes/getFee.ts | 19 ------------------- 2 files changed, 20 deletions(-) delete mode 100644 packages/sdk/src/routes/getFee.ts diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 7d43e8c..f9396f0 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -3,7 +3,6 @@ export type { IntentExecutionType, RoutesSupportedChainId, RoutesSupportedStable export { RoutesService } from "./routes/RoutesService"; export type { CreateSimpleIntentParams, CreateIntentParams } from "./routes/types"; -export { getFee } from "./routes/getFee"; export { OpenQuotingClient } from "./quotes/OpenQuotingClient"; export type { RequestQuotesForIntentParams, InitiateGaslessIntentParams, SolverQuote, QuoteData, QuoteSelectorResult, PermitData, Permit1, Permit2, SinglePermit2Data, BatchPermit2Data, Permit2DataDetails, InitiateGaslessIntentResponse } from "./quotes/types"; diff --git a/packages/sdk/src/routes/getFee.ts b/packages/sdk/src/routes/getFee.ts deleted file mode 100644 index f6f74f0..0000000 --- a/packages/sdk/src/routes/getFee.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { IntentType } from "@eco-foundation/routes-ts"; -import { formatUnits } from "viem"; - -/** - * Get the fee for an intent - * - * @param intent - The intent object containing route and reward tokens - * @returns a string of the fee in formatted dollars - * @throws Error if the reward tokens sum is strictly less than the route tokens sum - */ -export function getFee(intent: IntentType): string { - // fee calculated as sum of reward tokens sub sum of route tokens - const routeTokensSum = intent.route.tokens.reduce((acc, token) => acc + BigInt(token.amount), BigInt(0)); - const rewardTokensSum = intent.reward.tokens.reduce((acc, token) => acc + BigInt(token.amount), BigInt(0)); - if (rewardTokensSum < routeTokensSum) { - throw new Error("Reward tokens sum should never be less than route tokens sum"); - } - return formatUnits(rewardTokensSum - routeTokensSum, 6); -} \ No newline at end of file From 945c405638d7b34f13fd5ddb57e75cfcaacb6ef4 Mon Sep 17 00:00:00 2001 From: Dirk Page Date: Mon, 26 May 2025 11:57:46 -0500 Subject: [PATCH 22/22] Updating SDK alpha version --- package-lock.json | 2 +- packages/sdk/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4510645..3dbe365 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11960,7 +11960,7 @@ }, "packages/sdk": { "name": "@eco-foundation/routes-sdk", - "version": "1.0.0-alpha.1", + "version": "1.0.0-alpha.2", "license": "MIT", "dependencies": { "axios": "^1.7.9", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 9c58c8f..749a3b4 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@eco-foundation/routes-sdk", - "version": "1.0.0-alpha.1", + "version": "1.0.0-alpha.2", "description": "Eco Routes SDK", "main": "./dist/index.cjs", "module": "./dist/index.js",