From 686ca5c2f62e828d0c8212eb2433556bed6554c7 Mon Sep 17 00:00:00 2001 From: Hussein Adeiza Date: Wed, 30 Sep 2026 00:42:56 +0200 Subject: [PATCH] refactor(client): compose action factories without `as unknown` casts Every action factory was declared against the full `GenLayerClient`, so `createClient` had to launder each intermediate step through `as unknown as GenLayerClient`: seven double-casts that disabled type checking exactly where the composition is most fragile. Each factory only reads a slice of the client, so those slices are now named (`ClientRequester`, `TransactionActionInput`, `TransactionReader`, `ContractWriter`, `ChainSwitcher`) and declared as the factory parameters. `createClient` type-checks hop by hop and needs a single narrowing assertion on the return. Two real mismatches surfaced once the casts stopped hiding them: - `transactionActions` reads viem's `getTransaction` and then installs its own GenLayer-typed override as a returned method, so its input cannot be typed as the override it produces (`TransactionActionInput`). - `ClientConfig.chain` was a loose structural type, so `GenLayerChain` never propagated and every chain consumer needed a cast. No behaviour change. `ClientRequestFn` keeps checking method names and argument shapes; the factories cast their own results either way. Tests: 209 passed, `tsc --noEmit` clean, typecheck clean, build and check:chains clean. `tests/client-action-surfaces.test.ts` fails to compile against the pre-fix tree (5 errors), so it pins the narrowed surfaces. Closes #30 --- src/accounts/actions.ts | 4 +- src/chains/actions.ts | 4 +- src/client/client.ts | 34 ++++---- src/contracts/actions.ts | 30 +++---- src/staking/actions.ts | 4 +- src/transactions/actions.ts | 9 +-- src/transactions/lifecycleFallback.ts | 9 +-- src/types/clients.ts | 69 ++++++++++++++++ src/vesting/actions.ts | 4 +- src/wallet/actions.ts | 4 +- src/wallet/connect.ts | 4 +- tests/client-action-surfaces.test.ts | 109 ++++++++++++++++++++++++++ 12 files changed, 227 insertions(+), 57 deletions(-) create mode 100644 tests/client-action-surfaces.test.ts diff --git a/src/accounts/actions.ts b/src/accounts/actions.ts index f6827e9..743a098 100644 --- a/src/accounts/actions.ts +++ b/src/accounts/actions.ts @@ -1,8 +1,8 @@ import {Address as ViemAddress, PublicClient, TransactionReceipt} from "viem"; -import {GenLayerClient, TransactionHash, GenLayerChain, Address} from "../types"; +import {ClientRequester, TransactionHash, Address} from "../types"; import {localnet} from "../chains"; -export function accountActions(client: GenLayerClient, publicClient: PublicClient) { +export function accountActions(client: ClientRequester, publicClient: PublicClient) { return { fundAccount: async ({address, amount}: {address: Address; amount: number}): Promise => { if (client.chain?.id !== localnet.id) { diff --git a/src/chains/actions.ts b/src/chains/actions.ts index 5fb6ca2..95caf02 100644 --- a/src/chains/actions.ts +++ b/src/chains/actions.ts @@ -1,6 +1,6 @@ -import {GenLayerClient, GenLayerChain} from "@/types"; +import {ChainSwitcher} from "@/types"; -export function chainActions(_client: GenLayerClient) { +export function chainActions(_client: ChainSwitcher) { return { /** * @deprecated This method is deprecated and will be removed in a future release. diff --git a/src/client/client.ts b/src/client/client.ts index 5e46852..394e68d 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -22,13 +22,7 @@ import {localnet} from "@/chains"; // Define the configuration interface for the client interface ClientConfig { - chain?: { - id: number; - name: string; - rpcUrls: {default: {http: readonly string[]}}; - nativeCurrency: {name: string; symbol: string; decimals: number}; - blockExplorers?: {default: {name: string; url: string}}; - }; + chain?: GenLayerChain; endpoint?: string; // Custom RPC endpoint account?: Account | Address; provider?: EthereumProvider; // Custom provider for wallet framework integration @@ -148,32 +142,32 @@ export const createClient = (config: ClientConfig = {chain: localnet}): GenLayer const clientWithBasicActions = baseClient .extend(publicActions) .extend(walletActions) - .extend(client => accountActions(client as unknown as GenLayerClient, publicClient)); + .extend(client => accountActions(client, publicClient)); const clientWithTransactionActions = { ...clientWithBasicActions, - ...transactionActions(clientWithBasicActions as unknown as GenLayerClient, publicClient), - ...chainActions(clientWithBasicActions as unknown as GenLayerClient), - ...genlayerWalletActions(clientWithBasicActions as unknown as GenLayerClient), - } as unknown as GenLayerClient; + ...transactionActions(clientWithBasicActions, publicClient), + ...chainActions(clientWithBasicActions), + ...genlayerWalletActions(clientWithBasicActions), + }; const clientWithAllActions = { ...clientWithTransactionActions, - ...contractActions(clientWithTransactionActions as unknown as GenLayerClient, publicClient), - } as unknown as GenLayerClient; + ...contractActions(clientWithTransactionActions, publicClient), + }; const clientWithReceiptActions = { ...clientWithAllActions, - ...receiptActions(clientWithAllActions as unknown as GenLayerClient, publicClient), - } as unknown as GenLayerClient; + ...receiptActions(clientWithAllActions, publicClient), + }; const finalClient = { ...clientWithReceiptActions, - ...stakingActions(clientWithReceiptActions as unknown as GenLayerClient, publicClient), - ...vestingActions(clientWithReceiptActions as unknown as GenLayerClient, publicClient), - } as unknown as GenLayerClient; + ...stakingActions(clientWithReceiptActions, publicClient), + ...vestingActions(clientWithReceiptActions, publicClient), + }; - return finalClient; + return finalClient as GenLayerClient; }; export const createPublicClient = ( diff --git a/src/contracts/actions.ts b/src/contracts/actions.ts index 624e6ae..7ba6378 100644 --- a/src/contracts/actions.ts +++ b/src/contracts/actions.ts @@ -6,8 +6,8 @@ import { Account, ContractSchema, DeveloperNft, - GenLayerChain, - GenLayerClient, + ClientRequester, + ContractWriter, CalldataEncodable, Address, TransactionHash, @@ -133,7 +133,7 @@ function transactionFeesToRpc(fees?: TransactionFeeOptions) { }; } -export const contractActions = (client: GenLayerClient, publicClient: PublicClient) => { +export const contractActions = (client: ContractWriter, publicClient: PublicClient) => { const estimateFeeValue = async ( distribution: FeesDistribution, policy?: FeePolicyQuote, @@ -1310,7 +1310,7 @@ const _resolveAddressManagerAddress = async ({ client, publicClient, }: { - client: GenLayerClient; + client: ContractWriter; publicClient: PublicClient; }): Promise<`0x${string}`> => { const consensusMainContract = client.chain.consensusMainContract; @@ -1346,7 +1346,7 @@ const _resolveNftMinterAddress = async ({ client, publicClient, }: { - client: GenLayerClient; + client: ContractWriter; publicClient: PublicClient; }): Promise<`0x${string}`> => { const addressManagerAddress = await _resolveAddressManagerAddress({client, publicClient}); @@ -1533,7 +1533,7 @@ const extractStudioFeePolicy = (config: unknown): FeePolicyQuote => { }; const readCurrentFeePolicy = async ( - client: GenLayerClient, + client: ClientRequester, publicClient: PublicClient, ): Promise => { if (client.chain.isStudio) { @@ -2037,7 +2037,7 @@ const _resolveTransactionFees = async ({ fees, numOfInitialValidators, }: { - client: GenLayerClient; + client: ClientRequester; publicClient: PublicClient; fees?: TransactionFeeOptions; numOfInitialValidators: number; @@ -2095,7 +2095,7 @@ const _encodeAddTransactionData = ({ userValue = 0n, transactionFees, }: { - client: GenLayerClient; + client: ClientRequester; senderAccount?: Account; recipient?: `0x${string}`; data?: `0x${string}`; @@ -2252,7 +2252,7 @@ const _readLifecycleIdentity = async ({ blockNumber, blockTimestamp, }: { - client: GenLayerClient; + client: ContractWriter; publicClient: PublicClient; txId: `0x${string}`; blockNumber?: bigint; @@ -2327,7 +2327,7 @@ const _readAppealContext = async ({ txId, includeQuote = true, }: { - client: GenLayerClient; + client: ContractWriter; publicClient: PublicClient; txId: `0x${string}`; includeQuote?: boolean; @@ -2418,7 +2418,7 @@ const _waitForSentEnvelope = async ({ operationName, revertDetails, }: { - client: GenLayerClient; + client: ContractWriter; publicClient: PublicClient; evmHash: `0x${string}`; operationName: string; @@ -2468,7 +2468,7 @@ const _sendEvmContractCall = async ({ value = 0n, operationName = "Contract call", }: { - client: GenLayerClient; + client: ContractWriter; publicClient: PublicClient; to: Address; encodedData: `0x${string}`; @@ -2546,7 +2546,7 @@ const _sendConsensusCall = async ({ value = 0n, operationName = "Consensus call", }: { - client: GenLayerClient; + client: ContractWriter; publicClient: PublicClient; encodedData: `0x${string}`; senderAccount?: Account; @@ -2613,7 +2613,7 @@ const _sendConsensusCall = async ({ * NewTransaction (immediately activated) or CreatedTransaction (queued) events. */ const extractTxIdFromLogs = ( - client: GenLayerClient, + client: ClientRequester, logs: any[], ): `0x${string}` | null => { const newTxEvents = parseEventLogs({ @@ -2645,7 +2645,7 @@ const _sendTransaction = async ({ transactionVariants, senderAccount, }: { - client: GenLayerClient; + client: ContractWriter; publicClient: PublicClient; transactionVariants: EncodedTransactionVariant[]; senderAccount?: Account; diff --git a/src/staking/actions.ts b/src/staking/actions.ts index f525e17..3c7bbf7 100644 --- a/src/staking/actions.ts +++ b/src/staking/actions.ts @@ -1,5 +1,5 @@ import {getContract, decodeEventLog, PublicClient, Client, Transport, Chain, Account, Address as ViemAddress, GetContractReturnType, toHex, encodeFunctionData, BaseError, ContractFunctionRevertedError, decodeErrorResult, RawContractError, zeroAddress} from "viem"; -import {GenLayerClient, GenLayerChain, Address} from "@/types"; +import {ClientRequester, Address} from "@/types"; import {STAKING_ABI, VALIDATOR_WALLET_ABI} from "@/abi/staking"; import {ADDRESS_MANAGER_ABI, CONSENSUS_ADDRESS_MANAGER_ABI} from "@/abi/vesting"; import {parseStakingAmount, formatStakingAmount} from "./utils"; @@ -104,7 +104,7 @@ function extractRevertReason(err: unknown): string { } export const stakingActions = ( - client: GenLayerClient, + client: ClientRequester, publicClient: PublicClient, ) => { const executeWrite = async (options: { diff --git a/src/transactions/actions.ts b/src/transactions/actions.ts index 53291f5..f32f78c 100644 --- a/src/transactions/actions.ts +++ b/src/transactions/actions.ts @@ -1,4 +1,4 @@ -import {GenLayerClient} from "../types/clients"; +import {TransactionActionInput, TransactionReader} from "../types/clients"; import { TransactionHash, TransactionStatus, @@ -19,7 +19,6 @@ import { } from "../types/transactions"; import {transactionsConfig} from "../config/transactions"; import {sleep} from "../utils/async"; -import {GenLayerChain} from "@/types"; import {Abi, PublicClient, Address, keccak256, concat, stringToBytes, toBytes, zeroAddress, parseAbiItem} from "viem"; import {decodeLocalnetTransaction, decodeTransaction, simplifyTransactionReceipt} from "./decoders"; import {isMethodNotFoundError, readStudioLifecycleFallback} from "./lifecycleFallback"; @@ -231,7 +230,7 @@ export const isSuccessful = (transaction: GenLayerTransaction): boolean => { ); }; -export const receiptActions = (client: GenLayerClient, publicClient: PublicClient) => ({ +export const receiptActions = (client: TransactionReader, publicClient: PublicClient) => ({ /** Polls until a transaction reaches the specified status. Returns the transaction receipt. */ waitForTransactionReceipt: async ({ hash, @@ -321,7 +320,7 @@ export const receiptActions = (client: GenLayerClient, publicClie }), }); -export const transactionActions = (client: GenLayerClient, publicClient: PublicClient) => ({ +export const transactionActions = (client: TransactionActionInput, publicClient: PublicClient) => ({ advanced: { /** * `advanced.getTransactionLifecycle` exposes stored/projected status, @@ -399,7 +398,7 @@ export const transactionActions = (client: GenLayerClient, public transaction.statusName = localnetStatus as TransactionStatus; transaction.lifecycle = transactionLifecycleFromStoredStatus( localnetStatus as TransactionStatus, - transaction.result, + transaction.result as number, ); return decodeLocalnetTransaction(transaction as unknown as GenLayerTransaction); } diff --git a/src/transactions/lifecycleFallback.ts b/src/transactions/lifecycleFallback.ts index 0f49183..c31ee7d 100644 --- a/src/transactions/lifecycleFallback.ts +++ b/src/transactions/lifecycleFallback.ts @@ -1,7 +1,6 @@ import {MethodNotFoundRpcError} from "viem"; -import {GenLayerClient} from "../types/clients"; -import {GenLayerChain} from "../types/chains"; +import {RawTransactionRead, TransactionActionInput} from "../types/clients"; import { GenLayerTransaction, TransactionHash, @@ -68,7 +67,7 @@ const UNSPECIFIED_RESOLUTION_SOURCE_CODE = 0; * while `contractActions` hands over the SDK transaction, whose `status` is * already the numeric code and whose `statusName` is already normalized. */ -const storedStatusName = (transaction: GenLayerTransaction): TransactionStatus | undefined => { +const storedStatusName = (transaction: GenLayerTransaction | RawTransactionRead): TransactionStatus | undefined => { const raw: unknown = transaction.statusName ?? transaction.status; const byCode = (code: string): TransactionStatus | undefined => Object.prototype.hasOwnProperty.call(transactionsStatusNumberToName, code) @@ -102,12 +101,12 @@ export const readStudioLifecycleFallback = async ({ timestamp, cause, }: { - client: GenLayerClient; + client: TransactionActionInput; hash: TransactionHash; timestamp?: number; cause: unknown; }): Promise => { - let transaction: GenLayerTransaction; + let transaction: RawTransactionRead; try { transaction = await client.getTransaction({hash}); } catch { diff --git a/src/types/clients.ts b/src/types/clients.ts index 8347d25..97b2e7b 100644 --- a/src/types/clients.ts +++ b/src/types/clients.ts @@ -49,6 +49,75 @@ export type GenLayerMethod = | {method: "sim_cancelTransaction"; params: [hash: TransactionHash, signature?: string, adminKey?: string]} | {method: "sim_getFeeConfig"; params: []}; +/* + The action factories below describe the slice of the client each one actually + touches, so the composition in `createClient` type-checks step by step instead + of being laundered through `as unknown as GenLayerClient` at + every hop. +*/ + +/** + * The RPC surface the action factories call. + * + * This is deliberately *not* `GenLayerClient["request"]`: that type is an + * intersection with a GenLayer-specific overload, and no viem client satisfies + * it structurally, which is why every call site previously needed a cast. The + * factories cast their own results anyway, so accepting both call shapes here + * keeps the checks that do matter (method names, argument shapes) while letting + * the chain compose. + */ +export type ClientRequestFn = { + ( + args: Extract, + ): Promise; + (args: {method: string; params?: unknown}): Promise; +}; + +/** `request`, `account` and `chain`: enough for read-only RPC-backed actions. */ +export type ClientRequester = { + request: ClientRequestFn; + account?: GenLayerClient["account"]; + chain: GenLayerChain; +}; + +/** Adds the GenLayer-typed `getTransaction` the receipt actions poll with. */ +export type TransactionReader = ClientRequester & { + getTransaction: GenLayerClient["getTransaction"]; +}; + +/** + * What `transactionActions` reads from the client it is handed. + * + * It calls the underlying `getTransaction` and then installs its own + * GenLayer-typed override as a returned method, so the input cannot be typed as + * the override it produces. Studio answers with a mutable record carrying a + * string `status` and a `result`, not viem's decoded transaction, and the + * action rewrites those fields in place before decoding. + */ +export type RawTransactionRead = { + status?: unknown; + statusName?: unknown; + result?: unknown; + triggered_transactions?: unknown; + [key: string]: unknown; +}; + +export type TransactionActionInput = ClientRequester & { + getTransaction: (args: {hash: TransactionHash}) => Promise; +}; + +/** Adds the custom write helpers used by the contract actions. */ +export type ContractWriter = ClientRequester & { + estimateTransactionGas: GenLayerClient["estimateTransactionGas"]; + getCurrentNonce: GenLayerClient["getCurrentNonce"]; + sendRawTransaction: WalletActions["sendRawTransaction"]; +}; + +/** `connect` reassigns `chain`, so the wallet actions need it writable. */ +export type ChainSwitcher = { + chain: GenLayerChain; +}; + /* Take all the properties from Client Remove getTransaction and readContract because they are redefined with custom implementations. diff --git a/src/vesting/actions.ts b/src/vesting/actions.ts index 86020f6..afdec21 100644 --- a/src/vesting/actions.ts +++ b/src/vesting/actions.ts @@ -16,7 +16,7 @@ import { } from "viem"; import {ADDRESS_MANAGER_ABI, CONSENSUS_ADDRESS_MANAGER_ABI, VESTING_ABI, VESTING_FACTORY_ABI} from "@/abi/vesting"; import {STAKING_ABI} from "@/abi/staking"; -import {Address, GenLayerChain, GenLayerClient} from "@/types"; +import {Address, ClientRequester} from "@/types"; import { VestingCategory, VestingContract, @@ -104,7 +104,7 @@ function encodeExtraCid(extraCid?: string): `0x${string}` { } export const vestingActions = ( - client: GenLayerClient, + client: ClientRequester, publicClient: PublicClient, ) => { const executeWrite = async (options: { diff --git a/src/wallet/actions.ts b/src/wallet/actions.ts index 8659eef..cc724a4 100644 --- a/src/wallet/actions.ts +++ b/src/wallet/actions.ts @@ -1,8 +1,8 @@ import {connect} from "./connect"; -import {GenLayerClient, GenLayerChain, Network, SnapSource} from "@/types"; +import {ChainSwitcher, Network, SnapSource} from "@/types"; import {metamaskClient} from "@/wallet/metamaskClient"; -export function walletActions(client: GenLayerClient) { +export function walletActions(client: ChainSwitcher) { return { connect: (network: Network, snapSource: SnapSource) => connect(client, network, snapSource), metamaskClient: (snapSource: SnapSource = "npm") => metamaskClient(snapSource), diff --git a/src/wallet/connect.ts b/src/wallet/connect.ts index 574153b..80503b9 100644 --- a/src/wallet/connect.ts +++ b/src/wallet/connect.ts @@ -3,7 +3,7 @@ import {studionet} from "@/chains/studionet"; import {studioDevnet} from "@/chains/studioDevnet"; import {testnetAsimov} from "@/chains/testnetAsimov"; import {testnetBradbury} from "@/chains/testnetBradbury"; -import {GenLayerClient, GenLayerChain} from "@/types"; +import {ChainSwitcher} from "@/types"; import {Network} from "@/types/network"; import {SnapSource} from "@/types/snapSource"; import {snapID} from "@/config/snapID"; @@ -17,7 +17,7 @@ const networks = { }; export const connect = async ( - client: GenLayerClient, + client: ChainSwitcher, network: Network = "studionet", snapSource: SnapSource = "npm", ): Promise => { diff --git a/tests/client-action-surfaces.test.ts b/tests/client-action-surfaces.test.ts new file mode 100644 index 0000000..410322b --- /dev/null +++ b/tests/client-action-surfaces.test.ts @@ -0,0 +1,109 @@ +import {describe, expect, it} from "vitest"; +import {createClient} from "@/client/client"; +import {localnet} from "@/chains/localnet"; +import { + ClientRequester, + ContractWriter, + TransactionActionInput, + TransactionReader, + ChainSwitcher, +} from "@/types/clients"; + +/** + * The action factories used to be declared against the full `GenLayerClient`, + * which forced `createClient` to cast every intermediate step through + * `as unknown as GenLayerClient`. These tests pin the narrowed + * surfaces so the cast chain cannot silently come back, and so a factory that + * starts reading a member it never declared is caught here rather than by a + * cast that swallows the error. + */ + +const request = (async () => "0x") as unknown as ClientRequester["request"]; +const account = undefined; + +const requester: ClientRequester = {request, account, chain: localnet}; + +const transactionReader: TransactionReader = { + ...requester, + getTransaction: async () => ({}) as never, +}; + +const transactionActionInput: TransactionActionInput = { + ...requester, + getTransaction: async () => ({}), +}; + +const contractWriter: ContractWriter = { + ...requester, + estimateTransactionGas: async () => 0n, + getCurrentNonce: async () => 0, + sendRawTransaction: async () => "0x" as never, +}; + +const chainSwitcher: ChainSwitcher = {chain: localnet}; + +describe("narrowed action-factory client surfaces", () => { + it("accepts a requester with no custom methods", () => { + expect(requester.chain.id).toBe(localnet.id); + }); + + it("keeps the transaction surfaces structurally distinct", () => { + // `transactionActions` must NOT require the GenLayer-typed `getTransaction` + // it installs as a returned method, or the composition cannot be ordered. + const onlyRaw: TransactionActionInput = {request, chain: localnet, getTransaction: async () => ({})}; + expect(onlyRaw.getTransaction).toBeTypeOf("function"); + expect(transactionActionInput.getTransaction).toBeTypeOf("function"); + }); + + it("requires the write helpers the contract actions actually call", () => { + expect(contractWriter.getCurrentNonce).toBeTypeOf("function"); + expect(contractWriter.estimateTransactionGas).toBeTypeOf("function"); + expect(contractWriter.sendRawTransaction).toBeTypeOf("function"); + expect(transactionReader.getTransaction).toBeTypeOf("function"); + }); + + it("allows the chain to be reassigned for connect()", () => { + const switcher: ChainSwitcher = {chain: localnet}; + switcher.chain = localnet; + expect(chainSwitcher.chain).toBe(localnet); + }); +}); + +describe("createClient composition", () => { + it("builds a client exposing every action surface without a cast", () => { + const client = createClient({chain: localnet}); + + // One representative method per factory in the composition chain. + for (const method of [ + "fundAccount", // accountActions + "getCurrentNonce", // accountActions + "transfer", // accountActions + "getTransaction", // transactionActions + "waitForTransactionReceipt", // receiptActions + "readContract", // contractActions + "writeContract", // contractActions + "deployContract", // walletActions / viem + "connect", // genlayerWalletActions + "initializeConsensusSmartContract", // chainActions + "validatorDeposit", // stakingActions + "vestingValidatorDeposit", // vestingActions + ]) { + expect(client, `createClient() is missing ${method}`).toHaveProperty(method); + expect(typeof (client as unknown as Record)[method], method).toBe("function"); + } + }); + + it("keeps the chain and account readable off the built client", () => { + const client = createClient({chain: localnet}); + expect(client.chain.id).toBe(localnet.id); + }); + + it("lets connect() swap the chain on the live client", () => { + const client = createClient({chain: localnet}); + // `connect` assigns `client.chain = selectedNetwork`, so `chain` must be a + // writable property rather than a readonly viem client field. + expect(() => { + client.chain = localnet; + }).not.toThrow(); + }); +});