diff --git a/src/components/Modal/tBTC/InitiateUnminting.tsx b/src/components/Modal/tBTC/InitiateUnminting.tsx index d86050215..c2e171d1a 100644 --- a/src/components/Modal/tBTC/InitiateUnminting.tsx +++ b/src/components/Modal/tBTC/InitiateUnminting.tsx @@ -49,20 +49,21 @@ const InitiateUnmintingBase: FC = ({ const { estimatedBTCAmount, thresholdNetworkFee } = useRedemptionEstimatedFees(unmintAmount) const threshold = useThreshold() + const isCrossChain = threshold.config.crossChain.isCrossChain const onSuccess: OnSuccessCallback = (receipt, additionalParams) => { //@ts-ignore - const { walletPublicKey } = additionalParams - if (walletPublicKey) { - navigate( - buildRedemptionDetailsLink( - receipt.transactionHash, - account!, - walletPublicKey, - btcAddress, - threshold.tbtc.bitcoinNetwork - ) - ) + const { walletPublicKey, chainName } = additionalParams + const link = buildRedemptionDetailsLink( + account!, + btcAddress, + threshold.tbtc.bitcoinNetwork, + !isCrossChain ? receipt.transactionHash : undefined, + walletPublicKey ?? undefined, + chainName ?? undefined + ) + if (link) { + navigate(link) } closeModal() } diff --git a/src/contexts/ThresholdContext.tsx b/src/contexts/ThresholdContext.tsx index b0e0e3e7b..8133b53a7 100644 --- a/src/contexts/ThresholdContext.tsx +++ b/src/contexts/ThresholdContext.tsx @@ -1,4 +1,11 @@ -import { createContext, FC, useContext, useEffect, useRef } from "react" +import { + createContext, + FC, + useContext, + useEffect, + useRef, + useState, +} from "react" import { getThresholdLibProvider, threshold } from "../utils/getThresholdLib" import { useLedgerLiveApp } from "./LedgerLiveAppContext" import { useIsActive } from "../hooks/useIsActive" @@ -6,7 +13,10 @@ import { useIsEmbed } from "../hooks/useIsEmbed" import { getEthereumDefaultProviderChainId } from "../utils/getEnvVariable" import { useWeb3React } from "@web3-react/core" import { ChainName } from "../threshold-ts/types" -import { isL2Network } from "../networks/utils" +import { + getEthereumNetworkNameFromChainId, + isL2Network, +} from "../networks/utils" import { useNonEVMConnection } from "../hooks/useNonEVMConnection" const defaultCrossChainConfig = { @@ -22,6 +32,7 @@ export const useThreshold = () => { } export const ThresholdProvider: FC = ({ children }) => { + const [thresholdState, setThresholdState] = useState(threshold) const { library } = useWeb3React() const hasThresholdLibConfigBeenUpdated = useRef(false) const { ledgerLiveAppEthereumSigner } = useLedgerLiveApp() @@ -30,6 +41,13 @@ export const ThresholdProvider: FC = ({ children }) => { useNonEVMConnection() const { isEmbed } = useIsEmbed() + useEffect(() => { + const unsubscribe = threshold.subscribe(() => + setThresholdState(Object.create(threshold)) + ) + return unsubscribe + }, []) + useEffect(() => { if (isActive && chainId) { threshold.updateConfig({ @@ -44,13 +62,25 @@ export const ThresholdProvider: FC = ({ children }) => { bitcoin: threshold.config.bitcoin, crossChain: { ...defaultCrossChainConfig, - chainName: isL2Network(chainId) ? ChainName.Ethereum : null, + chainName: getEthereumNetworkNameFromChainId(chainId) as ChainName, isCrossChain: isL2Network(chainId), }, }) hasThresholdLibConfigBeenUpdated.current = true } + if (isNonEVMActive) { + threshold.updateConfig({ + ethereum: threshold.config.ethereum, + bitcoin: threshold.config.bitcoin, + crossChain: { + isCrossChain: true, + chainName: nonEVMChainName as Exclude, + nonEVMProvider: nonEVMProvider, + }, + }) + } + if (!isActive && hasThresholdLibConfigBeenUpdated.current) { threshold.updateConfig({ ethereum: { @@ -61,23 +91,11 @@ export const ThresholdProvider: FC = ({ children }) => { }, bitcoin: threshold.config.bitcoin, crossChain: { - ...threshold.config.crossChain, + ...defaultCrossChainConfig, }, }) hasThresholdLibConfigBeenUpdated.current = false } - - if (isNonEVMActive) { - threshold.updateConfig({ - ethereum: threshold.config.ethereum, - bitcoin: threshold.config.bitcoin, - crossChain: { - isCrossChain: true, - chainName: nonEVMChainName as Exclude, - nonEVMProvider: nonEVMProvider, - }, - }) - } }, [ isActive, account, @@ -89,7 +107,7 @@ export const ThresholdProvider: FC = ({ children }) => { ]) return ( - + {children} ) diff --git a/src/hooks/tbtc/index.ts b/src/hooks/tbtc/index.ts index 22ee9ccd1..d920082f0 100644 --- a/src/hooks/tbtc/index.ts +++ b/src/hooks/tbtc/index.ts @@ -1,9 +1,11 @@ export * from "./useFetchDepositDetails" +export * from "./useFetchTBTCMetrics" export * from "./useFetchRecentDeposits" export * from "./useFetchTBTCMetrics" export * from "./useRedemptionEstimatedFees" export * from "./useRequestRedemption" export * from "./useRevealDepositTransaction" +export * from "./useApproveL2TBTCToken" export * from "./useSubscribeToOptimisticMintingFinalizedEvent" export * from "./useSubscribeToOptimisticMintingRequestedEvent" export * from "./useSubscribeToRedemptionRequestedEvent" @@ -13,3 +15,5 @@ export * from "./useTBTCVaultContract" export * from "./useSubscribeToRedemptionsCompletedEvent" export * from "./useFindRedemptionInBitcoinTx" export * from "./useStarknetTBTCBalance" +export * from "./useFetchCrossChainRedemptionDetails" +export * from "./useSubscribeToL1BitcoinRedeemerRedemptionRequestedEvent" diff --git a/src/hooks/tbtc/useApproveL2TBTCToken.ts b/src/hooks/tbtc/useApproveL2TBTCToken.ts new file mode 100644 index 000000000..6cc74cd4b --- /dev/null +++ b/src/hooks/tbtc/useApproveL2TBTCToken.ts @@ -0,0 +1,22 @@ +import { useThreshold } from "../../contexts/ThresholdContext" +import { + OnErrorCallback, + OnSuccessCallback, + useSendTransactionFromFn, +} from "../../web3/hooks" + +export const useApproveL2TBTCToken = ( + onSuccess?: OnSuccessCallback, + onError?: OnErrorCallback +) => { + const threshold = useThreshold() + const pendingText = + "Approving tBTC for cross-chain redemption. Please sign in your wallet." + + return useSendTransactionFromFn( + threshold.tbtc.approveL2TBTCToken, + onSuccess, + onError, + pendingText + ) +} diff --git a/src/hooks/tbtc/useFetchCrossChainRedemptionDetails.ts b/src/hooks/tbtc/useFetchCrossChainRedemptionDetails.ts new file mode 100644 index 000000000..c9e2919a5 --- /dev/null +++ b/src/hooks/tbtc/useFetchCrossChainRedemptionDetails.ts @@ -0,0 +1,198 @@ +import { useEffect, useState } from "react" +import { useThreshold } from "../../contexts/ThresholdContext" +import { + isValidType, + fromSatoshiToTokenPrecision, + getContractPastEvents, +} from "../../threshold-ts/utils" +import { useGetBlock } from "../../web3/hooks" +import { isEmptyOrZeroAddress } from "../../web3/utils" +import { useFindRedemptionInBitcoinTx } from "./useFindRedemptionInBitcoinTx" + +interface CrossChainRedemptionDetails { + requestedAmount: string // in token precision + receivedAmount?: string // in satoshi + redemptionRequestedTxHash: string + redemptionCompletedTxHash?: { + chain: string + bitcoin: string + } + requestedAt: number + completedAt?: number + treasuryFee: string // in token precision + isTimedOut: boolean + redemptionTimedOutTxHash?: string + btcAddress?: string + walletPublicKeyHash: string + redemptionKey: string +} + +type FetchRedemptionDetailsParamType = string | null | undefined + +export const useFetchCrossChainRedemptionDetails = ( + redeemerOutputScript: FetchRedemptionDetailsParamType, + redeemer: FetchRedemptionDetailsParamType +) => { + const threshold = useThreshold() + const getBlock = useGetBlock() + const findRedemptionInBitcoinTx = useFindRedemptionInBitcoinTx() + const [isFetching, setIsFetching] = useState(false) + const [error, setError] = useState("") + const [redemptionData, setRedemptionData] = useState< + CrossChainRedemptionDetails | undefined + >() + + useEffect(() => { + setError("") + if (!redeemer || isEmptyOrZeroAddress(redeemer)) { + setError("Invalid redeemer value.") + return + } + + if (!redeemerOutputScript || !isValidType("bytes", redeemerOutputScript)) { + setError("Invalid redeemerOutputScript value.") + return + } + + if (!threshold.tbtc.l1BitcoinRedeemerContract) { + setError("L1 Bitcoin Redeemer contract not initialized.") + return + } + + const fetch = async () => { + setIsFetching(true) + try { + // Get RedemptionRequested events from L1BTCRedeemerWormhole filtered by redeemerOutputScript + const redemptionRequestedEvents = await getContractPastEvents( + threshold.tbtc.l1BitcoinRedeemerContract!, + { + eventName: "RedemptionRequested", + // Filter by indexed redemptionOutputScript parameter (4th parameter in event) + filterParams: [null, null, null, redeemerOutputScript], + fromBlock: 0, // You might want to optimize this with a more recent block + } + ) + + if (redemptionRequestedEvents.length === 0) { + throw new Error("Cross-chain redemption not found...") + } + + // Get the most recent event + const redemptionRequestedEvent = + redemptionRequestedEvents[redemptionRequestedEvents.length - 1] + + // Extract data from event + const redemptionKey = redemptionRequestedEvent.args?.redemptionKey + const walletPublicKeyHash = + redemptionRequestedEvent.args?.walletPubKeyHash + const amount = redemptionRequestedEvent.args?.amount + const mainUtxo = redemptionRequestedEvent.args?.mainUtxo + + const { timestamp: redemptionRequestedEventTimestamp } = await getBlock( + redemptionRequestedEvent.blockNumber + ) + + // Build redemption key to check status + const computedRedemptionKey = threshold.tbtc.buildRedemptionKey( + walletPublicKeyHash, + redeemerOutputScript + ) + + // Check if the redemption has pending or timedOut status + const { isPending, isTimedOut, requestedAt } = + await threshold.tbtc.getRedemptionRequest(computedRedemptionKey) + + // Find timeout event if timed out + const timedOutTxHash: undefined | string = isTimedOut + ? ( + await threshold.tbtc.getRedemptionTimedOutEvents({ + walletPublicKeyHash, + fromBlock: redemptionRequestedEvent.blockNumber, + }) + ).find( + (event) => event.redeemerOutputScript === redeemerOutputScript + )?.txHash + : undefined + + if ( + (isTimedOut || isPending) && + requestedAt === redemptionRequestedEventTimestamp + ) { + setRedemptionData({ + requestedAmount: fromSatoshiToTokenPrecision(amount).toString(), + redemptionRequestedTxHash: redemptionRequestedEvent.transactionHash, + redemptionCompletedTxHash: undefined, + requestedAt: requestedAt, + redemptionTimedOutTxHash: timedOutTxHash, + treasuryFee: "0", // Treasury fee is not available in L1BTCRedeemerWormhole event + isTimedOut, + walletPublicKeyHash: walletPublicKeyHash, + redemptionKey: computedRedemptionKey, + }) + return + } + + // If redemption was completed, find the completion event + const redemptionCompletedEvents = + await threshold.tbtc.getRedemptionsCompletedEvents({ + walletPublicKeyHash, + fromBlock: redemptionRequestedEvent.blockNumber, + }) + + for (const { + redemptionBitcoinTxHash, + txHash, + blockNumber: redemptionCompletedBlockNumber, + } of redemptionCompletedEvents) { + const redemptionBitcoinTransfer = await findRedemptionInBitcoinTx( + redemptionBitcoinTxHash, + redemptionCompletedBlockNumber, + redeemerOutputScript + ) + + if (!redemptionBitcoinTransfer) continue + + const { receivedAmount, redemptionCompletedTimestamp, btcAddress } = + redemptionBitcoinTransfer + + setRedemptionData({ + requestedAmount: fromSatoshiToTokenPrecision(amount).toString(), + receivedAmount, + redemptionRequestedTxHash: redemptionRequestedEvent.transactionHash, + redemptionCompletedTxHash: { + chain: txHash, + bitcoin: redemptionBitcoinTxHash, + }, + requestedAt: redemptionRequestedEventTimestamp, + completedAt: redemptionCompletedTimestamp, + treasuryFee: "0", + isTimedOut: false, + btcAddress, + walletPublicKeyHash: walletPublicKeyHash, + redemptionKey: computedRedemptionKey, + }) + + return + } + } catch (error) { + console.error( + "Could not fetch the cross-chain redemption request details!", + error + ) + setError((error as Error).toString()) + } finally { + setIsFetching(false) + } + } + + fetch() + }, [ + redeemerOutputScript, + redeemer, + threshold, + getBlock, + findRedemptionInBitcoinTx, + ]) + + return { isFetching, data: redemptionData, error } +} diff --git a/src/hooks/tbtc/useSubscribeToL1BitcoinRedeemerRedemptionRequestedEvent.ts b/src/hooks/tbtc/useSubscribeToL1BitcoinRedeemerRedemptionRequestedEvent.ts new file mode 100644 index 000000000..d3607b547 --- /dev/null +++ b/src/hooks/tbtc/useSubscribeToL1BitcoinRedeemerRedemptionRequestedEvent.ts @@ -0,0 +1,46 @@ +import { useSubscribeToContractEvent } from "../../web3/hooks" +import { useThreshold } from "../../contexts/ThresholdContext" +import { BigNumber, Event } from "ethers" + +type L1BitcoinRedeemerRedemptionRequestedEventCallback = ( + redemptionKey: BigNumber, + walletPublicKeyHash: string, + mainUtxo: any, + redemptionOutputScript: string, + amount: BigNumber, + event: Event +) => void + +export const useSubscribeToL1BitcoinRedeemerRedemptionRequestedEvent = ( + callback: L1BitcoinRedeemerRedemptionRequestedEventCallback, + filterParams?: any[], + shouldSubscribeIfUserNotConnected?: boolean +) => { + const threshold = useThreshold() + const contract = threshold.tbtc.l1BitcoinRedeemerContract + + useSubscribeToContractEvent( + contract, + "RedemptionRequested", + //@ts-ignore + async ( + redemptionKey: BigNumber, + walletPublicKeyHash: string, + mainUtxo: any, + redemptionOutputScript: string, + amount: BigNumber, + event: Event + ) => { + callback( + redemptionKey, + walletPublicKeyHash, + mainUtxo, + redemptionOutputScript, + amount, + event + ) + }, + filterParams ?? [], + !!shouldSubscribeIfUserNotConnected + ) +} diff --git a/src/hooks/tbtc/useSubscribeToRedemptionRequestedEvent.ts b/src/hooks/tbtc/useSubscribeToRedemptionRequestedEvent.ts index 02aa68566..4bb54a48d 100644 --- a/src/hooks/tbtc/useSubscribeToRedemptionRequestedEvent.ts +++ b/src/hooks/tbtc/useSubscribeToRedemptionRequestedEvent.ts @@ -8,7 +8,21 @@ import { BigNumber, Event } from "ethers" import { useThreshold } from "../../contexts/ThresholdContext" import { fromSatoshiToTokenPrecision } from "../../threshold-ts/utils" -export const useSubscribeToRedemptionRequestedEvent = () => { +type RedemptionsCompletedEventCallback = ( + walletPublicKeyHash: string, + redeemerOutputScript: string, + redeemer: string, + requestedAmount: BigNumber, + treasuryFee: BigNumber, + txMaxFee: BigNumber, + event: Event +) => void + +export const useSubscribeToRedemptionRequestedEvent = ( + callback?: RedemptionsCompletedEventCallback, + filterParams?: any[], + shouldSubscribeIfUserNotConnected?: boolean +) => { const contract = useBridgeContract() const dispatch = useAppDispatch() const { account } = useWeb3React() @@ -27,6 +41,19 @@ export const useSubscribeToRedemptionRequestedEvent = () => { txMaxFee: BigNumber, event: Event ) => { + if (callback) { + callback( + walletPublicKeyHash, + redeemerOutputScript, + redeemer, + requestedAmount, + treasuryFee, + txMaxFee, + event + ) + return + } + if (!account || !isSameETHAddress(redeemer, account)) return const redemptionKey = threshold.tbtc.buildRedemptionKey( @@ -44,6 +71,7 @@ export const useSubscribeToRedemptionRequestedEvent = () => { }) ) }, - [null, null, account] + filterParams ?? [null, null, account], + !!shouldSubscribeIfUserNotConnected ) } diff --git a/src/pages/tBTC/Bridge/MintUnmintNav.tsx b/src/pages/tBTC/Bridge/MintUnmintNav.tsx index b4e2e95a4..a410b1b7a 100644 --- a/src/pages/tBTC/Bridge/MintUnmintNav.tsx +++ b/src/pages/tBTC/Bridge/MintUnmintNav.tsx @@ -1,4 +1,4 @@ -import { ComponentProps, FC } from "react" +import { ComponentProps, FC, useEffect, useState } from "react" import { matchPath, resolvePath, @@ -11,8 +11,14 @@ import { PageComponent } from "../../../types" import { isL2Network } from "../../../networks/utils" import { useIsActive } from "../../../hooks/useIsActive" import { useNonEVMConnection } from "../../../hooks/useNonEVMConnection" +import { useThreshold } from "../../../contexts/ThresholdContext" +import { Contract } from "ethers" -const renderNavItem = (page: PageComponent, index: number) => ( +const renderNavItem = ( + page: PageComponent, + index: number, + isDisabled: boolean +) => ( ( tabId={index.toString()} textDecoration="none" _groupHover={{ textDecoration: "none" }} + disabled={isDisabled} > {page.route.title} @@ -28,10 +35,15 @@ const renderNavItem = (page: PageComponent, index: number) => ( export const MintUnmintNav: FC< ComponentProps & { pages: PageComponent[] } > = ({ pages, ...props }) => { - const { chainId } = useIsActive() - const { nonEVMPublicKey } = useNonEVMConnection() + const threshold = useThreshold() const resolved = useResolvedPath("") const location = useLocation() + const [isL2BitcoinRedeemerContract, setIsL2BitcoinRedeemerContract] = + useState(null) + + useEffect(() => { + setIsL2BitcoinRedeemerContract(threshold.tbtc.l2BitcoinRedeemerContract) + }, [threshold.tbtc.l2BitcoinRedeemerContract]) const activeTabId = pages .map((page) => @@ -43,20 +55,19 @@ export const MintUnmintNav: FC< .findIndex((match) => !!match) .toString() + const shouldDisableUnmint = + threshold.config.crossChain.isCrossChain && !isL2BitcoinRedeemerContract + return ( - {isL2Network(chainId) || nonEVMPublicKey - ? pages - .filter( - (page) => - !!page.route.title && - page.route.title.toLowerCase() === "mint" - ) - .map((filteredPage, index) => renderNavItem(filteredPage, index)) - : pages - .filter((page) => !!page.route.title) - .map((filteredPage, index) => renderNavItem(filteredPage, index))} + {pages + .filter((page) => !!page.route.title) + .map((page, index) => { + const isUnmint = + !!page.route.title && page.route.title.toLowerCase() !== "mint" + return renderNavItem(page, index, shouldDisableUnmint && isUnmint) + })} ) diff --git a/src/pages/tBTC/Bridge/Unmint.tsx b/src/pages/tBTC/Bridge/Unmint.tsx index 4c6a54a54..639570780 100644 --- a/src/pages/tBTC/Bridge/Unmint.tsx +++ b/src/pages/tBTC/Bridge/Unmint.tsx @@ -62,6 +62,9 @@ import { BridgeProcessEmptyState } from "./components/BridgeProcessEmptyState" import { useIsActive } from "../../../hooks/useIsActive" import SubmitTxButton from "../../../components/SubmitTxButton" import { isSupportedNetwork } from "../../../networks/utils" +import { useEffect, useState } from "react" +import { useApproveL2TBTCToken } from "../../../hooks/tbtc" +import { BigNumber } from "ethers" const UnmintFormPage: PageComponent = ({}) => { const { balance } = useToken(Token.TBTCV2) @@ -155,9 +158,80 @@ const UnmintFormBase: FC = ({ "unmint", bitcoinNetwork ) - const { isSubmitting, getFieldMeta } = useFormikContext() + const { isSubmitting, getFieldMeta, values } = + useFormikContext() const { error } = getFieldMeta("wallet") const errorColor = useColorModeValue("red.500", "red.300") + const { account } = useIsActive() + const { closeModal } = useModal() + + const [allowance, setAllowance] = useState(BigNumber.from(0)) + const [isCheckingAllowance, setIsCheckingAllowance] = useState(false) + const [isApproving, setIsApproving] = useState(false) + + const isCrossChain = !!threshold.tbtc.l2BitcoinRedeemerContract + const amountToUnmint = values.amount + ? BigNumber.from(values.amount) + : BigNumber.from(0) + const needsApproval = + isCrossChain && amountToUnmint.gt(0) && allowance.lt(amountToUnmint) + + const checkAllowance = async () => { + if (!isCrossChain || !account || !values.amount) return + + setIsCheckingAllowance(true) + try { + // Double-check account exists before making the call + if (!account) { + console.warn("Account not available for allowance check") + return + } + + const currentAllowance = await threshold.tbtc.getL2TBTCAllowance(account) + setAllowance(BigNumber.from(currentAllowance)) + } catch (error) { + console.error("Error checking allowance:", error) + // Reset allowance on error to ensure user can still attempt approval + setAllowance(BigNumber.from(0)) + } finally { + setIsCheckingAllowance(false) + } + } + + useEffect(() => { + // Only check allowance if we have all required values + if (isCrossChain && account && values.amount) { + checkAllowance() + } + }, [account, values.amount, isCrossChain]) + + const { sendTransaction: approveToken } = useApproveL2TBTCToken( + async () => { + setIsApproving(false) + // Re-check allowance after approval + await checkAllowance() + closeModal() + }, + () => { + setIsApproving(false) + closeModal() + } + ) + + const handleApprove = async () => { + setIsApproving(true) + await approveToken(amountToUnmint.toString()) + } + + const isButtonDisabled = + !threshold.tbtc.bridgeContract || + isSubmitting || + isCheckingAllowance || + isApproving || + !values.amount || + !values.btcAddress || + !!getFieldMeta("amount").error || + !!getFieldMeta("btcAddress").error return (
@@ -194,16 +268,29 @@ const UnmintFormBase: FC = ({ {error} )} - - Unmint - + {needsApproval ? ( + + {isCheckingAllowance ? "Checking allowance..." : "Approve tBTC"} + + ) : ( + + {isCheckingAllowance ? "Checking allowance..." : "Unmint"} + + )} ) } diff --git a/src/pages/tBTC/Bridge/UnmintDetails.tsx b/src/pages/tBTC/Bridge/UnmintDetails.tsx index 25310cc0c..9934b07c8 100644 --- a/src/pages/tBTC/Bridge/UnmintDetails.tsx +++ b/src/pages/tBTC/Bridge/UnmintDetails.tsx @@ -58,30 +58,106 @@ import { useFindRedemptionInBitcoinTx, useSubscribeToRedemptionsCompletedEventBase, } from "../../../hooks/tbtc" +import { useFetchCrossChainRedemptionDetails } from "../../../hooks/tbtc/useFetchCrossChainRedemptionDetails" +import { useSubscribeToL1BitcoinRedeemerRedemptionRequestedEvent } from "../../../hooks/tbtc/useSubscribeToL1BitcoinRedeemerRedemptionRequestedEvent" import { useAppDispatch } from "../../../hooks/store" import { tbtcSlice } from "../../../store/tbtc" import { useThreshold } from "../../../contexts/ThresholdContext" +import { ChainName } from "../../../threshold-ts/types" export const UnmintDetails: PageComponent = () => { const [searchParams] = useSearchParams() - const walletPublicKeyHash = searchParams.get("walletPublicKeyHash") + const { redemptionRequestedTxHash: txHashFromParams } = useParams() + const [walletPublicKeyHash, setWalletPublicKeyHash] = useState< + string | undefined + >(searchParams.get("walletPublicKeyHash") || undefined) + const [redemptionRequestedTxHash, setRedemptionRequestedTxHash] = useState< + string | undefined + >(txHashFromParams || undefined) const redeemerOutputScript = searchParams.get("redeemerOutputScript") const redeemer = searchParams.get("redeemer") - const { redemptionRequestedTxHash } = useParams() + const chainName = searchParams.get("chainName") const dispatch = useAppDispatch() const threshold = useThreshold() + const isCrossChainRedemption = !!chainName && chainName !== ChainName.Ethereum - const { data, isFetching, error } = useFetchRedemptionDetails( - redemptionRequestedTxHash, - walletPublicKeyHash, - redeemerOutputScript, - redeemer + // Use cross-chain redemption details for L2 redemptions + const { + data: crossChainData, + isFetching: isFetchingCrossChain, + error: crossChainError, + } = useFetchCrossChainRedemptionDetails( + isCrossChainRedemption ? redeemerOutputScript : null, + isCrossChainRedemption ? redeemer : null ) + + // Use regular redemption details for L1 redemptions + const { + data: l1Data, + isFetching: isFetchingL1, + error: l1Error, + } = useFetchRedemptionDetails( + !isCrossChainRedemption ? redemptionRequestedTxHash : null, + !isCrossChainRedemption ? walletPublicKeyHash : null, + !isCrossChainRedemption ? redeemerOutputScript : null, + !isCrossChainRedemption ? redeemer : null + ) + + // Combine data from both hooks + const data = isCrossChainRedemption ? crossChainData : l1Data + const isFetching = isCrossChainRedemption + ? isFetchingCrossChain + : isFetchingL1 + const error = isCrossChainRedemption ? crossChainError : l1Error + + // Update state variables when cross-chain data is fetched + useEffect(() => { + if (isCrossChainRedemption && crossChainData) { + setWalletPublicKeyHash(crossChainData.walletPublicKeyHash) + setRedemptionRequestedTxHash(crossChainData.redemptionRequestedTxHash) + } + }, [isCrossChainRedemption, crossChainData]) + const findRedemptionInBitcoinTx = useFindRedemptionInBitcoinTx() const [redemptionFromBitcoinTx, setRedemptionFromBitcoinTx] = useState< Awaited> | undefined >(undefined) + // Subscribe to L1BitcoinRedeemer RedemptionRequested events for cross-chain redemptions + useSubscribeToL1BitcoinRedeemerRedemptionRequestedEvent( + async ( + redemptionKey, + eventWalletPublicKeyHash, + mainUtxo, + eventRedeemerOutputScript, + amount, + event + ) => { + if (!isCrossChainRedemption || !redeemerOutputScript) return + if (eventRedeemerOutputScript !== redeemerOutputScript) return + + // Update state with the wallet public key hash and tx hash from the event + setWalletPublicKeyHash(eventWalletPublicKeyHash) + setRedemptionRequestedTxHash(event.transactionHash) + + // Dispatch redemption requested action + dispatch( + tbtcSlice.actions.redemptionRequested({ + redemptionKey: redemptionKey.toString(), + blockNumber: event.blockNumber, + amount: amount.toString(), + txHash: event.transactionHash, + additionalData: { + redeemerOutputScript: eventRedeemerOutputScript, + walletPublicKeyHash: eventWalletPublicKeyHash, + }, + }) + ) + }, + [null, null, null, redeemerOutputScript], + isCrossChainRedemption + ) + useSubscribeToRedemptionsCompletedEventBase( async (eventWalletPublicKeyHash, redemptionTxHash, event) => { if (eventWalletPublicKeyHash !== walletPublicKeyHash) return @@ -95,7 +171,11 @@ export const UnmintDetails: PageComponent = () => { setRedemptionFromBitcoinTx(redemption) - if (redemptionRequestedTxHash && redeemerOutputScript) { + if ( + redemptionRequestedTxHash && + redeemerOutputScript && + walletPublicKeyHash + ) { dispatch( tbtcSlice.actions.redemptionCompleted({ redemptionKey: threshold.tbtc.buildRedemptionKey( @@ -107,7 +187,7 @@ export const UnmintDetails: PageComponent = () => { ) } }, - [], + walletPublicKeyHash ? [walletPublicKeyHash] : [], true ) @@ -117,6 +197,7 @@ export const UnmintDetails: PageComponent = () => { const _isFetching = isFetching || !data const wasDataFetched = !isFetching && !!data + const isRedemptionRequested = !!redemptionRequestedTxHash const isProcessCompleted = !!redemptionFromBitcoinTx?.bitcoinTxHash const shouldForceIsProcessCompleted = !!data?.redemptionCompletedTxHash?.bitcoin @@ -231,26 +312,53 @@ export const UnmintDetails: PageComponent = () => { size="sm" bg={timelineBadgeBgColor} position="absolute" - bottom="10px" + bottom="-25px" left="50%" transform="translateX(-50%)" > usual duration - 3-5 hours - + {isCrossChainRedemption && ( + + + + + + + + + tBTC sent to L1 + + + )} + - + {isRedemptionRequested && ( + + )} @@ -262,7 +370,9 @@ export const UnmintDetails: PageComponent = () => { status={ isProcessCompleted || shouldForceIsProcessCompleted ? "active" - : "semi-active" + : isRedemptionRequested + ? "semi-active" + : "inactive" } > diff --git a/src/threshold-ts/index.ts b/src/threshold-ts/index.ts index 78a4e0353..945ddb100 100644 --- a/src/threshold-ts/index.ts +++ b/src/threshold-ts/index.ts @@ -12,11 +12,23 @@ export class Threshold { multiAppStaking!: MultiAppStaking vendingMachines!: IVendingMachines tbtc!: ITBTC + private _listeners: (() => void)[] = [] constructor(config: ThresholdConfig) { this._initialize(config) } + subscribe = (listener: () => void) => { + this._listeners.push(listener) + return () => { + this._listeners = this._listeners.filter((l) => l !== listener) + } + } + + private _notify = () => { + this._listeners.forEach((l) => l()) + } + private _initialize = (config: ThresholdConfig) => { this.config = config const { ethereum, bitcoin, crossChain } = config @@ -29,10 +41,24 @@ export class Threshold { this.multicall, ethereum ) - this.tbtc = new TBTC(ethereum, bitcoin, crossChain) + this.tbtc = new TBTC(ethereum, bitcoin, crossChain, this._notify) } updateConfig = async (config: ThresholdConfig) => { this._initialize(config) + + const awaitTbtcReady = new Promise((resolve) => { + const checkTbtc = () => { + if (this.tbtc.isTbtcReady) { + resolve() + } else { + setTimeout(checkTbtc, 100) + } + } + checkTbtc() + }) + + await awaitTbtcReady + this._notify() } } diff --git a/src/threshold-ts/tbtc/index.ts b/src/threshold-ts/tbtc/index.ts index 686798c57..cc1436c99 100644 --- a/src/threshold-ts/tbtc/index.ts +++ b/src/threshold-ts/tbtc/index.ts @@ -226,10 +226,19 @@ export interface ITBTC { readonly l2TbtcToken: DestinationChainTBTCToken | null + readonly l1BitcoinRedeemerContract: Contract | null + + readonly l2BitcoinRedeemerContract: Contract | null + readonly deposit: Deposit | undefined readonly crossChainConfig: CrossChainConfig + /** + * A flag to indicate if the tBTC SDK is ready to be used. + */ + isTbtcReady: boolean + /** * Initializes tbtc-v2 SDK * @param ethereumProviderOrSigner Ethers instance of Provider (if wallet is not @@ -413,7 +422,8 @@ export interface ITBTC { ): Promise<{ hash: string additionalParams: { - walletPublicKey: string + walletPublicKey?: string + chainName?: ChainName | null } }> @@ -487,6 +497,20 @@ export interface ITBTC { treasuryFee: string estimatedAmountToBeReceived: string }> + + /** + * Gets the current allowance of the L2 tBTC token for the L2 Bitcoin Redeemer contract. + * @param owner The address of the token owner + * @returns The current allowance amount in string format + */ + getL2TBTCAllowance(owner: string): Promise + + /** + * Approves the L2 Bitcoin Redeemer contract to spend L2 tBTC tokens. + * @param amount The amount to approve in tBTC token unit + * @returns Transaction receipt or transaction hash + */ + approveL2TBTCToken(amount: BigNumberish): Promise } export class TBTC implements ITBTC { @@ -495,6 +519,8 @@ export class TBTC implements ITBTC { private _tokenContract: Contract | null private _l1BitcoinDepositorContract: Contract | null = null private _l2TbtcToken: DestinationChainTBTCToken | null = null + private _l1BitcoinRedeemerContract: Contract | null = null + private _l2BitcoinRedeemerContract: Contract | null = null private _multicall: IMulticall private _bitcoinClient: BitcoinClient private _ethereumConfig: EthereumConfig @@ -523,11 +549,14 @@ export class TBTC implements ITBTC { private _sdkPromise: Promise private _deposit: Deposit | undefined private _crossChainConfig: CrossChainConfig + private _onAsyncInitializationDone: () => void + isTbtcReady: boolean constructor( ethereumConfig: EthereumConfig, bitcoinConfig: BitcoinConfig, - crossChainConfig: CrossChainConfig + crossChainConfig: CrossChainConfig, + onAsyncInitializationDone: () => void = () => {} ) { if (!bitcoinConfig.client && !bitcoinConfig.credentials) { throw new Error( @@ -602,6 +631,12 @@ export class TBTC implements ITBTC { ? getEthereumNetworkNameFromChainId(chainId) : this._crossChainConfig.chainName + const l1BitcoinRedeemerArtifact = getArtifact( + "L1BitcoinRedeemer" as ArtifactNameType, + mainnetOrTestnetEthereumChainId, + shouldUseTestnetDevelopmentContracts + ) + const l1BitcoinDepositorArtifact = getArtifact( `${networkName}L1BitcoinDepositor` as ArtifactNameType, mainnetOrTestnetEthereumChainId, @@ -616,6 +651,15 @@ export class TBTC implements ITBTC { account ) : null + + this._l1BitcoinRedeemerContract = l1BitcoinRedeemerArtifact + ? getContract( + l1BitcoinRedeemerArtifact.address, + l1BitcoinRedeemerArtifact.abi, + defaultOrConnectedProvider, + account + ) + : null } // @ts-ignore @@ -629,6 +673,8 @@ export class TBTC implements ITBTC { this._ethereumConfig = ethereumConfig this._bitcoinConfig = bitcoinConfig this._sdkPromise = new Promise((resolve) => resolve(undefined)) + this._onAsyncInitializationDone = onAsyncInitializationDone + this.isTbtcReady = false this.initializeSdk(ethereumProviderOrSigner, account) } @@ -701,6 +747,8 @@ export class TBTC implements ITBTC { account as string ) } + this.isTbtcReady = true + this._onAsyncInitializationDone() } catch (err) { throw new Error(`Something went wrong when initializing tbtc sdk: ${err}`) } @@ -742,6 +790,14 @@ export class TBTC implements ITBTC { return this._l2TbtcToken } + get l1BitcoinRedeemerContract() { + return this._l1BitcoinRedeemerContract + } + + get l2BitcoinRedeemerContract() { + return this._l2BitcoinRedeemerContract + } + private _getSdk = async (): Promise => { const sdk = await this._sdkPromise if (!sdk) throw new EmptySdkObjectError() @@ -771,6 +827,8 @@ export class TBTC implements ITBTC { this._crossChainConfig.chainName as DestinationChainName ) this._l2TbtcToken = crossChainContracts?.destinationChainTbtcToken ?? null + this._l2BitcoinRedeemerContract = + crossChainContracts?.l2BitcoinRedeemer as unknown as Contract | null } initiateDeposit = async (btcRecoveryAddress: string): Promise => { @@ -1623,7 +1681,8 @@ export class TBTC implements ITBTC { ): Promise<{ hash: string additionalParams: { - walletPublicKey: string + walletPublicKey?: string + chainName?: ChainName | null } }> => { const sdk = await this._getSdk() @@ -1634,6 +1693,23 @@ export class TBTC implements ITBTC { ) } + if (this._crossChainConfig.isCrossChain) { + const { targetChainTxHash } = + await sdk.redemptions.requestCrossChainRedemption( + btcAddress, + BigNumber.from(amount), + this._crossChainConfig.chainName as DestinationChainName + ) + + return { + hash: targetChainTxHash.toPrefixedString(), + additionalParams: { + walletPublicKey: undefined, + chainName: this._crossChainConfig.chainName, + }, + } + } + const { targetChainTxHash, walletPublicKey } = await sdk.redemptions.requestRedemption( btcAddress, @@ -1644,6 +1720,7 @@ export class TBTC implements ITBTC { hash: targetChainTxHash.toPrefixedString(), additionalParams: { walletPublicKey: walletPublicKey.toString(), + chainName: ChainName.Ethereum, }, } } @@ -1949,4 +2026,86 @@ export class TBTC implements ITBTC { return this._redemptionTreasuryFeeDivisor } + + getL2TBTCAllowance = async (owner: string): Promise => { + if (!owner) { + throw new Error("Owner address is required") + } + + if (!this._l2TbtcToken) { + throw new Error("L2 tBTC token is not initialized") + } + + if (!this._l2BitcoinRedeemerContract) { + throw new Error("L2 Bitcoin Redeemer contract is not initialized") + } + + // Get the L2 tBTC token address + const l2TbtcTokenAddress = + this._l2TbtcToken.getChainIdentifier().identifierHex + + const l2BitcoinRedeemerAddress = + this._l2BitcoinRedeemerContract.getChainIdentifier().identifierHex + + // Create a standard ERC20 contract instance + const erc20Abi = [ + "function allowance(address owner, address spender) view returns (uint256)", + ] + + const l2TbtcTokenContract = getContract( + `0x${l2TbtcTokenAddress}`, + erc20Abi, + this._ethereumConfig.ethereumProviderOrSigner as + | providers.Provider + | Signer, + this._ethereumConfig.account + ) + + const allowance = await l2TbtcTokenContract.allowance( + owner, + `0x${l2BitcoinRedeemerAddress}` + ) + + return allowance.toString() + } + + approveL2TBTCToken = async ( + amount: BigNumberish + ): Promise => { + if (!this._l2TbtcToken) { + throw new Error("L2 tBTC token is not initialized") + } + + if (!this._l2BitcoinRedeemerContract) { + throw new Error("L2 Bitcoin Redeemer contract is not initialized") + } + + // Get the L2 tBTC token address + const l2TbtcTokenAddress = + this._l2TbtcToken.getChainIdentifier().identifierHex + + const l2BitcoinRedeemerAddress = + this._l2BitcoinRedeemerContract.getChainIdentifier().identifierHex + + // Create a standard ERC20 contract instance + const erc20Abi = [ + "function approve(address spender, uint256 amount) returns (bool)", + ] + + const l2TbtcTokenContract = getContract( + `0x${l2TbtcTokenAddress}`, + erc20Abi, + this._ethereumConfig.ethereumProviderOrSigner as + | providers.Provider + | Signer, + this._ethereumConfig.account + ) + + const tx = await l2TbtcTokenContract.approve( + `0x${l2BitcoinRedeemerAddress}`, + amount + ) + + return tx.wait() + } } diff --git a/src/threshold-ts/tbtc/sepolia-artifacts/L1BitcoinRedeemer.json b/src/threshold-ts/tbtc/sepolia-artifacts/L1BitcoinRedeemer.json new file mode 100644 index 000000000..b165e7439 --- /dev/null +++ b/src/threshold-ts/tbtc/sepolia-artifacts/L1BitcoinRedeemer.json @@ -0,0 +1,509 @@ +{ + "address": "0xe8312BD306512c5CAD4D650df373D5597B1C697A", + "abi": [ + { + "inputs": [], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "requestRedemptionGasOffset", + "type": "uint256" + } + ], + "name": "GasOffsetParametersUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint8", + "name": "version", + "type": "uint8" + } + ], + "name": "Initialized", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "previousOwner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "OwnershipTransferred", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "redemptionKey", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "bytes20", + "name": "walletPubKeyHash", + "type": "bytes20" + }, + { + "components": [ + { + "internalType": "bytes32", + "name": "txHash", + "type": "bytes32" + }, + { + "internalType": "uint32", + "name": "txOutputIndex", + "type": "uint32" + }, + { + "internalType": "uint64", + "name": "txOutputValue", + "type": "uint64" + } + ], + "indexed": false, + "internalType": "struct BitcoinTx.UTXO", + "name": "mainUtxo", + "type": "tuple" + }, + { + "indexed": true, + "internalType": "bytes", + "name": "redemptionOutputScript", + "type": "bytes" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "RedemptionRequested", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "_address", + "type": "address" + }, + { + "indexed": false, + "internalType": "bool", + "name": "authorization", + "type": "bool" + } + ], + "name": "ReimbursementAuthorizationUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "newReimbursementPool", + "type": "address" + } + ], + "name": "ReimbursementPoolUpdated", + "type": "event" + }, + { + "inputs": [], + "name": "SATOSHI_MULTIPLIER", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "bank", + "outputs": [ + { + "internalType": "contract IBank", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "gasReimbursements", + "outputs": [ + { + "internalType": "address", + "name": "receiver", + "type": "address" + }, + { + "internalType": "uint96", + "name": "gasSpent", + "type": "uint96" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_thresholdBridge", + "type": "address" + }, + { + "internalType": "address", + "name": "_wormholeTokenBridge", + "type": "address" + }, + { + "internalType": "address", + "name": "_tbtcToken", + "type": "address" + }, + { + "internalType": "address", + "name": "_bank", + "type": "address" + } + ], + "name": "initialize", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "owner", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "reimbursementAuthorizations", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "reimbursementPool", + "outputs": [ + { + "internalType": "contract ReimbursementPool", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "renounceOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes20", + "name": "walletPubKeyHash", + "type": "bytes20" + }, + { + "components": [ + { + "internalType": "bytes32", + "name": "txHash", + "type": "bytes32" + }, + { + "internalType": "uint32", + "name": "txOutputIndex", + "type": "uint32" + }, + { + "internalType": "uint64", + "name": "txOutputValue", + "type": "uint64" + } + ], + "internalType": "struct BitcoinTx.UTXO", + "name": "mainUtxo", + "type": "tuple" + }, + { + "internalType": "bytes", + "name": "encodedVm", + "type": "bytes" + } + ], + "name": "requestRedemption", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "requestRedemptionGasOffset", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "rescueTbtc", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "tbtcToken", + "outputs": [ + { + "internalType": "contract IERC20Upgradeable", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "thresholdBridge", + "outputs": [ + { + "internalType": "contract IBridge", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "transferOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_requestRedemptionGasOffset", + "type": "uint256" + } + ], + "name": "updateGasOffsetParameters", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_address", + "type": "address" + }, + { + "internalType": "bool", + "name": "authorization", + "type": "bool" + } + ], + "name": "updateReimbursementAuthorization", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "contract ReimbursementPool", + "name": "_reimbursementPool", + "type": "address" + } + ], + "name": "updateReimbursementPool", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "wormholeTokenBridge", + "outputs": [ + { + "internalType": "contract IWormholeTokenBridge", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + } + ], + "transactionHash": "0x80c47206452e22911037a90c772ac390a291665fbc074d3b6f03d1494b0ae08b", + "receipt": { + "to": null, + "from": "0x15424dC94D4da488DB0d0e0B7aAdB86835813a63", + "contractAddress": "0xe8312BD306512c5CAD4D650df373D5597B1C697A", + "transactionIndex": 127, + "gasUsed": "750835", + "logsBloom": "0x00000000000000000000008000000000400000000000000000800000000000000008000000000800000000000000000000000000001000000000000000000000000000000000000000000000000002000001000000000000000000200000000000000000020000000000000000000800000000800000000000000000000000400000000000000000000000000000000000000000010080000000000000800000000000000000000000000000001400000000000000000000000000000000000000000020000000000000000000040000000000000400000000000000000020000000000800000000000000000000000000000000000000002000000000000000", + "blockHash": "0x0ec1cb292930568aae0139a10b1f14cb05df29edfe70dd0fd8212cb11cecc547", + "transactionHash": "0x80c47206452e22911037a90c772ac390a291665fbc074d3b6f03d1494b0ae08b", + "logs": [ + { + "transactionIndex": 127, + "blockNumber": 8558171, + "transactionHash": "0x80c47206452e22911037a90c772ac390a291665fbc074d3b6f03d1494b0ae08b", + "address": "0xe8312BD306512c5CAD4D650df373D5597B1C697A", + "topics": [ + "0xbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b", + "0x000000000000000000000000cdfb1051630aa1cbe500466d96abe9d6ed3a6809" + ], + "data": "0x", + "logIndex": 100, + "blockHash": "0x0ec1cb292930568aae0139a10b1f14cb05df29edfe70dd0fd8212cb11cecc547" + }, + { + "transactionIndex": 127, + "blockNumber": 8558171, + "transactionHash": "0x80c47206452e22911037a90c772ac390a291665fbc074d3b6f03d1494b0ae08b", + "address": "0xe8312BD306512c5CAD4D650df373D5597B1C697A", + "topics": [ + "0x8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e0", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x00000000000000000000000015424dc94d4da488db0d0e0b7aadb86835813a63" + ], + "data": "0x", + "logIndex": 101, + "blockHash": "0x0ec1cb292930568aae0139a10b1f14cb05df29edfe70dd0fd8212cb11cecc547" + }, + { + "transactionIndex": 127, + "blockNumber": 8558171, + "transactionHash": "0x80c47206452e22911037a90c772ac390a291665fbc074d3b6f03d1494b0ae08b", + "address": "0xe8312BD306512c5CAD4D650df373D5597B1C697A", + "topics": [ + "0x7f26b83ff96e1f2b6a682f133852f6798a09c465da95921460cefb3847402498" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000000000001", + "logIndex": 102, + "blockHash": "0x0ec1cb292930568aae0139a10b1f14cb05df29edfe70dd0fd8212cb11cecc547" + }, + { + "transactionIndex": 127, + "blockNumber": 8558171, + "transactionHash": "0x80c47206452e22911037a90c772ac390a291665fbc074d3b6f03d1494b0ae08b", + "address": "0xe8312BD306512c5CAD4D650df373D5597B1C697A", + "topics": [ + "0x7e644d79422f17c01e4894b5f4f588d331ebfa28653d42ae832dc59e38c9798f" + ], + "data": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000008d58747ef805317270ff7f8e51a8018d3488b17b", + "logIndex": 103, + "blockHash": "0x0ec1cb292930568aae0139a10b1f14cb05df29edfe70dd0fd8212cb11cecc547" + } + ], + "blockNumber": 8558171, + "cumulativeGasUsed": "14203642", + "status": 1, + "byzantium": true + }, + "numDeployments": 1, + "implementation": "0xCdFb1051630AA1cbe500466d96Abe9d6ed3a6809", + "devdoc": "Contract deployed as upgradable proxy" +} diff --git a/src/threshold-ts/utils/contract.ts b/src/threshold-ts/utils/contract.ts index b5b457bd8..26f5a2e7b 100644 --- a/src/threshold-ts/utils/contract.ts +++ b/src/threshold-ts/utils/contract.ts @@ -40,7 +40,6 @@ import VendingMachineNuCypherSepolia from "../vending-machine/sepolia-artifacts/ import WalletRegistryArtifactSepolia from "@keep-network/tbtc-v2.ts/src/lib/ethereum/artifacts/sepolia/WalletRegistry.json" import StakingArtifactSepolia from "../staking/sepolia-artifacts/TokenStaking.json" import RandomBeaconArtifactSepolia from "../tbtc/sepolia-artifacts/RandomBeacon.json" -import LegacyKeepStakingArtifactSepolia from "../staking/sepolia-artifacts/LegacyKeepStaking.json" import TacoArtifactSepolia from "@nucypher/nucypher-contracts/deployment/artifacts/tapir.json" import BridgeArtifactDappDevelopmentSepolia from "../tbtc/dapp-development-sepolia-artifacts/Bridge.json" @@ -54,7 +53,8 @@ import WalletRegistryArtifactDappDevelopmentSepolia from "../tbtc/dapp-developme import StakingArtifactDappDevelopmentSepolia from "../staking/dapp-development-sepolia-artifacts/TokenStaking.json" import RandomBeaconArtifactDappDevelopmentSepolia from "../tbtc/dapp-development-sepolia-artifacts/RandomBeacon.json" import LegacyKeepStakingArtifactDappDevelopmentSepolia from "../staking/dapp-development-sepolia-artifacts/LegacyKeepStaking.json" -import TacoArtifactDappDevelopmentSepolia from "@nucypher/nucypher-contracts/deployment/artifacts/dashboard.json" + +import L1BitcoinRedeemerArtifactSepolia from "../tbtc/sepolia-artifacts/L1BitcoinRedeemer.json" export type ArtifactNameType = | "TacoRegistry" @@ -72,6 +72,7 @@ export type ArtifactNameType = | "ArbitrumL1BitcoinDepositor" | "BaseL1BitcoinDepositor" | "StarkNetBitcoinDepositor" + | "L1BitcoinRedeemer" type ArtifactType = { address: string abi: ContractInterface @@ -103,6 +104,7 @@ const contractArtifacts: ContractArtifacts = { VendingMachineNuCypher: VendingMachineNuCypherMainnet, }, [SupportedChainIds.Sepolia]: { + L1BitcoinRedeemer: L1BitcoinRedeemerArtifactSepolia, ArbitrumL1BitcoinDepositor: ArbitrumL1BitcoinDepositorArtifactSepolia, BaseL1BitcoinDepositor: BaseL1BitcoinDepositorArtifactSepolia, StarkNetBitcoinDepositor: StarkNetBitcoinDepositorArtifactSepolia, @@ -120,6 +122,7 @@ const contractArtifacts: ContractArtifacts = { VendingMachineNuCypher: VendingMachineNuCypherSepolia, }, [SupportedChainIds.Localhost]: { + L1BitcoinRedeemer: L1BitcoinRedeemerArtifactSepolia, TacoRegistry: TacoArtifactSepolia[SupportedChainIds.Sepolia].TACoApplication, LegacyKeepStaking: LegacyKeepStakingArtifactDappDevelopmentSepolia, diff --git a/src/utils/tBTC.ts b/src/utils/tBTC.ts index 6e7aae583..6b1ca7f63 100644 --- a/src/utils/tBTC.ts +++ b/src/utils/tBTC.ts @@ -49,9 +49,9 @@ const getSupportedAddressPrefixesText = ( const isLast = index === prefixesLength - 1 const isBeforeLast = index === prefixesLength - 2 const text = reducer.concat( - `“`, + `"`, prefix, - `”`, + `"`, isBeforeLast ? " or " : isLast ? "" : ", " ) @@ -92,6 +92,7 @@ export class RedemptionDetailsLinkBuilder { private txHash?: string private redeemer?: string private redeemerOutputScript?: string + private chainName?: string static createFromTxHash = (txHash: string) => { const builder = new RedemptionDetailsLinkBuilder() @@ -130,26 +131,23 @@ export class RedemptionDetailsLinkBuilder { return this } + withChainName = (chainName: string) => { + this.chainName = chainName + return this + } + withTxHash = (txHash: string) => { this.txHash = txHash return this } build = () => { - const params = [ - { label: "transaction hash", value: this.txHash }, - { label: "wallet public key hash", value: this.walletPublicKeyHash }, - { label: "redeemer output script", value: this.redeemerOutputScript }, - { label: "redeemer", value: this.redeemer }, - ] - - if ( - !this.txHash || - !this.walletPublicKeyHash || - !this.redeemerOutputScript || - !this.redeemer - ) { - const missingParams = params.filter((_) => !_.value) + if (!this.txHash || !this.redeemerOutputScript || !this.redeemer) { + const missingParams = [ + { label: "transaction hash", value: this.txHash }, + { label: "redeemer output script", value: this.redeemerOutputScript }, + { label: "redeemer", value: this.redeemer }, + ].filter((_) => !_.value) throw new Error( `Required parameters not set. Set ${missingParams @@ -158,9 +156,20 @@ export class RedemptionDetailsLinkBuilder { ) } + if (!this.walletPublicKeyHash && !this.chainName) { + throw new Error( + "Required parameters not set. Set wallet public key hash or chain name." + ) + } + const queryParams = new URLSearchParams() queryParams.set("redeemer", this.redeemer) - queryParams.set("walletPublicKeyHash", this.walletPublicKeyHash) + if (this.walletPublicKeyHash) { + queryParams.set("walletPublicKeyHash", this.walletPublicKeyHash) + } + if (this.chainName) { + queryParams.set("chainName", this.chainName) + } queryParams.set("redeemerOutputScript", this.redeemerOutputScript) return `/tBTC/unmint/redemption/${this.txHash}?${queryParams.toString()}` @@ -168,15 +177,26 @@ export class RedemptionDetailsLinkBuilder { } export const buildRedemptionDetailsLink = ( - txHash: string, redeemer: string, - walletPublicKey: string, btcAddress: string, - bitcoinNetwork: BitcoinNetwork + bitcoinNetwork: BitcoinNetwork, + txHash?: string, + walletPublicKey?: string, + chainName?: string | null ): string => { - return RedemptionDetailsLinkBuilder.createFromTxHash(txHash) + const redemptionDetailsLinkBuilder = new RedemptionDetailsLinkBuilder() + + if (txHash) { + redemptionDetailsLinkBuilder.withTxHash(txHash) + } + if (walletPublicKey) { + redemptionDetailsLinkBuilder.withWalletPublicKey(walletPublicKey) + } + if (chainName) { + redemptionDetailsLinkBuilder.withChainName(chainName) + } + return redemptionDetailsLinkBuilder .withRedeemer(redeemer) - .withWalletPublicKey(walletPublicKey) .withBitcoinAddress(btcAddress, bitcoinNetwork) .build() } diff --git a/src/web3/hooks/useSubscribeToContractEvent.ts b/src/web3/hooks/useSubscribeToContractEvent.ts index 1e71336dd..3b7f1e17d 100644 --- a/src/web3/hooks/useSubscribeToContractEvent.ts +++ b/src/web3/hooks/useSubscribeToContractEvent.ts @@ -22,6 +22,7 @@ export const useSubscribeToContractEvent = ( const firstIndexedParam = indexedFilterParams[0] || null const secondIndexedParam = indexedFilterParams[1] || null const thirdIndexedParam = indexedFilterParams[2] || null + const fourthIndexedParam = indexedFilterParams[3] || null // TODO: Debug this issue: https://keepnetwork.atlassian.net/browse/DAPP-515 // When called by the ERC20 transfer hook ,the balance is always 0 in the callback. @@ -42,20 +43,21 @@ export const useSubscribeToContractEvent = ( callbackRef.current(...args) } - const fileterParams = [ + const filterParams = [ firstIndexedParam, secondIndexedParam, thirdIndexedParam, + fourthIndexedParam, ] // Remove unnecessary params, otherwise encoding topic filter will fail. For // example, we can't pass `[
, null]` if we want to filter the // `Transfer` event only by `from`. - fileterParams.length = indexedFilterParamsLength + filterParams.length = indexedFilterParamsLength const eventNameOrFilter: string | EventFilter = indexedFilterParamsLength === 0 ? eventName - : contract.filters[eventName](...fileterParams) + : contract.filters[eventName](...filterParams) // Ethers.js considers the current block as part of "from now on" so we // start subscribing to event in the next block. If the user submit a @@ -79,6 +81,7 @@ export const useSubscribeToContractEvent = ( firstIndexedParam, secondIndexedParam, thirdIndexedParam, + fourthIndexedParam, indexedFilterParamsLength, ]) }