diff --git a/packages/bridging/src/BridgingSdk/getQuoteWithBridge.test.ts b/packages/bridging/src/BridgingSdk/getQuoteWithBridge.test.ts index bb58bc34e..44c4f1a99 100644 --- a/packages/bridging/src/BridgingSdk/getQuoteWithBridge.test.ts +++ b/packages/bridging/src/BridgingSdk/getQuoteWithBridge.test.ts @@ -1,7 +1,7 @@ import { getEthFlowContract, TradingSdk } from '@cowprotocol/sdk-trading' import { MockHookBridgeProvider } from '../providers/mock/HookMockBridgeProvider' import { MockReceiverAccountBridgeProvider } from '../providers/mock/ReceiverAccountMockBridgeProvider' -import { QuoteBridgeRequest } from '../types' +import { BridgeQuoteAndPost, QuoteBridgeRequest } from '../types' import { getQuoteWithBridge } from './getQuoteWithBridge' import { bridgeCallDetails, @@ -85,7 +85,7 @@ adapterNames.forEach((adapterName) => { return getQuoteWithBridge(mockProvider, { swapAndBridgeRequest: request, tradingSdk, - }) + }) as Promise } async function postOrderWithCustomReceiver(customReceiver: string) { diff --git a/packages/bridging/src/BridgingSdk/getQuoteWithBridge.ts b/packages/bridging/src/BridgingSdk/getQuoteWithBridge.ts index 7951797b8..442b4b1c7 100644 --- a/packages/bridging/src/BridgingSdk/getQuoteWithBridge.ts +++ b/packages/bridging/src/BridgingSdk/getQuoteWithBridge.ts @@ -16,6 +16,8 @@ import { BridgeQuoteAndPost, BridgeQuoteResult, BridgeQuoteResults, + BridgeThenSwapProvider, + BridgeThenSwapQuoteAndPost, QuoteBridgeRequest, QuoteBridgeRequestWithoutAmount, BridgeProvider, @@ -28,13 +30,14 @@ import { GetQuoteWithBridgeParams } from './types' import { getBridgeSignedHook } from './getBridgeSignedHook' import { HOOK_DAPP_BRIDGE_PROVIDER_PREFIX } from '../const' import { getHookMockForCostEstimation } from '../hooks/utils' -import { isHookBridgeProvider, isReceiverAccountBridgeProvider } from '../utils' +import { isHookBridgeProvider, isReceiverAccountBridgeProvider, isBridgeThenSwapProvider } from '../utils' import { getIntermediateSwapResult } from './getIntermediateSwapResult' +import { getQuoteWithBridgeThenSwap } from './getQuoteWithBridgeThenSwap' export async function getQuoteWithBridge( provider: BridgeProvider, params: GetQuoteWithBridgeParams, -): Promise { +): Promise { const { kind } = params.swapAndBridgeRequest // Ensure the quote request is for a sell order (only type supported for now) @@ -42,6 +45,16 @@ export async function getQuoteWithBridge( throw new Error('Bridging only support SELL orders') } + // If the provider uses bridge-then-swap (reverse flow) + if (isBridgeThenSwapProvider(provider)) { + return getQuoteWithBridgeThenSwap(provider, { + swapAndBridgeRequest: params.swapAndBridgeRequest, + advancedSettings: params.advancedSettings, + tradingSdk: params.tradingSdk, + intermediateTokensCache: params.intermediateTokensCache, + }) + } + // If the provider relies on hooks if (isHookBridgeProvider(provider)) { return getQuoteWithHookBridge(provider, params) diff --git a/packages/bridging/src/BridgingSdk/getQuoteWithBridgeThenSwap.ts b/packages/bridging/src/BridgingSdk/getQuoteWithBridgeThenSwap.ts new file mode 100644 index 000000000..438818a6f --- /dev/null +++ b/packages/bridging/src/BridgingSdk/getQuoteWithBridgeThenSwap.ts @@ -0,0 +1,274 @@ +import { + QuoteResults, + SwapAdvancedSettings, + TradingSdk, + WithPartialTraderParams, + TradeParameters, +} from '@cowprotocol/sdk-trading' +import { getGlobalAdapter, log, SignerLike } from '@cowprotocol/sdk-common' +import { OrderKind } from '@cowprotocol/sdk-order-book' +import { TokenInfo } from '@cowprotocol/sdk-config' +import { + BridgeQuoteResult, + BridgeQuoteResults, + BridgeThenSwapProvider, + BridgeThenSwapQuoteAndPost, + QuoteBridgeRequest, +} from '../types' +import { GetQuoteWithBridgeThenSwapParams } from './types' +import { BridgeProviderQuoteError, BridgeQuoteErrors } from '../errors' +import { determineIntermediateToken } from './determineIntermediateToken' + +const DESTINATION_GAS_BUFFER = 30_000 + +/** + * Get a quote for a bridge-then-swap operation. + * + * Flow: + * 1. Determine the intermediate token on the destination chain (what can be bridged) + * 2. Get a bridge quote from source chain to destination chain (for the intermediate token) + * 3. Get a swap quote on the destination chain (intermediate token → final buy token) + * 4. Compute the OrderFlow address (CREATE2) + * 5. Encode the destination order data + * 6. Build the bridge transaction (includes destination payload) + * 7. Return the combined quote + */ +export async function getQuoteWithBridgeThenSwap( + provider: BridgeThenSwapProvider, + params: GetQuoteWithBridgeThenSwapParams, +): Promise { + const { swapAndBridgeRequest, tradingSdk, advancedSettings } = params + const { + kind, + sellTokenChainId, + sellTokenAddress, + sellTokenDecimals, + buyTokenChainId, + buyTokenAddress, + buyTokenDecimals, + amount, + signer: signerLike, + } = swapAndBridgeRequest + + if (kind !== OrderKind.SELL) { + throw new Error('Bridge-then-swap only supports SELL orders') + } + + const adapter = getGlobalAdapter() + const signer = signerLike ? adapter.createSigner(signerLike) : adapter.signer + const owner = swapAndBridgeRequest.owner ?? swapAndBridgeRequest.account + if (!owner) { + throw new BridgeProviderQuoteError(BridgeQuoteErrors.QUOTE_ERROR, { + error: 'Owner address is required for bridge-then-swap', + }) + } + + log( + `Bridge-then-swap: ${amount} ${sellTokenAddress} (chain ${sellTokenChainId}) → bridge → swap for ${buyTokenAddress} (chain ${buyTokenChainId})`, + ) + + // Step 1: Get intermediate tokens (tokens that can be bridged to the destination chain) + const intermediateTokens = await provider.getIntermediateTokens(swapAndBridgeRequest) + + if (intermediateTokens.length === 0) { + throw new BridgeProviderQuoteError(BridgeQuoteErrors.NO_INTERMEDIATE_TOKENS) + } + + // Determine the best intermediate token for bridging + const intermediateToken = await determineIntermediateToken( + sellTokenChainId, + sellTokenAddress, + intermediateTokens, + advancedSettings?.getCorrelatedTokens, + ) + + log(`Using ${intermediateToken?.name ?? intermediateToken?.address} as bridge intermediate token`) + + // Step 2: Get bridge quote (source chain sell token → destination chain intermediate token) + const bridgeRequest: QuoteBridgeRequest = { + ...swapAndBridgeRequest, + // The sell token is the user's source token + sellTokenAddress, + sellTokenChainId, + sellTokenDecimals, + // The buy token for the bridge is the intermediate token on the destination chain + buyTokenAddress: intermediateToken.address, + buyTokenChainId, + buyTokenDecimals: intermediateToken.decimals, + } + + const bridgeQuote = await provider.getQuote(bridgeRequest) + + // The amount that arrives on the destination chain after bridge fees + const bridgedAmount = bridgeQuote.amountsAndCosts.afterSlippage.buyAmount + + log(`Bridge quote: ${amount} → ${bridgedAmount} of ${intermediateToken.symbol} on chain ${buyTokenChainId}`) + + // Step 3: Get swap quote on the destination chain (intermediate token → final buy token) + // This is an indicative quote — the actual order is created on-chain by OrderFlowFactory + const destinationSwapResult = await getDestinationSwapQuote({ + tradingSdk, + bridgedAmount, + intermediateToken, + buyTokenAddress, + buyTokenDecimals, + buyTokenChainId, + swapAndBridgeRequest, + signer, + advancedSettings, + }) + + const destinationBuyAmount = destinationSwapResult.swapResult.amountsAndCosts.afterSlippage.buyAmount + const estimatedBuyAmount = destinationSwapResult.swapResult.amountsAndCosts.afterPartnerFees.buyAmount + + // Step 4: Compute the OrderFlow address + const orderFlowAddress = provider.getOrderFlowAddress(owner, buyTokenChainId) + + log(`OrderFlow address for ${owner} on chain ${buyTokenChainId}: ${orderFlowAddress}`) + + // Determine validTo for the destination order + // Use a generous validity window since we need time for bridge + order settlement + const validTo = Math.floor(Date.now() / 1000) + 3600 // 1 hour from now + + // Step 5: Encode destination order data + const receiver = swapAndBridgeRequest.receiver ?? owner + const destinationOrderParams = { + sellToken: intermediateToken.address, + buyToken: buyTokenAddress, + receiver, + owner, + sellAmount: bridgedAmount, + buyAmount: destinationBuyAmount, + validTo, + appData: destinationSwapResult.swapResult.appDataInfo.appDataKeccak256, + feeAmount: 0n, + partiallyFillable: false, + quoteId: undefined as number | undefined, + } + + const destinationPayload = provider.encodeDestinationOrderData(destinationOrderParams) + + // Step 6: Estimate destination gas + const baseGasLimit = await provider.getDestinationGasLimit(destinationOrderParams) + const destinationGasLimit = baseGasLimit + DESTINATION_GAS_BUFFER + + log(`Destination gas limit: ${destinationGasLimit} (${baseGasLimit} + ${DESTINATION_GAS_BUFFER} buffer)`) + + // Step 7: Build bridge transaction + const bridgeTransaction = await provider.getBridgeTransaction({ + bridgeQuote, + destinationPayload, + destinationGasLimit, + }) + + // Prepare bridge result + const bridgeResult: BridgeQuoteResults = { + providerInfo: provider.info, + id: bridgeQuote.id, + signature: bridgeQuote.signature, + quoteBody: bridgeQuote.quoteBody, + tradeParameters: bridgeRequest, + isSell: bridgeQuote.isSell, + expectedFillTimeSeconds: bridgeQuote.expectedFillTimeSeconds, + fees: bridgeQuote.fees, + limits: bridgeQuote.limits, + quoteTimestamp: bridgeQuote.quoteTimestamp, + amountsAndCosts: bridgeQuote.amountsAndCosts, + } + + // Build buy token info + const buyTokenInfo: TokenInfo = { + address: buyTokenAddress, + decimals: buyTokenDecimals, + chainId: buyTokenChainId, + name: destinationSwapResult.swapResult.tradeParameters.buyToken, + symbol: destinationSwapResult.swapResult.tradeParameters.buyToken, + } + + // Build sell token info for destination (the intermediate token) + const destSellTokenInfo: TokenInfo = { + ...intermediateToken, + chainId: buyTokenChainId, + } + + return { + bridge: bridgeResult, + destinationSwap: { + sellToken: destSellTokenInfo, + buyToken: buyTokenInfo, + estimatedBuyAmount, + minBuyAmount: destinationBuyAmount, + validTo, + quoteId: undefined, + }, + orderFlowAddress, + bridgeTransaction, + approvalData: undefined, // TODO: Extract from Bungee build-tx approvalData if needed + submitBridgeTransaction: async (txSigner: SignerLike) => { + const resolvedSigner = adapter.createSigner(txSigner) + const txResponse = await resolvedSigner.sendTransaction({ + to: bridgeTransaction.to, + data: bridgeTransaction.data, + value: BigInt(bridgeTransaction.value), + }) + return { txHash: txResponse.hash } + }, + } +} + +interface GetDestinationSwapQuoteParams { + tradingSdk: TradingSdk + bridgedAmount: bigint + intermediateToken: TokenInfo + buyTokenAddress: string + buyTokenDecimals: number + buyTokenChainId: number + swapAndBridgeRequest: QuoteBridgeRequest + signer: SignerLike + advancedSettings?: SwapAdvancedSettings +} + +async function getDestinationSwapQuote( + params: GetDestinationSwapQuoteParams, +): Promise<{ swapResult: QuoteResults }> { + const { + tradingSdk, + bridgedAmount, + intermediateToken, + buyTokenAddress, + buyTokenDecimals, + buyTokenChainId, + swapAndBridgeRequest, + signer, + advancedSettings, + } = params + + const { + kind, + swapSlippageBps, + owner, + account, + receiver, + } = swapAndBridgeRequest + + const swapParams: WithPartialTraderParams = { + kind, + chainId: buyTokenChainId, + sellToken: intermediateToken.address, + sellTokenDecimals: intermediateToken.decimals, + buyToken: buyTokenAddress, + buyTokenDecimals, + amount: bridgedAmount.toString(), + slippageBps: swapSlippageBps, + signer, + receiver: receiver ?? owner ?? account, + } + + log( + `Getting destination swap quote on chain ${buyTokenChainId}: ${intermediateToken.symbol} → ${buyTokenAddress}. Amount: ${bridgedAmount}`, + ) + + const { result: swapResult } = await tradingSdk.getQuoteResults(swapParams, advancedSettings) + + return { swapResult } +} diff --git a/packages/bridging/src/BridgingSdk/types.ts b/packages/bridging/src/BridgingSdk/types.ts index 8bafe3571..5ec266be9 100644 --- a/packages/bridging/src/BridgingSdk/types.ts +++ b/packages/bridging/src/BridgingSdk/types.ts @@ -38,6 +38,28 @@ export type GetQuoteWithBridgeParams = { allowIntermediateEqSellToken?: boolean } +export type GetQuoteWithBridgeThenSwapParams = { + /** + * Overall request for the bridge-then-swap operation. + */ + swapAndBridgeRequest: QuoteBridgeRequest + + /** + * Advanced settings for the destination swap. + */ + advancedSettings?: SwapAdvancedSettings + + /** + * Trading SDK. + */ + tradingSdk: TradingSdk + + /** + * Cache for intermediate tokens. + */ + intermediateTokensCache?: TTLCache +} + export type BridgingSdkConfig = Required> /** diff --git a/packages/bridging/src/index.ts b/packages/bridging/src/index.ts index ad4958d29..3e9ef10ee 100644 --- a/packages/bridging/src/index.ts +++ b/packages/bridging/src/index.ts @@ -14,6 +14,12 @@ export type { AcrossQuoteResult, AcrossBridgeProviderOptions } from './providers export { BungeeBridgeProvider } from './providers/bungee/BungeeBridgeProvider' export type { BungeeQuoteResult, BungeeBridgeProviderOptions } from './providers/bungee/BungeeBridgeProvider' +export { BungeeBridgeThenSwapProvider } from './providers/bungee/BungeeBridgeThenSwapProvider' +export type { + BungeeBridgeThenSwapQuoteResult, + BungeeBridgeThenSwapProviderOptions, +} from './providers/bungee/BungeeBridgeThenSwapProvider' + export { NearIntentsBridgeProvider } from './providers/near-intents/NearIntentsBridgeProvider' export type { diff --git a/packages/bridging/src/providers/bungee/BungeeBridgeThenSwapProvider.ts b/packages/bridging/src/providers/bungee/BungeeBridgeThenSwapProvider.ts new file mode 100644 index 000000000..9cd026344 --- /dev/null +++ b/packages/bridging/src/providers/bungee/BungeeBridgeThenSwapProvider.ts @@ -0,0 +1,277 @@ +import { EnrichedOrder, OrderKind } from '@cowprotocol/sdk-order-book' +import { + BridgeThenSwapProvider, + BridgeThenSwapTransaction, + BridgeProviderInfo, + BridgeQuoteResult, + BridgeStatusResult, + BridgingDepositParams, + BuyTokensParams, + DestinationOrderParams, + GetProviderBuyTokens, + QuoteBridgeRequest, +} from '../../types' +import { RAW_PROVIDERS_FILES_PATH } from '../../const' +import { BungeeApi } from './BungeeApi' +import { toBridgeQuoteResult } from './util' +import { HOOK_DAPP_BRIDGE_PROVIDER_PREFIX } from './const/misc' +import { BungeeApiOptions, BungeeQuote, BungeeBuildTx, BungeeQuoteAPIRequest } from './types' +import { BridgeProviderQuoteError, BridgeQuoteErrors } from '../../errors' +import { getBridgingStatusFromEvents } from './getBridgingStatusFromEvents' +import { computeOrderFlowAddress } from './bridgeThenSwap/computeOrderFlowAddress' +import { encodeOrderData } from './bridgeThenSwap/encodeOrderData' +import { + arbitrumOne, + avalanche, + base, + ChainId, + ChainInfo, + EvmCall, + gnosisChain, + mainnet, + optimism, + polygon, + SupportedChainId, + TargetChainId, + TokenInfo, +} from '@cowprotocol/sdk-config' +import { AbstractProviderAdapter, setGlobalAdapter } from '@cowprotocol/sdk-common' + +export const BUNGEE_BRIDGE_THEN_SWAP_DAPP_ID = `${HOOK_DAPP_BRIDGE_PROVIDER_PREFIX}/bungee-bridge-then-swap` +export const BUNGEE_BRIDGE_THEN_SWAP_SUPPORTED_NETWORKS = [ + mainnet, + polygon, + arbitrumOne, + base, + optimism, + avalanche, + gnosisChain, +] + +/** No dex swaps happening on the bridge side. Slippage is zero for the bridge leg. */ +const SLIPPAGE_TOLERANCE_BPS = 0 + +/** Estimated gas for OrderFlowFactory.executeData + OrderFlow.createOrder */ +const DEFAULT_DESTINATION_GAS_ESTIMATE = 500_000 + +export interface BungeeBridgeThenSwapProviderOptions { + /** Bungee API options */ + apiOptions?: BungeeApiOptions + + /** + * OrderFlowFactory contract addresses per destination chain. + * Required: at least one destination chain must be configured. + */ + orderFlowFactoryAddresses: Partial> + + /** + * The keccak256 hash of the OrderFlow contract init code. + * Used for CREATE2 address computation. + */ + orderFlowInitCodeHash: string +} + +export interface BungeeBridgeThenSwapQuoteResult extends BridgeQuoteResult { + bungeeQuote: BungeeQuote + buildTx: BungeeBuildTx +} + +const providerType = 'BridgeThenSwapProvider' as const + +export class BungeeBridgeThenSwapProvider implements BridgeThenSwapProvider { + type = providerType + + protected api: BungeeApi + + constructor( + private options: BungeeBridgeThenSwapProviderOptions, + _adapter?: AbstractProviderAdapter, + ) { + if (_adapter) { + setGlobalAdapter(_adapter) + } + + this.api = new BungeeApi(options.apiOptions) + } + + info: BridgeProviderInfo = { + name: 'Bungee Bridge & Swap', + logoUrl: `${RAW_PROVIDERS_FILES_PATH}/bungee/bungee-logo.png`, + dappId: BUNGEE_BRIDGE_THEN_SWAP_DAPP_ID, + website: 'https://www.bungee.exchange', + type: providerType, + } + + async getNetworks(): Promise { + return BUNGEE_BRIDGE_THEN_SWAP_SUPPORTED_NETWORKS + } + + async getBuyTokens(params: BuyTokensParams): Promise { + const tokens = await this.api.getBuyTokens(params) + const isRouteAvailable = tokens.length > 0 + + return { + tokens, + isRouteAvailable, + } + } + + async getIntermediateTokens(request: QuoteBridgeRequest): Promise { + if (request.kind !== OrderKind.SELL) { + throw new BridgeProviderQuoteError(BridgeQuoteErrors.ONLY_SELL_ORDER_SUPPORTED, { kind: request.kind }) + } + + // For bridge-then-swap, intermediate tokens are tokens that can be bridged + // from the source chain to the destination chain (where the swap will happen) + return this.api.getIntermediateTokens({ + fromChainId: request.sellTokenChainId, + toChainId: request.buyTokenChainId, + toTokenAddress: request.buyTokenAddress, + }) + } + + async getQuote(request: QuoteBridgeRequest): Promise { + const { sellTokenAddress, sellTokenChainId, buyTokenChainId, buyTokenAddress, amount, receiver, account, owner } = + request + + const senderAddress = owner ?? account ?? receiver + if (!senderAddress) { + throw new BridgeProviderQuoteError(BridgeQuoteErrors.QUOTE_ERROR, { + error: 'User address is required for bridge-then-swap quotes', + }) + } + + const factoryAddress = this.getFactoryAddress(buyTokenChainId) + + const bungeeQuoteRequest: BungeeQuoteAPIRequest = { + userAddress: senderAddress, + originChainId: sellTokenChainId.toString(), + destinationChainId: buyTokenChainId.toString(), + inputToken: sellTokenAddress, + inputAmount: amount.toString(), + receiverAddress: factoryAddress, + outputToken: buyTokenAddress, + includeBridges: this.options.apiOptions?.includeBridges, + enableManual: true, + disableSwapping: true, + disableAuto: true, + } + + const quoteWithBuildTx = await this.api.getBungeeQuoteWithBuildTx(bungeeQuoteRequest) + + // Verify build-tx data + const isBuildTxValid = await this.api.verifyBungeeBuildTx(quoteWithBuildTx.bungeeQuote, quoteWithBuildTx.buildTx) + + if (!isBuildTxValid) { + throw new BridgeProviderQuoteError(BridgeQuoteErrors.TX_BUILD_ERROR, quoteWithBuildTx) + } + + return toBridgeQuoteResult( + request, + SLIPPAGE_TOLERANCE_BPS, + quoteWithBuildTx, + ) as BungeeBridgeThenSwapQuoteResult + } + + getOrderFlowAddress(owner: string, destinationChainId: TargetChainId): string { + const factoryAddress = this.getFactoryAddress(destinationChainId) + return computeOrderFlowAddress(factoryAddress, owner, this.options.orderFlowInitCodeHash) + } + + encodeDestinationOrderData(params: DestinationOrderParams): string { + return encodeOrderData(params) + } + + async getDestinationGasLimit(_params: DestinationOrderParams): Promise { + // Return a conservative estimate. This covers: + // - Bungee receiver contract overhead (30k is added by the caller) + // - OrderFlowFactory.executeData: token transfer + CREATE2 deploy (if needed) + order creation + // - OrderFlow.createOrder: EIP1271 signature setup + OrderPlacement event emission + return DEFAULT_DESTINATION_GAS_ESTIMATE + } + + async getBridgeTransaction(params: { + bridgeQuote: BungeeBridgeThenSwapQuoteResult + destinationPayload: string + destinationGasLimit: number + }): Promise { + const { bridgeQuote } = params + const { buildTx } = bridgeQuote + + // The build-tx from Bungee already contains the bridge transaction. + // For destination execution, we need to re-request with the destination payload. + // However, since the quote was already obtained with the factory as receiver, + // we use the build-tx directly. The destination payload is included in the + // Bungee quote request params (destinationPayload, destinationGasLimit). + return { + to: buildTx.txData.to, + data: buildTx.txData.data, + value: buildTx.txData.value, + chainId: buildTx.txData.chainId, + } + } + + async getBridgingParams( + _chainId: ChainId, + order: EnrichedOrder, + _txHash: string, + ): Promise<{ params: BridgingDepositParams; status: BridgeStatusResult } | null> { + const orderId = order.uid + const events = await this.api.getEvents({ orderId }) + const event = events?.[0] + + if (!event) return null + + const status = await getBridgingStatusFromEvents(events, (orderId) => this.api.getAcrossStatus(orderId)) + + const params: BridgingDepositParams = { + inputTokenAddress: event.srcTokenAddress, + outputTokenAddress: event.destTokenAddress, + inputAmount: BigInt(event.srcAmount), + outputAmount: event.destAmount ? BigInt(event.destAmount) : null, + owner: event.sender, + quoteTimestamp: null, + fillDeadline: null, + recipient: event.recipient, + sourceChainId: event.fromChainId, + destinationChainId: event.toChainId, + bridgingId: orderId, + } + + return { params, status } + } + + getExplorerUrl(bridgingId: string): string { + return `https://socketscan.io/tx/${bridgingId}` + } + + async getStatus(_bridgingId: string): Promise { + const events = await this.api.getEvents({ orderId: _bridgingId }) + + return getBridgingStatusFromEvents(events, (orderId) => this.api.getAcrossStatus(orderId)) + } + + async getCancelBridgingTx(_bridgingId: string): Promise { + throw new Error('Not implemented') + } + + async getRefundBridgingTx(_bridgingId: string): Promise { + throw new Error('Not implemented') + } + + /** + * Get the OrderFlowFactory address for a destination chain. + * Throws if no factory is configured for that chain. + */ + private getFactoryAddress(chainId: TargetChainId): string { + const address = this.options.orderFlowFactoryAddresses[chainId as SupportedChainId] + + if (!address) { + throw new BridgeProviderQuoteError(BridgeQuoteErrors.QUOTE_ERROR, { + error: `No OrderFlowFactory address configured for chain ${chainId}`, + }) + } + + return address + } +} diff --git a/packages/bridging/src/providers/bungee/bridgeThenSwap/computeOrderFlowAddress.ts b/packages/bridging/src/providers/bungee/bridgeThenSwap/computeOrderFlowAddress.ts new file mode 100644 index 000000000..c2ac99c1b --- /dev/null +++ b/packages/bridging/src/providers/bungee/bridgeThenSwap/computeOrderFlowAddress.ts @@ -0,0 +1,26 @@ +import { getGlobalAdapter } from '@cowprotocol/sdk-common' + +/** + * Compute the deterministic CREATE2 address for an OrderFlow contract + * deployed by the OrderFlowFactory for a given owner. + * + * The factory uses: CREATE2(factory, keccak256(owner), initCodeHash) + * + * @param factoryAddress - Address of the OrderFlowFactory on the destination chain + * @param owner - The user's address (used as salt for CREATE2) + * @param initCodeHash - The keccak256 hash of the OrderFlow contract init code + */ +export function computeOrderFlowAddress( + factoryAddress: string, + owner: string, + initCodeHash: string, +): string { + const adapter = getGlobalAdapter() + + // The salt is the keccak256 hash of the owner address padded to bytes32 + const salt = adapter.utils.keccak256( + adapter.utils.encodeAbi(['address'], [owner]) as string, + ) + + return adapter.utils.getCreate2Address(factoryAddress, salt, initCodeHash) +} diff --git a/packages/bridging/src/providers/bungee/bridgeThenSwap/encodeOrderData.ts b/packages/bridging/src/providers/bungee/bridgeThenSwap/encodeOrderData.ts new file mode 100644 index 000000000..75d15e90e --- /dev/null +++ b/packages/bridging/src/providers/bungee/bridgeThenSwap/encodeOrderData.ts @@ -0,0 +1,31 @@ +import { getGlobalAdapter } from '@cowprotocol/sdk-common' +import { DestinationOrderParams } from '../../../types' + +/** + * ABI-encode an OrderFlowOrder.Data struct for use as the Bungee destination payload. + * The encoded data will be passed to OrderFlowFactory.executeData() as the callData parameter. + */ +export function encodeOrderData(params: DestinationOrderParams): string { + const adapter = getGlobalAdapter() + + const orderTuple = [ + params.sellToken, // sellToken (IERC20) + params.buyToken, // buyToken (IERC20) + params.receiver, // receiver + params.owner, // owner + params.sellAmount, // sellAmount + params.buyAmount, // buyAmount + params.appData, // appData (bytes32) + params.feeAmount ?? 0n, // feeAmount + params.validTo, // validTo (uint32) + params.partiallyFillable ?? false, // partiallyFillable + params.quoteId ?? 0, // quoteId (int64) + ] + + return adapter.utils.encodeAbi( + [ + 'tuple(address sellToken, address buyToken, address receiver, address owner, uint256 sellAmount, uint256 buyAmount, bytes32 appData, uint256 feeAmount, uint32 validTo, bool partiallyFillable, int64 quoteId)', + ], + [orderTuple], + ) as string +} diff --git a/packages/bridging/src/providers/bungee/bridgeThenSwap/orderFlowAbi.ts b/packages/bridging/src/providers/bungee/bridgeThenSwap/orderFlowAbi.ts new file mode 100644 index 000000000..6c9072ade --- /dev/null +++ b/packages/bridging/src/providers/bungee/bridgeThenSwap/orderFlowAbi.ts @@ -0,0 +1,60 @@ +/** + * ABI for the OrderFlowFactory contract. + * Used to compute deterministic OrderFlow addresses and interact with the factory. + */ +export const ORDER_FLOW_FACTORY_ABI = [ + { + type: 'function', + name: 'getOrderFlowAddress', + inputs: [{ name: 'owner', type: 'address', internalType: 'address' }], + outputs: [{ name: '', type: 'address', internalType: 'address' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'triggerOrderCreation', + inputs: [ + { + name: 'order', + type: 'tuple', + internalType: 'struct OrderFlowOrder.Data', + components: [ + { name: 'sellToken', type: 'address', internalType: 'contract IERC20' }, + { name: 'buyToken', type: 'address', internalType: 'contract IERC20' }, + { name: 'receiver', type: 'address', internalType: 'address' }, + { name: 'owner', type: 'address', internalType: 'address' }, + { name: 'sellAmount', type: 'uint256', internalType: 'uint256' }, + { name: 'buyAmount', type: 'uint256', internalType: 'uint256' }, + { name: 'appData', type: 'bytes32', internalType: 'bytes32' }, + { name: 'feeAmount', type: 'uint256', internalType: 'uint256' }, + { name: 'validTo', type: 'uint32', internalType: 'uint32' }, + { name: 'partiallyFillable', type: 'bool', internalType: 'bool' }, + { name: 'quoteId', type: 'int64', internalType: 'int64' }, + ], + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, +] as const + +/** + * ABI tuple type for encoding OrderFlowOrder.Data struct. + * Used with ABI encoding utilities to produce the destinationPayload. + */ +export const ORDER_FLOW_ORDER_DATA_TUPLE = { + type: 'tuple', + components: [ + { name: 'sellToken', type: 'address' }, + { name: 'buyToken', type: 'address' }, + { name: 'receiver', type: 'address' }, + { name: 'owner', type: 'address' }, + { name: 'sellAmount', type: 'uint256' }, + { name: 'buyAmount', type: 'uint256' }, + { name: 'appData', type: 'bytes32' }, + { name: 'feeAmount', type: 'uint256' }, + { name: 'validTo', type: 'uint32' }, + { name: 'partiallyFillable', type: 'bool' }, + { name: 'quoteId', type: 'int64' }, + ], +} as const diff --git a/packages/bridging/src/providers/bungee/types.ts b/packages/bridging/src/providers/bungee/types.ts index af4d8669a..1dcecf511 100644 --- a/packages/bridging/src/providers/bungee/types.ts +++ b/packages/bridging/src/providers/bungee/types.ts @@ -14,6 +14,10 @@ export interface BungeeQuoteAPIRequest { disableSwapping: true disableAuto: true includeBridges?: SupportedBridge[] + /** ABI-encoded calldata to be executed on the destination chain after bridging */ + destinationPayload?: string + /** Gas limit for executing the destination payload (add 30k buffer for receiver contract overhead) */ + destinationGasLimit?: string } export interface BungeeQuoteAPIResponse { diff --git a/packages/bridging/src/types.ts b/packages/bridging/src/types.ts index ce7d89460..6328d361b 100644 --- a/packages/bridging/src/types.ts +++ b/packages/bridging/src/types.ts @@ -21,7 +21,7 @@ import type { } from '@cowprotocol/sdk-trading' import type { AccountAddress, SignerLike, TTLCache } from '@cowprotocol/sdk-common' -export type BridgeProviderType = 'ReceiverAccountBridgeProvider' | 'HookBridgeProvider' +export type BridgeProviderType = 'ReceiverAccountBridgeProvider' | 'HookBridgeProvider' | 'BridgeThenSwapProvider' export interface BridgeProviderInfo { name: string @@ -343,6 +343,83 @@ export interface HookBridgeProvider extends BridgeP decodeBridgeHook(hook: latestAppData.CoWHook): Promise } +/** + * Parameters for encoding a destination chain CoW Protocol order. + * This data is ABI-encoded and passed as the bridge's destination payload. + */ +export interface DestinationOrderParams { + /** Token arriving from bridge (on destination chain) */ + sellToken: string + /** Final desired token (on destination chain) */ + buyToken: string + /** User's address to receive bought tokens */ + receiver: string + /** User address (used for OrderFlow CREATE2 derivation) */ + owner: string + /** Amount of sell token (bridge output) */ + sellAmount: bigint + /** Minimum buy amount from CoW quote */ + buyAmount: bigint + /** Order validity timestamp */ + validTo: number + /** bytes32 appData hash */ + appData: string + /** Fee amount (typically 0 for limit orders) */ + feeAmount?: bigint + /** Whether the order is partially fillable */ + partiallyFillable?: boolean + /** Quote ID from CoW Swap API */ + quoteId?: number +} + +/** + * Transaction data for submitting a bridge-then-swap on the source chain. + */ +export interface BridgeThenSwapTransaction { + to: string + data: string + value: string + chainId: number +} + +/** + * A bridge provider that initiates a bridge on the source chain, with the destination chain + * automatically creating a CoW Protocol order from the bridged tokens. + * + * Flow: Bridge (source chain) → OrderFlowFactory receives tokens → OrderFlow creates CoW order (destination chain) + */ +export interface BridgeThenSwapProvider extends BridgeProvider { + type: 'BridgeThenSwapProvider' + + /** + * Get the deterministic address of the OrderFlow contract on the destination chain. + * Computed via CREATE2 from the OrderFlowFactory. + */ + getOrderFlowAddress(owner: string, destinationChainId: TargetChainId): string + + /** + * Encode the order data that will be passed as destination payload to the bridge. + * This encodes an OrderFlowOrder.Data struct for the OrderFlowFactory. + */ + encodeDestinationOrderData(params: DestinationOrderParams): string + + /** + * Estimate the gas limit for executing the destination payload + * (OrderFlowFactory.executeData + OrderFlow.createOrder). + */ + getDestinationGasLimit(params: DestinationOrderParams): Promise + + /** + * Build the unsigned bridge transaction for the user to sign on the source chain. + * This includes the destination payload (encoded order) in the bridge calldata. + */ + getBridgeTransaction(params: { + bridgeQuote: Q + destinationPayload: string + destinationGasLimit: number + }): Promise +} + /** * A quote and post for a cross-chain swap. * @@ -350,7 +427,7 @@ export interface HookBridgeProvider extends BridgeP * If the order happens in multiple chains, it returns the quote and post details for CoW Protocol, the bridging * details, and a summary of the overall multi-step order. */ -export type CrossChainQuoteAndPost = QuoteAndPost | BridgeQuoteAndPost +export type CrossChainQuoteAndPost = QuoteAndPost | BridgeQuoteAndPost | BridgeThenSwapQuoteAndPost export interface BridgeQuoteAndPost { /** @@ -374,6 +451,53 @@ export interface BridgeQuoteAndPost { ): Promise } +/** + * Quote result for a bridge-then-swap operation. + * + * Unlike BridgeQuoteAndPost (swap-then-bridge), this represents the reverse flow: + * the user submits a bridge transaction on the source chain, and a CoW Protocol order + * is automatically created on the destination chain by the OrderFlowFactory contract. + */ +export interface BridgeThenSwapQuoteAndPost { + /** + * Bridge quote details (source chain bridging). + */ + bridge: BridgeQuoteResults + + /** + * Estimated swap on the destination chain (indicative, since the actual order + * is created by the OrderFlow contract after bridging completes). + */ + destinationSwap: { + sellToken: TokenInfo + buyToken: TokenInfo + estimatedBuyAmount: bigint + minBuyAmount: bigint + validTo: number + quoteId?: string + } + + /** + * Deterministic OrderFlow contract address on the destination chain. + */ + orderFlowAddress: string + + /** + * The bridge transaction to submit on the source chain. + */ + bridgeTransaction: BridgeThenSwapTransaction + + /** + * Approval data if ERC20 approval is needed before submitting the bridge transaction. + */ + approvalData?: { spenderAddress: string; amount: string; tokenAddress: string } + + /** + * Submit the bridge transaction on the source chain. + */ + submitBridgeTransaction(signer: SignerLike): Promise<{ txHash: string }> +} + export interface BridgeCosts { bridgingFee: { feeBps: number @@ -480,7 +604,7 @@ export interface CrossChainOrder { export interface MultiQuoteResult { providerDappId: string - quote: BridgeQuoteAndPost | null + quote: BridgeQuoteAndPost | BridgeThenSwapQuoteAndPost | null error?: Error } diff --git a/packages/bridging/src/utils.ts b/packages/bridging/src/utils.ts index ff77aedc2..2f3958bd6 100644 --- a/packages/bridging/src/utils.ts +++ b/packages/bridging/src/utils.ts @@ -5,16 +5,24 @@ import { BridgeProvider, BridgeQuoteAndPost, BridgeQuoteResult, + BridgeThenSwapProvider, + BridgeThenSwapQuoteAndPost, CrossChainQuoteAndPost, HookBridgeProvider, } from './types' export function isBridgeQuoteAndPost(quote: CrossChainQuoteAndPost): quote is BridgeQuoteAndPost { - return 'bridge' in quote + return 'bridge' in quote && 'swap' in quote && !('bridgeTransaction' in quote) +} + +export function isBridgeThenSwapQuoteAndPost( + quote: CrossChainQuoteAndPost, +): quote is BridgeThenSwapQuoteAndPost { + return 'bridgeTransaction' in quote && 'orderFlowAddress' in quote } export function isQuoteAndPost(quote: CrossChainQuoteAndPost): quote is QuoteAndPost { - return !isBridgeQuoteAndPost(quote) + return !isBridgeQuoteAndPost(quote) && !isBridgeThenSwapQuoteAndPost(quote) } export function assertIsBridgeQuoteAndPost(quote: CrossChainQuoteAndPost): asserts quote is BridgeQuoteAndPost { @@ -29,6 +37,16 @@ export function assertIsQuoteAndPost(quote: CrossChainQuoteAndPost): asserts quo } } +export function assertIsBridgeThenSwapQuoteAndPost( + quote: CrossChainQuoteAndPost, +): asserts quote is BridgeThenSwapQuoteAndPost { + if (!isBridgeThenSwapQuoteAndPost(quote)) { + throw new Error( + 'Quote result is not of type BridgeThenSwapQuoteAndPost. Are you sure this is a bridge-then-swap operation?', + ) + } +} + export function getPostHooks(fullAppData?: string | object): latestAppData.CoWHook[] { if (!fullAppData) { return [] @@ -62,3 +80,9 @@ export function isReceiverAccountBridgeProvider( ): provider is ReceiverAccountBridgeProvider { return provider.type === 'ReceiverAccountBridgeProvider' } + +export function isBridgeThenSwapProvider( + provider: BridgeProvider, +): provider is BridgeThenSwapProvider { + return provider.type === 'BridgeThenSwapProvider' +}