From 96118421cb392b19ce33312f22d15cbbc8029965 Mon Sep 17 00:00:00 2001 From: Petar Todorovic Date: Wed, 23 Apr 2025 12:47:06 +0200 Subject: [PATCH 1/2] feat(externalProvider): add solana and ton support --- .changeset/thirty-beans-own.md | 5 + package.json | 2 +- packages/widget/src/domain/types/chains.ts | 14 +- .../src/domain/types/external-providers.ts | 6 +- packages/widget/src/domain/types/wallet.ts | 2 + .../domain/types/wallets/generic-wallet.ts | 55 +++--- .../widget/src/domain/types/wallets/index.ts | 4 +- .../review/pages/common-page/common.page.tsx | 3 - .../steps/hooks/use-steps-machine.hook.ts | 1 + .../src/providers/external-provider/index.ts | 25 ++- .../widget/src/providers/sk-wallet/index.tsx | 54 ++++-- .../widget/src/providers/sk-wallet/utils.ts | 35 ++-- .../src/providers/sk-wallet/validation.ts | 31 ++++ .../external-provider.test.tsx | 161 +++++++++++++++--- .../widget/tests/use-cases/sk-wallet.test.tsx | 138 +++++++++++++++ .../tests/use-cases/staking-flow/setup.ts | 3 +- 16 files changed, 443 insertions(+), 96 deletions(-) create mode 100644 .changeset/thirty-beans-own.md create mode 100644 packages/widget/tests/use-cases/sk-wallet.test.tsx diff --git a/.changeset/thirty-beans-own.md b/.changeset/thirty-beans-own.md new file mode 100644 index 00000000..3d55c5be --- /dev/null +++ b/.changeset/thirty-beans-own.md @@ -0,0 +1,5 @@ +--- +"@stakekit/widget": patch +--- + +feat(externalProvider): add solana and ton support diff --git a/package.json b/package.json index 1520e89c..8f5f9620 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "knip": "^5.50.3", "turbo": "^2.5.0" }, - "packageManager": "pnpm@10.8.0", + "packageManager": "pnpm@10.9.0", "pnpm": { "overrides": { "@types/react": "19.0.10", diff --git a/packages/widget/src/domain/types/chains.ts b/packages/widget/src/domain/types/chains.ts index ed3af355..84919be4 100644 --- a/packages/widget/src/domain/types/chains.ts +++ b/packages/widget/src/domain/types/chains.ts @@ -112,10 +112,22 @@ export type SubstrateChainsMap = { }; }; +export const isEvmChain = (chain: string): chain is SupportedEvmChain => { + return supportedEVMChainsSet.has(chain as SupportedEvmChain); +}; + +export const isSolanaChain = (chain: string): chain is SupportedMiscChains => { + return chain === MiscNetworks.Solana; +}; + +export const isTonChain = (chain: string): chain is SupportedMiscChains => { + return chain === MiscNetworks.Ton; +}; + export const isSupportedChain = (chain: string): chain is SupportedSKChains => { return ( + isEvmChain(chain) || supportedCosmosChainsSet.has(chain as SupportedCosmosChains) || - supportedEVMChainsSet.has(chain as SupportedEvmChain) || supportedMiscChainsSet.has(chain as SupportedMiscChains) || supportedSubstrateChainsSet.has(chain as SupportedSubstrateChains) ); diff --git a/packages/widget/src/domain/types/external-providers.ts b/packages/widget/src/domain/types/external-providers.ts index 42206cda..a60e659d 100644 --- a/packages/widget/src/domain/types/external-providers.ts +++ b/packages/widget/src/domain/types/external-providers.ts @@ -2,7 +2,7 @@ import type { ActionDto, TransactionDto } from "@stakekit/api-hooks"; import { EitherAsync, Left } from "purify-ts"; import type { RefObject } from "react"; import type { SKExternalProviders } from "./wallets"; -import type { EVMTx } from "./wallets/generic-wallet"; +import type { SKTx } from "./wallets/generic-wallet"; export class ExternalProvider { constructor(private variantProvider: RefObject) {} @@ -12,7 +12,7 @@ export class ExternalProvider { } sendTransaction( - tx: EVMTx, + tx: SKTx, txMeta: { txId: TransactionDto["id"]; actionId: ActionDto["id"] } ) { const _sendTransaction = @@ -28,7 +28,7 @@ export class ExternalProvider { }); } - switchChain({ chainId }: { chainId: string }) { + switchChain({ chainId }: { chainId: number }) { return EitherAsync(() => this.variantProvider.current.provider.switchChain(chainId) ).mapLeft((e) => { diff --git a/packages/widget/src/domain/types/wallet.ts b/packages/widget/src/domain/types/wallet.ts index f4f6740e..4b217ab4 100644 --- a/packages/widget/src/domain/types/wallet.ts +++ b/packages/widget/src/domain/types/wallet.ts @@ -2,6 +2,7 @@ import type { Account } from "@ledgerhq/wallet-api-client"; import type { ActionDto, AddressWithTokenDtoAdditionalAddresses, + Networks, TransactionDto, } from "@stakekit/api-hooks"; import type { EitherAsync } from "purify-ts"; @@ -22,6 +23,7 @@ export type SKWallet = { tx: NonNullable; txMeta: { txId: TransactionDto["id"]; actionId: ActionDto["id"] }; ledgerHwAppId: Nullable; + network: Networks; }) => EitherAsync< TransactionDecodeError | SendTransactionError, { signedTx: SignedTxOrMessage; broadcasted: boolean } diff --git a/packages/widget/src/domain/types/wallets/generic-wallet.ts b/packages/widget/src/domain/types/wallets/generic-wallet.ts index da365168..e18fee4d 100644 --- a/packages/widget/src/domain/types/wallets/generic-wallet.ts +++ b/packages/widget/src/domain/types/wallets/generic-wallet.ts @@ -1,35 +1,52 @@ import type { ActionDto, TransactionDto } from "@stakekit/api-hooks"; import type { Hex } from "viem"; +type Base64String = string; + export enum TxType { Legacy = "0x1", EIP1559 = "0x2", } export type EVMTx = { - data: Hex; - from: Hex; - to: Hex; - value: Hex | undefined; - nonce: Hex; - gas: Hex; - chainId: Hex; - type: Hex; -} & ( - | { - type: TxType.EIP1559; // EIP-1559 - maxFeePerGas: Hex | undefined; - maxPriorityFeePerGas: Hex | undefined; - } - | { type: TxType.Legacy } // Legacy -); + type: "evm"; + tx: { + data: Hex; + from: Hex; + to: Hex; + value: Hex | undefined; + nonce: Hex; + gas: Hex; + chainId: Hex; + type: Hex; + } & ( + | { + type: TxType.EIP1559; // EIP-1559 + maxFeePerGas: Hex | undefined; + maxPriorityFeePerGas: Hex | undefined; + } + | { type: TxType.Legacy } // Legacy + ); +}; + +export type SolanaTx = { type: "solana"; tx: Base64String }; + +export type TonTx = { + type: "ton"; + tx: { + seqno: bigint; + message: Base64String; + }; +}; + +export type SKTx = EVMTx | SolanaTx | TonTx; -export type EVMWallet = { +export type SKWallet = { signMessage: (message: string) => Promise; - switchChain: (chainId: string) => Promise; + switchChain: (chainId: number) => Promise; getTransactionReceipt?(txHash: string): Promise<{ transactionHash?: string }>; sendTransaction( - tx: EVMTx, + tx: SKTx, txMeta: { txId: TransactionDto["id"]; actionId: ActionDto["id"] } ): Promise; }; diff --git a/packages/widget/src/domain/types/wallets/index.ts b/packages/widget/src/domain/types/wallets/index.ts index 26b3d8a9..02158de1 100644 --- a/packages/widget/src/domain/types/wallets/index.ts +++ b/packages/widget/src/domain/types/wallets/index.ts @@ -1,5 +1,5 @@ import type { TokenString } from "@sk-widget/domain/types"; -import type { EVMWallet } from "./generic-wallet"; +import type { SKWallet } from "./generic-wallet"; export type SKExternalProviders = { currentChain?: number; @@ -7,5 +7,5 @@ export type SKExternalProviders = { initToken?: TokenString; supportedChainIds?: number[]; type: "generic"; - provider: EVMWallet; + provider: SKWallet; }; diff --git a/packages/widget/src/pages/review/pages/common-page/common.page.tsx b/packages/widget/src/pages/review/pages/common-page/common.page.tsx index 4a19186f..70da3fce 100644 --- a/packages/widget/src/pages/review/pages/common-page/common.page.tsx +++ b/packages/widget/src/pages/review/pages/common-page/common.page.tsx @@ -13,7 +13,6 @@ import { Box } from "../../../../components/atoms/box"; import { Text } from "../../../../components/atoms/typography"; import { WarningBox } from "../../../../components/atoms/warning-box"; import type { RewardTokenDetails } from "../../../../components/molecules/reward-token-details"; -import { useTrackPage } from "../../../../hooks/tracking/use-track-page"; import { AnimationPage } from "../../../../navigation/containers/animation-page"; import { PageContainer } from "../../../components"; import { MetaInfo } from "../../../components/meta-info"; @@ -53,8 +52,6 @@ export const ReviewPage = ({ feeConfigLoading = false, ...rest }: ReviewPageProps) => { - useTrackPage("stakeReview"); - const trackEvent = useTrackEvent(); const { t } = useTranslation(); diff --git a/packages/widget/src/pages/steps/hooks/use-steps-machine.hook.ts b/packages/widget/src/pages/steps/hooks/use-steps-machine.hook.ts index 751e4d4c..7a31502a 100644 --- a/packages/widget/src/pages/steps/hooks/use-steps-machine.hook.ts +++ b/packages/widget/src/pages/steps/hooks/use-steps-machine.hook.ts @@ -268,6 +268,7 @@ const getMachine = ( actionId: ref.current.actionId, txId: constructedTx.id, }, + network: constructedTx.network, }) .map((val) => ({ ...val, diff --git a/packages/widget/src/providers/external-provider/index.ts b/packages/widget/src/providers/external-provider/index.ts index f9014cfc..c68b481d 100644 --- a/packages/widget/src/providers/external-provider/index.ts +++ b/packages/widget/src/providers/external-provider/index.ts @@ -43,13 +43,13 @@ export const externalProviderConnector = ( iconUrl: config.appIcon, iconBackground: "#fff", createConnector: () => - createConnector((config) => { + createConnector((connectorConfig) => { const $filteredChains = new BehaviorSubject( Maybe.fromNullable(variant.current.supportedChainIds) .map((val) => new Set(val)) .mapOrDefault( - (val) => config.chains.filter((c) => val.has(c.id)), - config.chains as [Chain, ...Chain[]] + (val) => connectorConfig.chains.filter((c) => val.has(c.id)), + connectorConfig.chains as [Chain, ...Chain[]] ) ); const provider = new ExternalProvider(variant); @@ -62,7 +62,7 @@ export const externalProviderConnector = ( const connect: ReturnType["connect"] = async () => { - config.emitter.emit("message", { type: "connecting" }); + connectorConfig.emitter.emit("message", { type: "connecting" }); const [accounts, chainId] = await Promise.all([ getAccounts(), @@ -78,13 +78,11 @@ export const externalProviderConnector = ( await EitherAsync.liftEither( List.find( (c) => c.id === chainId, - config.chains as unknown as Array + connectorConfig.chains as unknown as Array ).toEither(new Error("Chain not found")) ) .chain((chain) => - provider - .switchChain({ chainId: `0x${chainId.toString(16)}` }) - .map(() => chain) + provider.switchChain({ chainId }).map(() => chain) ) .ifRight((chain) => onChainChanged(chain.id.toString())) ).unsafeCoerce(); @@ -101,19 +99,19 @@ export const externalProviderConnector = ( const onDisconnect: ReturnType["onDisconnect"] = () => { - config.emitter.emit("disconnect"); + connectorConfig.emitter.emit("disconnect"); }; const onChainChanged: ReturnType["onChainChanged"] = (chainId) => { - config.emitter.emit("change", { + connectorConfig.emitter.emit("change", { chainId: skNormalizeChainId(chainId), }); }; const onAccountsChanged: ReturnType["onAccountsChanged"] = (accounts) => { - config.emitter.emit("change", { + connectorConfig.emitter.emit("change", { accounts: accounts.filter((a) => !!a).map((a) => getAddress(a)), }); }; @@ -124,8 +122,9 @@ export const externalProviderConnector = ( Maybe.fromFalsy(!!supportedChainIds.length) .map(() => new Set(supportedChainIds)) .mapOrDefault( - (val) => config.chains.filter((c) => val.has(c.id)), - config.chains as [Chain, ...Chain[]] + (val) => + connectorConfig.chains.filter((c) => val.has(c.id)), + connectorConfig.chains as [Chain, ...Chain[]] ) ); diff --git a/packages/widget/src/providers/sk-wallet/index.tsx b/packages/widget/src/providers/sk-wallet/index.tsx index b547e9a0..a8316cd5 100644 --- a/packages/widget/src/providers/sk-wallet/index.tsx +++ b/packages/widget/src/providers/sk-wallet/index.tsx @@ -1,6 +1,12 @@ import type { Account } from "@ledgerhq/wallet-api-client"; import { withRequestErrorRetry } from "@sk-widget/common/utils"; import { config } from "@sk-widget/config"; +import { + isEvmChain, + isSolanaChain, + isTonChain, +} from "@sk-widget/domain/types/chains"; +import type { SKTx } from "@sk-widget/domain/types/wallets/generic-wallet"; import { useCheckIsUnmounted } from "@sk-widget/hooks/use-check-is-unmounted"; import { isSafeConnector } from "@sk-widget/providers/safe/safe-connector-meta"; import { SafeFailedError } from "@sk-widget/providers/sk-wallet/errors"; @@ -44,6 +50,8 @@ import { useSyncExternalProvider } from "./use-sync-external-provider"; import { prepareEVMTx, wagmiNetworkToSKNetwork } from "./utils"; import { unsignedEVMTransactionCodec, + unsignedSolanaTransactionCodec, + unsignedTonTransactionCodec, unsignedTronTransactionCodec, } from "./validation"; @@ -148,17 +156,13 @@ export const SKWalletProvider = ({ children }: PropsWithChildren) => { EitherAsync.liftEither( !isConnected || !network || !connector || !address ? Left(new Error("No wallet connected")) - : Right({ - conn: connector, - network, - address, - }) + : Right({ conn: connector, network, address }) ), [connector, isConnected, network, address] ); const signTransaction = useCallback( - ({ tx, ledgerHwAppId, txMeta }) => + ({ tx, ledgerHwAppId, txMeta, network }) => connectorDetails.chain< TransactionDecodeError | SendTransactionError | NotSupportedFlowError, { signedTx: string; broadcasted: boolean } @@ -246,19 +250,33 @@ export const SKWalletProvider = ({ children }: PropsWithChildren) => { */ if (isExternalProviderConnector(conn)) { return EitherAsync.liftEither( - Either.encase(() => JSON.parse(tx)) - .chain((val) => unsignedEVMTransactionCodec.decode(val)) + Right(null) + .chain(() => { + if (isEvmChain(network)) { + return Either.encase(() => JSON.parse(tx)) + .mapLeft(() => "Failed to parse tx") + .chain((val) => unsignedEVMTransactionCodec.decode(val)) + .map((v) => prepareEVMTx({ address, decodedTx: v })); + } + + if (isSolanaChain(network)) { + return unsignedSolanaTransactionCodec.decode(tx); + } + + if (isTonChain(network)) { + return Either.encase(() => JSON.parse(tx)) + .mapLeft(() => "Failed to parse tx") + .chain((val) => unsignedTonTransactionCodec.decode(val)); + } + + return Left("Unsupported network"); + }) .mapLeft((e) => { console.log(e); return new TransactionDecodeError(); }) ) - .chain((val) => - conn.sendTransaction( - prepareEVMTx({ address, decodedTx: val }), - txMeta - ) - ) + .chain((val) => conn.sendTransaction(val, txMeta)) .map((val) => ({ signedTx: val, broadcasted: true })); } @@ -272,14 +290,14 @@ export const SKWalletProvider = ({ children }: PropsWithChildren) => { .map((val) => prepareEVMTx({ address, decodedTx: val })) .mapLeft(() => new TransactionDecodeError()) ) - .chain((val) => + .chain(({ tx }) => conn .sendTransactions({ txs: [ { - data: val.data, - to: val.to, - value: val.value ?? "0", + data: tx.data, + to: tx.to, + value: tx.value ?? "0", }, ], }) diff --git a/packages/widget/src/providers/sk-wallet/utils.ts b/packages/widget/src/providers/sk-wallet/utils.ts index 08d2635b..9e4f0e6f 100644 --- a/packages/widget/src/providers/sk-wallet/utils.ts +++ b/packages/widget/src/providers/sk-wallet/utils.ts @@ -42,20 +42,23 @@ export const prepareEVMTx = ({ address: Hex; decodedTx: DecodedEVMTransaction; }): EVMTx => ({ - to: decodedTx.to, - from: address, - data: decodedTx.data, - value: decodedTx.value ? numberToHex(decodedTx.value) : undefined, - nonce: numberToHex(decodedTx.nonce), - gas: numberToHex(decodedTx.gasLimit), - chainId: numberToHex(decodedTx.chainId), - ...(decodedTx.maxFeePerGas - ? { - type: TxType.EIP1559, - maxFeePerGas: numberToHex(decodedTx.maxFeePerGas), - maxPriorityFeePerGas: decodedTx.maxPriorityFeePerGas - ? numberToHex(decodedTx.maxPriorityFeePerGas) - : undefined, - } - : { type: TxType.Legacy }), + type: "evm", + tx: { + to: decodedTx.to, + from: address, + data: decodedTx.data, + value: decodedTx.value ? numberToHex(decodedTx.value) : undefined, + nonce: numberToHex(decodedTx.nonce), + gas: numberToHex(decodedTx.gasLimit), + chainId: numberToHex(decodedTx.chainId), + ...(decodedTx.maxFeePerGas + ? { + type: TxType.EIP1559, + maxFeePerGas: numberToHex(decodedTx.maxFeePerGas), + maxPriorityFeePerGas: decodedTx.maxPriorityFeePerGas + ? numberToHex(decodedTx.maxPriorityFeePerGas) + : undefined, + } + : { type: TxType.Legacy }), + }, }); diff --git a/packages/widget/src/providers/sk-wallet/validation.ts b/packages/widget/src/providers/sk-wallet/validation.ts index 67df4643..4c094e18 100644 --- a/packages/widget/src/providers/sk-wallet/validation.ts +++ b/packages/widget/src/providers/sk-wallet/validation.ts @@ -1,3 +1,7 @@ +import type { + SolanaTx, + TonTx, +} from "@sk-widget/domain/types/wallets/generic-wallet"; import type { Transaction as TronTx } from "@tronweb3/tronwallet-abstract-adapter"; import type { GetType } from "purify-ts"; import { Codec, Left, Right, number, optional } from "purify-ts"; @@ -60,3 +64,30 @@ export const unsignedTronTransactionCodec = Codec.custom({ return value; }, }); + +export const unsignedSolanaTransactionCodec = Codec.custom({ + decode(value) { + if (typeof value !== "string") { + return Left("Invalid solana transaction"); + } + + return Right({ type: "solana", tx: value } as SolanaTx); + }, + encode(value) { + return value; + }, +}); + +export const unsignedTonTransactionCodec = Codec.custom({ + decode(value) { + const val = value as Partial; + + if (typeof val.seqno === "number" && typeof val.message === "string") { + return Right({ type: "ton", tx: val } as TonTx); + } + return Left("Invalid TON transaction"); + }, + encode(value) { + return value; + }, +}); diff --git a/packages/widget/tests/use-cases/external-provider/external-provider.test.tsx b/packages/widget/tests/use-cases/external-provider/external-provider.test.tsx index f3718909..f5ee4457 100644 --- a/packages/widget/tests/use-cases/external-provider/external-provider.test.tsx +++ b/packages/widget/tests/use-cases/external-provider/external-provider.test.tsx @@ -1,4 +1,5 @@ import { SKApp, type SKAppProps } from "@sk-widget/App"; +import { solana, ton } from "@sk-widget/providers/misc/chains"; import { VirtualizerObserveElementRectProvider } from "@sk-widget/providers/virtual-scroll"; import { formatAddress } from "@sk-widget/utils"; import type { TokenDto, YieldDto } from "@stakekit/api-hooks"; @@ -6,23 +7,27 @@ import { getYieldV2ControllerGetYieldByIdResponseMock } from "@stakekit/api-hook import userEvent from "@testing-library/user-event"; import { http, HttpResponse, delay } from "msw"; import { Just } from "purify-ts"; -import { describe, expect, it } from "vitest"; +import { avalanche, mainnet } from "viem/chains"; +import { describe, expect, it, vi } from "vitest"; import { server } from "../../mocks/server"; import { renderApp, waitFor, within } from "../../utils/test-utils"; describe("External Provider", () => { it("Handles changing address and supported chains correctly", async () => { + const switchChainSpy = vi.fn(async (_: number) => {}); + const sendTransactionSpy = vi.fn(async () => "hash"); + const skProps = { apiKey: import.meta.env.VITE_API_KEY, externalProviders: { type: "generic", provider: { signMessage: async () => "hash", - switchChain: async () => {}, - sendTransaction: async () => "hash", + switchChain: switchChainSpy, + sendTransaction: sendTransactionSpy, }, currentAddress: "0xB6c5273e79E2aDD234EBC07d87F3824e0f94B2F7", - supportedChainIds: [1, 43114], + supportedChainIds: [mainnet.id, avalanche.id, solana.id, ton.id], }, } satisfies SKAppProps; @@ -46,6 +51,24 @@ describe("External Provider", () => { logoURI: "https://assets.stakek.it/tokens/eth.svg", }; + const solanaToken: TokenDto = { + network: "solana", + name: "Solana", + symbol: "SOL", + decimals: 9, + coinGeckoId: "solana", + logoURI: "https://assets.stakek.it/tokens/sol.svg", + }; + + const tonToken: TokenDto = { + network: "ton", + name: "Toncoin", + symbol: "TON", + decimals: 9, + coinGeckoId: "the-open-network", + logoURI: "https://assets.stakek.it/tokens/ton.svg", + }; + const avalancheAvaxNativeStaking = Just( getYieldV2ControllerGetYieldByIdResponseMock() ) @@ -88,12 +111,56 @@ describe("External Provider", () => { ) .unsafeCoerce(); + const solanaNativeStaking = Just( + getYieldV2ControllerGetYieldByIdResponseMock() + ) + .map( + (val) => + ({ + ...val, + id: "solana-sol-native-staking", + token: solanaToken, + tokens: [solanaToken], + status: { enter: true, exit: true }, + args: { enter: { args: { nfts: undefined } } }, + metadata: { + ...val.metadata, + type: "staking", + gasFeeToken: solanaToken, + }, + }) satisfies YieldDto + ) + .unsafeCoerce(); + + const tonNativeStaking = Just( + getYieldV2ControllerGetYieldByIdResponseMock() + ) + .map( + (val) => + ({ + ...val, + id: "ton-native-staking", + token: tonToken, + tokens: [tonToken], + status: { enter: true, exit: true }, + args: { enter: { args: { nfts: undefined } } }, + metadata: { + ...val.metadata, + type: "staking", + gasFeeToken: tonToken, + }, + }) satisfies YieldDto + ) + .unsafeCoerce(); + server.use( http.get("*/v1/yields/enabled/networks", async () => { await delay(); return HttpResponse.json([ etherNativeStaking.token.network, avalancheAvaxNativeStaking.token.network, + solanaNativeStaking.token.network, + tonNativeStaking.token.network, ]); }), @@ -106,6 +173,8 @@ describe("External Provider", () => { token: avalancheCToken, availableYields: [avalancheAvaxNativeStaking.id], }, + { token: solanaToken, availableYields: [solanaNativeStaking.id] }, + { token: tonToken, availableYields: [tonNativeStaking.id] }, ]); }), @@ -122,6 +191,16 @@ describe("External Provider", () => { amount: "3", availableYields: [avalancheAvaxNativeStaking.id], }, + { + token: solanaToken, + amount: "3", + availableYields: [solanaNativeStaking.id], + }, + { + token: tonToken, + amount: "3", + availableYields: [tonNativeStaking.id], + }, ]); }), @@ -134,6 +213,16 @@ describe("External Provider", () => { await delay(); return HttpResponse.json(avalancheAvaxNativeStaking); + }), + http.get(`*/v1/yields/${solanaNativeStaking.id}`, async () => { + await delay(); + + return HttpResponse.json(solanaNativeStaking); + }), + http.get(`*/v1/yields/${tonNativeStaking.id}`, async () => { + await delay(); + + return HttpResponse.json(tonNativeStaking); }) ); @@ -143,12 +232,19 @@ describe("External Provider", () => { ).toBeInTheDocument() ); - const chainNames = { eth: "Ethereum", avalanche: "Avalanche" } as const; + const chainNames = { + eth: "Ethereum", + avalanche: "Avalanche", + solana: "Solana", + ton: "Ton", + } as const; const element = await waitFor(() => { const el = app.queryByText(chainNames.eth) || - app.queryByText(chainNames.avalanche); + app.queryByText(chainNames.avalanche) || + app.queryByText(chainNames.solana) || + app.queryByText(chainNames.ton); if (!el) throw new Error("Element not found"); @@ -164,9 +260,23 @@ describe("External Provider", () => { .getAllByTestId("rk-chain-option", { exact: false }) .filter((el) => (el as HTMLButtonElement).type === "button"); - await waitFor(() => expect(getChainOptions().length).toBe(2)); + await waitFor(() => expect(getChainOptions().length).toBe(4)); const user = userEvent.setup(); + + const solanaOption = getChainOptions().find( + (option) => + within(option as HTMLElement).queryByText(chainNames.solana) !== null + ); + + if (!solanaOption) throw new Error("Solana option not found"); + + (solanaOption as HTMLElement).click(); + + await waitFor(() => { + expect(switchChainSpy).toHaveBeenCalledWith(501); + }); + user.keyboard("[Escape]"); app.rerender( @@ -175,28 +285,41 @@ describe("External Provider", () => { {...skProps} externalProviders={{ ...skProps.externalProviders, - supportedChainIds: [43114], + supportedChainIds: [avalanche.id, ton.id], }} /> ); - await waitFor(() => - expect(app.getByText(chainNames.avalanche)).toBeInTheDocument() - ); + await waitFor(() => { + expect( + app.queryByText(chainNames.avalanche) || app.queryByText(chainNames.ton) + ).toBeInTheDocument(); + }); + + ( + app.queryByText(chainNames.avalanche) || app.queryByText(chainNames.ton) + )?.click(); - app.getByText(chainNames.avalanche).click(); + await waitFor(() => expect(getChainOptions().length).toBe(2)); - await waitFor(() => expect(getChainOptions().length).toBe(1)); + const tonOption = getChainOptions().find( + (option) => + within(option as HTMLElement).queryByText(chainNames.ton) !== null + ); - const container = getChainOptions()[0]; + if (!tonOption) throw new Error("TON option not found"); - if (!container) throw new Error("Container not found"); + (tonOption as HTMLElement).click(); - within(container as HTMLElement).getByText(chainNames.avalanche); + await waitFor(() => { + expect(switchChainSpy).toHaveBeenCalledWith(ton.id); + }); user.keyboard("[Escape]"); + expect(sendTransactionSpy).not.toHaveBeenCalled(); + skProps.externalProviders.currentAddress = "0xB7c5273e79E2aDD234EBC07d87F3824e0f94B2f7"; @@ -206,7 +329,7 @@ describe("External Provider", () => { {...skProps} externalProviders={{ ...skProps.externalProviders, - supportedChainIds: [43114], + supportedChainIds: [avalanche.id, ton.id], }} /> @@ -227,7 +350,7 @@ describe("External Provider", () => { {...skProps} externalProviders={{ ...skProps.externalProviders, - supportedChainIds: [43114], + supportedChainIds: [avalanche.id, ton.id], }} /> @@ -246,7 +369,7 @@ describe("External Provider", () => { {...skProps} externalProviders={{ ...skProps.externalProviders, - supportedChainIds: [43114], + supportedChainIds: [avalanche.id, ton.id], }} /> diff --git a/packages/widget/tests/use-cases/sk-wallet.test.tsx b/packages/widget/tests/use-cases/sk-wallet.test.tsx new file mode 100644 index 00000000..4c558c82 --- /dev/null +++ b/packages/widget/tests/use-cases/sk-wallet.test.tsx @@ -0,0 +1,138 @@ +import type { SKExternalProviders } from "@sk-widget/domain/types/wallets"; +import { SKApiClientProvider } from "@sk-widget/providers/api/api-client-provider"; +import { ton } from "@sk-widget/providers/misc/chains"; +import { solana } from "@sk-widget/providers/misc/chains"; +import { SKQueryClientProvider } from "@sk-widget/providers/query-client"; +import { SettingsContextProvider } from "@sk-widget/providers/settings"; +import { SKWalletProvider, useSKWallet } from "@sk-widget/providers/sk-wallet"; +import { TrackingContextProviderWithProps } from "@sk-widget/providers/tracking"; +import { WagmiConfigProvider } from "@sk-widget/providers/wagmi/provider"; +import { MiscNetworks } from "@stakekit/common"; +import { http } from "msw"; +import { HttpResponse, delay } from "msw"; +import { describe, expect, it, vi } from "vitest"; +import { server } from "../mocks/server"; +import { renderHook, waitFor } from "../utils/test-utils"; + +const renderHookWithExternalProvider = ( + externalProviders: SKExternalProviders +) => + renderHook(useSKWallet, { + wrapper: ({ children }) => ( + + + + + + {children} + + + + + + ), + }); + +describe("SK Wallet", () => { + it("should work with solana external provider", async () => { + const switchChainSpy = vi.fn(async (_: number) => {}); + const sendTransactionSpy = vi.fn(async () => "hash"); + + server.use( + http.get("*/v1/yields/enabled/networks", async () => { + await delay(); + return HttpResponse.json([MiscNetworks.Solana]); + }) + ); + + const solanaWallet = renderHookWithExternalProvider({ + type: "generic", + currentAddress: "9TCnDo7Txc5bC9SnE9iKsU5CyffLfeK4nrv1BFUmxkiJ", + currentChain: solana.id, + supportedChainIds: [solana.id], + provider: { + signMessage: async () => "hash", + switchChain: switchChainSpy, + sendTransaction: sendTransactionSpy, + }, + }); + await waitFor(() => + expect(solanaWallet.result.current.isConnected).toBe(true) + ); + + const solanaRes = await solanaWallet.result.current.signTransaction({ + network: "solana", + tx: "12345", + txMeta: { txId: "", actionId: "" }, + ledgerHwAppId: null, + }); + + expect(solanaRes.extract()).toEqual({ + signedTx: "hash", + broadcasted: true, + }); + expect(sendTransactionSpy).toHaveBeenCalledWith( + { + type: "solana", + tx: "12345", + }, + { + txId: "", + actionId: "", + } + ); + }); + + it("should work with ton external provider", async () => { + const switchChainSpy = vi.fn(async (_: number) => {}); + const sendTransactionSpy = vi.fn(async (_: unknown) => "hash"); + + server.use( + http.get("*/v1/yields/enabled/networks", async () => { + await delay(); + return HttpResponse.json([MiscNetworks.Ton]); + }) + ); + + const tonWallet = renderHookWithExternalProvider({ + type: "generic", + currentAddress: "UQDyiNAyPy8QRQy45-SjxzrbKVOTOVyXaVGPZSLI9jxHF_Sy", + currentChain: ton.id, + supportedChainIds: [ton.id], + provider: { + signMessage: async () => "hash", + switchChain: switchChainSpy, + sendTransaction: sendTransactionSpy, + }, + }); + await waitFor(() => + expect(tonWallet.result.current.isConnected).toBe(true) + ); + + const tonRes = await tonWallet.result.current.signTransaction({ + network: "ton", + tx: JSON.stringify({ seqno: 0, message: "12345" }), + txMeta: { txId: "", actionId: "" }, + ledgerHwAppId: null, + }); + + expect(tonRes.extract()).toEqual({ + signedTx: "hash", + broadcasted: true, + }); + expect(sendTransactionSpy).toHaveBeenCalledWith( + { + type: "ton", + tx: { seqno: 0, message: "12345" }, + }, + { + txId: "", + actionId: "", + } + ); + }); +}); diff --git a/packages/widget/tests/use-cases/staking-flow/setup.ts b/packages/widget/tests/use-cases/staking-flow/setup.ts index 6f5c4e20..bafad682 100644 --- a/packages/widget/tests/use-cases/staking-flow/setup.ts +++ b/packages/widget/tests/use-cases/staking-flow/setup.ts @@ -7,6 +7,7 @@ import type { YieldDto, } from "@stakekit/api-hooks"; import { http, HttpResponse, delay } from "msw"; +import { avalanche } from "viem/chains"; import { vitest } from "vitest"; import { waitForMs } from "../../../src/utils"; import { server } from "../../mocks/server"; @@ -331,7 +332,7 @@ export const setup = async () => { case "eth_sendTransaction": return "transaction_hash"; case "eth_chainId": - return 43114; + return avalanche.id; case "eth_requestAccounts": return [account]; From c2f24f59dc95e79959d1b3e404fa1dd7e86dca73 Mon Sep 17 00:00:00 2001 From: Petar Todorovic Date: Wed, 23 Apr 2025 13:10:30 +0200 Subject: [PATCH 2/2] feat(externalProvider): expose actionType and txType in sendTransaction txMeta --- .changeset/cyan-queens-create.md | 5 +++++ .../src/domain/types/external-providers.ts | 7 ++++++- packages/widget/src/domain/types/wallet.ts | 7 ++++++- .../src/domain/types/wallets/generic-wallet.ts | 7 ++++++- .../steps/hooks/use-steps-machine.hook.ts | 6 ++++++ .../src/pages/steps/hooks/use-steps.hook.ts | 1 + .../widget/tests/use-cases/sk-wallet.test.tsx | 18 ++++++++++++++++-- 7 files changed, 46 insertions(+), 5 deletions(-) create mode 100644 .changeset/cyan-queens-create.md diff --git a/.changeset/cyan-queens-create.md b/.changeset/cyan-queens-create.md new file mode 100644 index 00000000..a6d6ae0d --- /dev/null +++ b/.changeset/cyan-queens-create.md @@ -0,0 +1,5 @@ +--- +"@stakekit/widget": patch +--- + +feat(externalProvider): expose actionType and txType in sendTransaction txMeta diff --git a/packages/widget/src/domain/types/external-providers.ts b/packages/widget/src/domain/types/external-providers.ts index a60e659d..197fc728 100644 --- a/packages/widget/src/domain/types/external-providers.ts +++ b/packages/widget/src/domain/types/external-providers.ts @@ -13,7 +13,12 @@ export class ExternalProvider { sendTransaction( tx: SKTx, - txMeta: { txId: TransactionDto["id"]; actionId: ActionDto["id"] } + txMeta: { + txId: TransactionDto["id"]; + actionId: ActionDto["id"]; + actionType: ActionDto["type"]; + txType: TransactionDto["type"]; + } ) { const _sendTransaction = this.variantProvider.current.provider.sendTransaction; diff --git a/packages/widget/src/domain/types/wallet.ts b/packages/widget/src/domain/types/wallet.ts index 4b217ab4..0f884fc0 100644 --- a/packages/widget/src/domain/types/wallet.ts +++ b/packages/widget/src/domain/types/wallet.ts @@ -21,7 +21,12 @@ export type SKWallet = { disconnect: () => Promise; signTransaction: (args: { tx: NonNullable; - txMeta: { txId: TransactionDto["id"]; actionId: ActionDto["id"] }; + txMeta: { + txId: TransactionDto["id"]; + actionId: ActionDto["id"]; + actionType: ActionDto["type"]; + txType: TransactionDto["type"]; + }; ledgerHwAppId: Nullable; network: Networks; }) => EitherAsync< diff --git a/packages/widget/src/domain/types/wallets/generic-wallet.ts b/packages/widget/src/domain/types/wallets/generic-wallet.ts index e18fee4d..8855f7cf 100644 --- a/packages/widget/src/domain/types/wallets/generic-wallet.ts +++ b/packages/widget/src/domain/types/wallets/generic-wallet.ts @@ -47,6 +47,11 @@ export type SKWallet = { getTransactionReceipt?(txHash: string): Promise<{ transactionHash?: string }>; sendTransaction( tx: SKTx, - txMeta: { txId: TransactionDto["id"]; actionId: ActionDto["id"] } + txMeta: { + txId: TransactionDto["id"]; + actionId: ActionDto["id"]; + actionType: ActionDto["type"]; + txType: TransactionDto["type"]; + } ): Promise; }; diff --git a/packages/widget/src/pages/steps/hooks/use-steps-machine.hook.ts b/packages/widget/src/pages/steps/hooks/use-steps-machine.hook.ts index 7a31502a..d61ddce7 100644 --- a/packages/widget/src/pages/steps/hooks/use-steps-machine.hook.ts +++ b/packages/widget/src/pages/steps/hooks/use-steps-machine.hook.ts @@ -59,10 +59,12 @@ export const useStepsMachine = ({ transactions, integrationId, actionId, + actionType, }: { transactions: ActionDto["transactions"]; integrationId: ActionDto["integrationId"]; actionId: ActionDto["id"]; + actionType: ActionDto["type"]; }) => { const { signTransaction, signMessage, isLedgerLive } = useSKWallet(); @@ -81,6 +83,7 @@ export const useStepsMachine = ({ signMessage, signTransaction, actionId, + actionType, }); return useMachine(useState(() => getMachine(machineParams))[0]); @@ -96,6 +99,7 @@ const getMachine = ( signMessage: ReturnType["signMessage"]; signTransaction: ReturnType["signTransaction"]; actionId: ActionDto["id"]; + actionType: ActionDto["type"]; }> > ) => { @@ -267,6 +271,8 @@ const getMachine = ( txMeta: { actionId: ref.current.actionId, txId: constructedTx.id, + actionType: ref.current.actionType, + txType: constructedTx.type, }, network: constructedTx.network, }) diff --git a/packages/widget/src/pages/steps/hooks/use-steps.hook.ts b/packages/widget/src/pages/steps/hooks/use-steps.hook.ts index b6ce2e8e..ed8dfec5 100644 --- a/packages/widget/src/pages/steps/hooks/use-steps.hook.ts +++ b/packages/widget/src/pages/steps/hooks/use-steps.hook.ts @@ -25,6 +25,7 @@ export const useSteps = ({ transactions: session.transactions, integrationId: session.integrationId, actionId: session.id, + actionType: session.type, }); /** diff --git a/packages/widget/tests/use-cases/sk-wallet.test.tsx b/packages/widget/tests/use-cases/sk-wallet.test.tsx index 4c558c82..90536eac 100644 --- a/packages/widget/tests/use-cases/sk-wallet.test.tsx +++ b/packages/widget/tests/use-cases/sk-wallet.test.tsx @@ -67,7 +67,12 @@ describe("SK Wallet", () => { const solanaRes = await solanaWallet.result.current.signTransaction({ network: "solana", tx: "12345", - txMeta: { txId: "", actionId: "" }, + txMeta: { + txId: "", + actionId: "", + actionType: "STAKE", + txType: "APPROVAL", + }, ledgerHwAppId: null, }); @@ -83,6 +88,8 @@ describe("SK Wallet", () => { { txId: "", actionId: "", + actionType: "STAKE", + txType: "APPROVAL", } ); }); @@ -116,7 +123,12 @@ describe("SK Wallet", () => { const tonRes = await tonWallet.result.current.signTransaction({ network: "ton", tx: JSON.stringify({ seqno: 0, message: "12345" }), - txMeta: { txId: "", actionId: "" }, + txMeta: { + txId: "", + actionId: "", + actionType: "STAKE", + txType: "APPROVAL", + }, ledgerHwAppId: null, }); @@ -132,6 +144,8 @@ describe("SK Wallet", () => { { txId: "", actionId: "", + actionType: "STAKE", + txType: "APPROVAL", } ); });