diff --git a/dapp/src/acre-react/contexts/AcreSdkContext.tsx b/dapp/src/acre-react/contexts/AcreSdkContext.tsx index 6be8694a6..75dd77a60 100644 --- a/dapp/src/acre-react/contexts/AcreSdkContext.tsx +++ b/dapp/src/acre-react/contexts/AcreSdkContext.tsx @@ -2,6 +2,8 @@ import React, { useCallback, useMemo, useState } from "react" import { LedgerLiveEthereumSigner } from "#/web3" import { Acre, EthereumNetwork } from "@acre-btc/sdk" +const TBTC_API_ENDPOINT = import.meta.env.VITE_TBTC_API_ENDPOINT + type AcreSdkContextValue = { acre?: Acre init: (ethereumAddress: string, network: EthereumNetwork) => Promise @@ -25,6 +27,7 @@ export function AcreSdkProvider({ children }: { children: React.ReactNode }) { const sdk = await Acre.initializeEthereum( await LedgerLiveEthereumSigner.fromAddress(ethereumAddress), network, + TBTC_API_ENDPOINT, ) setAcre(sdk) setIsInitialized(true) diff --git a/dapp/src/acre-react/hooks/useStakeFlow.ts b/dapp/src/acre-react/hooks/useStakeFlow.ts index b13c3b8a5..7b301ee4a 100644 --- a/dapp/src/acre-react/hooks/useStakeFlow.ts +++ b/dapp/src/acre-react/hooks/useStakeFlow.ts @@ -2,7 +2,6 @@ import { useCallback, useState } from "react" import { StakeInitialization, EthereumAddress, - DepositorProxy, DepositReceipt, } from "@acre-btc/sdk" import { useAcreContext } from "./useAcreContext" @@ -12,7 +11,6 @@ export type UseStakeFlowReturn = { bitcoinRecoveryAddress: string, ethereumAddress: string, referral: number, - depositor?: DepositorProxy, ) => Promise btcAddress?: string depositReceipt?: DepositReceipt @@ -36,7 +34,6 @@ export function useStakeFlow(): UseStakeFlowReturn { bitcoinRecoveryAddress: string, ethereumAddress: string, referral: number, - depositor?: DepositorProxy, ) => { if (!acre || !isInitialized) throw new Error("Acre SDK not defined") @@ -44,7 +41,6 @@ export function useStakeFlow(): UseStakeFlowReturn { bitcoinRecoveryAddress, EthereumAddress.from(ethereumAddress), referral, - depositor, ) const btcDepositAddress = await initializedStakeFlow.getBitcoinAddress() diff --git a/dapp/src/assets/icons/BoostArrowIcon.tsx b/dapp/src/assets/icons/BoostArrowIcon.tsx new file mode 100644 index 000000000..f0bc1da59 --- /dev/null +++ b/dapp/src/assets/icons/BoostArrowIcon.tsx @@ -0,0 +1,303 @@ +import React from "react" +import { createIcon } from "@chakra-ui/react" + +export const BoostArrowIcon = createIcon({ + displayName: "BoostArrow", + viewBox: "0 0 230 300", + defaultProps: { w: 230, h: 300 }, + path: [ + , + , + , + , + , + , + , + , + , + , + , + , + , + , + , + , + , + , + , + , + , + , + , + , + , + , + , + , + , + , + , + , + , + , + , + , + + + + + + + + + + + + + + + + + + + + + + , + ], +}) diff --git a/dapp/src/assets/icons/index.ts b/dapp/src/assets/icons/index.ts index d786957f5..62d802cf8 100644 --- a/dapp/src/assets/icons/index.ts +++ b/dapp/src/assets/icons/index.ts @@ -17,3 +17,4 @@ export * from "./LoadingSpinnerSuccessIcon" export * from "./BitcoinIcon" export * from "./EthereumIcon" export * from "./CableWithPlugIcon" +export * from "./BoostArrowIcon" diff --git a/dapp/src/components/shared/CurrencyBalance/index.tsx b/dapp/src/components/shared/CurrencyBalance/index.tsx index 7302a7d40..e14fcc0d6 100644 --- a/dapp/src/components/shared/CurrencyBalance/index.tsx +++ b/dapp/src/components/shared/CurrencyBalance/index.tsx @@ -5,11 +5,11 @@ import { getCurrencyByType, numberToLocaleString, } from "#/utils" -import { CurrencyType } from "#/types" +import { CurrencyType, AmountType } from "#/types" export type CurrencyBalanceProps = { currency: CurrencyType - amount?: string | number | bigint + amount?: AmountType shouldBeFormatted?: boolean desiredDecimals?: number size?: string diff --git a/dapp/src/components/shared/IconTag.tsx b/dapp/src/components/shared/IconTag.tsx new file mode 100644 index 000000000..7c2f8b51e --- /dev/null +++ b/dapp/src/components/shared/IconTag.tsx @@ -0,0 +1,27 @@ +import React from "react" +import { Tag, TagLeftIcon, TagLabel, TagProps, Icon } from "@chakra-ui/react" + +type IconTagProps = TagProps & { + icon: typeof Icon +} + +export default function IconTag(props: IconTagProps) { + const { children, icon, ...restProps } = props + + return ( + + + {children} + + ) +} diff --git a/dapp/src/contexts/StakeFlowContext.tsx b/dapp/src/contexts/StakeFlowContext.tsx index d19cad509..adadd561c 100644 --- a/dapp/src/contexts/StakeFlowContext.tsx +++ b/dapp/src/contexts/StakeFlowContext.tsx @@ -5,8 +5,6 @@ import { useStakeFlow, } from "#/acre-react/hooks" import { REFERRAL } from "#/constants" -import { RelayerDepositorProxy } from "#/web3" -import { EthereumBitcoinDepositor } from "@acre-btc/sdk" type StakeFlowContextValue = Omit & { initStake: ( @@ -35,14 +33,7 @@ export function StakeFlowProvider({ children }: { children: React.ReactNode }) { async (bitcoinRecoveryAddress: string, ethereumAddress: string) => { if (!acre) throw new Error("Acre SDK not defined") - await acreInitStake( - bitcoinRecoveryAddress, - ethereumAddress, - REFERRAL, - RelayerDepositorProxy.fromEthereumBitcoinDepositor( - acre.contracts.bitcoinDepositor as EthereumBitcoinDepositor, - ), - ) + await acreInitStake(bitcoinRecoveryAddress, ethereumAddress, REFERRAL) }, [acreInitStake, acre], ) diff --git a/dapp/src/pages/OverviewPage/DashboardCard.tsx b/dapp/src/pages/OverviewPage/DashboardCard.tsx new file mode 100644 index 000000000..53bd4b16d --- /dev/null +++ b/dapp/src/pages/OverviewPage/DashboardCard.tsx @@ -0,0 +1,93 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import React from "react" +import { + Button, + CardHeader, + CardBody, + Card, + CardProps, + Tag, + HStack, + VStack, + ButtonProps, +} from "@chakra-ui/react" +import { TextMd } from "#/components/shared/Typography" +import IconTag from "#/components/shared/IconTag" +import { BoostArrowIcon } from "#/assets/icons" +import { CurrencyBalanceWithConversion } from "#/components/shared/CurrencyBalanceWithConversion" +import { AmountType } from "#/types" + +const buttonStyles: ButtonProps = { + size: "lg", + flex: 1, + maxW: "12.5rem", // 200px + fontWeight: "bold", + lineHeight: 6, + px: 7, + h: "auto", +} + +type DashboardCardProps = CardProps & { + bitcoinAmount: AmountType + positionPercentage?: number // TODO: Make this required in post MVP phase +} + +export default function DashboardCard(props: DashboardCardProps) { + const { bitcoinAmount, positionPercentage, ...restProps } = props + return ( + + + + My position + {positionPercentage && ( + + Top {positionPercentage}% + + )} + + + + + + + + + Rewards Boost + + + + + + + + + ) +} diff --git a/dapp/src/pages/OverviewPage/GrantedSeasonPassCard.tsx b/dapp/src/pages/OverviewPage/GrantedSeasonPassCard.tsx new file mode 100644 index 000000000..8ad864e54 --- /dev/null +++ b/dapp/src/pages/OverviewPage/GrantedSeasonPassCard.tsx @@ -0,0 +1,40 @@ +import React from "react" +import { + Card, + CardBody, + CardHeader, + CardProps, + HStack, + Icon, +} from "@chakra-ui/react" +import { IconDiscountCheckFilled, IconLock } from "@tabler/icons-react" +import { TextMd } from "#/components/shared/Typography" + +type GrantedSeasonPassCardProps = CardProps & { + heading: string +} + +export default function GrantedSeasonPassCard( + props: GrantedSeasonPassCardProps, +) { + const { heading, ...restProps } = props + + return ( + + + {heading} + + + + + Your seat is secured. + + + ) +} diff --git a/dapp/src/theme/Button.ts b/dapp/src/theme/Button.ts index ccb108bd7..5b221fb44 100644 --- a/dapp/src/theme/Button.ts +++ b/dapp/src/theme/Button.ts @@ -8,12 +8,12 @@ export const buttonTheme: ComponentSingleStyleConfig = { sizes: { md: { fontSize: "sm", - py: "0.5rem", + py: 2, borderRadius: "md", }, lg: { fontSize: "md", - py: "1rem", + py: 4, borderRadius: "lg", }, }, diff --git a/dapp/src/types/currency.ts b/dapp/src/types/currency.ts index e717cdc76..2285a901d 100644 --- a/dapp/src/types/currency.ts +++ b/dapp/src/types/currency.ts @@ -6,3 +6,5 @@ export type Currency = { } export type CurrencyType = "bitcoin" | "ethereum" | "usd" | "stbtc" + +export type AmountType = string | number | bigint diff --git a/dapp/src/vite-env.d.ts b/dapp/src/vite-env.d.ts index f127eea1e..3f78f074c 100644 --- a/dapp/src/vite-env.d.ts +++ b/dapp/src/vite-env.d.ts @@ -4,7 +4,7 @@ interface ImportMetaEnv { readonly VITE_SENTRY_DSN: string readonly VITE_ETH_HOSTNAME_HTTP: string readonly VITE_REFERRAL: number - readonly VITE_DEFENDER_RELAYER_WEBHOOK_URL: string + readonly VITE_TBTC_API_ENDPOINT: string } interface ImportMeta { diff --git a/dapp/src/web3/index.ts b/dapp/src/web3/index.ts index 3d4e2a169..f38e7e840 100644 --- a/dapp/src/web3/index.ts +++ b/dapp/src/web3/index.ts @@ -1,2 +1 @@ export * from "./ledger-live-signer" -export * from "./relayer-depositor-proxy" diff --git a/dapp/src/web3/relayer-depositor-proxy.ts b/dapp/src/web3/relayer-depositor-proxy.ts deleted file mode 100644 index 7207df6f9..000000000 --- a/dapp/src/web3/relayer-depositor-proxy.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { - BitcoinRawTxVectors, - DepositorProxy, - EthereumBitcoinDepositor, - ChainIdentifier, - DepositReceipt, - Hex, - packRevealDepositParameters, - BitcoinDepositor, -} from "@acre-btc/sdk" -import axios from "axios" - -const DEFENDER_WEBHOOK_URL = import.meta.env.VITE_DEFENDER_RELAYER_WEBHOOK_URL - -/** - * Implementation of @see DepositorProxy that redirects stake requests to the - * Open Zeppelin Defender Relayer which initializes stake on the user's behalf. - * Sends the HTTP POST request to the webhook at the provided URL with the data - * necessary to initialize stake. - */ -class RelayerDepositorProxy - implements DepositorProxy -{ - /** - * Chain-specific handle to @see BitcoinDepositor contract. - */ - #bitcoinDepositor: T - - /** - * Defines the Open Zeppelin Defender webhook URL. - */ - #defenderWebhookUrl: string - - /** - * Creates the instance of the relayer depositor proxy for Ethereum chain. - * @param bitcoinDepositor Ethereum handle to @see BitcoinDepositor contract. - * @returns Instance of @see RelayerDepositorProxy. - */ - static fromEthereumBitcoinDepositor( - bitcoinDepositor: EthereumBitcoinDepositor, - ): RelayerDepositorProxy { - return new RelayerDepositorProxy(bitcoinDepositor, DEFENDER_WEBHOOK_URL) - } - - private constructor(_bitcoinDepositor: T, _defenderWebhookUr: string) { - this.#bitcoinDepositor = _bitcoinDepositor - this.#defenderWebhookUrl = _defenderWebhookUr - } - - /** - * @see {DepositorProxy#getChainIdentifier} - */ - getChainIdentifier(): ChainIdentifier { - return this.#bitcoinDepositor.getChainIdentifier() - } - - /** - * @see {DepositorProxy#revealDeposit} - * @dev Sends HTTP POST request to Open Zeppelin Defender Relayer. - */ - async revealDeposit( - depositTx: BitcoinRawTxVectors, - depositOutputIndex: number, - deposit: DepositReceipt, - ): Promise { - const { fundingTx, reveal, extraData } = packRevealDepositParameters( - depositTx, - depositOutputIndex, - deposit, - await this.#bitcoinDepositor.getTbtcVaultChainIdentifier(), - ) - - if (!extraData) throw new Error("Invalid extra data") - - const { depositOwner, referral } = - this.#bitcoinDepositor.decodeExtraData(extraData) - - // TODO: Catch and handle errors + sentry. - const response = await axios.post<{ result: string }>( - this.#defenderWebhookUrl, - { - fundingTx, - reveal, - depositOwner: `0x${depositOwner.identifierHex}`, - referral, - }, - ) - - // Defender returns result as string so we need to parse it. - const { txHash } = JSON.parse(response.data.result) as { txHash: string } - - return Hex.from(txHash) - } -} - -export { RelayerDepositorProxy } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da6eba6e3..89c0b0bc4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -145,6 +145,9 @@ importers: '@babel/preset-env': specifier: ^7.23.7 version: 7.23.7(@babel/core@7.23.3) + '@ethersproject/bignumber': + specifier: ^5.7.0 + version: 5.7.0 '@thesis-co/eslint-config': specifier: github:thesis/eslint-config#7b9bc8c version: github.com/thesis/eslint-config/7b9bc8c(eslint@8.54.0)(prettier@3.1.0)(typescript@5.3.2) @@ -294,11 +297,11 @@ importers: subgraph: dependencies: '@graphprotocol/graph-cli': - specifier: 0.68.3 - version: 0.68.3(@types/node@20.9.4)(node-fetch@2.7.0)(typescript@5.3.2) + specifier: 0.71.0 + version: 0.71.0(@types/node@20.9.4)(node-fetch@2.7.0)(typescript@5.3.2) '@graphprotocol/graph-ts': - specifier: 0.32.0 - version: 0.32.0 + specifier: 0.35.1 + version: 0.35.1 assemblyscript: specifier: 0.19.23 version: 0.19.23 @@ -316,8 +319,8 @@ importers: specifier: ^8.54.0 version: 8.54.0 matchstick-as: - specifier: 0.5.0 - version: 0.5.0 + specifier: 0.6.0 + version: 0.6.0 prettier: specifier: ^3.1.0 version: 3.1.0 @@ -3882,8 +3885,8 @@ packages: html-entities: 2.4.0 strip-ansi: 6.0.1 - /@graphprotocol/graph-cli@0.68.3(@types/node@20.9.4)(node-fetch@2.7.0)(typescript@5.3.2): - resolution: {integrity: sha512-0WMiS7DpxanADLhcj8mhfugx5r4WL8RL46eQ35GcaSfr3H61Pz5RtusEayxfrhtXnzNcFcbnE4fr5TrR9R0iaQ==} + /@graphprotocol/graph-cli@0.71.0(@types/node@20.9.4)(node-fetch@2.7.0)(typescript@5.3.2): + resolution: {integrity: sha512-ITcSBHuXPuaoRs7FzNtqD0tCOIy4JGsM3j4IQNA2yZgXgr/TmmHG7KTB/c3B5Zlnsr9foXrU71T6ixGmwJ4PKw==} engines: {node: '>=18'} hasBin: true dependencies: @@ -3926,14 +3929,8 @@ packages: - utf-8-validate dev: false - /@graphprotocol/graph-ts@0.27.0: - resolution: {integrity: sha512-r1SPDIZVQiGMxcY8rhFSM0y7d/xAbQf5vHMWUf59js1KgoyWpM6P3tczZqmQd7JTmeyNsDGIPzd9FeaxllsU4w==} - dependencies: - assemblyscript: 0.19.10 - dev: true - - /@graphprotocol/graph-ts@0.32.0: - resolution: {integrity: sha512-YfKLT2w+ItXD/VPYQiAKtINQONVsAOkcqVFMHlhUy0fcEBVWuFBT53hJNI0/l5ujQa4TSxtzrKW/7EABAdgI8g==} + /@graphprotocol/graph-ts@0.35.1: + resolution: {integrity: sha512-74CfuQmf7JI76/XCC34FTkMMKeaf+3Pn0FIV3m9KNeaOJ+OI3CvjMIVRhOZdKcJxsFCBGaCCl0eQjh47xTjxKA==} dependencies: assemblyscript: 0.19.10 dev: false @@ -8207,6 +8204,7 @@ packages: dependencies: binaryen: 101.0.0-nightly.20210723 long: 4.0.0 + dev: false /assemblyscript@0.19.23: resolution: {integrity: sha512-fwOQNZVTMga5KRsfY80g7cpOl4PsFQczMwHzdtgoqLXaYhkhavufKb0sB0l3T1DUxpAufA0KNhlbpuuhZUwxMA==} @@ -8215,6 +8213,7 @@ packages: binaryen: 102.0.0-nightly.20211028 long: 5.2.3 source-map-support: 0.5.21 + dev: false /assert-plus@1.0.0: resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} @@ -8701,10 +8700,12 @@ packages: /binaryen@101.0.0-nightly.20210723: resolution: {integrity: sha512-eioJNqhHlkguVSbblHOtLqlhtC882SOEPKmNFZaDuz1hzQjolxZ+eu3/kaS10n3sGPONsIZsO7R9fR00UyhEUA==} hasBin: true + dev: false /binaryen@102.0.0-nightly.20211028: resolution: {integrity: sha512-GCJBVB5exbxzzvyt8MGDv/MeUjs6gkXDvf4xOIItRBptYl0Tz5sm1o/uG95YK0L0VeG5ajDu3hRtkBP2kzqC5w==} hasBin: true + dev: false /bindings@1.5.0: resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} @@ -15664,9 +15665,11 @@ packages: /long@4.0.0: resolution: {integrity: sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==} + dev: false /long@5.2.3: resolution: {integrity: sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==} + dev: false /loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} @@ -15792,11 +15795,9 @@ packages: resolution: {integrity: sha512-0EESkXiTkWzrQQntBu2uzKvLu6vVkUGz40nGPbSZuegcfE5UuSzNjLaIu76zJWuaT/2I3Z/8M06OlUOZLGwLlQ==} dev: true - /matchstick-as@0.5.0: - resolution: {integrity: sha512-4K619YDH+so129qt4RB4JCNxaFwJJYLXPc7drpG+/mIj86Cfzg6FKs/bA91cnajmS1CLHdhHl9vt6Kd6Oqvfkg==} + /matchstick-as@0.6.0: + resolution: {integrity: sha512-E36fWsC1AbCkBFt05VsDDRoFvGSdcZg6oZJrtIe/YDBbuFh8SKbR5FcoqDhNWqSN+F7bN/iS2u8Md0SM+4pUpw==} dependencies: - '@graphprotocol/graph-ts': 0.27.0 - assemblyscript: 0.19.23 wabt: 1.0.24 dev: true diff --git a/sdk/.eslintrc b/sdk/.eslintrc index f330305d6..2bddd4296 100644 --- a/sdk/.eslintrc +++ b/sdk/.eslintrc @@ -3,7 +3,7 @@ "extends": ["@thesis-co"], "overrides": [ { - "files": ["test/*/*.test.ts"], + "files": ["test/**/*.test.ts"], "rules": { "@typescript-eslint/unbound-method": "off" } diff --git a/sdk/jest.config.js b/sdk/jest.config.ts similarity index 83% rename from sdk/jest.config.js rename to sdk/jest.config.ts index ab00fc628..37ce37c74 100644 --- a/sdk/jest.config.js +++ b/sdk/jest.config.ts @@ -1,4 +1,4 @@ -module.exports = { +export default { preset: "ts-jest", testPathIgnorePatterns: ["/dist/", "/node_modules/"], } diff --git a/sdk/package.json b/sdk/package.json index e29b1f487..519616401 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -15,6 +15,7 @@ }, "devDependencies": { "@babel/preset-env": "^7.23.7", + "@ethersproject/bignumber": "^5.7.0", "@thesis-co/eslint-config": "github:thesis/eslint-config#7b9bc8c", "@types/jest": "^29.5.11", "@types/node": "^20.9.4", @@ -26,8 +27,8 @@ "typescript": "^5.3.2" }, "dependencies": { - "@keep-network/tbtc-v2.ts": "2.4.0-dev.3", "@acre-btc/contracts": "workspace:*", + "@keep-network/tbtc-v2.ts": "2.4.0-dev.3", "ethers": "^6.10.0" } } diff --git a/sdk/src/acre.ts b/sdk/src/acre.ts index f7458d78d..dce8d8226 100644 --- a/sdk/src/acre.ts +++ b/sdk/src/acre.ts @@ -1,4 +1,3 @@ -import { TBTC } from "@keep-network/tbtc-v2.ts" import { AcreContracts } from "./lib/contracts" import { ChainEIP712Signer } from "./lib/eip712-signer" import { @@ -7,10 +6,11 @@ import { getEthereumContracts, } from "./lib/ethereum" import { StakingModule } from "./modules/staking" +import Tbtc from "./modules/tbtc" import { EthereumSignerCompatibleWithEthersV5 } from "./lib/utils" class Acre { - readonly #tbtc: TBTC + readonly #tbtc: Tbtc readonly #messageSigner: ChainEIP712Signer @@ -21,7 +21,7 @@ class Acre { constructor( _contracts: AcreContracts, _messageSigner: ChainEIP712Signer, - _tbtc: TBTC, + _tbtc: Tbtc, ) { this.contracts = _contracts this.#tbtc = _tbtc @@ -36,31 +36,19 @@ class Acre { static async initializeEthereum( signer: EthereumSignerCompatibleWithEthersV5, network: EthereumNetwork, + tbtcApiUrl: string, ): Promise { - const tbtc = await Acre.#getTBTCEthereumSDK(signer, network) const contracts = getEthereumContracts(signer, network) const messages = new EthereumEIP712Signer(signer) - return new Acre(contracts, messages, tbtc) - } + const tbtc = await Tbtc.initialize( + signer, + network, + tbtcApiUrl, + contracts.bitcoinDepositor, + ) - static #getTBTCEthereumSDK( - signer: EthereumSignerCompatibleWithEthersV5, - network: EthereumNetwork, - ): Promise { - switch (network) { - case "sepolia": - // @ts-expect-error We require the `signer` must include the ether v5 - // signer's methods used in tBTC-v2.ts SDK so if we pass signer from - // ethers v6 it won't break the Acre SDK initialization. - return TBTC.initializeSepolia(signer) - case "mainnet": - default: - // @ts-expect-error We require the `signer` must include the ether v5 - // signer's methods used in tBTC-v2.ts SDK so if we pass signer from - // ethers v6 it won't break the Acre SDK initialization. - return TBTC.initializeMainnet(signer) - } + return new Acre(contracts, messages, tbtc) } } diff --git a/sdk/src/lib/api/HttpApi.ts b/sdk/src/lib/api/HttpApi.ts new file mode 100644 index 000000000..d1759d6d2 --- /dev/null +++ b/sdk/src/lib/api/HttpApi.ts @@ -0,0 +1,47 @@ +/** + * Represents an abstract HTTP API. + */ +export default abstract class HttpApi { + #apiUrl: string + + constructor(apiUrl: string) { + this.#apiUrl = apiUrl + } + + /** + * Factory function for running GET requests. + * @param endpoint API endpoint. + * @param requestInit Additional data passed to request. + * @returns API response. + */ + protected async getRequest( + endpoint: string, + requestInit?: RequestInit, + ): Promise { + return fetch(new URL(endpoint, this.#apiUrl), { + credentials: "include", + ...requestInit, + }) + } + + /** + * Factory function for running POST requests. + * @param endpoint API endpoint, + * @param body Data passed to POST request. + * @param requestInit Additional data passed to request. + * @returns API response. + */ + protected async postRequest( + endpoint: string, + body: unknown, + requestInit?: RequestInit, + ): Promise { + return fetch(new URL(endpoint, this.#apiUrl), { + method: "POST", + body: JSON.stringify(body), + credentials: "include", + headers: { "Content-Type": "application/json" }, + ...requestInit, + }) + } +} diff --git a/sdk/src/lib/api/TbtcApi.ts b/sdk/src/lib/api/TbtcApi.ts new file mode 100644 index 000000000..a64c3b9fa --- /dev/null +++ b/sdk/src/lib/api/TbtcApi.ts @@ -0,0 +1,118 @@ +import { BitcoinTxHash, BitcoinTxOutpoint } from "@keep-network/tbtc-v2.ts" +import HttpApi from "./HttpApi" + +/** + * Represents a class for integration with tBTC API. + */ +export default class TbtcApi extends HttpApi { + /** + * Register deposit data in the tBTC API. + * @param revealData Deposit data. + * @returns True if the reveal was successfully saved to the database, false + * otherwise. + */ + async saveReveal(revealData: SaveRevealRequest): Promise { + const response = await this.postRequest("reveals", revealData) + + if (!response.ok) + throw new Error( + `Reveal not saved properly in the database, response: ${response.status}`, + ) + + const { success } = (await response.json()) as { success: boolean } + + return success + } + + /** + * Initiate a bitcoin deposit in the tBTC API. + * @param depositData Data of the deposit. + * @returns Details of the initiated deposit. + */ + async createDeposit(depositData: CreateDepositRequest): Promise<{ + depositId: string + depositStatus: DepositStatus + fundingOutpoint: BitcoinTxOutpoint + }> { + const response = await this.postRequest("deposits", depositData) + if (!response.ok) + throw new Error( + `Bitcoin deposit creation failed, response: ${response.status}`, + ) + + const responseData = (await response.json()) as CreateDepositResponse + + return { + depositId: responseData.depositId, + depositStatus: responseData.newDepositStatus, + fundingOutpoint: { + transactionHash: BitcoinTxHash.from(responseData.transactionHash), + outputIndex: responseData.outputIndex, + }, + } + } +} + +/** + * Represents the metadata for a reveal operation. + */ +type RevealMetadata = { + depositOwner: string + referral: number +} + +// TODO: This type is copied from tbtc-api, we should consider exporting it from there. +/** + * Represents the request payload for saving a reveal. + */ +export type SaveRevealRequest = { + address: string + revealInfo: BitcoinDepositReceiptJson + metadata: RevealMetadata + application: string +} + +// TODO: This type is copied from tbtc-api, we should consider exporting it from there. +/** + * Represents the JSON structure of a Bitcoin deposit receipt. + */ +type BitcoinDepositReceiptJson = { + depositor: string + blindingFactor: string + walletPublicKeyHash: string + refundPublicKeyHash: string + refundLocktime: string + extraData: string +} + +// TODO: This type is copied from tbtc-api, we should consider exporting it from there. +/** + * Represents the request payload for creating a deposit. + */ +type CreateDepositRequest = { + depositReceipt: BitcoinDepositReceiptJson + depositOwner: string + referral: number +} + +// TODO: This type is copied from tbtc-api, we should consider exporting it from there. +/** + * Represents the status of a deposit. + */ +export enum DepositStatus { + Queued = "QUEUED", + Initialized = "INITIALIZED", + Finalized = "FINALIZED", + Cancelled = "CANCELLED", +} + +// TODO: This type is copied from tbtc-api, we should consider exporting it from there. +/** + * Represents the response structure of a deposit creation request. + */ +type CreateDepositResponse = { + depositId: string + newDepositStatus: DepositStatus + transactionHash: string + outputIndex: number +} diff --git a/sdk/src/lib/contracts/bitcoin-depositor.ts b/sdk/src/lib/contracts/bitcoin-depositor.ts index baa71a770..2b31f78d9 100644 --- a/sdk/src/lib/contracts/bitcoin-depositor.ts +++ b/sdk/src/lib/contracts/bitcoin-depositor.ts @@ -2,8 +2,6 @@ import { Hex } from "../utils" import { ChainIdentifier } from "./chain-identifier" import { DepositorProxy } from "./depositor-proxy" -export { DepositReceipt } from "@keep-network/tbtc-v2.ts" - export type DecodedExtraData = { depositOwner: ChainIdentifier referral: number diff --git a/sdk/src/lib/ethereum/bitcoin-depositor.ts b/sdk/src/lib/ethereum/bitcoin-depositor.ts index 7e31d2913..7f9b836c6 100644 --- a/sdk/src/lib/ethereum/bitcoin-depositor.ts +++ b/sdk/src/lib/ethereum/bitcoin-depositor.ts @@ -15,10 +15,9 @@ import { ChainIdentifier, DecodedExtraData, BitcoinDepositor, - DepositReceipt, DepositFees, } from "../contracts" -import { BitcoinRawTxVectors } from "../bitcoin" + import { EthereumAddress } from "./address" import { EthersContractConfig, @@ -89,33 +88,9 @@ class EthereumBitcoinDepositor return EthereumAddress.from(vault) } - /** - * @see {BitcoinDepositor#revealDeposit} - */ - async revealDeposit( - depositTx: BitcoinRawTxVectors, - depositOutputIndex: number, - deposit: DepositReceipt, - ): Promise { - const { fundingTx, reveal, extraData } = packRevealDepositParameters( - depositTx, - depositOutputIndex, - deposit, - await this.getTbtcVaultChainIdentifier(), - ) - - if (!extraData) throw new Error("Invalid extra data") - - const { depositOwner, referral } = this.decodeExtraData(extraData) - - const tx = await this.instance.initializeDeposit( - fundingTx, - reveal, - `0x${depositOwner.identifierHex}`, - referral, - ) - - return Hex.from(tx.hash) + // eslint-disable-next-line class-methods-use-this + revealDeposit(): Promise { + throw new Error("Unsupported") } /** diff --git a/sdk/src/lib/utils/backoff.ts b/sdk/src/lib/utils/backoff.ts index ff5dd6676..5643453f5 100644 --- a/sdk/src/lib/utils/backoff.ts +++ b/sdk/src/lib/utils/backoff.ts @@ -2,4 +2,18 @@ import { backoffRetrier } from "@keep-network/tbtc-v2.ts" type BackoffRetrierParameters = Parameters -export { backoffRetrier, BackoffRetrierParameters } +type RetryOptions = { + /** + * The number of retries to perform before bubbling the failure out. + * @see backoffRetrier for more details. + */ + retries: BackoffRetrierParameters[0] + /** + * Initial backoff step in milliseconds that will be increased exponentially + * for subsequent retry attempts. (default = 1000 ms) + * @see backoffRetrier for more details. + */ + backoffStepMs: BackoffRetrierParameters[1] +} + +export { backoffRetrier, RetryOptions } diff --git a/sdk/src/modules/staking/index.ts b/sdk/src/modules/staking/index.ts index 32e8b5363..1892e6744 100644 --- a/sdk/src/modules/staking/index.ts +++ b/sdk/src/modules/staking/index.ts @@ -1,8 +1,11 @@ -import { ChainIdentifier, TBTC } from "@keep-network/tbtc-v2.ts" -import { AcreContracts, DepositorProxy, DepositFees } from "../../lib/contracts" +import { ChainIdentifier } from "@keep-network/tbtc-v2.ts" +import { AcreContracts, DepositFees } from "../../lib/contracts" import { ChainEIP712Signer } from "../../lib/eip712-signer" import { StakeInitialization } from "./stake-initialization" import { fromSatoshi, toSatoshi } from "../../lib/utils" +import Tbtc from "../tbtc" + +export { DepositReceipt } from "../tbtc" /** * Represents all total deposit fees grouped by network. @@ -28,14 +31,14 @@ class StakingModule { readonly #messageSigner: ChainEIP712Signer /** - * tBTC SDK. + * tBTC Module. */ - readonly #tbtc: TBTC + readonly #tbtc: Tbtc constructor( _contracts: AcreContracts, _messageSigner: ChainEIP712Signer, - _tbtc: TBTC, + _tbtc: Tbtc, ) { this.#contracts = _contracts this.#messageSigner = _messageSigner @@ -49,19 +52,17 @@ class StakingModule { * funds. * @param staker The address to which the stBTC shares will be minted. * @param referral Data used for referral program. - * @param depositorProxy Depositor proxy used to initiate the deposit. * @returns Object represents the staking process. */ async initializeStake( bitcoinRecoveryAddress: string, - staker: ChainIdentifier, + staker: ChainIdentifier, // TODO: We should resolve the address with OrangeKit SDK referral: number, - depositorProxy?: DepositorProxy, ) { - const deposit = await this.#tbtc.deposits.initiateDepositWithProxy( + const tbtcDeposit = await this.#tbtc.initiateDeposit( + staker, bitcoinRecoveryAddress, - depositorProxy ?? this.#contracts.bitcoinDepositor, - this.#contracts.bitcoinDepositor.encodeExtraData(staker, referral), + referral, ) return new StakeInitialization( @@ -69,7 +70,7 @@ class StakingModule { this.#messageSigner, bitcoinRecoveryAddress, staker, - deposit, + tbtcDeposit, ) } diff --git a/sdk/src/modules/staking/stake-initialization.ts b/sdk/src/modules/staking/stake-initialization.ts index 9ed9b31a5..722067179 100644 --- a/sdk/src/modules/staking/stake-initialization.ts +++ b/sdk/src/modules/staking/stake-initialization.ts @@ -1,4 +1,7 @@ -import { Deposit as TbtcDeposit } from "@keep-network/tbtc-v2.ts" +import TbtcDeposit from "../tbtc/Deposit" + +import type { DepositReceipt } from "." + import { ChainEIP712Signer, ChainSignedMessage, @@ -6,28 +9,8 @@ import { Message, Types, } from "../../lib/eip712-signer" -import { - AcreContracts, - DepositReceipt, - ChainIdentifier, -} from "../../lib/contracts" -import { Hex, backoffRetrier, BackoffRetrierParameters } from "../../lib/utils" +import { AcreContracts, ChainIdentifier } from "../../lib/contracts" -type StakeOptions = { - /** - * The number of retries to perform before bubbling the failure out. - * @see backoffRetrier for more details. - */ - retires: BackoffRetrierParameters[0] - /** - * Initial backoff step in milliseconds that will be increased exponentially - * for subsequent retry attempts. (default = 1000 ms) - * @see backoffRetrier for more details. - */ - backoffStepMs: BackoffRetrierParameters[1] -} - -// TODO: Rename to `DepositInitialization` to be consistent with the naming. /** * Represents an instance of the staking flow. Staking flow requires a few steps * which should be done to stake BTC. @@ -44,7 +27,7 @@ class StakeInitialization { readonly #messageSigner: ChainEIP712Signer /** - * Component representing an instance of the tBTC v2 deposit process. + * Component representing an instance of the tBTC deposit process. */ readonly #tbtcDeposit: TbtcDeposit @@ -158,30 +141,14 @@ class StakeInitialization { * @see StakeOptions for more details. * @returns Transaction hash of the stake initiation transaction. */ - async stake( - options: StakeOptions = { retires: 5, backoffStepMs: 5_000 }, - ): Promise { + async stake(options = { retries: 5, backoffStepMs: 5_000 }): Promise { if (!this.#signedMessage) { throw new Error("Sign message first") } - await this.#waitForBitcoinFundingTx(options) - - return this.#tbtcDeposit.initiateMinting() - } + await this.#tbtcDeposit.waitForFunding(options) - async #waitForBitcoinFundingTx({ - retires, - backoffStepMs, - }: StakeOptions): Promise { - await backoffRetrier( - retires, - backoffStepMs, - )(async () => { - const utxos = await this.#tbtcDeposit.detectFunding() - - if (utxos.length === 0) throw new Error("Deposit not funded yet") - }) + return this.#tbtcDeposit.createDeposit() } } diff --git a/sdk/src/modules/tbtc/Deposit.ts b/sdk/src/modules/tbtc/Deposit.ts new file mode 100644 index 000000000..a719b25bb --- /dev/null +++ b/sdk/src/modules/tbtc/Deposit.ts @@ -0,0 +1,89 @@ +import { Deposit as TbtcSdkDeposit } from "@keep-network/tbtc-v2.ts" + +import type { DepositReceipt as TbtcSdkDepositReceipt } from "@keep-network/tbtc-v2.ts" +import type { SaveRevealRequest as DepositRevealData } from "../../lib/api/TbtcApi" + +import TbtcApi from "../../lib/api/TbtcApi" + +import { backoffRetrier, RetryOptions } from "../../lib/utils" + +export type DepositReceipt = TbtcSdkDepositReceipt + +/** + * Represents a deposit for the tBTC protocol. + */ +export default class Deposit { + readonly #tbtcApi: TbtcApi + + readonly #tbtcSdkDeposit: TbtcSdkDeposit + + readonly #revealData: DepositRevealData + + constructor( + tbtcApi: TbtcApi, + tbtcSdkDeposit: TbtcSdkDeposit, + revealData: DepositRevealData, + ) { + this.#tbtcApi = tbtcApi + this.#tbtcSdkDeposit = tbtcSdkDeposit + this.#revealData = revealData + } + + /** + * Retrieves the Bitcoin address corresponding to this deposit. + * This address should be used as the destination for sending BTC to fund the + * deposit. + * @returns The Bitcoin address corresponding to this deposit. + */ + async getBitcoinAddress(): Promise { + return this.#tbtcSdkDeposit.getBitcoinAddress() + } + + /** + * Retrieves the receipt corresponding to the tbtc deposit. + * @returns The deposit receipt. + */ + getReceipt(): DepositReceipt { + return this.#tbtcSdkDeposit.getReceipt() + } + + /** + * Waits for the deposit to be funded with BTC. + * @param options The retry options for waiting. + * @throws Error if the deposit is not funded within the specified retries. + */ + async waitForFunding(options: RetryOptions): Promise { + await backoffRetrier( + options.retries, + options.backoffStepMs, + )(async () => { + const utxos = await this.#tbtcSdkDeposit.detectFunding() + + if (utxos.length === 0) throw new Error("Deposit not funded yet") + }) + } + + /** + * Creates a bitcoin deposit on the tBTC API backend side. + * This function should be called after the bitcoin transaction is made to the + * deposit address. + * @throws Error if the deposit creation fails on the tBTC API side or the bitcoin + * funding transaction couldn't be found. + * @returns The tBTC API deposit ID. + */ + async createDeposit(): Promise { + const { revealInfo, metadata } = this.#revealData + const { depositOwner, referral } = metadata + + const createBitcoinDepositData = { + depositReceipt: revealInfo, + depositOwner, + referral, + } + + const response = await this.#tbtcApi.createDeposit(createBitcoinDepositData) + + // TODO: Determine return type based on dApp needs, possibly calculate depositKey. + return response.depositId + } +} diff --git a/sdk/src/modules/tbtc/Tbtc.ts b/sdk/src/modules/tbtc/Tbtc.ts new file mode 100644 index 000000000..ddafdcc94 --- /dev/null +++ b/sdk/src/modules/tbtc/Tbtc.ts @@ -0,0 +1,114 @@ +import { ChainIdentifier, TBTC as TbtcSdk } from "@keep-network/tbtc-v2.ts" + +import TbtcApi from "../../lib/api/TbtcApi" +import { BitcoinDepositor } from "../../lib/contracts" +import { EthereumNetwork } from "../../lib/ethereum" + +import Deposit from "./Deposit" +import { EthereumSignerCompatibleWithEthersV5 } from "../../lib/utils" + +/** + * Represents the tBTC module. + */ +export default class Tbtc { + readonly #tbtcApi: TbtcApi + + readonly #tbtcSdk: TbtcSdk + + readonly #bitcoinDepositor: BitcoinDepositor + + constructor( + tbtcApi: TbtcApi, + tbtcSdk: TbtcSdk, + bitcoinDepositor: BitcoinDepositor, + ) { + this.#tbtcApi = tbtcApi + this.#tbtcSdk = tbtcSdk + this.#bitcoinDepositor = bitcoinDepositor + } + + /** + * Initializes the Tbtc module. + * + * @param signer The Ethereum signer compatible with ethers v5. + * @param network The Ethereum network. + * @param tbtcApiUrl The tBTC API URL. + * @param bitcoinDepositor The Bitcoin depositor contract handle. + * @returns A Promise that resolves to an instance of Tbtc. + */ + static async initialize( + signer: EthereumSignerCompatibleWithEthersV5, + network: EthereumNetwork, + tbtcApiUrl: string, + bitcoinDepositor: BitcoinDepositor, + ): Promise { + const tbtcApi = new TbtcApi(tbtcApiUrl) + + const tbtcSdk = + network === "mainnet" + ? // @ts-expect-error We require the `signer` must include the ethers v5 + // signer's methods used in tBTC-v2.ts SDK so if we pass signer from + // ethers v6 it won't break the Acre SDK initialization. + await TbtcSdk.initializeMainnet(signer) + : // @ts-expect-error We require the `signer` must include the ethers v5 + // signer's methods used in tBTC-v2.ts SDK so if we pass signer from + // ethers v6 it won't break the Acre SDK initialization. + await TbtcSdk.initializeSepolia(signer) + + return new Tbtc(tbtcApi, tbtcSdk, bitcoinDepositor) + } + + /** + * Function to initialize a tBTC deposit. It submits deposit data to the tBTC + * API and returns the deposit object. + * @param depositOwner Ethereum address of the deposit owner. + * @param bitcoinRecoveryAddress P2PKH or P2WPKH Bitcoin address that can be + * used for emergency recovery of the deposited funds. + * @param referral Deposit referral number. + */ + async initiateDeposit( + depositOwner: ChainIdentifier, + bitcoinRecoveryAddress: string, + referral: number, + ): Promise { + if (!depositOwner || !bitcoinRecoveryAddress) { + throw new Error("Ethereum or Bitcoin address is not available") + } + + const extraData = this.#bitcoinDepositor.encodeExtraData( + depositOwner, + referral, + ) + + const tbtcDeposit = await this.#tbtcSdk.deposits.initiateDepositWithProxy( + bitcoinRecoveryAddress, + this.#bitcoinDepositor, + extraData, + ) + + const receipt = tbtcDeposit.getReceipt() + + const revealData = { + address: depositOwner.identifierHex, + revealInfo: { + depositor: receipt.depositor.identifierHex, + blindingFactor: receipt.blindingFactor.toString(), + walletPublicKeyHash: receipt.walletPublicKeyHash.toString(), + refundPublicKeyHash: receipt.refundPublicKeyHash.toString(), + refundLocktime: receipt.refundLocktime.toString(), + extraData: receipt.extraData!.toString(), + }, + metadata: { + depositOwner: depositOwner.identifierHex, + referral, + }, + application: "acre", + } + + const revealSaved: boolean = await this.#tbtcApi.saveReveal(revealData) + if (!revealSaved) + throw new Error("Reveal not saved properly in the database") + + return new Deposit(this.#tbtcApi, tbtcDeposit, revealData) + } +} diff --git a/sdk/src/modules/tbtc/index.ts b/sdk/src/modules/tbtc/index.ts new file mode 100644 index 000000000..435e472f3 --- /dev/null +++ b/sdk/src/modules/tbtc/index.ts @@ -0,0 +1,5 @@ +import Tbtc from "./Tbtc" + +export * from "./Deposit" + +export default Tbtc diff --git a/sdk/test/data/deposit.ts b/sdk/test/data/deposit.ts new file mode 100644 index 000000000..adf38d735 --- /dev/null +++ b/sdk/test/data/deposit.ts @@ -0,0 +1,62 @@ +import { BitcoinTxHash, EthereumAddress, Hex } from "@keep-network/tbtc-v2.ts" +import { BigNumber } from "@ethersproject/bignumber" +import type { ChainIdentifier } from "../../src/lib/contracts" + +import type { DepositReceipt } from "../../src/modules/tbtc" +import type { SaveRevealRequest } from "../lib/api/TbtcApi" + +const depositTestData: { + depositOwner: ChainIdentifier + bitcoinRecoveryAddress: string + referral: number + extraData: Hex +} = { + depositOwner: EthereumAddress.from( + "0xa9B38eA6435c8941d6eDa6a46b68E3e211719699", + ), + bitcoinRecoveryAddress: "mjc2zGWypwpNyDi4ZxGbBNnUA84bfgiwYc", + referral: 23505, + extraData: Hex.from( + "0xa9b38ea6435c8941d6eda6a46b68e3e2117196995bd100000000000000000000", + ), +} + +const receiptTestData: DepositReceipt = { + depositor: EthereumAddress.from("0x2cd680318747b720d67bf4246eb7403b476adb34"), + blindingFactor: Hex.from("0xf9f0c90d00039523"), + walletPublicKeyHash: Hex.from("0x8db50eb52063ea9d98b3eac91489a90f738986f6"), + refundPublicKeyHash: Hex.from("0x28e081f285138ccbe389c1eb8985716230129f89"), + refundLocktime: Hex.from("0x60bcea61"), + extraData: depositTestData.extraData, +} + +const revealTestData: SaveRevealRequest = { + address: depositTestData.depositOwner.identifierHex, + revealInfo: { + depositor: receiptTestData.depositor.identifierHex, + blindingFactor: receiptTestData.blindingFactor.toString(), + walletPublicKeyHash: receiptTestData.walletPublicKeyHash.toString(), + refundPublicKeyHash: receiptTestData.refundPublicKeyHash.toString(), + refundLocktime: receiptTestData.refundLocktime.toString(), + extraData: depositTestData.extraData.toString(), + }, + metadata: { + depositOwner: depositTestData.depositOwner.identifierHex, + referral: depositTestData.referral, + }, + application: "acre", +} + +const fundingUtxo: { + transactionHash: BitcoinTxHash + outputIndex: number + value: BigNumber +} = { + transactionHash: BitcoinTxHash.from( + "2f952bdc206bf51bb745b967cb7166149becada878d3191ffe341155ebcd4883", + ), + outputIndex: 1, + value: BigNumber.from(3933200), +} + +export { depositTestData, receiptTestData, revealTestData, fundingUtxo } diff --git a/sdk/test/lib/ethereum/tbtc-depositor.test.ts b/sdk/test/lib/ethereum/tbtc-depositor.test.ts index aff33ce6d..8a2ca326e 100644 --- a/sdk/test/lib/ethereum/tbtc-depositor.test.ts +++ b/sdk/test/lib/ethereum/tbtc-depositor.test.ts @@ -2,7 +2,6 @@ import ethers, { Contract, ZeroAddress, getAddress } from "ethers" import { EthereumBitcoinDepositor, EthereumAddress, - Hex, EthereumSigner, DepositFees, } from "../../../src" @@ -87,122 +86,6 @@ describe("BitcoinDepositor", () => { }) }) - describe("revealDeposit", () => { - const depositOwner = EthereumAddress.from( - "0x000055d85E80A49B5930C4a77975d44f012D86C1", - ) - const bitcoinFundingTransaction = { - version: Hex.from("00000000"), - inputs: Hex.from("11111111"), - outputs: Hex.from("22222222"), - locktime: Hex.from("33333333"), - } - const depositReveal = { - fundingOutputIndex: 2, - walletPublicKeyHash: Hex.from("8db50eb52063ea9d98b3eac91489a90f738986f6"), - refundPublicKeyHash: Hex.from("28e081f285138ccbe389c1eb8985716230129f89"), - blindingFactor: Hex.from("f9f0c90d00039523"), - refundLocktime: Hex.from("60bcea61"), - depositor: depositOwner, - } - describe("when extra data is defined", () => { - const extraData = { - depositOwner, - referral: 6851, - hex: Hex.from( - "0x000055d85e80a49b5930c4a77975d44f012d86c11ac300000000000000000000", - ), - } - - const depositWithExtraData = { - ...depositReveal, - extraData: extraData.hex, - } - - const { referral } = extraData - - const mockedTx = Hex.from( - "0483fe6a05f245bccc7b10085f3c4d282d87ca42f27437d077acfd75e91183a0", - ) - let result: Hex - - beforeAll(async () => { - mockedContractInstance.initializeDeposit.mockReturnValue({ - hash: mockedTx.toPrefixedString(), - }) - - const { fundingOutputIndex, ...restDepositData } = depositWithExtraData - - result = await depositor.revealDeposit( - bitcoinFundingTransaction, - fundingOutputIndex, - restDepositData, - ) - }) - - it("should get the vault address", () => { - expect(mockedContractInstance.tbtcVault).toHaveBeenCalled() - }) - - it("should decode extra data", () => { - expect(spyOnEthersDataSlice).toHaveBeenNthCalledWith( - 1, - extraData.hex.toPrefixedString(), - 0, - 20, - ) - expect(spyOnEthersDataSlice).toHaveBeenNthCalledWith( - 2, - extraData.hex.toPrefixedString(), - 20, - 22, - ) - }) - - it("should initialize deposit", () => { - const btcTxInfo = { - version: bitcoinFundingTransaction.version.toPrefixedString(), - inputVector: bitcoinFundingTransaction.inputs.toPrefixedString(), - outputVector: bitcoinFundingTransaction.outputs.toPrefixedString(), - locktime: bitcoinFundingTransaction.locktime.toPrefixedString(), - } - - const revealInfo = { - fundingOutputIndex: depositReveal.fundingOutputIndex, - blindingFactor: depositReveal.blindingFactor.toPrefixedString(), - walletPubKeyHash: - depositReveal.walletPublicKeyHash.toPrefixedString(), - refundPubKeyHash: - depositReveal.refundPublicKeyHash.toPrefixedString(), - refundLocktime: depositReveal.refundLocktime.toPrefixedString(), - vault: `0x${vaultAddress.identifierHex}`, - } - - expect(mockedContractInstance.initializeDeposit).toHaveBeenCalledWith( - btcTxInfo, - revealInfo, - `0x${depositOwner.identifierHex}`, - referral, - ) - expect(result.toPrefixedString()).toBe(mockedTx.toPrefixedString()) - }) - }) - - describe("when extra data not defined", () => { - it("should throw an error", async () => { - const { fundingOutputIndex, ...restDepositData } = depositReveal - - await expect( - depositor.revealDeposit( - bitcoinFundingTransaction, - fundingOutputIndex, - restDepositData, - ), - ).rejects.toThrow("Invalid extra data") - }) - }) - }) - describe("encodeExtraData", () => { const spyOnSolidityPacked = jest.spyOn(ethers, "solidityPacked") diff --git a/sdk/test/modules/staking.test.ts b/sdk/test/modules/staking.test.ts index 81909295e..f269b2d31 100644 --- a/sdk/test/modules/staking.test.ts +++ b/sdk/test/modules/staking.test.ts @@ -5,8 +5,6 @@ import { StakingModule, Hex, StakeInitialization, - DepositorProxy, - DepositReceipt, EthereumAddress, DepositFees, DepositFee, @@ -14,7 +12,8 @@ import { import * as satoshiConverter from "../../src/lib/utils/satoshi-converter" import { MockAcreContracts } from "../utils/mock-acre-contracts" import { MockMessageSigner } from "../utils/mock-message-signer" -import { MockTBTC } from "../utils/mock-tbtc" +import { MockTbtc } from "../utils/mock-tbtc" +import { DepositReceipt } from "../../src/modules/tbtc" const stakingModuleData: { initializeStake: { @@ -73,6 +72,7 @@ const stakingModuleData: { const stakingInitializationData: { depositReceipt: Omit mockedInitializeTxHash: Hex + mockedDepositId: string fundingUtxo: { transactionHash: BitcoinTxHash outputIndex: number @@ -87,6 +87,7 @@ const stakingInitializationData: { extraData: stakingModuleData.initializeStake.extraData, }, mockedInitializeTxHash: Hex.from("999999"), + mockedDepositId: "deposit-id-1234", fundingUtxo: { transactionHash: BitcoinTxHash.from( "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", @@ -99,7 +100,7 @@ const stakingInitializationData: { describe("Staking", () => { const contracts: AcreContracts = new MockAcreContracts() const messageSigner = new MockMessageSigner() - const tbtc = new MockTBTC() + const tbtc = new MockTbtc() const staking: StakingModule = new StakingModule( contracts, @@ -115,11 +116,14 @@ describe("Staking", () => { referral, extraData, } = stakingModuleData.initializeStake + + const { mockedDepositId } = stakingInitializationData + const mockedDeposit = { getBitcoinAddress: jest.fn().mockResolvedValue(mockedDepositBTCAddress), - detectFunding: jest.fn(), + waitForFunding: jest.fn(), getReceipt: jest.fn().mockReturnValue({ extraData }), - initiateMinting: jest.fn(), + createDeposit: jest.fn().mockReturnValue(mockedDepositId), } describe("with default depositor proxy implementation", () => { @@ -136,9 +140,7 @@ describe("Staking", () => { .fn() .mockReturnValue(extraData) - tbtc.deposits.initiateDepositWithProxy = jest - .fn() - .mockReturnValue(mockedDeposit) + tbtc.initiateDeposit = jest.fn().mockReturnValue(mockedDeposit) messageSigner.sign = jest.fn().mockResolvedValue(mockedSignedMessage) @@ -149,15 +151,11 @@ describe("Staking", () => { ) }) - it("should encode extra data", () => { - expect(contracts.bitcoinDepositor.encodeExtraData(staker, referral)) - }) - it("should initiate tBTC deposit", () => { - expect(tbtc.deposits.initiateDepositWithProxy).toHaveBeenCalledWith( + expect(tbtc.initiateDeposit).toHaveBeenCalledWith( + staker, bitcoinRecoveryAddress, - contracts.bitcoinDepositor, - extraData, + referral, ) }) @@ -176,7 +174,7 @@ describe("Staking", () => { mockedDeposit.getReceipt.mockReturnValue(depositReceipt) }) - describe("getDepositAddress", () => { + describe("getBitcoinAddress", () => { it("should return bitcoin deposit address", async () => { expect(await result.getBitcoinAddress()).toBe( mockedDepositBTCAddress, @@ -261,136 +259,30 @@ describe("Staking", () => { }) describe("when message has already been signed", () => { - let tx: Hex - const { mockedInitializeTxHash: mockedTxHash, fundingUtxo } = - stakingInitializationData + let depositId: string beforeAll(async () => { - mockedDeposit.initiateMinting.mockResolvedValue(mockedTxHash) - mockedDeposit.detectFunding.mockResolvedValue([fundingUtxo]) + mockedDeposit.waitForFunding.mockResolvedValue(undefined) await result.signMessage() - tx = await result.stake() + depositId = await result.stake() }) - it("should stake tokens via tbtc depositor proxy", () => { - expect(mockedDeposit.initiateMinting).toHaveBeenCalled() + it("should wait for funding", () => { + expect(mockedDeposit.waitForFunding).toHaveBeenCalled() }) - it("should return transaction hash", () => { - expect(tx).toBe(mockedTxHash) - }) - }) - - describe("when waiting for bitcoin deposit tx", () => { - const { mockedInitializeTxHash: mockedTxHash } = - stakingInitializationData - - describe("when can't find transaction after max number of retries", () => { - beforeEach(async () => { - jest.useFakeTimers() - - mockedDeposit.initiateMinting.mockResolvedValue(mockedTxHash) - mockedDeposit.detectFunding.mockClear() - mockedDeposit.detectFunding.mockResolvedValue([]) - - await result.signMessage() - }) - - it("should throw an error", async () => { - // eslint-disable-next-line no-void - void expect(result.stake()).rejects.toThrow( - "Deposit not funded yet", - ) - - await jest.runAllTimersAsync() - - expect(mockedDeposit.detectFunding).toHaveBeenCalledTimes(6) - }) + it("should create the deposit", () => { + expect(mockedDeposit.createDeposit).toHaveBeenCalled() }) - describe("when the funding tx is available", () => { - const { fundingUtxo } = stakingInitializationData - let txPromise: Promise - - beforeAll(async () => { - jest.useFakeTimers() - - mockedDeposit.initiateMinting.mockResolvedValue(mockedTxHash) - - mockedDeposit.detectFunding.mockClear() - mockedDeposit.detectFunding - // First attempt. Deposit not funded yet. - .mockResolvedValueOnce([]) - // Second attempt. Deposit funded. - .mockResolvedValueOnce([fundingUtxo]) - - await result.signMessage() - - txPromise = result.stake() - - await jest.runAllTimersAsync() - }) - - it("should wait for deposit transaction", () => { - expect(mockedDeposit.detectFunding).toHaveBeenCalledTimes(2) - }) - - it("should stake tokens via tbtc depositor proxy", () => { - expect(mockedDeposit.initiateMinting).toHaveBeenCalled() - }) - - it("should return transaction hash", async () => { - const txHash = await txPromise - - expect(txHash).toBe(mockedTxHash) - }) + it("should return deposit id", () => { + expect(depositId).toBe(mockedDepositId) }) }) }) }) }) - - describe("with custom depositor proxy", () => { - const customDepositorProxy: DepositorProxy = { - getChainIdentifier: jest.fn(), - revealDeposit: jest.fn(), - } as unknown as DepositorProxy - - let result: StakeInitialization - - beforeEach(async () => { - contracts.bitcoinDepositor.encodeExtraData = jest - .fn() - .mockReturnValue(extraData) - - tbtc.deposits.initiateDepositWithProxy = jest - .fn() - .mockReturnValue(mockedDeposit) - - result = await staking.initializeStake( - bitcoinRecoveryAddress, - staker, - referral, - customDepositorProxy, - ) - }) - - it("should initiate tBTC deposit", () => { - expect(tbtc.deposits.initiateDepositWithProxy).toHaveBeenCalledWith( - bitcoinRecoveryAddress, - customDepositorProxy, - extraData, - ) - }) - - it("should return stake initialization object", () => { - expect(result).toBeInstanceOf(StakeInitialization) - expect(result.getBitcoinAddress).toBeDefined() - expect(result.stake).toBeDefined() - expect(result.signMessage).toBeDefined() - }) - }) }) describe("sharesBalance", () => { diff --git a/sdk/test/modules/tbtc/Deposit.test.ts b/sdk/test/modules/tbtc/Deposit.test.ts new file mode 100644 index 000000000..5e6e48937 --- /dev/null +++ b/sdk/test/modules/tbtc/Deposit.test.ts @@ -0,0 +1,106 @@ +import { Deposit as TbtcSdkDeposit } from "@keep-network/tbtc-v2.ts" +import TbtcApi, { DepositStatus } from "../../../src/lib/api/TbtcApi" +import Deposit from "../../../src/modules/tbtc/Deposit" + +import { fundingUtxo, revealTestData } from "../../data/deposit" + +describe("Deposit", () => { + const tbtcApi: TbtcApi = new TbtcApi("https://api.acre.fi/v1/deposit/") + const tbtcSdkDeposit: TbtcSdkDeposit = jest.fn() as unknown as TbtcSdkDeposit + + let deposit: Deposit + + beforeAll(() => { + tbtcSdkDeposit.detectFunding = jest.fn() + + deposit = new Deposit(tbtcApi, tbtcSdkDeposit, revealTestData) + }) + + describe("waitForFunding", () => { + describe("when waiting for bitcoin deposit tx", () => { + describe("when can't find transaction after max number of retries", () => { + beforeAll(() => { + jest.useFakeTimers() + + jest + .spyOn(tbtcSdkDeposit, "detectFunding") + .mockClear() + .mockResolvedValue([]) + }) + + it("should throw an error", async () => { + // eslint-disable-next-line no-void + void expect( + deposit.waitForFunding({ + retries: 5, + backoffStepMs: 1234, + }), + ).rejects.toThrow("Deposit not funded yet") + + await jest.runAllTimersAsync() + + expect(tbtcSdkDeposit.detectFunding).toHaveBeenCalledTimes(6) + }) + }) + + describe("when the funding tx is available", () => { + let txPromise: Promise + + beforeAll(async () => { + jest.useFakeTimers() + + jest + .spyOn(tbtcSdkDeposit, "detectFunding") + .mockClear() + // First attempt. Deposit not funded yet. + .mockResolvedValueOnce([]) + // Second attempt. Deposit not funded yet. + .mockResolvedValueOnce([]) + // Third attempt. Deposit funded. + .mockResolvedValueOnce([fundingUtxo]) + + txPromise = deposit.waitForFunding({ + retries: 95, + backoffStepMs: 1234, + }) + + await jest.runAllTimersAsync() + }) + + it("should wait for deposit transaction", () => { + expect(tbtcSdkDeposit.detectFunding).toHaveBeenCalledTimes(3) + }) + + it("should resolve promise", async () => { + await txPromise + }) + }) + }) + }) + + describe("createDeposit", () => { + const mockedDepositId = "some-deposit-id" + + beforeAll(() => { + jest.spyOn(tbtcApi, "createDeposit").mockResolvedValue({ + depositId: mockedDepositId, + depositStatus: DepositStatus.Queued, + fundingOutpoint: fundingUtxo, + }) + }) + + afterAll(() => { + jest.spyOn(tbtcApi, "createDeposit").mockClear() + }) + + it("should create a deposit in tBTC API", async () => { + await deposit.createDeposit() + + expect(tbtcApi.createDeposit).toHaveBeenCalledWith({ + depositReceipt: revealTestData.revealInfo, + depositOwner: revealTestData.metadata.depositOwner, + referral: revealTestData.metadata.referral, + }) + }) + }) +}) diff --git a/sdk/test/modules/tbtc/Tbtc.test.ts b/sdk/test/modules/tbtc/Tbtc.test.ts new file mode 100644 index 000000000..043ddec7c --- /dev/null +++ b/sdk/test/modules/tbtc/Tbtc.test.ts @@ -0,0 +1,178 @@ +import { + EthereumNetwork, + TBTC as TbtcSdk, + Deposit as TbtcSdkDeposit, +} from "@keep-network/tbtc-v2.ts" + +import { EthereumSignerCompatibleWithEthersV5 } from "../../../src" +import Deposit from "../../../src/modules/tbtc/Deposit" +import TbtcApi from "../../../src/lib/api/TbtcApi" + +import Tbtc from "../../../src/modules/tbtc" +import { + depositTestData, + receiptTestData, + revealTestData, +} from "../../data/deposit" +import { MockAcreContracts } from "../../utils/mock-acre-contracts" + +import { MockTbtcSdk } from "../../utils/mock-tbtc-sdk" + +jest.mock("@keep-network/tbtc-v2.ts", (): object => ({ + TbtcSdk: jest.fn(), + ...jest.requireActual("@keep-network/tbtc-v2.ts"), +})) + +class MockEthereumSignerCompatibleWithEthersV5 extends EthereumSignerCompatibleWithEthersV5 { + getAddress = jest.fn() + + connect = jest.fn() + + signTransaction = jest.fn() + + signMessage = jest.fn() + + signTypedData = jest.fn() +} + +describe("Tbtc", () => { + const tbtcApiUrl = "https://api.acre.fi/v1/deposit/" + + const tbtcSdk: TbtcSdk = new MockTbtcSdk() + + const { bitcoinDepositor } = new MockAcreContracts() + + describe("initialize", () => { + const mockedSigner: EthereumSignerCompatibleWithEthersV5 = + new MockEthereumSignerCompatibleWithEthersV5() + + describe("when network is mainnet", () => { + const network: EthereumNetwork = "mainnet" + + let result: Tbtc + + beforeAll(async () => { + jest.spyOn(TbtcSdk, "initializeMainnet").mockResolvedValueOnce(tbtcSdk) + + result = await Tbtc.initialize( + mockedSigner, + network, + tbtcApiUrl, + bitcoinDepositor, + ) + }) + + it("should initialize TbtcSdk for mainnet", () => { + expect(TbtcSdk.initializeMainnet).toHaveBeenCalledWith(mockedSigner) + }) + + it("should return initialized Tbtc module", () => { + expect(result).toBeInstanceOf(Tbtc) + }) + }) + + describe("when network is sepolia", () => { + const network: EthereumNetwork = "sepolia" + + let result: Tbtc + + beforeAll(async () => { + jest.spyOn(TbtcSdk, "initializeSepolia").mockResolvedValueOnce(tbtcSdk) + + result = await Tbtc.initialize( + mockedSigner, + network, + tbtcApiUrl, + bitcoinDepositor, + ) + }) + + it("should initialize TbtcSdk for sepolia", () => { + expect(TbtcSdk.initializeSepolia).toHaveBeenCalledWith(mockedSigner) + }) + + it("should return initialized Tbtc module", () => { + expect(result).toBeInstanceOf(Tbtc) + }) + }) + }) + + describe("initiateDeposit", () => { + const tbtcApi: TbtcApi = new TbtcApi(tbtcApiUrl) + + let tbtc: Tbtc + + beforeAll(() => { + tbtc = new Tbtc(tbtcApi, tbtcSdk, bitcoinDepositor) + }) + + describe("when Bitcoin address is empty", () => { + it("should throw an error", async () => { + const emptyBitcoinRecoveryAddress = "" + + await expect( + tbtc.initiateDeposit( + depositTestData.depositOwner, + emptyBitcoinRecoveryAddress, + depositTestData.referral, + ), + ).rejects.toThrow("Ethereum or Bitcoin address is not available") + }) + }) + + describe("when Bitcoin address is provided", () => { + beforeAll(() => { + bitcoinDepositor.encodeExtraData = jest + .fn() + .mockReturnValue(depositTestData.extraData) + + const tbtcSdkDeposit: TbtcSdkDeposit = + jest.fn() as unknown as TbtcSdkDeposit + + tbtcSdkDeposit.getReceipt = jest.fn().mockReturnValue(receiptTestData) + + tbtcSdk.deposits.initiateDepositWithProxy = jest + .fn() + .mockReturnValue(tbtcSdkDeposit) + }) + + describe("when saveReveal succeeded", () => { + let result: Deposit + + beforeAll(async () => { + jest.spyOn(tbtcApi, "saveReveal").mockResolvedValueOnce(true) + + result = await tbtc.initiateDeposit( + depositTestData.depositOwner, + depositTestData.bitcoinRecoveryAddress, + depositTestData.referral, + ) + }) + + it("should call saveReveal", () => { + expect(tbtcApi.saveReveal).toHaveBeenCalledWith(revealTestData) + }) + + it("should initiate a deposit", () => { + expect(result).toBeInstanceOf(Deposit) + }) + }) + + describe("when saveReveal failed", () => { + beforeAll(() => { + jest.spyOn(tbtcApi, "saveReveal").mockResolvedValueOnce(false) + }) + + it("should throw an error", async () => { + await expect( + tbtc.initiateDeposit( + depositTestData.depositOwner, + depositTestData.bitcoinRecoveryAddress, + depositTestData.referral, + ), + ).rejects.toThrow("Reveal not saved properly in the database") + }) + }) + }) + }) +}) diff --git a/sdk/test/utils/mock-tbtc-sdk.ts b/sdk/test/utils/mock-tbtc-sdk.ts new file mode 100644 index 000000000..714a25782 --- /dev/null +++ b/sdk/test/utils/mock-tbtc-sdk.ts @@ -0,0 +1,33 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +import { + BitcoinClient, + BitcoinNetwork, + DepositsService, + MaintenanceService, + RedemptionsService, + TBTC, + TBTCContracts, +} from "@keep-network/tbtc-v2.ts" + +// eslint-disable-next-line import/prefer-default-export +export class MockTbtcSdk implements TBTC { + deposits: DepositsService + + maintenance: MaintenanceService + + redemptions: RedemptionsService + + tbtcContracts: TBTCContracts + + bitcoinClient: BitcoinClient + + constructor() { + this.deposits = jest.fn() as unknown as DepositsService + this.maintenance = jest.fn() as unknown as MaintenanceService + this.redemptions = jest.fn() as unknown as RedemptionsService + this.tbtcContracts = jest.fn() as unknown as TBTCContracts + this.bitcoinClient = { + getNetwork: jest.fn().mockResolvedValue(BitcoinNetwork.Testnet), + } as unknown as BitcoinClient + } +} diff --git a/sdk/test/utils/mock-tbtc.ts b/sdk/test/utils/mock-tbtc.ts index 7e8192a41..aee034ac0 100644 --- a/sdk/test/utils/mock-tbtc.ts +++ b/sdk/test/utils/mock-tbtc.ts @@ -1,33 +1,17 @@ -/* eslint-disable @typescript-eslint/no-unsafe-call */ -import { - BitcoinClient, - BitcoinNetwork, - DepositsService, - MaintenanceService, - RedemptionsService, - TBTC, - TBTCContracts, -} from "@keep-network/tbtc-v2.ts" +import { TBTC as TbtcSdk } from "@keep-network/tbtc-v2.ts" -// eslint-disable-next-line import/prefer-default-export -export class MockTBTC implements TBTC { - deposits: DepositsService - - maintenance: MaintenanceService - - redemptions: RedemptionsService +import Tbtc from "../../src/modules/tbtc" +import TbtcApi from "../../src/lib/api/TbtcApi" - tbtcContracts: TBTCContracts - - bitcoinClient: BitcoinClient +import { BitcoinDepositor } from "../../src/lib/contracts" +// eslint-disable-next-line import/prefer-default-export +export class MockTbtc extends Tbtc { constructor() { - this.deposits = jest.fn() as unknown as DepositsService - this.maintenance = jest.fn() as unknown as MaintenanceService - this.redemptions = jest.fn() as unknown as RedemptionsService - this.tbtcContracts = jest.fn() as unknown as TBTCContracts - this.bitcoinClient = { - getNetwork: jest.fn().mockResolvedValue(BitcoinNetwork.Testnet), - } as unknown as BitcoinClient + const tbtcApi = jest.fn() as unknown as TbtcApi + const tbtcSdk = jest.fn() as unknown as TbtcSdk + const bitcoinDepositor = jest.fn() as unknown as BitcoinDepositor + + super(tbtcApi, tbtcSdk, bitcoinDepositor) } } diff --git a/solidity/contracts/BitcoinDepositor.sol b/solidity/contracts/BitcoinDepositor.sol index 2251dbd57..bbcaa7784 100644 --- a/solidity/contracts/BitcoinDepositor.sol +++ b/solidity/contracts/BitcoinDepositor.sol @@ -261,7 +261,7 @@ contract BitcoinDepositor is AbstractTBTCDepositor, Ownable2StepUpgradeable { // Compute depositor fee. The fee is calculated based on the initial funding // transaction amount, before the tBTC protocol network fees were taken. uint256 depositorFee = depositorFeeDivisor > 0 - ? (initialAmount / depositorFeeDivisor) + ? Math.ceilDiv(initialAmount, depositorFeeDivisor) : 0; // Ensure the depositor fee does not exceed the approximate minted tBTC diff --git a/solidity/contracts/stBTC.sol b/solidity/contracts/stBTC.sol index 458364380..a8065cde1 100644 --- a/solidity/contracts/stBTC.sol +++ b/solidity/contracts/stBTC.sol @@ -209,7 +209,7 @@ contract stBTC is ERC4626Fees, PausableOwnable { function deposit( uint256 assets, address receiver - ) public override whenNotPaused returns (uint256) { + ) public override returns (uint256) { if (assets < minimumDepositAmount) { revert LessThanMinDeposit(assets, minimumDepositAmount); } @@ -234,7 +234,7 @@ contract stBTC is ERC4626Fees, PausableOwnable { function mint( uint256 shares, address receiver - ) public override whenNotPaused returns (uint256 assets) { + ) public override returns (uint256 assets) { if ((assets = super.mint(shares, receiver)) < minimumDepositAmount) { revert LessThanMinDeposit(assets, minimumDepositAmount); } @@ -251,7 +251,7 @@ contract stBTC is ERC4626Fees, PausableOwnable { uint256 assets, address receiver, address owner - ) public override whenNotPaused returns (uint256) { + ) public override returns (uint256) { uint256 currentAssetsBalance = IERC20(asset()).balanceOf(address(this)); // If there is not enough assets in stBTC to cover user withdrawals and // withdrawal fees then pull the assets from the dispatcher. @@ -273,7 +273,7 @@ contract stBTC is ERC4626Fees, PausableOwnable { uint256 shares, address receiver, address owner - ) public override whenNotPaused returns (uint256) { + ) public override returns (uint256) { uint256 assets = convertToAssets(shares); uint256 currentAssetsBalance = IERC20(asset()).balanceOf(address(this)); if (assets > currentAssetsBalance) { @@ -283,6 +283,46 @@ contract stBTC is ERC4626Fees, PausableOwnable { return super.redeem(shares, receiver, owner); } + /// @dev Returns the maximum amount of the underlying asset that can be + /// deposited into the Vault for the receiver, through a deposit call. + /// If the Vault is paused, returns 0. + function maxDeposit(address) public view override returns (uint256) { + if (paused()) { + return 0; + } + return type(uint256).max; + } + + /// @dev Returns the maximum amount of the Vault shares that can be minted + /// for the receiver, through a mint call. + /// If the Vault is paused, returns 0. + function maxMint(address) public view override returns (uint256) { + if (paused()) { + return 0; + } + return type(uint256).max; + } + + /// @dev Returns the maximum amount of the underlying asset that can be + /// withdrawn from the owner balance in the Vault, through a withdraw call. + /// If the Vault is paused, returns 0. + function maxWithdraw(address owner) public view override returns (uint256) { + if (paused()) { + return 0; + } + return super.maxWithdraw(owner); + } + + /// @dev Returns the maximum amount of Vault shares that can be redeemed from + /// the owner balance in the Vault, through a redeem call. + /// If the Vault is paused, returns 0. + function maxRedeem(address owner) public view override returns (uint256) { + if (paused()) { + return 0; + } + return super.maxRedeem(owner); + } + /// @notice Returns value of assets that would be exchanged for the amount of /// shares owned by the `account`. /// @param account Owner of shares. @@ -291,6 +331,21 @@ contract stBTC is ERC4626Fees, PausableOwnable { return convertToAssets(balanceOf(account)); } + /// @dev Transfers a `value` amount of tokens from `from` to `to`, or + /// alternatively mints (or burns) if `from` (or `to`) is the zero + /// address. All customizations to transfers, mints, and burns should + /// be done by overriding this function. + /// @param from Sender of tokens. + /// @param to Receiver of tokens. + /// @param value Amount of tokens to transfer. + function _update( + address from, + address to, + uint256 value + ) internal override whenNotPaused { + super._update(from, to, value); + } + /// @return Returns entry fee basis point used in deposits. function _entryFeeBasisPoints() internal view override returns (uint256) { return entryFeeBasisPoints; diff --git a/solidity/test/stBTC.test.ts b/solidity/test/stBTC.test.ts index 5cd5e5b1f..37e8a04ad 100644 --- a/solidity/test/stBTC.test.ts +++ b/solidity/test/stBTC.test.ts @@ -1793,30 +1793,72 @@ describe("stBTC", () => { beforeAfterSnapshotWrapper() before(async () => { + await tbtc.mint(depositor1.address, amount) + await tbtc.connect(depositor1).approve(await stbtc.getAddress(), amount) + await stbtc.connect(depositor1).deposit(amount, depositor1) await stbtc.connect(pauseAdmin).pause() }) it("should pause deposits", async () => { - await expect( - stbtc.connect(depositor1).deposit(amount, depositor1), - ).to.be.revertedWithCustomError(stbtc, "EnforcedPause") + await expect(stbtc.connect(depositor1).deposit(amount, depositor1)) + .to.be.revertedWithCustomError(stbtc, "ERC4626ExceededMaxDeposit") + .withArgs(depositor1.address, amount, 0) }) it("should pause minting", async () => { - await expect( - stbtc.connect(depositor1).mint(amount, depositor1), - ).to.be.revertedWithCustomError(stbtc, "EnforcedPause") + await expect(stbtc.connect(depositor1).mint(amount, depositor1)) + .to.be.revertedWithCustomError(stbtc, "ERC4626ExceededMaxMint") + .withArgs(depositor1.address, amount, 0) }) it("should pause withdrawals", async () => { await expect( - stbtc.connect(depositor1).withdraw(amount, depositor1, depositor1), - ).to.be.revertedWithCustomError(stbtc, "EnforcedPause") + stbtc.connect(depositor1).withdraw(to1e18(1), depositor1, depositor1), + ) + .to.be.revertedWithCustomError(stbtc, "ERC4626ExceededMaxWithdraw") + .withArgs(depositor1.address, to1e18(1), 0) }) it("should pause redemptions", async () => { await expect( - stbtc.connect(depositor1).redeem(amount, depositor1, depositor1), + stbtc.connect(depositor1).redeem(to1e18(1), depositor1, depositor1), + ) + .to.be.revertedWithCustomError(stbtc, "ERC4626ExceededMaxRedeem") + .withArgs(depositor1.address, to1e18(1), 0) + }) + + it("should return 0 when calling maxDeposit", async () => { + expect(await stbtc.maxDeposit(depositor1)).to.be.eq(0) + }) + + it("should return 0 when calling maxMint", async () => { + expect(await stbtc.maxMint(depositor1)).to.be.eq(0) + }) + + it("should return 0 when calling maxRedeem", async () => { + expect(await stbtc.maxRedeem(depositor1)).to.be.eq(0) + }) + + it("should return 0 when calling maxWithdraw", async () => { + expect(await stbtc.maxWithdraw(depositor1)).to.be.eq(0) + }) + + it("should pause transfers", async () => { + await expect( + stbtc.connect(depositor1).transfer(depositor2, amount), + ).to.be.revertedWithCustomError(stbtc, "EnforcedPause") + }) + + it("should pause transfersFrom", async () => { + await expect( + stbtc + .connect(depositor1) + .approve(depositor2.address, amount) + .then(() => + stbtc + .connect(depositor2) + .transferFrom(depositor1, depositor2, amount), + ), ).to.be.revertedWithCustomError(stbtc, "EnforcedPause") }) }) diff --git a/subgraph/README.md b/subgraph/README.md index 28d373e25..ad586ab07 100644 --- a/subgraph/README.md +++ b/subgraph/README.md @@ -40,9 +40,9 @@ create a private one 1. Install Docker on your local machine: - - Mac: https://docs.docker.com/desktop/install/mac-install/ - - Windows: https://docs.docker.com/desktop/install/windows-install/ - - Linux: https://docs.docker.com/desktop/install/linux-install/ + - [Mac](https://docs.docker.com/desktop/install/mac-install/) + - [Windows](https://docs.docker.com/desktop/install/windows-install/) + - [Linux](https://docs.docker.com/desktop/install/linux-install/) 2. Set the API key in the `docker-compose.yaml` file. @@ -78,39 +78,67 @@ Note: use it only if your subgraph is not created in the local Graph node. ### Deploy the subgraph to Subgraph Studio -1. You need to connect wallet to use Subgraph Studio [Metamask, WalletConnect, Coinbase Wallet or Safe]. +1. Go to [Subgraph Studio](https://thegraph.com/studio/). Connect wallet to use + the Subgraph Studio using Metamask, WalletConnect, Coinbase Wallet or Safe. + Use a dedicated account for the Acre team. + +2. Once the account is connected, all subgraphs are available in the [My + Dashboard](https://thegraph.com/studio/) tab. Select the correct subgraph. + +3. Before being able to deploy subgraph to the Subgraph Studio, you need to + login into your account within the CLI. ``` - https://thegraph.com/studio/ + graph auth --studio ``` -2. We're going to create a Subgraph. To do that you need to click Create a Subgraph button in My Dashboard of Subgraph Studio. - -3. In the next step you'll need to add name of Subgraph and choose indexed blockchain from the list. + The `` can be found on "My Subgraphs" page or subgraph details + page. -4. Once your subgraph has been created in Subgraph Studio you can initialize the subgraph code using this command: +4. Deploying a Subgraph to Subgraph Studio ``` - graph init --studio + graph deploy --studio ``` - The value can be found on your subgraph details page in Subgraph Studio - (https://thegraph.com/docs/en/deploying/deploying-a-subgraph-to-studio/#create-your-subgraph-in-subgraph-studio) + The `` can be found on subgraph details page in the Subgraph + Studio. -5. Before being able to deploy your subgraph to Subgraph Studio, you need to login into your account within the CLI. + After running this command, the CLI will ask for a version label, you can + name it however you want, you can use labels such as 0.1 and 0.2 or use + letters as well such as uniswap-v2-0.1. - ``` - graph auth --studio - ``` +If you have any problems, take a look +[here](https://thegraph.com/docs/en/deploying/deploying-a-subgraph-to-studio/). - The can be found on your "My Subgraphs" page or your subgraph details page. +### Publish the subgraph to the Decentralized Network -6. Deploying a Subgraph to Subgraph Studio +Subgraphs can be published to the decentralized network directly from the +Subgraph Studio dashboard. - ``` - graph deploy --studio - ``` +1. Select the correct subgraph from the Subgraph Studio. + +2. Click the "Publish" button + +3. While you’re going through your publishing flow, you’ll be able to push to + either Arbitrum One or Arbitrum Sepolia. + + - Publishing to Arbitrum Sepolia is free. This will allow you to see how the + subgraph will work in the [Graph Explorer](https://thegraph.com/explorer) + and will allow you to test curation elements. This is recommended for + testing purposes only. + +4. During the publication flow, it is possible to add signal to your subgraph. + This is not a required step and you can add GRT signal to a published + subgraph from the Graph Explorer later. + + - Adding signal to a subgraph which is not eligible for rewards will not + attract additional Indexers. More info + [here](https://thegraph.com/docs/en/publishing/publishing-a-subgraph/#adding-signal-to-your-subgraph). - After running this command, the CLI will ask for a version label, you can name it however you want, you can use labels such as 0.1 and 0.2 or use letters as well such as uniswap-v2-0.1. +5. Click the "Publish new Subgraph" button. Once a subgraph is published, it + will be available to view in the [Graph + Explorer](https://thegraph.com/explorer). -7. More information about deploying your subgraph to Subgraph Studio: https://thegraph.com/docs/en/deploying/subgraph-studio/ +If you have any problems, take a look +[here](https://thegraph.com/docs/en/publishing/publishing-a-subgraph/). diff --git a/subgraph/package.json b/subgraph/package.json index 42bcca0f4..3a2b00094 100644 --- a/subgraph/package.json +++ b/subgraph/package.json @@ -18,8 +18,8 @@ "lint:config:fix": "prettier -w '**/*.@(json|yaml|toml)'" }, "dependencies": { - "@graphprotocol/graph-cli": "0.68.3", - "@graphprotocol/graph-ts": "0.32.0", + "@graphprotocol/graph-cli": "0.71.0", + "@graphprotocol/graph-ts": "0.35.1", "assemblyscript": "0.19.23" }, "devDependencies": { @@ -27,7 +27,7 @@ "@typescript-eslint/eslint-plugin": "^6.12.0", "@typescript-eslint/parser": "^6.12.0", "eslint": "^8.54.0", - "matchstick-as": "0.5.0", + "matchstick-as": "0.6.0", "prettier": "^3.1.0" } } diff --git a/subgraph/src/bitcoin-depositor.ts b/subgraph/src/bitcoin-depositor.ts index 957af9a2e..98849dc4e 100644 --- a/subgraph/src/bitcoin-depositor.ts +++ b/subgraph/src/bitcoin-depositor.ts @@ -8,6 +8,7 @@ import { getOrCreateDeposit, getOrCreateEvent, } from "./utils" +import { findBitcoinTransactionIdFromTransactionReceipt } from "./tbtc-utils" export function handleDepositInitialized(event: DepositInitializedEvent): void { const depositOwnerEntity = getOrCreateDepositOwner(event.params.depositOwner) @@ -15,12 +16,12 @@ export function handleDepositInitialized(event: DepositInitializedEvent): void { event.params.depositKey.toHexString(), ) - // TODO: Get the bitcoin transaction hash from this Ethereum transaction - // by finding the `DepositRevealed` event in logs from the tBTC-v2 Bridge - // contract. depositEntity.depositOwner = depositOwnerEntity.id depositEntity.initialDepositAmount = event.params.initialAmount + depositEntity.bitcoinTransactionId = + findBitcoinTransactionIdFromTransactionReceipt(event.receipt) + const eventEntity = getOrCreateEvent( `${event.transaction.hash.toHexString()}_DepositInitialized`, ) diff --git a/subgraph/src/tbtc-utils.ts b/subgraph/src/tbtc-utils.ts new file mode 100644 index 000000000..cc03bd155 --- /dev/null +++ b/subgraph/src/tbtc-utils.ts @@ -0,0 +1,65 @@ +import { + Address, + ethereum, + crypto, + ByteArray, + BigInt, + Bytes, + dataSource, +} from "@graphprotocol/graph-ts" + +const DEPOSIT_REVEALED_EVENT_SIGNATURE = crypto.keccak256( + ByteArray.fromUTF8( + "DepositRevealed(bytes32,uint32,address,uint64,bytes8,bytes20,bytes20,bytes4,address)", + ), +) + +// eslint-disable-next-line import/prefer-default-export +export function findBitcoinTransactionIdFromTransactionReceipt( + transactionReceipt: ethereum.TransactionReceipt | null, +): string { + const tbtcV2BridgeAddress = Address.fromBytes( + dataSource.context().getBytes("tbtcBridgeAddress"), + ) + + if (!transactionReceipt) { + throw new Error("Transaction receipt not available") + } + + // We must cast manually to `ethereum.TransactionReceipt` otherwise + // AssemblyScript will fail. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + const receipt = transactionReceipt as ethereum.TransactionReceipt + + let depositRevealedLogIndex = -1 + for (let i = 0; i < receipt.logs.length; i += 1) { + const receiptLog = receipt.logs[i] + + if ( + receiptLog.address.equals(tbtcV2BridgeAddress) && + receiptLog.topics[0].equals(DEPOSIT_REVEALED_EVENT_SIGNATURE) + ) { + depositRevealedLogIndex = i + } + } + + if (depositRevealedLogIndex < 0) { + throw new Error("Cannot find `DepositRevealed` event in transaction logs") + } + + const depositRevealedLog = receipt.logs[depositRevealedLogIndex] + + // Bitcoin transaction hash in little-endian byte order. The first 32 bytes + // (w/o `0x` prefix) points to the Bitcoin transaction hash. + const bitcoinTxHash = depositRevealedLog.data.toHexString().slice(2, 66) + + // Bitcoin transaction id in the same byte order as used by the + // Bitcoin block explorers. + const bitcoinTransactionId = BigInt.fromUnsignedBytes( + Bytes.fromHexString(bitcoinTxHash), + ) + .toHexString() + .slice(2) + + return bitcoinTransactionId +} diff --git a/subgraph/subgraph.yaml b/subgraph/subgraph.yaml index 96ba12d8e..6827437a0 100644 --- a/subgraph/subgraph.yaml +++ b/subgraph/subgraph.yaml @@ -7,6 +7,10 @@ dataSources: - kind: ethereum name: BitcoinDepositor network: sepolia + context: + tbtcBridgeAddress: + type: Bytes + data: "0x9b1a7fE5a16A15F2f9475C5B231750598b113403" source: address: "0x2F86FE8C5683372Db667E6f6d88dcB6d55a81286" abi: BitcoinDepositor diff --git a/subgraph/tests/bitcoin-depositor-utils.ts b/subgraph/tests/bitcoin-depositor-utils.ts index cb809517c..cf1099b1f 100644 --- a/subgraph/tests/bitcoin-depositor-utils.ts +++ b/subgraph/tests/bitcoin-depositor-utils.ts @@ -1,4 +1,10 @@ -import { ethereum, BigInt, Address } from "@graphprotocol/graph-ts" +import { + ethereum, + BigInt, + Address, + Bytes, + Wrapped, +} from "@graphprotocol/graph-ts" import { newMockEvent } from "matchstick-as/assembly/defaults" import { DepositInitialized, @@ -10,6 +16,7 @@ export function createDepositInitializedEvent( caller: Address, depositOwner: Address, initialAmount: BigInt, + btcFundingTxHash: string, ): DepositInitialized { const depositInitializedEvent = changetype(newMockEvent()) @@ -34,6 +41,40 @@ export function createDepositInitializedEvent( ethereum.Value.fromUnsignedBigInt(initialAmount), ) + // Logs data from https://sepolia.etherscan.io/tx/0x6805986942c86496853cb1d0146120a6b55e57fb4feec605c49edef2b34903bb#eventlog + const log = new ethereum.Log( + Address.fromString("0x9b1a7fE5a16A15F2f9475C5B231750598b113403"), + [ + // Event signature + Bytes.fromHexString( + "0xa7382159a693ed317a024daf0fd1ba30805cdf9928ee09550af517c516e2ef05", + ), + // `depositor` - indexed topic 1 + Bytes.fromHexString( + "0x0000000000000000000000002f86fe8c5683372db667e6f6d88dcb6d55a81286", + ), + // `walletPubKeyHash` - indexed topic 2 + Bytes.fromHexString( + "0x79073502d1fcf0cc9b9a1b7c56cadda76d33fe98000000000000000000000000", + ), + ], + Bytes.fromHexString( + `0x${btcFundingTxHash}000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000186a00f56f5715acb9a25000000000000000000000000000000000000000000000000f2096fc9cbf2aca10025af13cbf9a685d963fde8000000000000000000000000e1c1946700000000000000000000000000000000000000000000000000000000000000000000000000000000b5679de944a79732a75ce556191df11f489448d5`, + ), + depositInitializedEvent.block.hash, + Bytes.fromI32(1), + depositInitializedEvent.transaction.hash, + depositInitializedEvent.transaction.index, + depositInitializedEvent.logIndex, + depositInitializedEvent.transactionLogIndex, + depositInitializedEvent.logType as string, + new Wrapped(false), + ) + + ;(depositInitializedEvent.receipt as ethereum.TransactionReceipt).logs.push( + log, + ) + depositInitializedEvent.parameters.push(depositKeyParam) depositInitializedEvent.parameters.push(callerParam) depositInitializedEvent.parameters.push(depositOwnerParam) diff --git a/subgraph/tests/bitcoin-depositor.test.ts b/subgraph/tests/bitcoin-depositor.test.ts index b9ac5d333..9b4fd5ee4 100644 --- a/subgraph/tests/bitcoin-depositor.test.ts +++ b/subgraph/tests/bitcoin-depositor.test.ts @@ -5,11 +5,15 @@ import { clearStore, beforeAll, afterAll, - beforeEach, - afterEach, + dataSourceMock, } from "matchstick-as/assembly/index" -import { BigInt, Address } from "@graphprotocol/graph-ts" +import { + BigInt, + Address, + DataSourceContext, + Bytes, +} from "@graphprotocol/graph-ts" import { createDepositInitializedEvent, createDepositFinalizedEvent, @@ -18,9 +22,9 @@ import { handleDepositFinalized, handleDepositInitialized, } from "../src/bitcoin-depositor" +import { DepositOwner } from "../generated/schema" -const depositKey = BigInt.fromI32(234) -const nextDepositKey = BigInt.fromI32(345) +// Shared const caller = Address.fromString("0x0000000000000000000000000000000000000001") const depositOwner = Address.fromString( "0x0000000000000000000000000000000000000001", @@ -30,20 +34,35 @@ const initialAmount = BigInt.fromI32(234) const bridgedAmount = BigInt.fromI32(234) const depositorFee = BigInt.fromI32(234) +// First deposit +const depositKey = BigInt.fromI32(234) +const fundingTxHash = + "03063F39C8F0F9D3D742A73D1D5AF22548BFFF2E4D292BEAFF2BE1FE75CE1556" +const expectedBitcoinTxId = + "5615CE75FEE12BFFEA2B294D2EFFBF4825F25A1D3DA742D7D3F9F0C8393F0603".toLowerCase() const depositInitializedEvent = createDepositInitializedEvent( depositKey, caller, depositOwner, initialAmount, + fundingTxHash, ) -const nextDepositInitializedEvent = createDepositInitializedEvent( - nextDepositKey, +// Second deposit +const secondDepositKey = BigInt.fromI32(555) +const secondFundingTxHash = + "1F0BCD97CD8556AFBBF0EE1D319A8F873B11EA60C536F57AAF25812B19A7F76C" +const secondExpectedBitcoinTxId = + "6CF7A7192B8125AF7AF536C560EA113B878F9A311DEEF0BBAF5685CD97CD0B1F".toLowerCase() +const secondDepositInitializedEvent = createDepositInitializedEvent( + secondDepositKey, caller, depositOwner, initialAmount, + secondFundingTxHash, ) +// First deposit finalized const depositFinalizedEvent = createDepositFinalizedEvent( depositKey, caller, @@ -53,6 +72,19 @@ const depositFinalizedEvent = createDepositFinalizedEvent( depositorFee, ) +// Set up context +const context = new DataSourceContext() +context.setBytes( + "tbtcBridgeAddress", + Bytes.fromHexString("0x9b1a7fE5a16A15F2f9475C5B231750598b113403"), +) + +dataSourceMock.setReturnValues( + "0x2F86FE8C5683372Db667E6f6d88dcB6d55a81286", + "sepolia", + context, +) + describe("handleDepositInitialized", () => { describe("when the deposit owner doesn't exist yet", () => { beforeAll(() => { @@ -76,19 +108,29 @@ describe("handleDepositInitialized", () => { }) test("Deposit entity has proper fields", () => { + const depositEntityId = + depositInitializedEvent.params.depositKey.toHexString() + assert.fieldEquals( "Deposit", - depositInitializedEvent.params.depositKey.toHexString(), + depositEntityId, "depositOwner", depositOwner.toHexString(), ) assert.fieldEquals( "Deposit", - depositInitializedEvent.params.depositKey.toHexString(), + depositEntityId, "initialDepositAmount", depositInitializedEvent.params.initialAmount.toString(), ) + + assert.fieldEquals( + "Deposit", + depositEntityId, + "bitcoinTransactionId", + expectedBitcoinTxId, + ) }) test("Event entity has proper fields", () => { @@ -115,70 +157,63 @@ describe("handleDepositInitialized", () => { describe("when the deposit owner already exists", () => { beforeAll(() => { handleDepositInitialized(depositInitializedEvent) - handleDepositInitialized(nextDepositInitializedEvent) + handleDepositInitialized(secondDepositInitializedEvent) }) afterAll(() => { clearStore() }) - test("should create DepositOwner entity", () => { - assert.entityCount("DepositOwner", 1) - }) + test("the DepositOwner entity should already exists", () => { + const existingDepositOwner = DepositOwner.load( + secondDepositInitializedEvent.params.depositOwner.toHexString(), + ) - test("should create Deposit entity", () => { - assert.entityCount("Deposit", 2) + assert.assertNotNull(existingDepositOwner) + assert.entityCount("DepositOwner", 1) }) - test("should create Event entity", () => { - assert.entityCount("Event", 1) - }) + test("should create the second deposit correctly", () => { + const secondDepositEntityId = + secondDepositInitializedEvent.params.depositKey.toHexString() - test("Deposit entity has proper fields", () => { assert.fieldEquals( "Deposit", - depositInitializedEvent.params.depositKey.toHexString(), + secondDepositEntityId, "depositOwner", depositOwner.toHexString(), ) assert.fieldEquals( "Deposit", - depositInitializedEvent.params.depositKey.toHexString(), + secondDepositEntityId, "initialDepositAmount", depositInitializedEvent.params.initialAmount.toString(), ) assert.fieldEquals( "Deposit", - nextDepositInitializedEvent.params.depositKey.toHexString(), - "depositOwner", - depositOwner.toHexString(), - ) - - assert.fieldEquals( - "Deposit", - nextDepositInitializedEvent.params.depositKey.toHexString(), - "initialDepositAmount", - nextDepositInitializedEvent.params.initialAmount.toString(), + secondDepositEntityId, + "bitcoinTransactionId", + secondExpectedBitcoinTxId, ) }) test("Event entity has proper fields", () => { - const nextTxId = `${nextDepositInitializedEvent.transaction.hash.toHexString()}_DepositInitialized` + const nextTxId = `${secondDepositInitializedEvent.transaction.hash.toHexString()}_DepositInitialized` assert.fieldEquals( "Event", nextTxId, "activity", - nextDepositInitializedEvent.params.depositKey.toHexString(), + secondDepositInitializedEvent.params.depositKey.toHexString(), ) assert.fieldEquals( "Event", nextTxId, "timestamp", - nextDepositInitializedEvent.block.timestamp.toString(), + secondDepositInitializedEvent.block.timestamp.toString(), ) assert.fieldEquals("Event", nextTxId, "type", "Initialized") @@ -188,12 +223,12 @@ describe("handleDepositInitialized", () => { describe("handleDepositFinalized", () => { describe("when deposit entity already exist", () => { - beforeEach(() => { + beforeAll(() => { handleDepositInitialized(depositInitializedEvent) handleDepositFinalized(depositFinalizedEvent) }) - afterEach(() => { + afterAll(() => { clearStore() })