From ae164d7b341dbbec4dd6b4bcd1c81cdf6ec2f647 Mon Sep 17 00:00:00 2001 From: Marco Date: Fri, 28 Aug 2026 16:08:52 +0200 Subject: [PATCH] perf(transactions): load the signing backends on demand Every consumer downloaded the whole signing stack, including the ones that only read contract state. `partisia-blockchain-applications-crypto` pulls in bip39, elliptic and tiny-secp256k1; the Ledger client pulls in `bip32-path` and the `@ledgerhq` transport. None of it is reachable until a transaction is actually signed. The four `createTransactionFrom*` entry points are now imported dynamically from `ContractRepository.createTransaction`, and each one loads only what its own strategy needs: the crypto module, the wallet module, the Ledger client. `serializeTransaction` does the same, so `builderToBytesBe` -- imported from that module by the record and domain actions, which are on the read path -- no longer drags the crypto module in with it. All three packages stay regular dependencies: nothing changes about what a consumer installs, and there is no new failure mode for a missing package. The imports exist so a bundler can split them out of the entry chunk. Bundling `dist/esm/index.js` with esbuild (minified, esm, code splitting): before 1,192,931 bytes in a single chunk after 342,939 bytes in the entry chunk (-71.2%) The total across all chunks is unchanged; what changes is how much of it a read-only consumer has to download. Full suite is 269/269 passing. Claude-Session: https://claude.ai/code/session_01GceWCwGXu66D1xEBDxZWBb --- src/repositories/contract-repository.ts | 20 ++++++++++---- src/transactions/helper.ts | 12 ++++++--- src/transactions/index.ts | 36 ++++++++++++++++++++----- 3 files changed, 53 insertions(+), 15 deletions(-) diff --git a/src/repositories/contract-repository.ts b/src/repositories/contract-repository.ts index e2b75155..b9128fc5 100644 --- a/src/repositories/contract-repository.ts +++ b/src/repositories/contract-repository.ts @@ -7,7 +7,6 @@ import { SecretsProvider } from '../providers/secrets' import { convertAvlTree as convertAvlTrees } from './helpers/contract' import { AvlClient } from './helpers/avl-client' import { getRequest, promiseRetry } from './helpers/client' -import { createTransactionFromLedgerClient, createTransactionFromMetaMaskClient, createTransactionFromPartisiaClient, createTransactionFromPrivateKey } from '../transactions' /** @@ -116,18 +115,29 @@ export class ContractRepository implements IContractRepository { // Remove contract cache as the state will change this.cleanCache(contractAddress) + // Loaded on demand. Signing pulls in the crypto stack -- several hundred + // kilobytes -- and reading contract state, which is what most consumers do, + // never reaches this method. switch (this.secrets.strategy) { - case 'privateKey': + case 'privateKey': { + const { createTransactionFromPrivateKey } = await import('../transactions') return createTransactionFromPrivateKey(this.rpc, contractAddress, this.secrets.privateKey, payload, isMainnet, gas) + } - case 'partisiaSdk': + case 'partisiaSdk': { + const { createTransactionFromPartisiaClient } = await import('../transactions') return createTransactionFromPartisiaClient(this.rpc, this.secrets.partisiaSdk, contractAddress, payload, gas) + } - case 'MetaMask': + case 'MetaMask': { + const { createTransactionFromMetaMaskClient } = await import('../transactions') return createTransactionFromMetaMaskClient(this.rpc, this.secrets.metaMask, contractAddress, payload, isMainnet, gas) + } - case 'Ledger': + case 'Ledger': { + const { createTransactionFromLedgerClient } = await import('../transactions') return createTransactionFromLedgerClient(this.rpc, this.secrets.ledger, contractAddress, payload, isMainnet, gas) + } default: throw new Error('Signing strategy not found') diff --git a/src/transactions/helper.ts b/src/transactions/helper.ts index 95eca149..4d31b4b5 100644 --- a/src/transactions/helper.ts +++ b/src/transactions/helper.ts @@ -1,7 +1,6 @@ -import { RpcContractBuilder } from "@partisiablockchain/abi-client" -import { serializedTransaction } from "partisia-blockchain-applications-crypto/lib/main/transaction" -import { PartisiaAccountClass } from "partisia-blockchain-applications-rpc/lib/main/accountInfo" -import { PartisiaRpcClass } from "partisia-blockchain-applications-rpc/lib/main/rpc" +import type { RpcContractBuilder } from "@partisiablockchain/abi-client" +import type { PartisiaAccountClass } from "partisia-blockchain-applications-rpc/lib/main/accountInfo" +import type { PartisiaRpcClass } from "partisia-blockchain-applications-rpc/lib/main/rpc" export const builderToBytesBe = (rpc: RpcContractBuilder) => { return rpc.getBytes() @@ -17,6 +16,11 @@ export const serializeTransaction = async ( cost: number | string, validityInMillis: number = 120_000 ) => { + // `builderToBytesBe` is imported from this module by the record and domain + // actions, which are on the read path. Keeping the crypto import dynamic means + // reading state never loads it. + const { serializedTransaction } = await import("partisia-blockchain-applications-crypto/lib/main/transaction") + const shardId = rpc.deriveShardId(walletAddress) const nonce = await rpc.getNonce(walletAddress, shardId) // Need to pass a number otherwise the internal library will throw an error diff --git a/src/transactions/index.ts b/src/transactions/index.ts index b3a50208..865530e7 100644 --- a/src/transactions/index.ts +++ b/src/transactions/index.ts @@ -1,13 +1,25 @@ -import { PartisiaAccountClass } from "partisia-blockchain-applications-rpc/lib/main/accountInfo" +import type { PartisiaAccountClass } from "partisia-blockchain-applications-rpc/lib/main/accountInfo" import type LedgerTransport from "@ledgerhq/hw-transport" -import { ITransactionIntent, MetaMaskSdk } from "../interface" -import { PartisiaLedgerClient, signatureToBuffer } from "./ledger" +import type { ITransactionIntent, MetaMaskSdk } from "../interface" import { buildTransactionResult, getChainId, serializeTransaction } from "./helper" -import { deriveDigest, getTransactionPayloadData, getTrxHash } from "partisia-blockchain-applications-crypto/lib/main/transaction" import { PartisiaRpc } from "partisia-blockchain-applications-rpc" import assert from "assert" -import PartisiaSdk from "partisia-blockchain-applications-sdk" -import { privateKeyToAccountAddress, signTransaction } from "partisia-blockchain-applications-crypto/lib/main/wallet" +import type PartisiaSdk from "partisia-blockchain-applications-sdk" + +/** + * The signing backends are loaded on demand. + * + * `partisia-blockchain-applications-crypto` pulls in bip39, elliptic and + * tiny-secp256k1 (~500 KB); the Ledger client pulls in `bip32-path` and the + * `@ledgerhq` transport. All three stay regular dependencies, so nothing extra + * has to be installed, but a bundler splits them out of the entry chunk: a + * consumer that only reads contract state never downloads them, one that signs + * with MetaMask does not pay for the Ledger transport, and one that signs with + * a Ledger does not pay for the BIP-39 wordlists. + */ +const loadTransactionCrypto = () => import("partisia-blockchain-applications-crypto/lib/main/transaction") +const loadWalletCrypto = () => import("partisia-blockchain-applications-crypto/lib/main/wallet") +const loadLedgerClient = () => import("./ledger") export const createTransactionFromLedgerClient = async ( rpc: PartisiaAccountClass, @@ -17,6 +29,11 @@ export const createTransactionFromLedgerClient = async ( isMainnet = false, cost: number | string = 10490 ): Promise => { + const [{ PartisiaLedgerClient, signatureToBuffer }, { deriveDigest, getTrxHash }] = await Promise.all([ + loadLedgerClient(), + loadTransactionCrypto() + ]) + const client = new PartisiaLedgerClient(transport) const walletAddress: string = await client.getAddress() const shardId = rpc.deriveShardId(walletAddress) @@ -48,6 +65,8 @@ export const createTransactionFromMetaMaskClient = async ( isMainnet = false, cost: number | string = 10490 ): Promise => { + const { deriveDigest, getTrxHash } = await loadTransactionCrypto() + const snapId = "npm:@partisiablockchain/snap" const walletAddress: string = await client.request({ method: "wallet_invokeSnap", @@ -132,6 +151,11 @@ export const createTransactionFromPrivateKey = async ( isMainnet = false, cost: number | string = 8490 ): Promise => { + const [{ deriveDigest, getTransactionPayloadData, getTrxHash }, { privateKeyToAccountAddress, signTransaction }] = await Promise.all([ + loadTransactionCrypto(), + loadWalletCrypto() + ]) + const walletAddress = privateKeyToAccountAddress(privateKey) const shardId = rpc.deriveShardId(walletAddress) const url = rpc.getShardUrl(shardId)