Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

- added: Add Bridgeless plugin

## 2.32.0 (2025-08-25)

- added: Fantom/Sonic Upgrade: throw `SwapAddressError` when from/to wallet addresses differ so the GUI can auto-select or split a FTM wallet
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
},
"dependencies": {
"@cosmjs/encoding": "^0.32.2",
"@scure/base": "^2.0.0",
"@unizen-io/unizen-contract-addresses": "^0.0.15",
"biggystring": "^4.2.3",
"cleaners": "^0.3.13",
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { makeLetsExchangePlugin } from './swap/central/letsexchange'
import { makeSideshiftPlugin } from './swap/central/sideshift'
import { makeSwapuzPlugin } from './swap/central/swapuz'
import { make0xGaslessPlugin } from './swap/defi/0x/0xGasless'
import { makeBridgelessPlugin } from './swap/defi/bridgeless'
import { makeCosmosIbcPlugin } from './swap/defi/cosmosIbc'
import { makeFantomSonicUpgradePlugin } from './swap/defi/fantomSonicUpgrade'
import { makeLifiPlugin } from './swap/defi/lifi'
Expand All @@ -26,6 +27,7 @@ import { xrpdex } from './swap/xrpDexInfo'

const plugins = {
// Swap plugins:
bridgeless: makeBridgelessPlugin,
changehero: makeChangeHeroPlugin,
changenow: makeChangeNowPlugin,
cosmosibc: makeCosmosIbcPlugin,
Expand Down
358 changes: 358 additions & 0 deletions src/swap/defi/bridgeless.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,358 @@
import { base16, base58 } from '@scure/base'
import { add, floor, lt, mul, sub } from 'biggystring'
import {
asArray,
asBoolean,
asEither,
asNull,
asNumber,
asObject,
asString
} from 'cleaners'
import {
EdgeAssetAction,
EdgeCorePluginOptions,
EdgeCurrencyWallet,
EdgeFetchFunction,
EdgeSpendInfo,
EdgeSwapInfo,
EdgeSwapPlugin,
EdgeSwapQuote,
EdgeSwapRequest,
EdgeToken,
EdgeTokenId,
EdgeTxActionSwap,
SwapBelowLimitError,
SwapCurrencyError
} from 'edge-core-js/types'

import {
getMaxSwappable,
makeSwapPluginQuote,
SwapOrder
} from '../../util/swapHelpers'
import { convertRequest, getAddress } from '../../util/utils'
import { EdgeSwapRequestPlugin, MakeTxParams } from '../types'

const pluginId = 'bridgeless'

const swapInfo: EdgeSwapInfo = {
pluginId,
displayName: 'Bridgeless',
isDex: true,
orderUri: undefined,
supportEmail: 'support@edge.com'
}

const BASE_URL = 'https://rpc-api.node0.mainnet.bridgeless.com'

const EDGE_PLUGINID_CHAINID_MAP: Record<string, string> = {
bitcoin: '0',
zano: '2'
}

const asTokenInfo = asObject({
address: asString,
decimals: asString,
chain_id: asString,
token_id: asString,
is_wrapped: asBoolean
})
type TokenInfo = ReturnType<typeof asTokenInfo>

const asToken = asObject({
id: asString,
// metadata: {
// name: 'Bitcoin',
// symbol: 'BTC',
// uri: 'https://avatars.githubusercontent.com/u/44211915?s=200&v=4',
// dex_name: ''
// },
info: asArray(asTokenInfo),
commission_rate: asString // '0.01'
})
type Token = ReturnType<typeof asToken>

const asPagination = asObject({
next_key: asEither(asString, asNull),
total: asString
})

const asBridgeChain = asObject({
chain: asObject({
id: asString,
type: asString,
bridge_address: asString,
operator: asString,
confirmations: asNumber,
name: asString
})
})

const asBridgeTokens = asObject({
tokens: asArray(asToken),
pagination: asPagination
})

const fetchBridgeless = async (
fetch: EdgeFetchFunction,
path: string
): Promise<unknown> => {
const res = await fetch(`${BASE_URL}/cosmos/bridge/${path}`)
if (!res.ok) {
const message = await res.text()
throw new Error(`Bridgeless could not fetch ${path}: ${message}`)
}
const json = await res.json()
return json
}

export function makeBridgelessPlugin(
opts: EdgeCorePluginOptions
): EdgeSwapPlugin {
const fetchSwapQuoteInner = async (
request: EdgeSwapRequestPlugin
): Promise<SwapOrder> => {
const toAddress = await getAddress(request.toWallet)

const fromChainId =
EDGE_PLUGINID_CHAINID_MAP[request.fromWallet.currencyInfo.pluginId]
const toChainId =
EDGE_PLUGINID_CHAINID_MAP[request.toWallet.currencyInfo.pluginId]
if (fromChainId == null || toChainId == null || fromChainId === toChainId) {
throw new SwapCurrencyError(swapInfo, request)
}

const fromChainInfoRaw = await fetchBridgeless(
opts.io.fetch,
`/chains/${fromChainId}`
)
const bridgeAddress = asBridgeChain(fromChainInfoRaw).chain.bridge_address

const getTokenId = async (
wallet: EdgeCurrencyWallet,
contractAddress: string
): Promise<EdgeTokenId> => {
if (contractAddress === '0x0000000000000000000000000000000000000000') {
return null
} else {
const fakeToken: EdgeToken = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// Pack the contractAddress into a dummy EdgeToken:

currencyCode: 'FAKE',
denominations: [{ name: 'FAKE', multiplier: '1' }],
displayName: 'FAKE',
networkLocation: {
contractAddress
}
}
return await wallet.currencyConfig.getTokenId(fakeToken)
}
}

let bridgelessToken: Token | undefined
let pageKey: string | undefined
while (true) {
const pageKeyStr = pageKey == null ? '' : `?pagination.key=${pageKey}`
const raw = await fetchBridgeless(fetch, `/tokens${pageKeyStr}`)
const response = asBridgeTokens(raw)

// Find a token object where both from and to infos are present
for (const token of response.tokens) {
let fromTokenInfo: TokenInfo | undefined
let toTokenInfo: TokenInfo | undefined
for (const info of token.info) {
try {
const tokenId = await getTokenId(request.fromWallet, info.address)
if (
info.chain_id ===
EDGE_PLUGINID_CHAINID_MAP[
request.fromWallet.currencyInfo.pluginId
] &&
tokenId === request.fromTokenId
) {
fromTokenInfo = info
}
if (
info.chain_id ===
EDGE_PLUGINID_CHAINID_MAP[
request.toWallet.currencyInfo.pluginId
] &&
tokenId === request.toTokenId
) {
toTokenInfo = info
}
} catch (e) {
// ignore tokens that fail validation
}
}
if (fromTokenInfo != null && toTokenInfo != null) {
bridgelessToken = token
break
}
}

if (response.pagination.next_key == null) {
break
}
pageKey = response.pagination.next_key
}
if (bridgelessToken == null) {
throw new SwapCurrencyError(swapInfo, request)
}

const commission = bridgelessToken.commission_rate
if (commission == null) {
throw new SwapCurrencyError(swapInfo, request)
}

let fromAmount: string
let toAmount: string
if (request.quoteFor === 'to') {
fromAmount = floor(mul(request.nativeAmount, add('1', commission)), 0)
toAmount = request.nativeAmount
} else {
fromAmount = request.nativeAmount
toAmount = floor(mul(request.nativeAmount, sub('1', commission)), 0)
}

// This will be provided by the /tokens endpoint in the future. For BTC/WBTC, we'lll enforce a limit of 500000 satoshis. This limit exists both ways.
// If endpoint returns a 0 that means no limit
const minAmount = '500000'
const direction = request.quoteFor === 'to' ? 'to' : 'from'
if (lt(direction === 'to' ? toAmount : fromAmount, minAmount.toString())) {
throw new SwapBelowLimitError(swapInfo, minAmount.toString(), direction)
}

let receiver: string | undefined
switch (request.toWallet.currencyInfo.pluginId) {
case 'bitcoin': {
receiver = toAddress
break
}
case 'zano': {
receiver = base16.encode(base58.decode(toAddress))
break
}
default: {
throw new SwapCurrencyError(swapInfo, request)
}
}

const assetAction: EdgeAssetAction = {
assetActionType: 'swap'
}
const savedAction: EdgeTxActionSwap = {
actionType: 'swap',
swapInfo,
isEstimate: false,
toAsset: {
pluginId: request.toWallet.currencyInfo.pluginId,
tokenId: request.toTokenId,
nativeAmount: toAmount
},
fromAsset: {
pluginId: request.fromWallet.currencyInfo.pluginId,
tokenId: request.fromTokenId,
nativeAmount: fromAmount
},
payoutAddress: toAddress,
payoutWalletId: request.toWallet.id
}

switch (request.fromWallet.currencyInfo.pluginId) {
case 'bitcoin': {
const opReturn = `${receiver}${Buffer.from(
`#${toChainId}`,
'utf8'
).toString('hex')}`

const spendInfo: EdgeSpendInfo = {
otherParams: {
memoIndex: 1
},
tokenId: request.fromTokenId,
spendTargets: [
{
nativeAmount: fromAmount,
publicAddress: bridgeAddress
}
],
memos: [{ type: 'hex', value: opReturn }],
assetAction,
savedAction
}

return {
request,
spendInfo,
swapInfo,
fromNativeAmount: fromAmount
}
}
case 'zano': {
const bodyData = {
service_id: 'B',
instruction: 'BI',
dst_add: toAddress,
dst_net_id: toChainId,
uniform_padding: ' '
}
const jsonString: string = JSON.stringify(bodyData)
const bytes: Uint8Array = new TextEncoder().encode(jsonString)
const bodyHex: string = base16.encode(bytes)

const zanoAction = {
assetId: request.fromTokenId,
burnAmount: parseInt(fromAmount),
nativeAmount: parseInt(fromAmount),
pointTxToAddress: bridgeAddress,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Large Amount Parsing Precision Issue

Using parseInt() on fromAmount can cause precision loss for large cryptocurrency native amounts. Since fromAmount is a string representing native units, it can exceed JavaScript's safe integer range, leading to incorrect transaction values.

Fix in Cursor Fix in Web

serviceEntries: [
{
body: bodyHex,
flags: 0,
instruction: 'K',
security:
'd8f6e37f28a632c06b0b3466db1b9d2d1b36a580ee35edfd971dc1423bc412a5',
service_id: 'C'
}
]
}

const encoder = new TextEncoder()
const unsignedTx = encoder.encode(JSON.stringify(zanoAction))

const makeTxParams: MakeTxParams = {
type: 'MakeTx',
unsignedTx: unsignedTx,
metadata: {
assetAction,
savedAction
}
}

return {
request,
makeTxParams,
swapInfo,
fromNativeAmount: fromAmount
}
}
default: {
throw new SwapCurrencyError(swapInfo, request)
}
}
}

const out: EdgeSwapPlugin = {
swapInfo,

async fetchSwapQuote(req: EdgeSwapRequest): Promise<EdgeSwapQuote> {
const request = convertRequest(req)

const newRequest = await getMaxSwappable(fetchSwapQuoteInner, request)
const swapOrder = await fetchSwapQuoteInner(newRequest)
return await makeSwapPluginQuote(swapOrder)
}
}

return out
}
Loading
Loading