From e6aca4b28e49e6f066ca0491893732ba6ea618fd Mon Sep 17 00:00:00 2001 From: jobotics Date: Wed, 6 May 2026 15:16:21 +0100 Subject: [PATCH 1/4] feat: separate legacy BTC and promote nBTC as canonical BTC Mirrors defuse-protocol/defuse-near#419: nBTC (nep141:nbtc.bridge.near) becomes the canonical BTC asset, while btc.omft.near (prod) and btc.stft.near (stage) move to a "BTC (legacy)" unified asset and are flagged in DEPRECATED_TOKENS. Withdraw quotes for nBTC now route to the 1CS native BTC asset (1cs_v1:btc:native:coin) via a new getWithdrawDestinationAsset override in oneClickAssetRouting. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...backgroundWithdraw1csQuoterMachine.test.ts | 313 ++++++++++++++++++ .../backgroundWithdraw1csQuoterMachine.ts | 5 +- .../features/machines/withdraw1csMachine.ts | 5 +- .../utils/oneClickAssetRouting.test.ts | 21 ++ .../DefuseSDK/utils/oneClickAssetRouting.ts | 14 + src/constants/tokens.ts | 2 + src/tokens/production.json | 43 ++- src/tokens/stage.json | 32 +- 8 files changed, 413 insertions(+), 22 deletions(-) create mode 100644 src/components/DefuseSDK/features/machines/backgroundWithdraw1csQuoterMachine.test.ts create mode 100644 src/components/DefuseSDK/utils/oneClickAssetRouting.test.ts create mode 100644 src/components/DefuseSDK/utils/oneClickAssetRouting.ts 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..db3ae8f16 --- /dev/null +++ b/src/components/DefuseSDK/features/machines/backgroundWithdraw1csQuoterMachine.test.ts @@ -0,0 +1,313 @@ +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 nearNbtcToken: BaseTokenInfo = { + defuseAssetId: "nep141:nbtc.bridge.near", + symbol: "nBTC", + name: "nBTC", + decimals: 8, + icon: "", + originChainName: "near", + deployments: [ + { + chainName: "near", + bridge: "direct", + decimals: 8, + address: "nbtc.bridge.near", + }, + ], +} + +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" }, + }), + }) + ) + }) + + it("uses 1cs routing ids for nBTC withdrawal quote", async () => { + 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("btc-destination"), + tokenIn: nearNbtcToken, + tokenOut: nearNbtcToken, + }, + }) + + await flushMicrotasks() + + expect(mockedGetWithdrawQuote).toHaveBeenCalledWith( + expect.objectContaining({ + originAsset: "nep141:nbtc.bridge.near", + destinationAsset: "1cs_v1:btc:native:coin", + }) + ) + }) +}) diff --git a/src/components/DefuseSDK/features/machines/backgroundWithdraw1csQuoterMachine.ts b/src/components/DefuseSDK/features/machines/backgroundWithdraw1csQuoterMachine.ts index 04f1fef0c..e1d1bdaa8 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,9 @@ async function fetchWithdrawQuote( ) => void ): Promise { const tokenInAssetId = quoteInput.tokenIn.defuseAssetId - const tokenOutAssetId = quoteInput.tokenOut.defuseAssetId + const tokenOutAssetId = getWithdrawDestinationAsset( + 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..de7663b05 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,9 @@ export const withdraw1csMachine = setup({ dry: false, slippageTolerance: Math.round(input.slippageBasisPoints / 100), originAsset: input.tokenIn.defuseAssetId, - destinationAsset: input.tokenOut.defuseAssetId, + destinationAsset: getWithdrawDestinationAsset( + 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/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..a253f5562 100644 --- a/src/tokens/production.json +++ b/src/tokens/production.json @@ -1168,53 +1168,62 @@ "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, "icon": "https://s2.coinmarketcap.com/static/img/coins/128x128/1.png", - "originChainName": "bitcoin", + "originChainName": "near", "deployments": [ { "type": "native", "decimals": 8, "chainName": "bitcoin", - "bridge": "poa" + "bridge": "near_omni" }, { - "address": "native", + "address": "nbtc.bridge.near", "decimals": 8, - "chainName": "hyperliquid", - "bridge": "poa" + "chainName": "near", + "bridge": "direct" } ], "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" + "bridge": "poa" }, { - "address": "nbtc.bridge.near", + "address": "native", "decimals": 8, - "chainName": "near", - "bridge": "direct" + "chainName": "hyperliquid", + "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..56dedd10c 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", From 641b506861032666281d447e4b653a7d5a0c6c7e Mon Sep 17 00:00:00 2001 From: jobotics Date: Wed, 6 May 2026 18:50:38 +0100 Subject: [PATCH 2/4] =?UTF-8?q?feat(btc):=20sync=20upstream=20PR=20#419=20?= =?UTF-8?q?follow-ups=20(legacy=20symbol=20casing,=20hyperliquid=20?= =?UTF-8?q?=E2=86=92=20canonical,=20migration=20notice)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors 4 follow-up commits to defuse-protocol/defuse-near#419: - Canonical BTC originChainName fixed to "bitcoin" - Hyperliquid deployment moved to canonical BTC group with bridge switched from "poa" to "near_omni"; legacy BTC retains only the bitcoin/poa deployment - "BTC (legacy)" symbol uppercased to "BTC (LEGACY)" in both production.json and stage.json - Adds LegacyBtcNotice host-app banner that prompts users with legacy BTC holdings to swap to canonical BTC; rendered above the AccountWidget on /account. Deep link targets the swap home (`/`) with from/to query params (defuse-frontend's swap is at root, not `/swap`). Uses radix-icons since this project doesn't ship heroicons. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/account/page.tsx | 46 ++++++++++++++++++-- src/components/LegacyBtcNotice.tsx | 70 ++++++++++++++++++++++++++++++ src/tokens/production.json | 18 ++++---- src/tokens/stage.json | 4 +- 4 files changed, 123 insertions(+), 15 deletions(-) create mode 100644 src/components/LegacyBtcNotice.tsx diff --git a/src/app/account/page.tsx b/src/app/account/page.tsx index 1401a5dc7..fc24b4d0b 100644 --- a/src/app/account/page.tsx +++ b/src/app/account/page.tsx @@ -1,9 +1,14 @@ "use client" +import type { AuthMethod } from "@defuse-protocol/internal-utils" +import { authIdentity } from "@defuse-protocol/internal-utils" import { AccountWidget } from "@src/components/DefuseSDK/features/account/components/AccountWidget" +import { useWatchHoldings } from "@src/components/DefuseSDK/features/account/hooks/useWatchHoldings" 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 { LegacyBtcNotice } from "@src/components/LegacyBtcNotice" import Paper from "@src/components/Paper" import { LIST_TOKENS } from "@src/constants/tokens" import { useConnectWallet } from "@src/hooks/useConnectWallet" @@ -15,17 +20,50 @@ 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 +}) { + const userId = + userAddress != null && userChainType != null + ? authIdentity.authHandleToIntentsUserId(userAddress, userChainType) + : null + const holdings = useWatchHoldings({ userId, tokenList }) + + return ( + <> + + + + ) +} diff --git a/src/components/LegacyBtcNotice.tsx b/src/components/LegacyBtcNotice.tsx new file mode 100644 index 000000000..4924b5859 --- /dev/null +++ b/src/components/LegacyBtcNotice.tsx @@ -0,0 +1,70 @@ +"use client" + +import { Cross2Icon, ExclamationTriangleIcon } from "@radix-ui/react-icons" +import type { Holding } from "@src/components/DefuseSDK/features/account/types/sharedTypes" +import { getTokenAid } from "@src/components/DefuseSDK/utils/token" +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, + isHidden, +}: { + holdings: Holding[] | undefined + isHidden: boolean +}) { + 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") + }, []) + + if (isHidden || !hasLegacyBtcHolding || isDismissed) { + return null + } + + return ( +
+
+ +

