diff --git a/src/app/account/page.tsx b/src/app/account/page.tsx index 1401a5dc7..f8b8b0bfa 100644 --- a/src/app/account/page.tsx +++ b/src/app/account/page.tsx @@ -1,8 +1,10 @@ "use client" +import type { AuthMethod } from "@defuse-protocol/internal-utils" import { AccountWidget } from "@src/components/DefuseSDK/features/account/components/AccountWidget" import { useHideBalances } from "@src/components/DefuseSDK/hooks/useHideBalances" import { QueryClientProvider } from "@src/components/DefuseSDK/providers/QueryClientProvider" +import type { TokenInfo } from "@src/components/DefuseSDK/types/base" import Paper from "@src/components/Paper" import { LIST_TOKENS } from "@src/constants/tokens" @@ -15,17 +17,43 @@ export default function AccountPage() { const tokenList = useTokenList(LIST_TOKENS) const hideBalances = useHideBalances() + const userAddress = (state.isVerified ? state.address : undefined) ?? null + const userChainType = state.chainType ?? null + + return ( + + + + ) +} + +function AccountPageBody({ + tokenList, + userAddress, + userChainType, + hideBalances, +}: { + tokenList: TokenInfo[] + userAddress: string | null + userChainType: AuthMethod | null + hideBalances: ReturnType +}) { return ( - - + <> + - - + + > ) } diff --git a/src/components/DefuseSDK/features/machines/backgroundWithdraw1csQuoterMachine.test.ts b/src/components/DefuseSDK/features/machines/backgroundWithdraw1csQuoterMachine.test.ts new file mode 100644 index 000000000..11dfdb9f4 --- /dev/null +++ b/src/components/DefuseSDK/features/machines/backgroundWithdraw1csQuoterMachine.test.ts @@ -0,0 +1,372 @@ +import { QuoteRequest } from "@defuse-protocol/one-click-sdk-typescript" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { type InputFrom, createActor, fromTransition } from "xstate" +import type { BaseTokenInfo } from "../../types/base" +import type { WithdrawQuote1csInput } from "./backgroundWithdraw1csQuoterMachine" + +vi.mock("./1cs", () => ({ + getWithdrawQuote: vi.fn(), +})) + +const { backgroundWithdraw1csQuoterMachine } = await import( + "./backgroundWithdraw1csQuoterMachine" +) +const { getWithdrawQuote } = await import("./1cs") + +const mockedGetWithdrawQuote = vi.mocked(getWithdrawQuote) + +type QuoterInput = InputFrom +type QuoterParentEvent = Parameters[0] + +const nearUsdcToken: BaseTokenInfo = { + defuseAssetId: "nep141:usdc.near", + symbol: "USDC", + name: "USDC", + decimals: 6, + icon: "", + originChainName: "near", + deployments: [ + { + chainName: "near", + bridge: "direct", + decimals: 6, + address: "usdc.near", + }, + ], +} + +const nbtcToken: BaseTokenInfo = { + defuseAssetId: "nep141:nbtc.bridge.near", + symbol: "BTC", + name: "Bitcoin", + decimals: 8, + icon: "", + originChainName: "near", + deployments: [ + { + type: "native", + chainName: "bitcoin", + bridge: "near_omni", + decimals: 8, + }, + { + chainName: "near", + bridge: "direct", + decimals: 8, + address: "nbtc.bridge.near", + }, + { + address: "native", + chainName: "hyperliquid", + bridge: "near_omni", + decimals: 8, + }, + ], +} + +function makeQuoteInput(recipient: string): WithdrawQuote1csInput { + return { + tokenIn: nearUsdcToken, + tokenOut: nearUsdcToken, + amount: { amount: 1_000_000n, decimals: 6 }, + swapType: QuoteRequest.swapType.EXACT_INPUT, + slippageBasisPoints: 0, + defuseUserId: "alice.near", + deadline: new Date(Date.now() + 60_000).toISOString(), + userAddress: "alice.near", + userChainType: "near" as const, + recipient, + recipientType: QuoteRequest.recipientType.DESTINATION_CHAIN, + } +} + +function makeQuoteResult(amountOut: string) { + const quoteRequest: QuoteRequest = { + dry: true, + swapType: QuoteRequest.swapType.EXACT_INPUT, + slippageTolerance: 0, + originAsset: nearUsdcToken.defuseAssetId, + depositType: QuoteRequest.depositType.INTENTS, + destinationAsset: nearUsdcToken.defuseAssetId, + amount: "1000000", + refundTo: "alice.near", + refundType: QuoteRequest.refundType.INTENTS, + recipient: "alice.near", + recipientType: QuoteRequest.recipientType.DESTINATION_CHAIN, + deadline: new Date(Date.now() + 60_000).toISOString(), + } + + return { + ok: { + correlationId: "test-correlation-id", + timestamp: new Date().toISOString(), + signature: "test-signature", + quoteRequest, + quote: { + amountIn: "1000000", + amountInFormatted: "1", + amountInUsd: "1", + minAmountIn: "1000000", + amountOut, + amountOutFormatted: "0.995", + amountOutUsd: "0.995", + minAmountOut: amountOut, + timeEstimate: 60, + }, + appFee: [] as [string, bigint][], + }, + } +} + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +async function flushMicrotasks() { + await Promise.resolve() + await Promise.resolve() +} + +function createParentRef( + onSend?: (event: QuoterParentEvent) => void +): QuoterInput["parentRef"] { + const actor = createActor( + fromTransition((state: null, event: QuoterParentEvent) => { + onSend?.(event) + return state + }, null) + ) + actor.start() + return actor +} + +describe("backgroundWithdraw1csQuoterMachine", () => { + beforeEach(() => { + mockedGetWithdrawQuote.mockReset() + }) + + it("emits only latest quote result when multiple requests are inflight", async () => { + const first = deferred>() + const second = deferred>() + + mockedGetWithdrawQuote + .mockImplementationOnce(async () => first.promise) + .mockImplementationOnce(async () => second.promise) + + const parentSend = vi.fn() + const actor = createActor(backgroundWithdraw1csQuoterMachine, { + input: { + parentRef: createParentRef(parentSend), + }, + }).start() + + actor.send({ + type: "NEW_QUOTE_INPUT", + params: makeQuoteInput("first.near"), + }) + actor.send({ + type: "NEW_QUOTE_INPUT", + params: makeQuoteInput("second.near"), + }) + + expect(mockedGetWithdrawQuote).toHaveBeenCalledTimes(2) + expect(mockedGetWithdrawQuote).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + dry: true, + slippageTolerance: 0, + originAsset: nearUsdcToken.defuseAssetId, + destinationAsset: nearUsdcToken.defuseAssetId, + amount: "1000000", + recipient: "first.near", + recipientType: QuoteRequest.recipientType.DESTINATION_CHAIN, + }) + ) + expect(mockedGetWithdrawQuote).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + dry: true, + slippageTolerance: 0, + originAsset: nearUsdcToken.defuseAssetId, + destinationAsset: nearUsdcToken.defuseAssetId, + amount: "1000000", + recipient: "second.near", + recipientType: QuoteRequest.recipientType.DESTINATION_CHAIN, + }) + ) + + second.resolve(makeQuoteResult("995000")) + await flushMicrotasks() + + expect(parentSend).toHaveBeenCalledTimes(1) + expect(parentSend).toHaveBeenCalledWith( + expect.objectContaining({ + type: "NEW_WITHDRAW_1CS_QUOTE", + params: expect.objectContaining({ + quoteInput: expect.objectContaining({ recipient: "second.near" }), + }), + }) + ) + + first.resolve(makeQuoteResult("990000")) + await flushMicrotasks() + + expect(parentSend).toHaveBeenCalledTimes(1) + }) + + it("invalidates inflight requests after PAUSE", async () => { + const inflight = deferred>() + mockedGetWithdrawQuote.mockImplementationOnce(async () => inflight.promise) + + const parentSend = vi.fn() + const actor = createActor(backgroundWithdraw1csQuoterMachine, { + input: { + parentRef: createParentRef(parentSend), + }, + }).start() + + actor.send({ + type: "NEW_QUOTE_INPUT", + params: makeQuoteInput("paused.near"), + }) + actor.send({ type: "PAUSE" }) + + inflight.resolve(makeQuoteResult("995000")) + await flushMicrotasks() + + expect(parentSend).not.toHaveBeenCalled() + }) + + it("invalidates inflight requests when actor is stopped", async () => { + const inflight = deferred>() + mockedGetWithdrawQuote.mockImplementationOnce(async () => inflight.promise) + + const parentSend = vi.fn() + const actor = createActor(backgroundWithdraw1csQuoterMachine, { + input: { + parentRef: createParentRef(parentSend), + }, + }).start() + + actor.send({ + type: "NEW_QUOTE_INPUT", + params: makeQuoteInput("stopped.near"), + }) + actor.stop() + + inflight.resolve(makeQuoteResult("995000")) + await flushMicrotasks() + + expect(parentSend).not.toHaveBeenCalled() + }) + + it("forwards error message when quote API throws", async () => { + mockedGetWithdrawQuote.mockRejectedValueOnce(new Error("network down")) + + const parentSend = vi.fn() + const actor = createActor(backgroundWithdraw1csQuoterMachine, { + input: { + parentRef: createParentRef(parentSend), + }, + }).start() + + actor.send({ + type: "NEW_QUOTE_INPUT", + params: makeQuoteInput("error.near"), + }) + + await flushMicrotasks() + + expect(parentSend).toHaveBeenCalledTimes(1) + expect(parentSend).toHaveBeenCalledWith( + expect.objectContaining({ + type: "NEW_WITHDRAW_1CS_QUOTE", + params: expect.objectContaining({ + result: { err: "network down" }, + }), + }) + ) + }) + + describe("nBTC destination asset routing", () => { + function sendNbtcQuote(params: { + destinationChainName: string + recipient: string + }) { + mockedGetWithdrawQuote.mockResolvedValueOnce(makeQuoteResult("995000")) + + const parentSend = vi.fn() + const actor = createActor(backgroundWithdraw1csQuoterMachine, { + input: { parentRef: createParentRef(parentSend) }, + }).start() + + actor.send({ + type: "NEW_QUOTE_INPUT", + params: { + ...makeQuoteInput(params.recipient), + tokenIn: nbtcToken, + tokenOut: nbtcToken, + destinationChainName: params.destinationChainName, + }, + }) + + return flushMicrotasks() + } + + it("routes nBTC → bitcoin via 1cs native BTC asset", async () => { + await sendNbtcQuote({ + destinationChainName: "bitcoin", + recipient: "bc1qexample", + }) + + expect(mockedGetWithdrawQuote).toHaveBeenCalledWith( + expect.objectContaining({ + originAsset: "nep141:nbtc.bridge.near", + destinationAsset: "1cs_v1:btc:native:coin", + destinationChainName: "bitcoin", + recipient: "bc1qexample", + }) + ) + }) + + it("routes nBTC → hyperliquid via 1cs native BTC asset (resolved as bitcoin)", async () => { + // resolveTokenOut substitutes hyperliquid → bitcoin upstream, so the + // quote sees destinationChainName="bitcoin" even though the user + // selected hyperliquid in the form. + await sendNbtcQuote({ + destinationChainName: "bitcoin", + recipient: "0xhyperliquid-proxy", + }) + + expect(mockedGetWithdrawQuote).toHaveBeenCalledWith( + expect.objectContaining({ + originAsset: "nep141:nbtc.bridge.near", + destinationAsset: "1cs_v1:btc:native:coin", + destinationChainName: "bitcoin", + }) + ) + }) + + it("keeps the NEP-141 id for nBTC → near (recipient is a NEAR account)", async () => { + await sendNbtcQuote({ + destinationChainName: "near", + recipient: "vodis_craftscript.tg", + }) + + expect(mockedGetWithdrawQuote).toHaveBeenCalledWith( + expect.objectContaining({ + originAsset: "nep141:nbtc.bridge.near", + destinationAsset: "nep141:nbtc.bridge.near", + destinationChainName: "near", + recipient: "vodis_craftscript.tg", + }) + ) + }) + }) +}) diff --git a/src/components/DefuseSDK/features/machines/backgroundWithdraw1csQuoterMachine.ts b/src/components/DefuseSDK/features/machines/backgroundWithdraw1csQuoterMachine.ts index 04f1fef0c..8514f6dfa 100644 --- a/src/components/DefuseSDK/features/machines/backgroundWithdraw1csQuoterMachine.ts +++ b/src/components/DefuseSDK/features/machines/backgroundWithdraw1csQuoterMachine.ts @@ -3,6 +3,7 @@ import { QuoteRequest } from "@defuse-protocol/one-click-sdk-typescript" import { logger } from "@src/utils/logger" import { type ActorRef, type Snapshot, fromCallback } from "xstate" import type { BaseTokenInfo } from "../../types/base" +import { getWithdrawDestinationAsset } from "../../utils/oneClickAssetRouting" import { adjustDecimals } from "../../utils/tokenUtils" import { getWithdrawQuote as getWithdrawQuoteApi } from "./1cs" @@ -137,7 +138,10 @@ async function fetchWithdrawQuote( ) => void ): Promise { const tokenInAssetId = quoteInput.tokenIn.defuseAssetId - const tokenOutAssetId = quoteInput.tokenOut.defuseAssetId + const tokenOutAssetId = + quoteInput.destinationChainName === "bitcoin" + ? getWithdrawDestinationAsset(quoteInput.tokenOut.defuseAssetId) + : quoteInput.tokenOut.defuseAssetId try { const result = await getWithdrawQuoteApi({ diff --git a/src/components/DefuseSDK/features/machines/withdraw1csMachine.ts b/src/components/DefuseSDK/features/machines/withdraw1csMachine.ts index 061380c77..22bfa84db 100644 --- a/src/components/DefuseSDK/features/machines/withdraw1csMachine.ts +++ b/src/components/DefuseSDK/features/machines/withdraw1csMachine.ts @@ -19,6 +19,7 @@ import { parseQuoteAmount, } from "@src/components/DefuseSDK/features/machines/utils/1csQuoteValidation" import type { IntentDescription } from "@src/components/DefuseSDK/types/intent" +import { getWithdrawDestinationAsset } from "@src/components/DefuseSDK/utils/oneClickAssetRouting" import { logger } from "@src/utils/logger" import { assign, fromPromise, log, setup } from "xstate" import { wrapPayloadAsWalletMessage } from "../../core/messages" @@ -182,7 +183,10 @@ export const withdraw1csMachine = setup({ dry: false, slippageTolerance: Math.round(input.slippageBasisPoints / 100), originAsset: input.tokenIn.defuseAssetId, - destinationAsset: input.tokenOut.defuseAssetId, + destinationAsset: + input.destinationChainName === "bitcoin" + ? getWithdrawDestinationAsset(input.tokenOut.defuseAssetId) + : input.tokenOut.defuseAssetId, amount: (input.swapType === QuoteRequest.swapType.EXACT_OUTPUT ? adjustDecimals( input.amountIn.amount, diff --git a/src/components/DefuseSDK/utils/oneClickAssetRouting.test.ts b/src/components/DefuseSDK/utils/oneClickAssetRouting.test.ts new file mode 100644 index 000000000..919d21f8a --- /dev/null +++ b/src/components/DefuseSDK/utils/oneClickAssetRouting.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest" +import { getWithdrawDestinationAsset } from "./oneClickAssetRouting" + +describe("getWithdrawDestinationAsset", () => { + it("maps nBTC to the 1CS native BTC routing asset", () => { + expect(getWithdrawDestinationAsset("nep141:nbtc.bridge.near")).toBe( + "1cs_v1:btc:native:coin" + ) + }) + + it("returns the input asset id unchanged when no override is registered", () => { + expect(getWithdrawDestinationAsset("nep141:usdc.near")).toBe( + "nep141:usdc.near" + ) + }) + + it("returns the input asset id unchanged for nep245 multi-token assets", () => { + const assetId = "nep245:v2_1.omni.hot.tg:56_11111111111111111111" + expect(getWithdrawDestinationAsset(assetId)).toBe(assetId) + }) +}) diff --git a/src/components/DefuseSDK/utils/oneClickAssetRouting.ts b/src/components/DefuseSDK/utils/oneClickAssetRouting.ts new file mode 100644 index 000000000..4739f844d --- /dev/null +++ b/src/components/DefuseSDK/utils/oneClickAssetRouting.ts @@ -0,0 +1,14 @@ +/** + * Maps a `defuseAssetId` to the 1CS routing asset id used as the destination + * asset in withdraw quotes, when the two differ. + * + * Example: `nep141:nbtc.bridge.near` is held on Intents as a NEP-141, but 1CS + * routes BTC withdrawals via the native `1cs_v1:btc:native:coin` asset. + */ +const WITHDRAW_DESTINATION_ASSET_OVERRIDES: Record = { + "nep141:nbtc.bridge.near": "1cs_v1:btc:native:coin", +} + +export function getWithdrawDestinationAsset(defuseAssetId: string): string { + return WITHDRAW_DESTINATION_ASSET_OVERRIDES[defuseAssetId] ?? defuseAssetId +} diff --git a/src/components/Layout/Main.tsx b/src/components/Layout/Main.tsx index e46de89e5..9322ebfe0 100644 --- a/src/components/Layout/Main.tsx +++ b/src/components/Layout/Main.tsx @@ -1,5 +1,6 @@ import { type PropsWithChildren, useContext } from "react" +import { LegacyBtcNotice } from "@src/components/LegacyBtcNotice" import { NearComRetirementCard } from "@src/components/NearComPromo" import { FeatureFlagsContext } from "@src/providers/FeatureFlagsProvider" import NearComPromo from "../NearComPromo" @@ -40,6 +41,7 @@ const Main = ({ children }: PropsWithChildren) => { return ( <> + {children} > ) diff --git a/src/components/LegacyBtcNotice.tsx b/src/components/LegacyBtcNotice.tsx new file mode 100644 index 000000000..259fd5ecb --- /dev/null +++ b/src/components/LegacyBtcNotice.tsx @@ -0,0 +1,94 @@ +"use client" + +import { authIdentity } from "@defuse-protocol/internal-utils" +import { Cross2Icon, ExclamationTriangleIcon } from "@radix-ui/react-icons" +import { useWatchHoldings } from "@src/components/DefuseSDK/features/account/hooks/useWatchHoldings" +import type { Holding } from "@src/components/DefuseSDK/features/account/types/sharedTypes" +import { getTokenAid } from "@src/components/DefuseSDK/utils/token" +import { LIST_TOKENS } from "@src/constants/tokens" +import { useConnectWallet } from "@src/hooks/useConnectWallet" +import { useTokenList } from "@src/hooks/useTokenList" +import Link from "next/link" +import { useCallback, useMemo, useState } from "react" + +const LEGACY_BTC_NOTICE_DISMISSED_KEY = "legacy-btc-notice-dismissed" +const LEGACY_BTC_SWAP_LINK = `/?from=${encodeURIComponent("BTC (LEGACY)")}&to=BTC` + +export function LegacyBtcNotice({ + holdings: holdingsProp, + isHidden: isHiddenProp, +}: { + holdings?: Holding[] | undefined + isHidden?: boolean +}) { + const { state } = useConnectWallet() + const tokenList = useTokenList(LIST_TOKENS) + const userAddress = (state.isVerified ? state.address : undefined) ?? null + const userChainType = state.chainType ?? null + const userId = + userAddress != null && userChainType != null + ? authIdentity.authHandleToIntentsUserId(userAddress, userChainType) + : null + const holdingsFromQuery = useWatchHoldings({ userId, tokenList }) + const holdings = holdingsProp ?? holdingsFromQuery + const isHidden = isHiddenProp ?? userId == null + + const [isDismissed, setIsDismissed] = useState(() => { + if (typeof window === "undefined") return false + return sessionStorage.getItem(LEGACY_BTC_NOTICE_DISMISSED_KEY) === "true" + }) + + const hasLegacyBtcHolding = useMemo( + () => + (holdings ?? []).some( + (holding) => + getTokenAid(holding.token) === "btc-legacy" && + (holding.value?.amount ?? 0n) > 0n + ), + [holdings] + ) + + const dismiss = useCallback(() => { + setIsDismissed(true) + sessionStorage.setItem(LEGACY_BTC_NOTICE_DISMISSED_KEY, "true") + }, []) + + const handleSwapClick = useCallback( + (e: React.MouseEvent) => { + e.preventDefault() + window.location.assign(LEGACY_BTC_SWAP_LINK) + }, + [] + ) + + if (isHidden || !hasLegacyBtcHolding || isDismissed) { + return null + } + + return ( + + + + + You have BTC (LEGACY). Swap it to BTC for better routing and support.{" "} + + Swap to BTC + + + + + Dismiss + + + + + ) +} diff --git a/src/constants/tokens.ts b/src/constants/tokens.ts index 971117956..5f420d083 100644 --- a/src/constants/tokens.ts +++ b/src/constants/tokens.ts @@ -13,6 +13,8 @@ export const LIST_TOKENS: TokenInfo[] = ( export const DEPRECATED_TOKENS: Record = { "nep141:aurora": true, + "nep141:btc.omft.near": true, + "nep141:btc.stft.near": true, } export const LIST_TOKENS_FLATTEN = flattenTokenList(LIST_TOKENS) diff --git a/src/tokens/production.json b/src/tokens/production.json index f36302db9..368772c44 100644 --- a/src/tokens/production.json +++ b/src/tokens/production.json @@ -1168,7 +1168,7 @@ "icon": "https://s2.coinmarketcap.com/static/img/coins/128x128/1.png", "groupedTokens": [ { - "defuseAssetId": "nep141:btc.omft.near", + "defuseAssetId": "nep141:nbtc.bridge.near", "symbol": "BTC", "name": "Bitcoin", "decimals": 8, @@ -1179,42 +1179,51 @@ "type": "native", "decimals": 8, "chainName": "bitcoin", - "bridge": "poa" + "bridge": "near_omni" + }, + { + "address": "nbtc.bridge.near", + "decimals": 8, + "chainName": "near", + "bridge": "direct" }, { "address": "native", "decimals": 8, "chainName": "hyperliquid", - "bridge": "poa" + "bridge": "near_omni" } ], "tags": ["mc:1", "tvol:6", "aid:btc"] - }, + } + ], + "tags": ["mc:1", "tvol:6", "aid:btc"] + }, + { + "unifiedAssetId": "btc-legacy", + "symbol": "BTC (LEGACY)", + "name": "Bitcoin (Legacy)", + "icon": "https://s2.coinmarketcap.com/static/img/coins/128x128/1.png", + "groupedTokens": [ { - "defuseAssetId": "nep141:nbtc.bridge.near", - "symbol": "nBTC", - "name": "Bitcoin", + "defuseAssetId": "nep141:btc.omft.near", + "symbol": "BTC (LEGACY)", + "name": "Bitcoin (Legacy)", "decimals": 8, "icon": "https://s2.coinmarketcap.com/static/img/coins/128x128/1.png", - "originChainName": "near", + "originChainName": "bitcoin", "deployments": [ { "type": "native", "decimals": 8, "chainName": "bitcoin", - "bridge": "near_omni" - }, - { - "address": "nbtc.bridge.near", - "decimals": 8, - "chainName": "near", - "bridge": "direct" + "bridge": "poa" } ], - "tags": ["mc:1", "tvol:6", "aid:btc"] + "tags": ["mc:1", "tvol:6", "aid:btc-legacy"] } ], - "tags": ["mc:1", "tvol:6", "aid:btc"] + "tags": ["mc:1", "tvol:6", "aid:btc-legacy"] }, { "unifiedAssetId": "sweat", diff --git a/src/tokens/stage.json b/src/tokens/stage.json index 9d83f9ae4..0ba2dc14e 100644 --- a/src/tokens/stage.json +++ b/src/tokens/stage.json @@ -146,11 +146,37 @@ "icon": "https://s2.coinmarketcap.com/static/img/coins/128x128/1.png", "groupedTokens": [ { - "defuseAssetId": "nep141:btc.stft.near", + "defuseAssetId": "nep141:nbtc.bridge.near", "symbol": "BTC", "name": "Bitcoin", "decimals": 8, "icon": "https://s2.coinmarketcap.com/static/img/coins/128x128/1.png", + "originChainName": "near", + "deployments": [ + { + "address": "nbtc.bridge.near", + "decimals": 8, + "chainName": "near", + "bridge": "direct" + } + ], + "tags": ["mc:1", "tvol:6", "aid:btc"] + } + ], + "tags": ["mc:1", "tvol:6"] + }, + { + "unifiedAssetId": "btc-legacy", + "symbol": "BTC (LEGACY)", + "name": "Bitcoin (Legacy)", + "icon": "https://s2.coinmarketcap.com/static/img/coins/128x128/1.png", + "groupedTokens": [ + { + "defuseAssetId": "nep141:btc.stft.near", + "symbol": "BTC (LEGACY)", + "name": "Bitcoin (Legacy)", + "decimals": 8, + "icon": "https://s2.coinmarketcap.com/static/img/coins/128x128/1.png", "originChainName": "bitcoin", "deployments": [ { @@ -160,10 +186,10 @@ "bridge": "poa" } ], - "tags": ["mc:1", "tvol:6"] + "tags": ["mc:1", "tvol:6", "aid:btc-legacy"] } ], - "tags": ["mc:1", "tvol:6"] + "tags": ["mc:1", "tvol:6", "aid:btc-legacy"] }, { "unifiedAssetId": "xrp",
+ You have BTC (LEGACY). Swap it to BTC for better routing and support.{" "} + + Swap to BTC + +