diff --git a/apps/sdk-demo/src/components/edit-config.tsx b/apps/sdk-demo/src/components/edit-config.tsx index 02be788..cf1f478 100644 --- a/apps/sdk-demo/src/components/edit-config.tsx +++ b/apps/sdk-demo/src/components/edit-config.tsx @@ -62,7 +62,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 bb9f438..1c50261 100644 --- a/apps/sdk-demo/src/utils/index.ts +++ b/apps/sdk-demo/src/utils/index.ts @@ -16,3 +16,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 8b0982f..caca137 100644 --- a/apps/sdk-demo/src/views/demo/components/create-intent.tsx +++ b/apps/sdk-demo/src/views/demo/components/create-intent.tsx @@ -9,12 +9,16 @@ import { chains } from "../../../wagmi"; type Props = { routesService: RoutesService, onNewIntent: (intent: IntentType, isNative: boolean) => void, + quoteType: "receive" | "spend", + setQuoteType: (type: "receive" | "spend") => void onIntentCleared?: () => void } export default function CreateIntent({ routesService, onNewIntent, + quoteType, + setQuoteType, onIntentCleared }: Props) { const { address } = useAccount(); @@ -106,7 +110,7 @@ export default function CreateIntent({ creator: address, originChainID: effectiveOriginChain, spendingToken: originToken, - spendingTokenLimit: balance, + spendingTokenLimit: quoteType === "receive" ? balance : BigInt(amount), destinationChainID: effectiveDestinationChain, receivingToken: destinationToken, amount: BigInt(amount), @@ -127,7 +131,7 @@ export default function CreateIntent({ setIsIntentValid(false) onIntentCleared?.() } - }, [isNativeIntent, balance, nativeBalance, address, effectiveOriginChain, originToken, effectiveDestinationChain, destinationToken, amount, recipient, prover, onNewIntent, onIntentCleared, routesService]); + }, [isNativeIntent, balance, nativeBalance, address, effectiveOriginChain, originToken, effectiveDestinationChain, destinationToken, amount, recipient, prover, onNewIntent, onIntentCleared, routesService, quoteType]); const originTokensAvailable = useMemo(() => effectiveOriginChain ? getAvailableStables(effectiveOriginChain) : [], [effectiveOriginChain]); const destinationTokensAvailable = useMemo(() => effectiveDestinationChain ? getAvailableStables(effectiveDestinationChain) : [], [effectiveDestinationChain]); @@ -301,13 +305,35 @@ export default function CreateIntent({ )}
-
- Desired Amount {isNativeIntent ? '(in wei)' : ''} + Amount {isNativeIntent ? '(in wei)' : ''} setAmount(e.target.value)} /> {amount && isNativeIntent && ({formatUnits(BigInt(amount), 18)} {nativeBalance?.symbol || 'ETH'})} {amount && !isNativeIntent && decimals && ({formatUnits(BigInt(amount), decimals)})} -
+
+ + | + +
+
Recipient @@ -322,7 +348,7 @@ export default function CreateIntent({
-
+
             {isNativeIntent ?
@@ -365,7 +391,7 @@ export default function CreateIntent({
             )}
           
- - + + ) } \ No newline at end of file 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 f8cf502..a006651 100644 --- a/apps/sdk-demo/src/views/demo/components/publish-intent.tsx +++ b/apps/sdk-demo/src/views/demo/components/publish-intent.tsx @@ -1,116 +1,380 @@ -import { RoutesService, RoutesSupportedChainId, SolverQuote } from "@eco-foundation/routes-sdk" -import { IntentType, IntentSourceAbi, InboxAbi } 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, ecoChains } from "../../../wagmi" +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 - isNativeIntent?: boolean + quote: SolverQuote | undefined, + isNativeIntent?: boolean, + openQuotingClient: OpenQuotingClient } -export default function PublishIntent({ routesService, intent, quotes, quote, isNativeIntent }: Props) { - const { chainId } = useAccount(); +export default function PublishIntent({ routesService, quotes, quote, isNativeIntent, 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]); + + // Set initial execution type when quote changes + useMemo(() => { + if (quote && availableExecutionTypes.length > 0 && !availableExecutionTypes.includes(selectedExecutionType)) { + setSelectedExecutionType(availableExecutionTypes[0]!); + } + }, [quote, availableExecutionTypes, selectedExecutionType]); + + 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 }) - - console.log("Quoted Intent:", quotedIntent) + setIsPublishing(true); - setIsPublishing(true) + // Get the intentData from the selected quote entry + const intentData = selectedQuoteEntry.intentData; - const intentSourceContract = routesService.getProtocolContractAddress(Number(quotedIntent.route.source), "IntentSource") + let intentHash: Hex | undefined; + const intentSourceContract = routesService.getProtocolContractAddress(sourceChainID, "IntentSource") - // 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], + value: isNativeIntent ? intentData.reward.nativeValue : BigInt(0) + }); - const publishTxHash = await writeContractAsync({ - chainId: Number(quotedIntent.route.source), - abi: IntentSourceAbi, - functionName: 'publishAndFund', - address: intentSourceContract, - args: [quotedIntent, false], - value: isNativeIntent ? quotedIntent.reward.nativeValue : BigInt(0), - }) + 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"); + } + + // 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, + ]); - 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) + // 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, isNativeIntent]) + }, [quote, writeContractAsync, routesService, selectedExecutionType, selectedQuoteEntry, sourceChainID, destinationChainID, inboxAddress, vaultAddress, address, publicClient, isNativeIntent, openQuotingClient]); - if (!intent || !quote) return null + if (!quote) return null; return (
@@ -133,10 +397,11 @@ export default function PublishIntent({ routesService, intent, quotes, quote, is {publishTxHash ? (
- Intent Published: + Intent {selectedExecutionType === "GASLESS" ? "Initiated" : "Published"}: {publishTxHash}
- ) : Publishing..} + ) : {selectedExecutionType === "GASLESS" ? "Initiating" : "Publishing"}..} + {fulfillmentTxHash ? (
Intent fulfilled: @@ -146,28 +411,86 @@ export default function PublishIntent({ routesService, intent, quotes, quote, is {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}`}
           
- - + + ) } \ No newline at end of file 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 4902612..f33b13d 100644 --- a/apps/sdk-demo/src/views/demo/components/select-quote.tsx +++ b/apps/sdk-demo/src/views/demo/components/select-quote.tsx @@ -1,23 +1,27 @@ import { RoutesSupportedChainId, SolverQuote, selectCheapestQuote, selectCheapestQuoteNativeSend } 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, isNativeIntent: boolean, - onQuoteSelected: (quote: SolverQuote) => void + onQuoteSelected: (quote: SolverQuote) => void, + quoteType: "receive" | "spend" } -export default function SelectQuote({ intent, quotes, isNativeIntent, onQuoteSelected }: Props) { +export default function SelectQuote({ intent, quotes, isNativeIntent, onQuoteSelected, quoteType }: Props) { if (!intent || !quotes) return null const handleSelectCheapest = () => { - const cheapestQuote = isNativeIntent ? - selectCheapestQuoteNativeSend(quotes) : + const cheapestQuote = isNativeIntent ? + selectCheapestQuoteNativeSend(quotes) : selectCheapestQuote(quotes); - onQuoteSelected(cheapestQuote); + const solverQuote = quotes.find(q => q.quoteID === cheapestQuote.quoteID && q.solverID === cheapestQuote.solverID); + if (solverQuote) { + onQuoteSelected(solverQuote); + } }; return ( @@ -25,46 +29,90 @@ export default function SelectQuote({ intent, quotes, isNativeIntent, onQuoteSel Quotes Available:
- {quotes.map((quote, index) => ( -
- Amounts requested by solver on the origin chain: - {isNativeIntent ? ( -
- Native Value: {formatUnits(BigInt(quote.quoteData.nativeValue || 0), 18)} ETH +
+
+
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):
+ {isNativeIntent ? ( +
+ Native Value: {formatUnits(BigInt(entry.intentData.reward.nativeValue || 0), 18)} ETH +
+ ) : ( +
    + {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):
+ {isNativeIntent ? ( +
+ Native Value: {formatUnits(BigInt(entry.intentData.reward.nativeValue || 0), 18)} ETH +
+ ) : ( +
    + {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()} +
- ) : ( -
    - {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()} - Estimated time to fulfill: {quote.quoteData.estimatedFulfillTimeSec} seconds - + ))} +
))}
-
+          
             {
-              `const quotes = await openQuotingClient.requestQuotesForIntent(intent);
+              `const quotes = await openQuotingClient.${quoteType === 'receive' ? 'requestQuotesForIntent' : 'requestReverseQuotesForIntent'}(intent);
+
+                // Select the cheapest quote
+                const selectedQuote = ${isNativeIntent ? 'selectCheapestQuoteNativeSend' : 'selectCheapestQuote'}(quotes);
 
-// Select the cheapest quote
-const selectedQuote = ${isNativeIntent ? 'selectCheapestQuoteNativeSend' : 'selectCheapestQuote'}(quotes);
-          
-console.log(quotes);
-${JSON.stringify(quotes, null, 2)}`}
+                console.log(quotes);
+                ${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 b1994af..090b634 100644 --- a/apps/sdk-demo/src/views/demo/demo-view.tsx +++ b/apps/sdk-demo/src/views/demo/demo-view.tsx @@ -17,6 +17,7 @@ export default function DemoView() { const [intent, setIntent] = useState(); const [quotes, setQuotes] = useState(); const [selectedQuote, setSelectedQuote] = useState(); + const [quoteType, setQuoteType] = useState<"receive" | "spend">("receive"); const [isNativeIntent, setIsNativeIntent] = useState(false); const handleNewIntent = useCallback((newIntent: IntentType, isNative: boolean) => { @@ -34,25 +35,36 @@ export default function DemoView() { 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 diff --git a/package-lock.json b/package-lock.json index cbefe2d..5701109 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12381,7 +12381,7 @@ }, "packages/sdk": { "name": "@eco-foundation/routes-sdk", - "version": "0.14.0", + "version": "1.0.0-alpha.2", "license": "MIT", "dependencies": { "axios": "^1.7.9", diff --git a/package.json b/package.json index 33b2dac..13969f9 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "changeset": "changeset", "update-versions": "changeset version", "publish-latest": "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-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", diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 0053478..73f861d 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -69,18 +69,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 const intent = routesService.createSimpleIntent({ @@ -119,28 +119,61 @@ const intent = routesService.createNativeSendIntent({ }) ``` -### 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, selectCheapestQuoteNativeSend } 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); // OR, for native send intents const selectedQuote = selectCheapestQuoteNativeSend(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, { isReverse: true }); + // OR, for native send intents + const selectedQuote = selectCheapestQuoteNativeSend(quotes, { isReverse: true }); + + const quotedIntent = selectedQuote.quote.intentData +} +catch (error) { + console.error('No reverse quotes available for intent', error) } ``` @@ -148,31 +181,33 @@ 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, QuoteSelectorOptions, 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[], opts: QuoteSelectorOptions): QuoteSelectorResult { + const { isReverse = false, allowedIntentExecutionTypes = ['SELF_PUBLISH'] } = opts; + // 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 { createWalletClient, createPublicClient, privateKeyToAccount, webSocket, http, erc20Abi } from 'viem'; import { optimism } from 'viem/chains'; import { IntentSourceAbi } 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({ @@ -188,7 +223,7 @@ const intentSourceContract = routesService.getProtocolContractAddress(originChai 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, @@ -205,7 +240,7 @@ try { abi: IntentSourceAbi, address: intentSourceContract, functionName: 'publishAndFund', - args: [intentWithQuote, false], + args: [quotedIntent, false], chain: originChain, account, value: intentWithQuote.reward.nativeValue // Send the required native value if applicable @@ -220,6 +255,129 @@ 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) ## Refunding Expired Intents If an intent expires before it's fulfilled by a solver, you can refund the tokens you deposited when creating the intent. To do this, you'll need the original intent data, which you can retrieve from the `IntentCreated` event log that was emitted when you published the intent. diff --git a/packages/sdk/package.json b/packages/sdk/package.json index b307784..947895c 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@eco-foundation/routes-sdk", - "version": "0.14.0", + "version": "1.0.0-alpha.2", "description": "Eco Routes SDK", "main": "./dist/index.cjs", "module": "./dist/index.js", diff --git a/packages/sdk/src/constants.ts b/packages/sdk/src/constants.ts index a6c73cf..720b2c8 100644 --- a/packages/sdk/src/constants.ts +++ b/packages/sdk/src/constants.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 @@ -126,3 +132,4 @@ export const stableAddresses: Record} 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 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: IntentType): Promise { + async requestQuotesForIntent({ intent, intentExecutionTypes = ['SELF_PUBLISH'] }: RequestQuotesForIntentParams): Promise { + if (intentExecutionTypes.length === 0) { + throw new Error("intentExecutionTypes must not be empty"); + } const payload: OpenQuotingAPI.Quotes.Request = { dAppID: this.dAppID, + intentExecutionTypes, + intentData: this.formatIntentData(intent) + } + payload.intentData.routeData.salt = "0x0"; + + const response = await this.axiosInstance.post(OpenQuotingAPI.Endpoints.Quotes, payload); + + return this.parseQuotesResponse(intent, response.data); + } + + /** + * Requests reverse quotes for a given intent. + * + * @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/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 { + 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) + } + payload.intentData.routeData.salt = "0x0"; + + const response = await this.axiosInstance.post(OpenQuotingAPI.Endpoints.ReverseQuotes, payload); + + return this.parseQuotesResponse(intent, 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(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: 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.routeCalls.map((call) => ({ + target: call.target, + data: call.data, + value: BigInt(call.value) + })) + }, + reward: { + 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) + })) + } + }, + expiryTime: BigInt(entry.expiryTime), + estimatedFulfillTimeSec: entry.estimatedFulfillTimeSec + })) + } + })); + } + + /** + * Initiates a gasless intent via 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/v2/quotes/initiateGaslessIntent` endpoint with the provided intent information. + */ + async initiateGaslessIntent({ funder, intent, quoteID, solverID, vaultAddress, permitData }: InitiateGaslessIntentParams): Promise { + const payload: OpenQuotingAPI.InitiateGaslessIntent.Request = { + dAppID: this.dAppID, + quoteID, + solverID, 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() - })) + ...this.formatIntentData(intent), + gaslessIntentData: { + funder, + vaultAddress, + permitData: permitData ? this.formatPermitData(permitData) : undefined, } } } - const response = await this.axiosInstance.post(OpenQuotingAPI.Endpoints.Quotes, payload); + 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 as Permit1).permit) { + // regular permit + const permit1 = permit as Permit1; + return { + permit: permit1.permit.map((p) => ({ + token: p.token, + data: { + signature: p.data.signature, + deadline: p.data.deadline.toString(), + } + })) + } + } + else { + 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(), + } + } + }, + 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: permit2.permit2.signature, + } + } + } + + } + } } diff --git a/packages/sdk/src/quotes/quoteSelectors.ts b/packages/sdk/src/quotes/quoteSelectors.ts index ddf91fe..3e3c72d 100644 --- a/packages/sdk/src/quotes/quoteSelectors.ts +++ b/packages/sdk/src/quotes/quoteSelectors.ts @@ -1,18 +1,63 @@ import { sum } from "../utils.js"; -import { SolverQuote } from "./types.js"; +import { QuoteSelectorOptions, QuoteSelectorResult, SolverQuote } from "./types.js"; -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; +export function selectCheapestQuote(solverQuotes: SolverQuote[], options: QuoteSelectorOptions = {}): QuoteSelectorResult { + const { isReverse = false, allowedIntentExecutionTypes = ["SELF_PUBLISH"] } = options; + + const flatQuotes = solverQuotes.flatMap(solverQuote => + solverQuote.quoteData.quoteEntries + .filter(quoteEntry => allowedIntentExecutionTypes.includes(quoteEntry.intentExecutionType)) + .map(quoteEntry => ({ + solverID: solverQuote.solverID, + quoteID: solverQuote.quoteID, + quote: quoteEntry + })) + ); + + if (flatQuotes.length === 0) { + throw new Error('No valid quotes found'); + } + + return flatQuotes.reduce((cheapest, current) => { + const currentTokens = isReverse ? current.quote.intentData.route.tokens : current.quote.intentData.reward.tokens; + const cheapestTokens = isReverse ? cheapest.quote.intentData.route.tokens : cheapest.quote.intentData.reward.tokens; + + const currentSum = sum(currentTokens.map(({ amount }) => amount)); + const cheapestSum = sum(cheapestTokens.map(({ amount }) => amount)); + + if (isReverse) { + return currentSum > cheapestSum ? current : cheapest; + } else { + return currentSum < cheapestSum ? current : cheapest; + } }); } -export function selectCheapestQuoteNativeSend(quotes: SolverQuote[]): SolverQuote { - return quotes.reduce((cheapest, quote) => { - const cheapestNativeValue = cheapest ? BigInt(cheapest.quoteData.nativeValue) : BigInt(Infinity); - const quoteNativeValue = BigInt(quote.quoteData.nativeValue); - return quoteNativeValue < cheapestNativeValue ? quote : cheapest; +export function selectCheapestQuoteNativeSend(solverQuotes: SolverQuote[], options: QuoteSelectorOptions = {}): QuoteSelectorResult { + const { isReverse = false, allowedIntentExecutionTypes = ["SELF_PUBLISH"] } = options; + + const flatQuotes = solverQuotes.flatMap(solverQuote => + solverQuote.quoteData.quoteEntries + .filter(quoteEntry => allowedIntentExecutionTypes.includes(quoteEntry.intentExecutionType)) + .map(quoteEntry => ({ + solverID: solverQuote.solverID, + quoteID: solverQuote.quoteID, + quote: quoteEntry + })) + ); + + if (flatQuotes.length === 0) { + throw new Error('No valid quotes found'); + } + + return flatQuotes.reduce((cheapest, current) => { + const currentNativeValue = current.quote.intentData.reward.nativeValue; + const cheapestNativeValue = cheapest.quote.intentData.reward.nativeValue; + + if (isReverse) { + return currentNativeValue > cheapestNativeValue ? current : cheapest; + } else { + return currentNativeValue < cheapestNativeValue ? current : cheapest; + } }); } diff --git a/packages/sdk/src/quotes/types.ts b/packages/sdk/src/quotes/types.ts index d41403e..1620f3c 100644 --- a/packages/sdk/src/quotes/types.ts +++ b/packages/sdk/src/quotes/types.ts @@ -1,55 +1,221 @@ -import { Hex } from "viem"; +import { IntentType } from "@eco-foundation/routes-ts"; +import { Hex, TransactionReceipt } from "viem"; +import { IntentExecutionType } from "../constants.js"; export namespace OpenQuotingAPI { export enum Endpoints { - Quotes = '/api/v1/quotes' + Quotes = '/api/v2/quotes', + ReverseQuotes = '/api/v2/quotes/reverse', + InitiateGaslessIntent = '/api/v2/quotes/initiateGaslessIntent', } export namespace Quotes { + export type IntentData = { + routeData: { + salt: Hex + 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; - 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: { + quoteID: string + solverID: string + quoteData: { + quoteEntries: { + intentExecutionType: IntentExecutionType + routeTokens: { + token: Hex + amount: string + }[] + routeCalls: { + target: Hex + data: Hex + value: string + }[] + rewardTokens: { + token: Hex + amount: string + }[] + expiryTime: string + estimatedFulfillTimeSec: number }[] } + }[] + } + } + + export namespace InitiateGaslessIntent { + export interface Request { + quoteID: string; + dAppID: string; + solverID: string; + intentData: Quotes.IntentData & { + gaslessIntentData: { + funder: Hex + vaultAddress: Hex + allowPartial?: boolean + permitData?: { + permit: { + token: Hex + data: { + signature: Hex + deadline: string + } + }[] + } | { + permit2: { + permitContract: Hex + permitData: { + singlePermitData: { + typedData: { + details: { + token: Hex + amount: string + expiration: string + nonce: string + } + spender: Hex + sigDeadline: string + } + } + } | { + batchPermitData: { + typedData: { + details: { + token: Hex + amount: string + expiration: string + nonce: string + }[] + spender: Hex + sigDeadline: string + } + } + } + signature: Hex + } + } + } } } export interface Response { - data: SolverQuote[] + data: TransactionReceipt } } } +export type RequestQuotesForIntentParams = { + intent: IntentType + intentExecutionTypes?: IntentExecutionType[] +} + +export type InitiateGaslessIntentParams = { + funder: Hex + intent: IntentType + vaultAddress: Hex + quoteID: string + solverID: string + permitData?: PermitData +} + export type SolverQuote = { - quoteData: QuoteData + quoteID: string + solverID: string + quoteData: { + quoteEntries: QuoteData[] + } } export type QuoteData = { - tokens: { - token: Hex, - amount: string - }[] - nativeValue: string - expiryTime: string // seconds since epoch + intentExecutionType: IntentExecutionType + intentData: IntentType + expiryTime: bigint // seconds since epoch estimatedFulfillTimeSec: number } + +export type QuoteSelectorOptions = { + isReverse?: boolean; + allowedIntentExecutionTypes?: IntentExecutionType[]; +} + +export type QuoteSelectorResult = { + quoteID: string; + solverID: string; + quote: QuoteData; +} + +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 = { + token: Hex + amount: bigint + expiration: bigint + nonce: bigint +} + +export type InitiateGaslessIntentResponse = { + transactionHash: Hex +} \ No newline at end of file diff --git a/packages/sdk/src/routes/RoutesService.ts b/packages/sdk/src/routes/RoutesService.ts index 8fccfb3..f3b3fcd 100644 --- a/packages/sdk/src/routes/RoutesService.ts +++ b/packages/sdk/src/routes/RoutesService.ts @@ -1,7 +1,7 @@ import { GetEventArgs, encodeFunctionData, erc20Abi, Hex, isAddress, isAddressEqual, zeroAddress } from "viem"; import { dateToTimestamp, generateRandomHex, getSecondsFromNow, isAmountInvalid } from "../utils.js"; import { stableAddresses, RoutesSupportedChainId, RoutesSupportedStable } from "../constants.js"; -import { CreateIntentParams, CreateSimpleIntentParams, ApplyQuoteToIntentParams, CreateNativeSendIntentParams, EcoProtocolContract, ProtocolAddresses } from "./types.js"; +import { CreateIntentParams, CreateSimpleIntentParams, CreateNativeSendIntentParams, EcoProtocolContract, ProtocolAddresses } from "./types.js"; import { EcoChainIdsEnv, EcoProtocolAddresses, IntentSourceAbi, IntentType } from "@eco-foundation/routes-ts"; import { ECO_SDK_CONFIG } from "../config.js"; @@ -50,9 +50,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({ @@ -134,10 +142,16 @@ export class RoutesService { /** * Creates an intent. * - * @param {CreateRouteParams} params - The parameters for creating the 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({ @@ -257,36 +271,11 @@ 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.nativeValue === "0" && !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) - })) - - intent.reward.nativeValue = BigInt(quote.quoteData.nativeValue || 0) - - return intent; - } - /** * 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): EcoChainIdsEnv { return `${chainId}${this.isPreprod ? "-pre" : ""}` @@ -388,6 +377,14 @@ export class RoutesService { } } + /** + * 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) { @@ -396,6 +393,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()) { diff --git a/packages/sdk/src/routes/types.ts b/packages/sdk/src/routes/types.ts index 344c307..5ac3288 100644 --- a/packages/sdk/src/routes/types.ts +++ b/packages/sdk/src/routes/types.ts @@ -1,6 +1,5 @@ import { Hex } from "viem" -import { SolverQuote } from "../quotes/types.js" -import { EcoChainIdsEnv, EcoProtocolAddresses, IntentType } from "@eco-foundation/routes-ts" +import { EcoChainIdsEnv, EcoProtocolAddresses } from "@eco-foundation/routes-ts" export type CreateSimpleIntentParams = { creator: Hex @@ -38,11 +37,6 @@ export type CreateNativeSendIntentParams = { expiryTime?: Date } -export type ApplyQuoteToIntentParams = { - intent: IntentType - quote: SolverQuote -} - export type EcoProtocolContract = keyof typeof EcoProtocolAddresses[EcoChainIdsEnv]; export type ProtocolAddresses = Record>>; 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..f6bc09d --- /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/index.js"; +import { PERMIT2_ADDRESS, signPermit, signPermit2 } from "../permit.js"; +import { getSecondsFromNow } from "../../src/utils.js"; +import { PermitAbi } from "../PermitAbi.js"; +import { Permit2Abi } from "../Permit2Abi.js"; + +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, quote } = selectCheapestQuote(quotes, { allowedIntentExecutionTypes: ["GASLESS"] }); + + const intentSource = EcoProtocolAddresses[routesService.getEcoChainId(originChain.id)].IntentSource + // initiate gasless intent + const vaultAddress = await publicClient.readContract({ + abi: IntentSourceAbi, + address: intentSource, + functionName: "intentVaultAddress", + args: [quote.intentData] + }) + + // approve + await Promise.all(quote.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.initiateGaslessIntent({ + funder: account.address, + intent: quote.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, quote } = selectCheapestQuote(quotes, { allowedIntentExecutionTypes: ["GASLESS"] }); + + const intentSource = EcoProtocolAddresses[routesService.getEcoChainId(originChain.id)].IntentSource + // initiate gasless intent + const vaultAddress = await publicClient.readContract({ + abi: IntentSourceAbi, + address: intentSource, + functionName: "intentVaultAddress", + args: [quote.intentData] + }) + + // approve + await Promise.all(quote.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.initiateGaslessIntent({ + funder: account.address, + intent: quote.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, quote } = selectCheapestQuote(quotes, { allowedIntentExecutionTypes: ["GASLESS"] }); + + const intentSource = EcoProtocolAddresses[routesService.getEcoChainId(originChain.id)].IntentSource + // initiate gasless intent + const vaultAddress = await publicClient.readContract({ + abi: IntentSourceAbi, + address: intentSource, + functionName: "intentVaultAddress", + args: [quote.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(quote.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.initiateGaslessIntent({ + funder: account.address, + intent: quote.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, quote } = selectCheapestQuote(quotes, { allowedIntentExecutionTypes: ["GASLESS"] }); + + const intentSource = EcoProtocolAddresses[routesService.getEcoChainId(originChain.id)].IntentSource + // initiate gasless intent + const vaultAddress = await publicClient.readContract({ + abi: IntentSourceAbi, + address: intentSource, + functionName: "intentVaultAddress", + args: [quote.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(quote.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(quote.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.initiateGaslessIntent({ + funder: account.address, + intent: quote.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 387acdd..30ef888 100644 --- a/packages/sdk/test/e2e/publishAndFund.test.ts +++ b/packages/sdk/test/e2e/publishAndFund.test.ts @@ -50,18 +50,55 @@ describe("publishAndFund", () => { }) // request quotes - const quotes = await openQuotingClient.requestQuotesForIntent(intent) - const selectedQuote = selectCheapestQuote(quotes) + const quotes = await openQuotingClient.requestQuotesForIntent({ intent }) + const { quote } = selectCheapestQuote(quotes) - // setup the intent for publishing - const quotedIntent = routesService.applyQuoteToIntent({ - intent, - quote: selectedQuote + // approve + await Promise.all(quote.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: [quote.intentData, false], + chain: originChain, + account }) - expect(quotedIntent).toBeDefined() + + await publicClient.waitForTransactionReceipt({ hash: publishTxHash }) + }, 20_000) + + test("onchain with reverse quote", async () => { + const intentSourceContract = routesService.getProtocolContractAddress(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 { quote } = selectCheapestQuote(quotes) // approve - await Promise.all(quotedIntent.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, @@ -78,15 +115,15 @@ describe("publishAndFund", () => { abi: IntentSourceAbi, address: intentSourceContract, functionName: 'publishAndFund', - args: [quotedIntent, false], + args: [quote.intentData, false], chain: originChain, account }) await publicClient.waitForTransactionReceipt({ hash: publishTxHash }) - }, 20_000) + }, 20_000); - test("simple intent onchain without quote", async () => { + test("sinple intent onchain without quote", async () => { const intent = routesService.createSimpleIntent({ creator: account.address, originChainID: originChain.id, @@ -170,26 +207,22 @@ describe("publishAndFund", () => { }) // request quotes - const quotes = await openQuotingClient.requestQuotesForIntent(intent) - const selectedQuote = selectCheapestQuoteNativeSend(quotes) + const quotes = await openQuotingClient.requestQuotesForIntent({ intent }) + const { quote } = selectCheapestQuoteNativeSend(quotes) // setup the intent for publishing - const quotedIntent = routesService.applyQuoteToIntent({ - intent, - quote: selectedQuote - }) - expect(quotedIntent).toBeDefined() - expect(quotedIntent.reward.nativeValue).toBeGreaterThan(BigInt(0)) + expect(quote.intentData).toBeDefined() + expect(quote.intentData.reward.nativeValue).toBeGreaterThan(BigInt(0)) // publish intent onchain with native value const publishTxHash = await baseWalletClient.writeContract({ abi: IntentSourceAbi, address: intentSourceContract, functionName: 'publishAndFund', - args: [quotedIntent, false], + args: [quote.intentData, false], chain: originChain, account, - value: quotedIntent.reward.nativeValue + value: quote.intentData.reward.nativeValue }) await publicClient.waitForTransactionReceipt({ hash: publishTxHash }) diff --git a/packages/sdk/test/integration/OpenQuotingClient.test.ts b/packages/sdk/test/integration/OpenQuotingClient.test.ts index 3082beb..741d957 100644 --- a/packages/sdk/test/integration/OpenQuotingClient.test.ts +++ b/packages/sdk/test/integration/OpenQuotingClient.test.ts @@ -1,19 +1,21 @@ 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/index.js"; import { dateToTimestamp, getSecondsFromNow } from "../../src/utils.js"; +import { validateSolverQuoteResponse } from "../utils.js"; -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,52 +34,36 @@ describe("OpenQuotingClient", () => { prover: 'HyperProver', recipient: creator, }); - }) + }); 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); - for (const quote of quotes) { - expect(quote.quoteData).toBeDefined(); - expect(quote.quoteData.expiryTime).toBeDefined(); - expect(quote.quoteData.nativeValue).toBeDefined(); - expect(quote.quoteData.nativeValue).toBe("0"); - 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, + originalIntent: validIntent + }); } }); 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); - for (const quote of quotes) { - expect(quote.quoteData).toBeDefined(); - expect(quote.quoteData.expiryTime).toBeDefined(); - expect(quote.quoteData.nativeValue).toBeDefined(); - expect(quote.quoteData.nativeValue).toBe("0"); - 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, + originalIntent: validIntent + }); } }); @@ -92,18 +78,16 @@ describe("OpenQuotingClient", () => { prover: 'HyperProver' }); - const quotes = await openQuotingClient.requestQuotesForIntent(nativeSendIntent); + const quotes = await openQuotingClient.requestQuotesForIntent({ intent: nativeSendIntent }); 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.nativeValue).toBeDefined(); - expect(BigInt(quote.quoteData.nativeValue)).toBeGreaterThan(0); - expect(quote.quoteData.tokens).toBeDefined(); - expect(quote.quoteData.tokens.length).toBe(0); + validateSolverQuoteResponse({ + solverQuoteResponse: quote, + originalIntent: nativeSendIntent, + }); } }); @@ -124,79 +108,220 @@ describe("OpenQuotingClient", () => { nativeValue: BigInt(0), tokens: [] } - } + }; - 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: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(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 () => { + test("invalid:intent.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 () => { + test("invalid:intent.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 () => { + test("invalid:intent.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 () => { + test("invalid:intent.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 () => { + test("invalid:intent.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 () => { + 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(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 () => { + test("invalid:intent.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 () => { + test("invalid:intent.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"); + }); + }); + + 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, + originalIntent: validIntent, + isReverseQuote: 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, + originalIntent: validIntent, + isReverseQuote: true + }); + } + }); + + 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; + // 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"); + + // 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"); + + // 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"); + }); + + 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/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/unit/RoutesService.test.ts b/packages/sdk/test/unit/RoutesService.test.ts index f32db27..0590366 100644 --- a/packages/sdk/test/unit/RoutesService.test.ts +++ b/packages/sdk/test/unit/RoutesService.test.ts @@ -1,10 +1,11 @@ -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 { EcoProtocolAddresses, IntentType } from "@eco-foundation/routes-ts"; -import { RoutesService, SolverQuote } from "../../src/index.js"; -import { dateToTimestamp, getSecondsFromNow } from "../../src/utils.js"; +import { EcoProtocolAddresses } from "@eco-foundation/routes-ts"; + +import { RoutesService } from "../../src/index.js"; +import { getSecondsFromNow } from "../../src/utils.js"; import { ECO_SDK_CONFIG } from "../../src/config.js"; const account = privateKeyToAccount(process.env.VITE_TESTING_PK as Hex) @@ -841,87 +842,4 @@ describe("RoutesService", () => { })).toThrow(`No MetaProver exists on '42161${ECO_SDK_CONFIG.isPreprod && '-pre'}'`); }) }) - - 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", - }], - nativeValue: "0", - expiryTime: dateToTimestamp(getSecondsFromNow(60)).toString(), - estimatedFulfillTimeSec: 2, - } - }; - }); - - 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"); - }); - }); }) diff --git a/packages/sdk/test/utils.ts b/packages/sdk/test/utils.ts new file mode 100644 index 0000000..ebe43e8 --- /dev/null +++ b/packages/sdk/test/utils.ts @@ -0,0 +1,157 @@ + +import { expect } from "vitest"; +import { INTENT_EXECUTION_TYPES, SolverQuote } from "../src/index.js"; +import { IntentType } from "@eco-foundation/routes-ts"; +import { decodeFunctionData, erc20Abi } from "viem"; +import { sum } from "../src/utils.js"; + +export type ValidateSolverQuoteResponseOptions = { + solverQuoteResponse: SolverQuote; + originalIntent: IntentType; + isReverseQuote?: boolean; +} + +export function validateSolverQuoteResponse(opts: ValidateSolverQuoteResponseOptions): void { + const { solverQuoteResponse, originalIntent, isReverseQuote = false } = opts; + const isNativeIntent = originalIntent.reward.nativeValue > 0n; + expect(solverQuoteResponse.solverID).toBeDefined(); + expect(solverQuoteResponse.quoteData).toBeDefined(); + expect(solverQuoteResponse.quoteData.quoteEntries).toBeDefined(); + expect(solverQuoteResponse.quoteData.quoteEntries.length).toBeGreaterThan(0); + + for (const quote of solverQuoteResponse.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(); + + if (isNativeIntent) { + expect(quote.intentData.reward.nativeValue).toBeGreaterThan(0n); + expect(quote.intentData.reward.tokens.length).toBe(0); + } + else { + 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(); + if (isNativeIntent) { + expect(quote.intentData.route.tokens.length).toBe(0); + } + else { + 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 quote native intents: + if (isNativeIntent) { + // 1. Validate that the native value is equal to the original intent's native value + expect(quote.intentData.reward.nativeValue).toEqual(originalIntent.reward.nativeValue); + // 2. Validate that the route and reward tokens are empty + expect(quote.intentData.route.tokens.length).toBe(0); + expect(quote.intentData.reward.tokens.length).toBe(0); + // 3. Validate that the calls value sum is less than or equal to the original intent's calls value sum + const callsValueSum = sum(quote.intentData.route.calls.map(call => BigInt(call.value))); + const originalCallsValueSum = sum(originalIntent.route.calls.map(call => BigInt(call.value))); + expect(callsValueSum, "Reverse quote should reduce route.calls value sum").toBeLessThanOrEqual(originalCallsValueSum); + } + else { + // For reverse quote ERC20 intents: + // 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 { + if (isNativeIntent) { + // For standard quote native intents: + // 1. Validate that the native value is greater or equal to the original intent's native value + expect(quote.intentData.reward.nativeValue).toBeGreaterThanOrEqual(originalIntent.reward.nativeValue); + // 2. Validate that the route and reward tokens are empty + expect(quote.intentData.route.tokens.length).toBe(0); + expect(quote.intentData.reward.tokens.length).toBe(0); + // 3. Validate that the calls value sum is equal to the original intent's calls value sum + const callsValueSum = sum(quote.intentData.route.calls.map(call => BigInt(call.value))); + const originalCallsValueSum = sum(originalIntent.route.calls.map(call => BigInt(call.value))); + expect(callsValueSum, "Reverse quote should reduce route.calls value sum").toEqual(originalCallsValueSum); + } + else { + // For standard quote ERC20 intents: + // 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 reducs reward.tokens sum").toBeLessThanOrEqual( + intentRewardTokensSum, + ); + + // 2. Route token amounts should remain unchanged + 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, + ); + } +} 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