+ You have BTC (LEGACY). Swap it to BTC for better routing and support. +

+
+ +
+ + Swap to BTC + + +
+
+ ) +} diff --git a/src/tokens/production.json b/src/tokens/production.json index a253f5562..368772c44 100644 --- a/src/tokens/production.json +++ b/src/tokens/production.json @@ -1173,7 +1173,7 @@ "name": "Bitcoin", "decimals": 8, "icon": "https://s2.coinmarketcap.com/static/img/coins/128x128/1.png", - "originChainName": "near", + "originChainName": "bitcoin", "deployments": [ { "type": "native", @@ -1186,6 +1186,12 @@ "decimals": 8, "chainName": "near", "bridge": "direct" + }, + { + "address": "native", + "decimals": 8, + "chainName": "hyperliquid", + "bridge": "near_omni" } ], "tags": ["mc:1", "tvol:6", "aid:btc"] @@ -1195,13 +1201,13 @@ }, { "unifiedAssetId": "btc-legacy", - "symbol": "BTC (legacy)", + "symbol": "BTC (LEGACY)", "name": "Bitcoin (Legacy)", "icon": "https://s2.coinmarketcap.com/static/img/coins/128x128/1.png", "groupedTokens": [ { "defuseAssetId": "nep141:btc.omft.near", - "symbol": "BTC (legacy)", + "symbol": "BTC (LEGACY)", "name": "Bitcoin (Legacy)", "decimals": 8, "icon": "https://s2.coinmarketcap.com/static/img/coins/128x128/1.png", @@ -1212,12 +1218,6 @@ "decimals": 8, "chainName": "bitcoin", "bridge": "poa" - }, - { - "address": "native", - "decimals": 8, - "chainName": "hyperliquid", - "bridge": "poa" } ], "tags": ["mc:1", "tvol:6", "aid:btc-legacy"] diff --git a/src/tokens/stage.json b/src/tokens/stage.json index 56dedd10c..0ba2dc14e 100644 --- a/src/tokens/stage.json +++ b/src/tokens/stage.json @@ -167,13 +167,13 @@ }, { "unifiedAssetId": "btc-legacy", - "symbol": "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)", + "symbol": "BTC (LEGACY)", "name": "Bitcoin (Legacy)", "decimals": 8, "icon": "https://s2.coinmarketcap.com/static/img/coins/128x128/1.png", From 9d82c4fcf3505164c6dc1fb8ff010620eb6c80a8 Mon Sep 17 00:00:00 2001 From: jobotics Date: Wed, 6 May 2026 22:20:13 +0100 Subject: [PATCH 3/4] =?UTF-8?q?fix(withdraw):=20gate=20nBTC=20=E2=86=92=20?= =?UTF-8?q?1cs=20BTC=20asset=20override=20on=20bitcoin=20destination?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors upstream defuse-near commit 09347737 on PR #419. The previous unconditional override rewrote nBTC's defuseAssetId to 1cs_v1:btc:native:coin for ALL destinations, which broke nBTC → near withdrawals (recipient is a NEAR account, asset must remain a NEP-141). Override now applies only when destinationChainName === "bitcoin". Co-Authored-By: Claude Opus 4.7 (1M context) --- ...backgroundWithdraw1csQuoterMachine.test.ts | 107 ++++++++++++++---- .../backgroundWithdraw1csQuoterMachine.ts | 7 +- .../features/machines/withdraw1csMachine.ts | 7 +- 3 files changed, 91 insertions(+), 30 deletions(-) diff --git a/src/components/DefuseSDK/features/machines/backgroundWithdraw1csQuoterMachine.test.ts b/src/components/DefuseSDK/features/machines/backgroundWithdraw1csQuoterMachine.test.ts index db3ae8f16..11dfdb9f4 100644 --- a/src/components/DefuseSDK/features/machines/backgroundWithdraw1csQuoterMachine.test.ts +++ b/src/components/DefuseSDK/features/machines/backgroundWithdraw1csQuoterMachine.test.ts @@ -35,20 +35,32 @@ const nearUsdcToken: BaseTokenInfo = { ], } -const nearNbtcToken: BaseTokenInfo = { +const nbtcToken: BaseTokenInfo = { defuseAssetId: "nep141:nbtc.bridge.near", - symbol: "nBTC", - name: "nBTC", + 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, + }, ], } @@ -282,32 +294,79 @@ describe("backgroundWithdraw1csQuoterMachine", () => { ) }) - it("uses 1cs routing ids for nBTC withdrawal quote", async () => { - mockedGetWithdrawQuote.mockResolvedValueOnce(makeQuoteResult("995000")) + 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, + }, + }) - const parentSend = vi.fn() - const actor = createActor(backgroundWithdraw1csQuoterMachine, { - input: { - parentRef: createParentRef(parentSend), - }, - }).start() + return flushMicrotasks() + } - actor.send({ - type: "NEW_QUOTE_INPUT", - params: { - ...makeQuoteInput("btc-destination"), - tokenIn: nearNbtcToken, - tokenOut: nearNbtcToken, - }, + 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", + }) + ) }) - await flushMicrotasks() + 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", + 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 e1d1bdaa8..8514f6dfa 100644 --- a/src/components/DefuseSDK/features/machines/backgroundWithdraw1csQuoterMachine.ts +++ b/src/components/DefuseSDK/features/machines/backgroundWithdraw1csQuoterMachine.ts @@ -138,9 +138,10 @@ async function fetchWithdrawQuote( ) => void ): Promise { const tokenInAssetId = quoteInput.tokenIn.defuseAssetId - const tokenOutAssetId = getWithdrawDestinationAsset( - 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 de7663b05..22bfa84db 100644 --- a/src/components/DefuseSDK/features/machines/withdraw1csMachine.ts +++ b/src/components/DefuseSDK/features/machines/withdraw1csMachine.ts @@ -183,9 +183,10 @@ export const withdraw1csMachine = setup({ dry: false, slippageTolerance: Math.round(input.slippageBasisPoints / 100), originAsset: input.tokenIn.defuseAssetId, - destinationAsset: getWithdrawDestinationAsset( - 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, From 4f208685e33c453a7b42a08668c981f975a6c48a Mon Sep 17 00:00:00 2001 From: jobotics Date: Wed, 6 May 2026 22:44:35 +0100 Subject: [PATCH 4/4] ui: move and restyle legacy BTC notice --- src/app/account/page.tsx | 44 ++++++++------------- src/components/Layout/Main.tsx | 2 + src/components/LegacyBtcNotice.tsx | 62 +++++++++++++++++++++--------- 3 files changed, 62 insertions(+), 46 deletions(-) diff --git a/src/app/account/page.tsx b/src/app/account/page.tsx index fc24b4d0b..f8b8b0bfa 100644 --- a/src/app/account/page.tsx +++ b/src/app/account/page.tsx @@ -1,14 +1,11 @@ "use client" import type { AuthMethod } from "@defuse-protocol/internal-utils" -import { authIdentity } from "@defuse-protocol/internal-utils" import { AccountWidget } from "@src/components/DefuseSDK/features/account/components/AccountWidget" -import { useWatchHoldings } from "@src/components/DefuseSDK/features/account/hooks/useWatchHoldings" 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 { LegacyBtcNotice } from "@src/components/LegacyBtcNotice" import Paper from "@src/components/Paper" import { LIST_TOKENS } from "@src/constants/tokens" import { useConnectWallet } from "@src/hooks/useConnectWallet" @@ -24,16 +21,14 @@ export default function AccountPage() { const userChainType = state.chainType ?? null return ( - - - - - + + + ) } @@ -48,22 +43,17 @@ function AccountPageBody({ userChainType: AuthMethod | null hideBalances: ReturnType }) { - const userId = - userAddress != null && userChainType != null - ? authIdentity.authHandleToIntentsUserId(userAddress, userChainType) - : null - const holdings = useWatchHoldings({ userId, tokenList }) - return ( <> - - + + + ) } 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 index 4924b5859..259fd5ecb 100644 --- a/src/components/LegacyBtcNotice.tsx +++ b/src/components/LegacyBtcNotice.tsx @@ -1,8 +1,13 @@ "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" @@ -10,12 +15,24 @@ 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, - isHidden, + holdings: holdingsProp, + isHidden: isHiddenProp, }: { - holdings: Holding[] | undefined - isHidden: boolean + 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" @@ -36,33 +53,40 @@ export function LegacyBtcNotice({ 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. +

+
+ +

+ You have BTC (LEGACY). Swap it to BTC for better routing and support.{" "} + + Swap to BTC +

-
-
- - Swap to BTC -