diff --git a/CHANGELOG.md b/CHANGELOG.md index ab02abd0..a2d28a5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/package.json b/package.json index 7df3b2ca..12820e70 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/index.ts b/src/index.ts index c6ddecce..f0c1961b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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' @@ -26,6 +27,7 @@ import { xrpdex } from './swap/xrpDexInfo' const plugins = { // Swap plugins: + bridgeless: makeBridgelessPlugin, changehero: makeChangeHeroPlugin, changenow: makeChangeNowPlugin, cosmosibc: makeCosmosIbcPlugin, diff --git a/src/swap/defi/bridgeless.ts b/src/swap/defi/bridgeless.ts new file mode 100644 index 00000000..b0d9e458 --- /dev/null +++ b/src/swap/defi/bridgeless.ts @@ -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 = { + bitcoin: '0', + zano: '2' +} + +const asTokenInfo = asObject({ + address: asString, + decimals: asString, + chain_id: asString, + token_id: asString, + is_wrapped: asBoolean +}) +type TokenInfo = ReturnType + +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 + +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 => { + 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 => { + 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 => { + if (contractAddress === '0x0000000000000000000000000000000000000000') { + return null + } else { + const fakeToken: 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, + 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 { + const request = convertRequest(req) + + const newRequest = await getMaxSwappable(fetchSwapQuoteInner, request) + const swapOrder = await fetchSwapQuoteInner(newRequest) + return await makeSwapPluginQuote(swapOrder) + } + } + + return out +} diff --git a/src/swap/types.ts b/src/swap/types.ts index e9dac4d3..ad1c2030 100644 --- a/src/swap/types.ts +++ b/src/swap/types.ts @@ -9,8 +9,12 @@ import { import { EdgeAssetAction, EdgeCurrencyWallet, + EdgeMemo, + EdgeMetadata, EdgeTransaction, - EdgeTxActionSwap + EdgeTxAction, + EdgeTxActionSwap, + EdgeTxSwap } from 'edge-core-js' export interface EdgeSwapRequestPlugin { @@ -66,6 +70,19 @@ export type MakeTxParams = savedAction: EdgeTxActionSwap pendingTxs?: EdgeTransaction[] } + | { + type: 'MakeTx' + unsignedTx: Uint8Array + metadata?: MakeTxMetadata + } + +export interface MakeTxMetadata { + assetAction?: EdgeAssetAction + savedAction?: EdgeTxAction + metadata?: EdgeMetadata + swapData?: EdgeTxSwap + memos?: EdgeMemo[] +} export const asRatesResponse = asObject({ data: asArray( diff --git a/src/util/swapHelpers.ts b/src/util/swapHelpers.ts index f7d85dda..770685c4 100644 --- a/src/util/swapHelpers.ts +++ b/src/util/swapHelpers.ts @@ -81,22 +81,35 @@ export async function makeSwapPluginQuote( tx = await fromWallet.makeSpend(spend) } else { const { makeTxParams } = order - const { assetAction, savedAction } = makeTxParams - const params = - preTx != null ? { ...makeTxParams, pendingTxs: [preTx] } : makeTxParams - tx = await fromWallet.otherMethods.makeTx(params) + + // Handle the new MakeTx type for SUI + if (makeTxParams.type === 'MakeTx') { + tx = await fromWallet.otherMethods.makeTx(makeTxParams) + if (tx.savedAction == null) { + tx.savedAction = makeTxParams.metadata?.savedAction + } + if (tx.assetAction == null) { + tx.assetAction = makeTxParams.metadata?.assetAction + } + } else { + const { assetAction, savedAction } = makeTxParams + const params = + preTx != null ? { ...makeTxParams, pendingTxs: [preTx] } : makeTxParams + tx = await fromWallet.otherMethods.makeTx(params) + if (tx.savedAction == null) { + tx.savedAction = savedAction + } + if (tx.assetAction == null) { + tx.assetAction = assetAction + } + } + if (tx.tokenId == null) { tx.tokenId = request.fromTokenId } if (tx.currencyCode == null) { tx.currencyCode = request.fromCurrencyCode } - if (tx.savedAction == null) { - tx.savedAction = savedAction - } - if (tx.assetAction == null) { - tx.assetAction = assetAction - } } const action = tx.savedAction diff --git a/yarn.lock b/yarn.lock index 455868b2..3ff11b54 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1477,6 +1477,11 @@ resolved "https://registry.yarnpkg.com/@scure/base/-/base-1.1.3.tgz#8584115565228290a6c6c4961973e0903bb3df2f" integrity sha512-/+SgoRjLq7Xlf0CWuLHq2LUZeL/w65kfzAPG5NH9pcmBhs+nunQTn4gvdwgMTIXnt9b2C/1SeL2XiysZEyIC9Q== +"@scure/base@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@scure/base/-/base-2.0.0.tgz#ba6371fddf92c2727e88ad6ab485db6e624f9a98" + integrity sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w== + "@scure/bip32@1.3.1": version "1.3.1" resolved "https://registry.yarnpkg.com/@scure/bip32/-/bip32-1.3.1.tgz#7248aea723667f98160f593d621c47e208ccbb10"