Skip to content

Commit

Permalink
feat(sdk-router): Gas.zip module [SYN-37] (#3528)
Browse files Browse the repository at this point in the history
* feat: scaffold GasZip module

* refactor: isolate logging utils

* refactor: isolate API utils

* feat: bridge, status first impl

* feat: supported chain ID check

* feat: `isSameAddress`

* feat: gas.zip quotes

* feat: no-op slippage

* feat: add gas.zip module

* fix: sanitize expected amount from gas.zip

* fix: bridge module name

* feat: prioritize gas.zip quotes for testing [REVERT LATER]

* feat: regenerate bridge map with gas.zip [SYN-38]

* feat: don't show slippage for gas.zip for now [SYN-39]

* feat: add BNB, BERA and other native tokens

* feat: add HyperEVM to the list of chains

* feat: add HYPE to bridge map

* chore: add hyperEVM to spellcheck

* chore: clean up TODOs

* fix: remove trailing slash

* fix: remove unsupported gas.zip assets [SYN-53]

* Revert "feat: prioritize gas.zip quotes for testing [REVERT LATER]"

This reverts commit 3072d75.

* feat: track gas.zip refund status

* fix: gas.zip refund tracking

* refactor: logs cleanup, better typing
  • Loading branch information
ChiTimesChi authored Feb 27, 2025
1 parent b3c1e93 commit 34db356
Show file tree
Hide file tree
Showing 26 changed files with 956 additions and 69 deletions.
1 change: 1 addition & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"gitbook",
"gorm",
"headlessui",
"hyperevm",
"hyperliquid",
"incentivized",
"interchain",
Expand Down
108 changes: 108 additions & 0 deletions packages/sdk-router/src/gaszip/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { BigNumber } from 'ethers'
import { Zero } from '@ethersproject/constants'

import { BigintIsh } from '../constants'
import { getWithTimeout } from '../utils/api'

const GAS_ZIP_API_URL = 'https://backend.gas.zip/v2'
const GAS_ZIP_API_TIMEOUT = 2000

interface Transaction {
status: string
}

interface TransactionStatusData {
txs: Transaction[]
}

interface Chains {
chains: [
{
name: string
chain: number // native chain id
short: number // unique Gas.zip id
gas: string // gas usage of a simple transfer
gwei: string // current gas price
bal: string // balance of the Gas.zip reloader
rpcs: string[]
symbol: string
price: number
}
]
}

interface CalldataQuoteResponse {
calldata: string
quotes: {
chain: number
expected: string
gas: string
speed: number
usd: number
}[]
}

export type GasZipQuote = {
amountOut: BigNumber
calldata: string
}

const EMPTY_GAS_ZIP_QUOTE: GasZipQuote = {
amountOut: Zero,
calldata: '0x',
}

export const getGasZipTxStatus = async (txHash: string): Promise<boolean> => {
const response = await getWithTimeout(
'Gas.Zip API',
`${GAS_ZIP_API_URL}/search/${txHash}`,
GAS_ZIP_API_TIMEOUT
)
if (!response) {
return false
}
const data: TransactionStatusData = await response.json()
return data.txs.length > 0 && data.txs[0].status === 'CONFIRMED'
}

export const getChainIds = async (): Promise<number[]> => {
const response = await getWithTimeout(
'Gas.Zip API',
`${GAS_ZIP_API_URL}/chains`,
GAS_ZIP_API_TIMEOUT
)
if (!response) {
return []
}
const data: Chains = await response.json()
return data.chains.map((chain) => chain.chain)
}

export const getGasZipQuote = async (
originChainId: number,
destChainId: number,
amount: BigintIsh,
to: string,
from?: string
): Promise<GasZipQuote> => {
const response = await getWithTimeout(
'Gas.Zip API',
`${GAS_ZIP_API_URL}/quotes/${originChainId}/${amount}/${destChainId}`,
GAS_ZIP_API_TIMEOUT,
{
from,
to,
}
)
if (!response) {
return EMPTY_GAS_ZIP_QUOTE
}
const data: CalldataQuoteResponse = await response.json()
if (data.quotes.length === 0 || !data.quotes[0].expected) {
return EMPTY_GAS_ZIP_QUOTE
}
return {
amountOut: BigNumber.from(data.quotes[0].expected.toString()),
calldata: data.calldata,
}
}
69 changes: 69 additions & 0 deletions packages/sdk-router/src/gaszip/gasZipModule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { Provider } from '@ethersproject/abstract-provider'
import { BigNumber, PopulatedTransaction } from 'ethers'
import invariant from 'tiny-invariant'

import { Query, SynapseModule } from '../module'
import { BigintIsh } from '../constants'
import { isNativeToken } from '../utils/handleNativeToken'
import { getGasZipQuote, getGasZipTxStatus } from './api'
import { isSameAddress } from '../utils/addressUtils'

export class GasZipModule implements SynapseModule {
readonly address = '0x391E7C679d29bD940d63be94AD22A25d25b5A604'

public readonly chainId: number
public readonly provider: Provider

constructor(chainId: number, provider: Provider) {
invariant(chainId, 'CHAIN_ID_UNDEFINED')
invariant(provider, 'PROVIDER_UNDEFINED')
this.chainId = chainId
this.provider = provider
}

/**
* @inheritdoc SynapseModule.bridge
*/
public async bridge(
to: string,
destChainId: number,
token: string,
amount: BigintIsh,
originQuery: Query,
destQuery: Query
): Promise<PopulatedTransaction> {
if (!isNativeToken(token)) {
throw new Error('Non-native token not supported by gas.zip')
}
if (isSameAddress(to, destQuery.rawParams)) {
return {
to: this.address,
value: BigNumber.from(amount),
data: originQuery.rawParams,
}
}
const quote = await getGasZipQuote(this.chainId, destChainId, amount, to)
if (quote.amountOut.lt(destQuery.minAmountOut)) {
throw new Error('Insufficient amount out')
}
return {
to: this.address,
value: BigNumber.from(amount),
data: quote.calldata,
}
}

/**
* @inheritdoc SynapseModule.getSynapseTxId
*/
public async getSynapseTxId(txHash: string): Promise<string> {
return txHash
}

/**
* @inheritdoc SynapseModule.getBridgeTxStatus
*/
public async getBridgeTxStatus(synapseTxId: string): Promise<boolean> {
return getGasZipTxStatus(synapseTxId)
}
}
172 changes: 172 additions & 0 deletions packages/sdk-router/src/gaszip/gasZipModuleSet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { BigNumber } from 'ethers'
import { Provider } from '@ethersproject/abstract-provider'
import { AddressZero, Zero } from '@ethersproject/constants'

import {
BridgeRoute,
createNoSwapQuery,
FeeConfig,
Query,
SynapseModule,
SynapseModuleSet,
} from '../module'
import { ChainProvider } from '../router'
import { getChainIds, getGasZipQuote } from './api'
import { GasZipModule } from './gasZipModule'
import { isNativeToken } from '../utils/handleNativeToken'
import { BigintIsh } from '../constants'

const MEDIAN_TIME_GAS_ZIP = 30

export class GasZipModuleSet extends SynapseModuleSet {
public readonly bridgeModuleName = 'Gas.zip'
public readonly allEvents = []

public modules: {
[chainId: number]: GasZipModule
}
public providers: {
[chainId: number]: Provider
}

private cachedChainIds: number[]

constructor(chains: ChainProvider[]) {
super()
this.modules = {}
this.providers = {}
this.cachedChainIds = []
chains.forEach(({ chainId, provider }) => {
this.modules[chainId] = new GasZipModule(chainId, provider)
this.providers[chainId] = provider
})
}

/**
* @inheritdoc SynapseModuleSet.getModule
*/
public getModule(chainId: number): SynapseModule | undefined {
return this.modules[chainId]
}

/**
* @inheritdoc SynapseModuleSet.getEstimatedTime
*/
public getEstimatedTime(): number {
return MEDIAN_TIME_GAS_ZIP
}

/**
* @inheritdoc SynapseModuleSet.getGasDropAmount
*/
public async getGasDropAmount(): Promise<BigNumber> {
return Zero
}

/**
* @inheritdoc SynapseModuleSet.getBridgeRoutes
*/
public async getBridgeRoutes(
originChainId: number,
destChainId: number,
tokenIn: string,
tokenOut: string,
amountIn: BigintIsh,
originUserAddress?: string
): Promise<BridgeRoute[]> {
// Check that both chains are supported by gas.zip
const supportedChainIds = await this.getChainIds()
if (
!supportedChainIds.includes(originChainId) ||
!supportedChainIds.includes(destChainId)
) {
return []
}
// Check that both tokens are native assets
if (!isNativeToken(tokenIn) || !isNativeToken(tokenOut)) {
return []
}
const user = originUserAddress ?? AddressZero
const quote = await getGasZipQuote(
originChainId,
destChainId,
amountIn,
user,
user
)
// Check that non-zero amount is returned
if (quote.amountOut.eq(Zero)) {
return []
}
// Save user address in the origin query raw params
const originQuery = createNoSwapQuery(tokenIn, BigNumber.from(amountIn))
originQuery.rawParams = quote.calldata
const destQuery = createNoSwapQuery(tokenOut, quote.amountOut)
destQuery.rawParams = user
const route: BridgeRoute = {
originChainId,
destChainId,
originQuery,
destQuery,
bridgeToken: {
symbol: 'NATIVE',
token: tokenIn,
},
bridgeModuleName: this.bridgeModuleName,
}
return [route]
}

/**
* @inheritdoc SynapseModuleSet.getFeeData
*/
public async getFeeData(): Promise<{
feeAmount: BigNumber
feeConfig: FeeConfig
}> {
// There's no good way to determine the fee for gas.zip
return {
feeAmount: Zero,
feeConfig: {
bridgeFee: 0,
minFee: BigNumber.from(0),
maxFee: BigNumber.from(0),
},
}
}

/**
* @inheritdoc SynapseModuleSet.getDefaultPeriods
*/
public getDefaultPeriods(): {
originPeriod: number
destPeriod: number
} {
// Deadline settings are not supported by gas.zip
return {
originPeriod: 0,
destPeriod: 0,
}
}

/**
* @inheritdoc SynapseModuleSet.applySlippage
*/
public applySlippage(
originQueryPrecise: Query,
destQueryPrecise: Query
): { originQuery: Query; destQuery: Query } {
// Slippage settings are not supported by gas.zip
return {
originQuery: originQueryPrecise,
destQuery: destQueryPrecise,
}
}

private async getChainIds(): Promise<number[]> {
if (this.cachedChainIds.length === 0) {
this.cachedChainIds = await getChainIds()
}
return this.cachedChainIds
}
}
3 changes: 3 additions & 0 deletions packages/sdk-router/src/gaszip/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './api'
export * from './gasZipModule'
export * from './gasZipModuleSet'
Loading

0 comments on commit 34db356

Please sign in to comment.