From be45ab80c66703fd2cb51fd1d2a9cd856ad01ca1 Mon Sep 17 00:00:00 2001 From: Marco Date: Sat, 29 Aug 2026 11:49:22 +0200 Subject: [PATCH] refactor: sign with the official transaction client `partisia-blockchain-applications-crypto` was last published 2024-10-04 and is no longer maintained. It carried 11 advisories, 5 of them critical, pinned `bip32` and `bip39` to exact versions that cannot be patched, and pulled in zxcvbn -- a 3.4 MB password-strength dictionary -- for a blockchain SDK. The official replacement, `@partisiablockchain/blockchain-api-transaction-client`, was already installed as a dependency of `@partisiablockchain/abi-client`. It is now a direct dependency and does the signing. `SignedTransaction.create` serializes, hashes and signs in one call, so the four `createTransactionFrom*` functions -- each repeating serialize, digest, sign, concatenate, base64, broadcast -- collapse into a single `createTransaction` plus four small `SenderAuthentication` implementations in `transactions/authentication.ts`: privateKey SenderAuthenticationKeyPair.fromString (provided) Ledger wraps the existing PartisiaLedgerClient MetaMask wraps the existing snap wallet_invokeSnap call partisiaSdk wraps PartisiaSdk.signMessage `serializeTransaction` in `transactions/helper.ts` is gone; the module keeps `builderToBytesBe`, `getChainId` and the result poller. The bytes are identical. Verified against the old package for a fixed key, nonce, gas and payload: same account address, same serialized transaction (nonce, valid-to and gas as big-endian i64s, then the contract address and the length-prefixed payload) and same signing digest (SHA-256 over those bytes plus the length-prefixed chain id). One deliberate difference: the old package signed with elliptic's `canonical: true`, the official client uses the default, so the low-s and high-s forms of the same signature are produced. Both are valid ECDSA over the same digest and recover the same public key -- the 65-byte encoding carries the recovery parameter -- and the full testnet suite confirms the chain accepts them. Two behavioural notes: - The Partisia wallet now signs with `dontBroadcast: true` and the SDK broadcasts, so every strategy goes to the chain the same way and its result is polled the same way. - Broadcast retries on a spent nonce are limited to non-interactive signers. Retrying a Ledger or wallet signature would silently prompt the user again. Bundled with esbuild (--bundle --minify --format=esm --splitting), total bytes across all chunks 915,298 -> 543,045 (-372,253, -40.7%); the entry chunk 345,347 -> 247,853 (-28.2%). The 343 KB wallet-crypto chunk is gone. `partisia-blockchain-applications-sdk` stays: it is the browser wallet connector and has no official replacement. It is used as a type only -- the connected client is passed in by the consumer -- so the SDK never imports it or its crypto subtree at runtime. 21 suites / 269 tests pass against live testnet. --- package.json | 2 +- src/repositories/contract-repository.ts | 41 ++--- src/transactions/authentication.ts | 115 ++++++++++++++ src/transactions/helper.ts | 25 --- src/transactions/index.ts | 198 ++++++------------------ yarn.lock | 4 +- 6 files changed, 188 insertions(+), 197 deletions(-) create mode 100644 src/transactions/authentication.ts diff --git a/package.json b/package.json index 2b23632..05f6aa5 100644 --- a/package.json +++ b/package.json @@ -73,9 +73,9 @@ "dependencies": { "@ledgerhq/hw-transport": "^6.34.0", "@partisiablockchain/abi-client": "^6.0.0", + "@partisiablockchain/blockchain-api-transaction-client": "^6.142.0", "@secata-public/bitmanipulation-ts": "^3.4.0", "bip32-path": "^0.4.2", - "partisia-blockchain-applications-crypto": "^1.0.34", "partisia-blockchain-applications-sdk": "^0.1.4" }, "devDependencies": { diff --git a/src/repositories/contract-repository.ts b/src/repositories/contract-repository.ts index 8c723ab..b54a4e3 100644 --- a/src/repositories/contract-repository.ts +++ b/src/repositories/contract-repository.ts @@ -114,29 +114,32 @@ 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. + const { createTransaction } = await import('../transactions') + const backend = await this.signingBackend() + + return createTransaction(this.rpc, backend, { contractAddress, payload, cost: gas, isMainnet }) + } + + /** + * 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. + */ + private async signingBackend() { + const backends = await import('../transactions/authentication') + switch (this.secrets.strategy) { - case 'privateKey': { - const { createTransactionFromPrivateKey } = await import('../transactions') - return createTransactionFromPrivateKey(this.rpc, contractAddress, this.secrets.privateKey, payload, isMainnet, gas) - } + case 'privateKey': + return backends.privateKeyBackend(this.secrets.privateKey) - case 'partisiaSdk': { - const { createTransactionFromPartisiaClient } = await import('../transactions') - return createTransactionFromPartisiaClient(this.rpc, this.secrets.partisiaSdk, contractAddress, payload, gas) - } + case 'partisiaSdk': + return backends.partisiaSdkBackend(this.secrets.partisiaSdk) - case 'MetaMask': { - const { createTransactionFromMetaMaskClient } = await import('../transactions') - return createTransactionFromMetaMaskClient(this.rpc, this.secrets.metaMask, contractAddress, payload, isMainnet, gas) - } + case 'MetaMask': + return backends.metaMaskBackend(this.secrets.metaMask) - case 'Ledger': { - const { createTransactionFromLedgerClient } = await import('../transactions') - return createTransactionFromLedgerClient(this.rpc, this.secrets.ledger, contractAddress, payload, isMainnet, gas) - } + case 'Ledger': + return backends.ledgerBackend(this.secrets.ledger) default: throw new Error('Signing strategy not found') diff --git a/src/transactions/authentication.ts b/src/transactions/authentication.ts new file mode 100644 index 0000000..0785a5b --- /dev/null +++ b/src/transactions/authentication.ts @@ -0,0 +1,115 @@ +import type LedgerTransport from "@ledgerhq/hw-transport" +import type PartisiaSdk from "partisia-blockchain-applications-sdk" +import type { SenderAuthentication } from "@partisiablockchain/blockchain-api-transaction-client" +import type { MetaMaskSdk } from "../interface" +import assert from "assert" + +/** + * A signing backend, expressed as the official client's `SenderAuthentication`. + * + * `SignedTransaction.create` serializes the transaction and hands the bytes to + * `sign`, so each strategy only has to answer two questions: which address is + * signing, and what is the signature over these bytes. Everything the four + * strategies used to repeat -- serializing, digesting, concatenating, encoding + * -- happens once, inside the official client. + */ +export interface SigningBackend { + authentication: SenderAuthentication + /** + * Interactive signers prompt the user on every signature. A rejected + * broadcast must not silently ask them to confirm again, so these are not + * retried. + */ + interactive: boolean +} + +const SNAP_ID = "npm:@partisiablockchain/snap" + +/** + * The backends are loaded on demand. `@partisiablockchain/blockchain-api-transaction-client` + * carries elliptic and hash.js; the Ledger backend adds the `@ledgerhq` + * transport. Reading contract state reaches none of this. + */ +export const privateKeyBackend = async (privateKey: string): Promise => { + const { SenderAuthenticationKeyPair } = await import("@partisiablockchain/blockchain-api-transaction-client") + + return { + authentication: SenderAuthenticationKeyPair.fromString(privateKey), + interactive: false, + } +} + +export const ledgerBackend = async (transport: LedgerTransport): Promise => { + const { PartisiaLedgerClient, signatureToBuffer } = await import("./ledger") + + const client = new PartisiaLedgerClient(transport) + // `getAddress` is synchronous on `SenderAuthentication`, and asking the + // device costs a round trip, so it is resolved once here. + const address = await client.getAddress() + + return { + authentication: { + getAddress: () => address, + sign: async (transactionPayload, chainId) => { + const signature = await client.signTransaction(transactionPayload, chainId) + + return signatureToBuffer(signature).toString("hex") + }, + }, + interactive: true, + } +} + +export const metaMaskBackend = async (client: MetaMaskSdk): Promise => { + const address: string = await client.request({ + method: "wallet_invokeSnap", + params: { snapId: SNAP_ID, request: { method: "get_address" } }, + }) + + return { + authentication: { + getAddress: () => address, + sign: async (transactionPayload, chainId) => { + const signature: string = await client.request({ + method: "wallet_invokeSnap", + params: { + snapId: SNAP_ID, + request: { + method: "sign_transaction", + params: { payload: transactionPayload.toString("hex"), chainId }, + }, + }, + }) + assert(Buffer.from(signature, "hex").length === 65) + + return signature + }, + }, + interactive: true, + } +} + +export const partisiaSdkBackend = (client: PartisiaSdk): SigningBackend => { + if (!client.connection) throw new Error('Client is not connected') + + const address = client.connection.account.address + + return { + authentication: { + getAddress: () => address, + sign: async (transactionPayload) => { + // `dontBroadcast` keeps the wallet from sending the transaction itself: + // the SDK broadcasts every strategy the same way, through the reader + // node, so the result is polled the same way too. + const { signature } = await client.signMessage({ + payload: transactionPayload.toString("hex"), + payloadType: "hex", + dontBroadcast: true, + }) + + return signature + }, + }, + interactive: true, + } +} diff --git a/src/transactions/helper.ts b/src/transactions/helper.ts index 5966869..f25f7ef 100644 --- a/src/transactions/helper.ts +++ b/src/transactions/helper.ts @@ -8,31 +8,6 @@ export const builderToBytesBe = (rpc: RpcContractBuilder) => { export const getChainId = (isMainnet: boolean): string => `Partisia Blockchain${isMainnet ? '' : ' Testnet'}` -export const serializeTransaction = async ( - client: ShardedClient, - walletAddress: string, - contractAddress: string, - payload: Buffer, - 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 = client.deriveShardId(walletAddress) - const nonce = await client.getNonce(walletAddress, shardId) - // Need to pass a number otherwise the internal library will throw an error - const validTo = (new Date().getTime() + validityInMillis) as unknown as string - - return serializedTransaction( - { nonce, cost, validTo }, - { contract: contractAddress }, - payload - ) -} - export const buildTransactionResult = ( client: ShardedClient, shardId: number, diff --git a/src/transactions/index.ts b/src/transactions/index.ts index 533554d..5b3c411 100644 --- a/src/transactions/index.ts +++ b/src/transactions/index.ts @@ -1,130 +1,11 @@ -import type LedgerTransport from "@ledgerhq/hw-transport" -import type { ITransactionIntent, MetaMaskSdk } from "../interface" -import { buildTransactionResult, getChainId, serializeTransaction } from "./helper" +import type { SenderAuthentication } from "@partisiablockchain/blockchain-api-transaction-client" +import type { ITransactionIntent } from "../interface" import type { ShardedClient } from "../repositories/helpers/sharded-client" +import { buildTransactionResult, getChainId } from "./helper" import assert from "assert" -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: ShardedClient, - transport: LedgerTransport, - contractAddress: string, - payload: Buffer, - 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) - - const serializedTransaction = await serializeTransaction(rpc, walletAddress, contractAddress, payload, cost) - const chainId = getChainId(isMainnet) - const digest = deriveDigest( chainId, serializedTransaction) - - const signature = await client.signTransaction(serializedTransaction, chainId) - - const signatureBuffer = signatureToBuffer(signature) - - const transactionPayload = Buffer.concat([signatureBuffer, serializedTransaction]).toString('base64') - - const transactionHash = getTrxHash(digest, signatureBuffer) - const isValid = await rpc.broadcastTransaction(walletAddress, transactionPayload) - assert(isValid, 'Unknown Error') - - return buildTransactionResult(rpc, shardId, transactionHash) -} - -export const createTransactionFromMetaMaskClient = async ( - rpc: ShardedClient, - client: MetaMaskSdk, - contractAddress: string, - payload: Buffer, - 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", - params: { snapId, request: { method: "get_address" } }, - }) - const shardId = rpc.deriveShardId(walletAddress) - - const serializedTransaction = await serializeTransaction(rpc, walletAddress, contractAddress, payload, cost) - const chainId = getChainId(isMainnet) - const digest = deriveDigest( - chainId, - serializedTransaction - ) - - const signatureHex: string = await client.request({ - method: "wallet_invokeSnap", - params: { - snapId, - request: { - method: "sign_transaction", - params: { - payload: serializedTransaction.toString("hex"), - chainId - }, - }, - }, - }) - const signature = Buffer.from(signatureHex, "hex") - assert(signature.length === 65) - - const transactionPayload = Buffer.concat([signature, serializedTransaction]).toString('base64') - - const transactionHash = getTrxHash(digest, signature) - const isValid = await rpc.broadcastTransaction(walletAddress, transactionPayload) - assert(isValid, 'Unknown Error') - - return buildTransactionResult(rpc, shardId, transactionHash) -} - -export const createTransactionFromPartisiaClient = async ( - rpc: ShardedClient, - client: PartisiaSdk, - contractAddress: string, - payload: Buffer, - cost: number | string = 8490 -): Promise => { - if (!client.connection) throw new Error('Client is not connected') - - const walletAddress = client.connection.account.address - const serializedTransaction = await serializeTransaction(rpc, walletAddress, contractAddress, payload, cost) - - const transaction = await client.signMessage({ - payload: serializedTransaction.toString("hex"), - payloadType: "hex", - dontBroadcast: false, - }) - - const shardId = rpc.deriveShardId(walletAddress) - - return buildTransactionResult(rpc, shardId, transaction.trxHash) -} +export type { SigningBackend } from "./authentication" +export { privateKeyBackend, ledgerBackend, metaMaskBackend, partisiaSdkBackend } from "./authentication" /** * The nonce comes from a reader node, which trails the chain by a moment: a @@ -132,46 +13,63 @@ export const createTransactionFromPartisiaClient = async ( * use elsewhere, can carry a nonce the chain has already spent. The node then * rejects the broadcast with 400 Bad Request. A rejected transaction never * reaches the chain, so re-reading the nonce and signing again is safe and - * costs nothing. + * costs nothing -- as long as signing does not prompt a human, which is why + * interactive backends get a single attempt. */ const BROADCAST_ATTEMPTS = 3 -export const createTransactionFromPrivateKey = async ( - rpc: ShardedClient, - contractAddress: string, - privateKey: string, - payload: Buffer, - isMainnet = false, - cost: number | string = 8490 +export interface CreateTransactionParams { + contractAddress: string + payload: Buffer + cost: number + isMainnet?: boolean + /** How long the chain will accept the transaction for. */ + validityInMillis?: number + /** Interactive signers are asked once; see `BROADCAST_ATTEMPTS`. */ + attempts?: number +} + +/** + * Sign a transaction with any of the signing backends and broadcast it. + * + * `SignedTransaction` comes from the official + * `@partisiablockchain/blockchain-api-transaction-client`, which replaces the + * unmaintained `partisia-blockchain-applications-crypto`. It produces the same + * bytes: an inner part of nonce, valid-to and gas as big-endian i64s followed + * by the contract address and the length-prefixed payload, signed over the + * SHA-256 of those bytes concatenated with the length-prefixed chain id. + */ +export const createTransaction = async ( + client: ShardedClient, + { authentication, interactive }: { authentication: SenderAuthentication, interactive: boolean }, + { contractAddress, payload, cost, isMainnet = false, validityInMillis = 120_000, attempts }: CreateTransactionParams ): Promise => { - const [{ deriveDigest, getTransactionPayloadData, getTrxHash }, { privateKeyToAccountAddress, signTransaction }] = await Promise.all([ - loadTransactionCrypto(), - loadWalletCrypto() - ]) + const { SignedTransaction } = await import("@partisiablockchain/blockchain-api-transaction-client") - const walletAddress = privateKeyToAccountAddress(privateKey) - const shardId = rpc.deriveShardId(walletAddress) + const walletAddress = authentication.getAddress() + const shardId = client.deriveShardId(walletAddress) + const chainId = getChainId(isMainnet) + const broadcastAttempts = attempts ?? (interactive ? 1 : BROADCAST_ATTEMPTS) let lastError: unknown - for (let attempt = 0; attempt < BROADCAST_ATTEMPTS; attempt++) { + for (let attempt = 0; attempt < broadcastAttempts; attempt++) { if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, 1_000 * attempt)) - const serializedTransaction = await serializeTransaction(rpc, walletAddress, contractAddress, payload, cost) - - const digest = deriveDigest( - `Partisia Blockchain${isMainnet ? '' : ' Testnet'}`, - serializedTransaction + const nonce = await client.getNonce(walletAddress, shardId) + const signedTransaction = await SignedTransaction.create( + authentication, + nonce, + Date.now() + validityInMillis, + cost, + chainId, + { address: contractAddress, rpc: payload } ) - const signature = signTransaction(digest, privateKey) - const trx = getTransactionPayloadData(serializedTransaction, signature) - - const transactionHash = getTrxHash(digest, signature) try { - const isValid = await rpc.broadcastTransaction(walletAddress, trx) + const isValid = await client.broadcastTransaction(walletAddress, signedTransaction.serialize()) assert(isValid, 'Unknown Error') - return buildTransactionResult(rpc, shardId, transactionHash) + return buildTransactionResult(client, shardId, signedTransaction.identifier()) } catch (error) { lastError = error } diff --git a/yarn.lock b/yarn.lock index 972ab9d..6da0737 100644 --- a/yarn.lock +++ b/yarn.lock @@ -670,7 +670,7 @@ bn.js "^5.2.1" hash.js "^1.1.7" -"@partisiablockchain/blockchain-api-transaction-client@6.142.0": +"@partisiablockchain/blockchain-api-transaction-client@6.142.0", "@partisiablockchain/blockchain-api-transaction-client@^6.142.0": version "6.142.0" resolved "https://registry.yarnpkg.com/@partisiablockchain/blockchain-api-transaction-client/-/blockchain-api-transaction-client-6.142.0.tgz#c7a0b705f060ccee61145ea2fc424be4d511fc4a" integrity sha512-3VOVBlinzZq815aTfNmO9DnN3gkIVo6N70JN61zPCgT1GQz8leror1Z4jAq7q8bAQOvZgZQlRMLDudFxm57Arw== @@ -3602,7 +3602,7 @@ parse-json@^5.2.0: json-parse-even-better-errors "^2.3.0" lines-and-columns "^1.1.6" -partisia-blockchain-applications-crypto@^1.0.23, partisia-blockchain-applications-crypto@^1.0.34: +partisia-blockchain-applications-crypto@^1.0.23: version "1.0.34" resolved "https://registry.yarnpkg.com/partisia-blockchain-applications-crypto/-/partisia-blockchain-applications-crypto-1.0.34.tgz#2ae58363e39455aae54d0f6e201490ad9fbb1cc4" integrity sha512-XGmbJ2OSy0mkCIxV7HcbrqfwcYXY9KPLX4LYRVLtcScDRa9f9GF7vQl5XSKo94mxNgGUk16Rh7demIp+0P9JXg==