From 75503aceaa249727d577d29811c87f32b89c8146 Mon Sep 17 00:00:00 2001 From: LucienSong Date: Tue, 26 May 2026 01:17:45 +0800 Subject: [PATCH] fix(signing): align blake3 hashes and key disposal Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 10 ++ README.md | 39 ++--- package-lock.json | 4 +- package.json | 2 +- src/adapters.ts | 62 +++++-- src/index.ts | 2 + src/keystore.ts | 48 +++++- src/signer.ts | 35 +++- src/transactions.ts | 230 +++++++++++++++++-------- tests/aa.test.mjs | 25 ++- tests/browser.integration.test.mjs | 2 - tests/fixtures/rust-compatibility.json | 4 +- tests/node.integration.test.mjs | 46 ++++- 13 files changed, 375 insertions(+), 134 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95f3147..0e53808 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [0.9.2] — 2026-05-25 + +### Fixed +- Replace legacy `keccak256(RLP(tx))` signing helpers with the shell-chain v0.23.0 canonical BLAKE3 preimages for standard transactions and AA bundles. +- Add `hashPaymasterTransaction()` plus Rust-compatible hash vectors for AA paymaster flows. +- Add `ShellSigner.dispose()` / `withDecryptedKeystoreSigner()` and zero decrypted secret-key buffers after keystore decryption. + +### Changed +- `ShellSigner.buildSignedTransaction()` now computes the correct signing hash automatically when `txHash` is omitted. + ## [0.9.1] — 2026-05-25 ### Changed diff --git a/README.md b/README.md index 5003c36..f52ff00 100644 --- a/README.md +++ b/README.md @@ -7,17 +7,11 @@ --- -> **⚠️ v0.23.0 alignment status (in progress)** +> **v0.23.0 aligned** > -> Addresses, system-contract IDs, and adapter types are aligned with shell-chain v0.23.0 -> (32-byte `0x…` BLAKE3 addresses; `algo_id` byte `Dilithium3=0`, `MlDsa65=1`, `SphincsSha2256f=2`). -> -> The transaction signing-hash helpers (`hashTransaction`, `hashBatchTransaction`) -> still implement the pre-v0.23.0 `keccak256(RLP(tx))` scheme. Shell-chain v0.23.0 -> nodes expect `BLAKE3(structured preimage including sig_type)` instead. A -> follow-up release will replace these helpers and regenerate the -> `tests/fixtures/rust-compatibility.json` vectors against the v0.23.0 chain. -> Do not rely on the `hashTransaction*` helpers against a v0.23.0 node yet. +> Addresses, system-contract IDs, and signing hashes now match shell-chain v0.23.0: +> 32-byte `0x…` BLAKE3 addresses, `algo_id` byte `Dilithium3=0`, `MlDsa65=1`, +> `SphincsSha2256f=2`, and BLAKE3-based transaction / AA signing hashes. ## Table of Contents @@ -84,7 +78,7 @@ Send a SHELL transfer in ~10 lines: import { MlDsa65Adapter } from "shell-sdk/adapters"; import { createShellProvider } from "shell-sdk/provider"; import { ShellSigner } from "shell-sdk/signer"; -import { buildTransferTransaction, hashTransaction } from "shell-sdk/transactions"; +import { buildTransferTransaction } from "shell-sdk/transactions"; import { parseEther } from "viem"; const adapter = MlDsa65Adapter.generate(); @@ -95,8 +89,7 @@ const provider = createShellProvider(); const nonce = await provider.client.getTransactionCount({ address: from }); const tx = buildTransferTransaction({ chainId: 424242, nonce, to: "0x…", value: parseEther("1") }); -const txHash = hashTransaction(tx); -const signed = await signer.buildSignedTransaction({ tx, txHash }); +const signed = await signer.buildSignedTransaction({ tx }); const hash = await provider.sendTransaction(signed); console.log("tx hash:", hash); ``` @@ -454,18 +447,19 @@ const signed = buildSignedTransaction({ #### `hashTransaction` -RLP-encode a `ShellTransactionRequest` using the Rust node's canonical field order and return its **keccak256** hash as a `Uint8Array`. This is the value you must pass as `txHash` to `signer.buildSignedTransaction`. +Compute the canonical shell-chain v0.23.0 signing hash as **BLAKE3** over the structured preimage: -Shell Chain signs the full unsigned transaction payload in this order: +`chain_id(8B BE) || nonce(8B BE) || to(32B|zero) || value(32B BE) || data || gas_limit(8B BE) || max_fee_per_gas(8B BE) || max_priority_fee_per_gas(8B BE) || sig_type(1B) || tx_type(1B)` -`[chainId, nonce, to, value, data, gasLimit, maxFeePerGas, maxPriorityFeePerGas, accessList, txType, blobFeeFlag, maxFeePerBlobGas, blobVersionedHashes]` +For blob transactions (`tx_type === 3`), append `max_fee_per_blob_gas(8B BE)` and each 32-byte blob hash. ```typescript import { buildTransferTransaction, hashTransaction } from "shell-sdk/transactions"; const tx = buildTransferTransaction({ chainId: 424242, nonce: 0, to: "0x…", value: 1n }); -const txHash = hashTransaction(tx); // Uint8Array (32 bytes) +const txHash = hashTransaction(tx, signer.signatureType); // Uint8Array (32 bytes) const signed = await signer.buildSignedTransaction({ tx, txHash }); +// Or simply: await signer.buildSignedTransaction({ tx }) ``` --- @@ -594,9 +588,8 @@ const tx = buildTransferTransaction({ value: parseEther("0.5"), }); -// 5. RLP-encode and hash for signing -// (Shell uses the same EIP-1559 signing hash as Ethereum) -const txHash = hashTransaction(tx); +// 5. Compute the canonical BLAKE3 signing hash +const txHash = hashTransaction(tx, signer.signatureType); // 6. Sign and build the complete signed transaction // includePublicKey=true is required for accounts that haven't been seen on-chain yet @@ -631,7 +624,7 @@ const tx = buildTransferTransaction({ value: parseEther("10"), }); -const txHash = hashTransaction(tx); +const txHash = hashTransaction(tx, signer.signatureType); const signed = await signer.buildSignedTransaction({ tx, txHash }); const hash = await provider.sendTransaction(signed); console.log(hash); @@ -661,7 +654,7 @@ async function submitTransfer({ signer, to, value, rpcHttpUrl }: { to, value, }); - const txHash = hashTransaction(tx); + const txHash = hashTransaction(tx, signer.signatureType); const signed = await signer.buildSignedTransaction({ tx, txHash, includePublicKey: nonce === 0 }); return provider.sendTransaction(signed); @@ -717,7 +710,7 @@ const tx = buildRotateKeyTransaction({ algorithmId: newSigner.algorithmId, // 1 for MlDsa65 }); -const txHash = hashTransaction(tx); +const txHash = hashTransaction(tx, currentSigner.signatureType); // Sign with the CURRENT key const signed = await currentSigner.buildSignedTransaction({ tx, txHash }); diff --git a/package-lock.json b/package-lock.json index c408097..72a3b59 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "shell-sdk", - "version": "0.8.2", + "version": "0.9.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "shell-sdk", - "version": "0.8.2", + "version": "0.9.2", "license": "MIT", "dependencies": { "@noble/ciphers": "^2.1.1", diff --git a/package.json b/package.json index 2726866..eaa13f8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "shell-sdk", - "version": "0.9.1", + "version": "0.9.2", "description": "TypeScript SDK for Shell Chain — build quantum-safe dApps before Q-Day.", "license": "MIT", "type": "module", diff --git a/src/adapters.ts b/src/adapters.ts index e699b7f..7e43865 100644 --- a/src/adapters.ts +++ b/src/adapters.ts @@ -74,10 +74,16 @@ export function generateSlhDsaKeyPair(seed?: Uint8Array): SlhDsaKeyPair { * ``` */ export class MlDsa65Adapter implements SignerAdapter { + private readonly _publicKey: Uint8Array; + private _secretKey: Uint8Array | null; + constructor( - private readonly _publicKey: Uint8Array, - private readonly _secretKey: Uint8Array, - ) {} + publicKey: Uint8Array, + secretKey: Uint8Array, + ) { + this._publicKey = new Uint8Array(publicKey); + this._secretKey = new Uint8Array(secretKey); + } /** * Generate a fresh ML-DSA-65 key pair and wrap it in an adapter. @@ -86,7 +92,11 @@ export class MlDsa65Adapter implements SignerAdapter { */ static generate(seed?: Uint8Array): MlDsa65Adapter { const kp = generateMlDsa65KeyPair(seed); - return new MlDsa65Adapter(kp.publicKey, kp.secretKey); + try { + return new MlDsa65Adapter(kp.publicKey, kp.secretKey); + } finally { + kp.secretKey.fill(0); + } } /** @@ -100,14 +110,23 @@ export class MlDsa65Adapter implements SignerAdapter { } /** Return the raw ML-DSA-65 public key bytes (1952 bytes). */ - getPublicKey(): Uint8Array { return this._publicKey; } + getPublicKey(): Uint8Array { return new Uint8Array(this._publicKey); } + + /** Zero the in-memory secret key buffer. */ + dispose(): void { + this._secretKey?.fill(0); + this._secretKey = null; + } /** * Sign `message` with ML-DSA-65 and return the raw signature bytes. * - * @param message - The bytes to sign (typically an RLP-encoded tx hash). + * @param message - The bytes to sign (typically a Shell signing hash). */ async sign(message: Uint8Array): Promise { + if (!this._secretKey) { + throw new Error("adapter secret key has been disposed"); + } return ml_dsa65.sign(message, this._secretKey); } } @@ -125,10 +144,16 @@ export class MlDsa65Adapter implements SignerAdapter { * ``` */ export class SlhDsaAdapter implements SignerAdapter { + private readonly _publicKey: Uint8Array; + private _secretKey: Uint8Array | null; + constructor( - private readonly _publicKey: Uint8Array, - private readonly _secretKey: Uint8Array, - ) {} + publicKey: Uint8Array, + secretKey: Uint8Array, + ) { + this._publicKey = new Uint8Array(publicKey); + this._secretKey = new Uint8Array(secretKey); + } /** * Generate a fresh SLH-DSA-SHA2-256f key pair and wrap it in an adapter. @@ -137,7 +162,11 @@ export class SlhDsaAdapter implements SignerAdapter { */ static generate(seed?: Uint8Array): SlhDsaAdapter { const kp = generateSlhDsaKeyPair(seed); - return new SlhDsaAdapter(kp.publicKey, kp.secretKey); + try { + return new SlhDsaAdapter(kp.publicKey, kp.secretKey); + } finally { + kp.secretKey.fill(0); + } } /** @@ -151,14 +180,23 @@ export class SlhDsaAdapter implements SignerAdapter { } /** Return the raw SLH-DSA public key bytes (64 bytes). */ - getPublicKey(): Uint8Array { return this._publicKey; } + getPublicKey(): Uint8Array { return new Uint8Array(this._publicKey); } + + /** Zero the in-memory secret key buffer. */ + dispose(): void { + this._secretKey?.fill(0); + this._secretKey = null; + } /** * Sign `message` with SLH-DSA-SHA2-256f and return the raw signature bytes. * - * @param message - The bytes to sign (typically an RLP-encoded tx hash). + * @param message - The bytes to sign (typically a Shell signing hash). */ async sign(message: Uint8Array): Promise { + if (!this._secretKey) { + throw new Error("adapter secret key has been disposed"); + } return slh_dsa_sha2_256f.sign(message, this._secretKey); } } diff --git a/src/index.ts b/src/index.ts index 54b034c..50e4b5f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -64,6 +64,7 @@ export { DEFAULT_TRANSFER_GAS_LIMIT, DEFAULT_TX_TYPE, hashBatchTransaction, + hashPaymasterTransaction, hashTransaction, type BuildBatchTransactionOptions, type BuildContractPaymasterTransactionOptions, @@ -74,6 +75,7 @@ export { assertSignerMatchesKeystore, decryptKeystore, exportEncryptedKeyJson, + withDecryptedKeystoreSigner, parseEncryptedKey, validateEncryptedKeyAddress, type ParsedShellKeystore, diff --git a/src/keystore.ts b/src/keystore.ts index c4c1c9f..92769cb 100644 --- a/src/keystore.ts +++ b/src/keystore.ts @@ -152,11 +152,19 @@ function hexToBytes(hex: string): Uint8Array { * @throws {Error} If the KDF or cipher is unsupported. * @throws {Error} If decryption fails (wrong password or corrupt ciphertext). * + * Call `signer.dispose()` as soon as you are done signing so the in-memory + * secret key copy can be zeroed. For one-shot workflows, prefer + * {@link withDecryptedKeystoreSigner} to decrypt, sign, and dispose in one pass. + * * @example * ```typescript * const signer = await decryptKeystore(readFileSync("key.json", "utf8"), "my-passphrase"); - * console.log(signer.getAddress()); // 0x… - * const hash = await provider.sendTransaction(await signer.buildSignedTransaction(…)); + * try { + * const signedTx = await signer.buildSignedTransaction({ tx, includePublicKey: true }); + * await provider.sendTransaction(signedTx); + * } finally { + * signer.dispose(); + * } * ``` */ export async function decryptKeystore( @@ -183,10 +191,36 @@ export async function decryptKeystore( }); const derivedKey = hexToBytes(derivedKeyHex); - const chacha = xchacha20poly1305(derivedKey, nonce); - // Plaintext is sk-only; public key comes from the JSON `public_key` field. - const secretKey = chacha.decrypt(ciphertext); + try { + const chacha = xchacha20poly1305(derivedKey, nonce); + // Plaintext is sk-only; public key comes from the JSON `public_key` field. + const secretKey = chacha.decrypt(ciphertext); + try { + const adapter = adapterFromKeyPair(parsed.signatureType, parsed.publicKey, secretKey); + return new ShellSigner(parsed.signatureType, adapter); + } finally { + secretKey.fill(0); + } + } finally { + derivedKey.fill(0); + } +} - const adapter = adapterFromKeyPair(parsed.signatureType, parsed.publicKey, secretKey); - return new ShellSigner(parsed.signatureType, adapter); +/** + * Decrypt a keystore, run a callback, then dispose the signer in a `finally` block. + * + * This is the preferred pattern for short-lived signing operations because the + * decrypted secret key only lives for the duration of the callback. + */ +export async function withDecryptedKeystoreSigner( + input: string | ShellEncryptedKey, + password: string, + fn: (signer: ShellSigner) => Promise | T, +): Promise { + const signer = await decryptKeystore(input, password); + try { + return await fn(signer); + } finally { + signer.dispose(); + } } diff --git a/src/signer.ts b/src/signer.ts index 5a9fd43..913a65b 100644 --- a/src/signer.ts +++ b/src/signer.ts @@ -15,6 +15,8 @@ import { deriveShellAddressFromPublicKey, normalizeShellAddress } from "./addres import { buildSignature, buildSignedTransaction, + hashBatchTransaction, + hashTransaction, type BuildSignedTransactionOptions, } from "./transactions.js"; import type { SignedShellTransaction, SignatureTypeName } from "./types.js"; @@ -67,13 +69,16 @@ export interface SignerAdapter { /** * Sign a raw message (the transaction hash bytes) and return the signature. * - * @param message - The bytes to sign (typically an RLP-encoded tx hash). + * @param message - The bytes to sign (typically a Shell signing hash). * @returns The raw signature bytes. */ sign(message: Uint8Array): Promise; /** Return the raw public key bytes for this signer. */ getPublicKey(): Uint8Array; + + /** Zero in-memory secret material when the signer is no longer needed. */ + dispose?(): void; } /** @@ -98,6 +103,7 @@ export class ShellSigner { readonly signatureType: SignatureTypeName; /** The underlying adapter that performs the actual cryptographic operations. */ readonly adapter: SignerAdapter; + private disposed = false; /** * @param signatureType - The PQ algorithm name. @@ -122,6 +128,15 @@ export class ShellSigner { return this.adapter.getPublicKey(); } + /** Zero in-memory secret material held by the underlying adapter. */ + dispose(): void { + if (this.disposed) { + return; + } + this.disposed = true; + this.adapter.dispose?.(); + } + /** * Derive and return the `0x…` hex address for this signer. * @@ -138,6 +153,9 @@ export class ShellSigner { * @returns Raw signature bytes. */ async sign(message: Uint8Array): Promise { + if (this.disposed) { + throw new Error("signer has been disposed"); + } return this.adapter.sign(message); } @@ -145,7 +163,8 @@ export class ShellSigner { * Sign a transaction hash and assemble a complete {@link SignedShellTransaction}. * * @param options.tx - The unsigned `ShellTransactionRequest` to embed. - * @param options.txHash - The bytes to sign (RLP-encoded EIP-1559 signing hash). + * @param options.txHash - Optional precomputed Shell signing hash. When omitted, + * the SDK derives the correct BLAKE3 transaction or AA bundle hash automatically. * @param options.includePublicKey - When `true`, embeds `sender_pubkey` in the * result. Required for accounts that have not yet appeared on-chain. * @returns A fully-signed transaction ready for {@link ShellProvider.sendTransaction}. @@ -162,12 +181,20 @@ export class ShellSigner { */ async buildSignedTransaction( options: Omit & { - txHash: Uint8Array; + txHash?: Uint8Array; includePublicKey?: boolean; aaBundle?: import("./types.js").AaBundle; }, ): Promise { - const signature = await this.sign(options.txHash); + if (options.tx.tx_type === 0x7e && !options.aaBundle) { + throw new Error("aaBundle is required when signing AA bundle transactions"); + } + + const txHash = options.txHash + ?? (options.aaBundle + ? hashBatchTransaction(options.tx, options.aaBundle, this.signatureType) + : hashTransaction(options.tx, this.signatureType)); + const signature = await this.sign(txHash); return buildSignedTransaction({ from: normalizeShellAddress(this.getAddress()), diff --git a/src/transactions.ts b/src/transactions.ts index 8694f15..9a0826a 100644 --- a/src/transactions.ts +++ b/src/transactions.ts @@ -7,7 +7,8 @@ * * @module transactions */ -import { bytesToHex, keccak256, toRlp, hexToBytes } from "viem"; +import { blake3 } from "@noble/hashes/blake3"; +import { bytesToHex, toRlp, hexToBytes } from "viem"; import type { AaBundle, @@ -23,6 +24,16 @@ import type { } from "./types.js"; import { AA_BUNDLE_TX_TYPE, AA_MAX_INNER_CALLS } from "./types.js"; export { AA_BUNDLE_TX_TYPE, AA_MAX_INNER_CALLS }; + +const BATCH_SIGNING_HASH_DOMAIN = AA_BUNDLE_TX_TYPE; +const PAYMASTER_SIGNING_HASH_DOMAIN = 0x7f; + +const SIGNATURE_TYPE_IDS: Record = { + "ML-DSA-65": 1, + Dilithium3: 0, + MlDsa65: 1, + SphincsSha2256f: 2, +}; import { accountManagerAddress, encodeClearValidationCodeCalldata, @@ -108,6 +119,62 @@ function toRlpUint(value: number | bigint | string): HexString { return `0x${numeric.toString(16)}`; } +function signatureTypeToId(signatureType: SignatureTypeName | number): number { + if (typeof signatureType === "number") { + if (!Number.isInteger(signatureType) || signatureType < 0 || signatureType > 255) { + throw new RangeError(`signatureType id must be a byte, got: ${signatureType}`); + } + return signatureType; + } + + const id = SIGNATURE_TYPE_IDS[signatureType]; + if (id == null) { + throw new Error(`unsupported signature type: ${signatureType}`); + } + return id; +} + +function encodeU64Be(value: number | bigint | string, fieldName: string): Uint8Array { + const numeric = typeof value === "string" ? BigInt(value) : BigInt(value); + if (numeric < 0n || numeric > 0xffff_ffff_ffff_ffffn) { + throw new RangeError(`${fieldName} must fit in u64, got: ${value}`); + } + + const bytes = new Uint8Array(8); + let remaining = numeric; + for (let index = 7; index >= 0; index -= 1) { + bytes[index] = Number(remaining & 0xffn); + remaining >>= 8n; + } + return bytes; +} + +function encodeU256Be(value: number | bigint | string, fieldName: string): Uint8Array { + const numeric = typeof value === "string" ? BigInt(value) : BigInt(value); + if (numeric < 0n || numeric > ((1n << 256n) - 1n)) { + throw new RangeError(`${fieldName} must fit in u256, got: ${value}`); + } + + const bytes = new Uint8Array(32); + let remaining = numeric; + for (let index = 31; index >= 0; index -= 1) { + bytes[index] = Number(remaining & 0xffn); + remaining >>= 8n; + } + return bytes; +} + +function concatBytes(...parts: Uint8Array[]): Uint8Array { + const totalLength = parts.reduce((sum, part) => sum + part.length, 0); + const combined = new Uint8Array(totalLength); + let offset = 0; + for (const part of parts) { + combined.set(part, offset); + offset += part.length; + } + return combined; +} + function toRlpAccessList( accessList?: ShellTransactionRequest["access_list"] | null, ): Array<[HexString, HexString[]]> { @@ -334,57 +401,63 @@ export function hexBytes(bytes: Uint8Array): HexString { } /** - * RLP-encode a `ShellTransactionRequest` and return its keccak256 hash. + * Compute the canonical Shell transaction signing hash. * - * This is the signing hash that must be passed to `ShellSigner.buildSignedTransaction` - * (or `signer.sign`). Shell Chain computes it identically on the node side as - * `keccak256(RLP(tx))` — the same scheme as Ethereum EIP-1559 signing. + * Shell-chain v0.23.0 signs `blake3` over the structured preimage from + * `shell-chain/crates/core/src/transaction.rs::Transaction::signing_hash`: * - * **Encoding order** (EIP-2718 type-2 fields): - * chainId, nonce, to, value, data, gasLimit, maxFeePerGas, maxPriorityFeePerGas, - * accessList, txType, blobFeeFlag, maxFeePerBlobGas, blobVersionedHashes + * `chain_id(8B BE) || nonce(8B BE) || to(32B|zero) || value(32B BE) || data ||` + * `gas_limit(8B BE) || max_fee_per_gas(8B BE) || max_priority_fee_per_gas(8B BE) ||` + * `sig_type(1B) || tx_type(1B)` * - * @example - * ```typescript - * import { buildTransferTransaction, hashTransaction } from "shell-sdk/transactions"; + * Blob transactions (`tx_type === 3`) append + * `max_fee_per_blob_gas(8B BE) || blob_hash_0(32B) || ...`. * - * const tx = buildTransferTransaction({ chainId: 424242, nonce: 0, to: "0x…", value: 1n }); - * const txHash = hashTransaction(tx); - * const signed = await signer.buildSignedTransaction({ tx, txHash }); - * ``` + * `access_list` is intentionally excluded because the chain's signing preimage + * does not include it. * * @param tx - The unsigned transaction to hash. - * @returns 32-byte keccak256 hash as a `Uint8Array`. + * @param signatureType - Signature algorithm name or numeric id. Defaults to Dilithium3 (`0`). + * @returns 32-byte BLAKE3 signing hash as a `Uint8Array`. */ -export function hashTransaction(tx: ShellTransactionRequest): Uint8Array { - const fields = [ - toRlpUint(tx.chain_id), - toRlpUint(tx.nonce), - tx.to ? bytesToHex(shellAddressToBytes(tx.to)) : "0x", - toRlpUint(tx.value), - tx.data, - toRlpUint(tx.gas_limit), - toRlpUint(tx.max_fee_per_gas), - toRlpUint(tx.max_priority_fee_per_gas), - toRlpAccessList(tx.access_list), - toRlpUint(tx.tx_type ?? DEFAULT_TX_TYPE), - toRlpUint(tx.max_fee_per_blob_gas != null ? 1 : 0), - toRlpUint(tx.max_fee_per_blob_gas ?? 0), - (tx.blob_versioned_hashes ?? []).map((hash) => hash as HexString), - ] as const; - - const rlpEncoded = toRlp(fields); - const hash = hexToBytes(keccak256(rlpEncoded)); - return hash; +export function hashTransaction( + tx: ShellTransactionRequest, + signatureType: SignatureTypeName | number = "Dilithium3", +): Uint8Array { + const txType = tx.tx_type ?? DEFAULT_TX_TYPE; + const preimageParts = [ + encodeU64Be(tx.chain_id, "chain_id"), + encodeU64Be(tx.nonce, "nonce"), + tx.to ? shellAddressToBytes(tx.to) : new Uint8Array(32), + encodeU256Be(tx.value, "value"), + hexToBytes(tx.data), + encodeU64Be(tx.gas_limit, "gas_limit"), + encodeU64Be(tx.max_fee_per_gas, "max_fee_per_gas"), + encodeU64Be(tx.max_priority_fee_per_gas, "max_priority_fee_per_gas"), + new Uint8Array([signatureTypeToId(signatureType)]), + new Uint8Array([txType]), + ]; + + if (txType === 3) { + preimageParts.push( + encodeU64Be(tx.max_fee_per_blob_gas ?? 0, "max_fee_per_blob_gas"), + ...((tx.blob_versioned_hashes ?? []).map((hash, index) => { + const bytes = hexToBytes(hash); + if (bytes.length !== 32) { + throw new RangeError(`blob_versioned_hashes[${index}] must be 32 bytes, got ${bytes.length}`); + } + return bytes; + })), + ); + } + + return blake3(concatBytes(...preimageParts)); } // --------------------------------------------------------------------------- // AA batch & sponsored transaction builders (v0.18.0) // --------------------------------------------------------------------------- -/** `keccak256` domain prefix byte for the batch signing hash (must match chain). */ -const BATCH_SIGNING_HASH_DOMAIN = 0x42; - /** * Options for {@link buildBatchTransaction}. */ @@ -517,20 +590,23 @@ export function buildSponsoredTransaction(options: BuildSponsoredTransactionOpti } /** - * Compute the `batch_signing_hash` for an AA bundle transaction. + * Compute the sender's canonical AA bundle signing hash. * - * This is the hash that the **sender** must sign (not the plain `tx.hash()`). - * The domain separation prevents replay attacks across tx types. + * Matches `shell-chain/crates/core/src/transaction.rs::SignedTransaction::batch_signing_hash`: + * `blake3( 0x7E || tx_signing_hash || rlp(aa_bundle_for_signing) )`. * - * Domain: `keccak256( 0x42 || RLP(tx) || RLP(aa_bundle_signing_fields) )` + * The signing-form bundle omits `paymaster_signature`, `session_auth.root_signature`, + * and `session_auth.session_signature`, but still commits to `paymaster_context`. * * @param tx - The outer unsigned transaction (must have `tx_type = 0x7E`). * @param bundle - The AA bundle that will be attached. - * @returns 32-byte keccak256 hash as a `Uint8Array`. + * @param signatureType - Signature algorithm name or numeric id. Defaults to Dilithium3 (`0`). + * @returns 32-byte BLAKE3 batch signing hash as a `Uint8Array`. */ export function hashBatchTransaction( tx: ShellTransactionRequest, bundle: AaBundle, + signatureType: SignatureTypeName | number = "Dilithium3", ): Uint8Array { if (tx.tx_type !== AA_BUNDLE_TX_TYPE) { throw new Error( @@ -538,53 +614,59 @@ export function hashBatchTransaction( ); } - // Encode inner calls for signing (matches chain's encode_for_signing). const innerCallsRlp = bundle.inner_calls.map((call) => [ call.to ? bytesToHex(shellAddressToBytes(call.to)) : "0x", toRlpUint(call.value), call.data, toRlpUint(call.gas_limit), ]); - - // Paymaster: 20-byte address or empty bytes. const paymasterField = bundle.paymaster ? (bytesToHex(shellAddressToBytes(bundle.paymaster)) as HexString) : ("0x" as HexString); - - // paymaster_context: raw bytes or empty. const paymasterContextField: HexString = bundle.paymaster_context && bundle.paymaster_context.length > 0 ? (bytesToHex(new Uint8Array(bundle.paymaster_context)) as HexString) : ("0x" as HexString); - - const txFields = [ - toRlpUint(tx.chain_id), - toRlpUint(tx.nonce), - tx.to ? bytesToHex(shellAddressToBytes(tx.to)) : "0x", - toRlpUint(tx.value), - tx.data, - toRlpUint(tx.gas_limit), - toRlpUint(tx.max_fee_per_gas), - toRlpUint(tx.max_priority_fee_per_gas), - toRlpAccessList(tx.access_list), - toRlpUint(tx.tx_type ?? AA_BUNDLE_TX_TYPE), - toRlpUint(tx.max_fee_per_blob_gas != null ? 1 : 0), - toRlpUint(tx.max_fee_per_blob_gas ?? 0), - (tx.blob_versioned_hashes ?? []).map((hash) => hash as HexString), - ] as const; - const bundleSigningFields = [innerCallsRlp, paymasterField, paymasterContextField] as const; - const domainBuf = new Uint8Array([BATCH_SIGNING_HASH_DOMAIN]); - const txRlp = hexToBytes(toRlp(txFields)); - const bundleRlp = hexToBytes(toRlp(bundleSigningFields)); + return blake3( + concatBytes( + new Uint8Array([BATCH_SIGNING_HASH_DOMAIN]), + hashTransaction(tx, signatureType), + hexToBytes(toRlp(bundleSigningFields)), + ), + ); +} - const combined = new Uint8Array(domainBuf.length + txRlp.length + bundleRlp.length); - combined.set(domainBuf, 0); - combined.set(txRlp, domainBuf.length); - combined.set(bundleRlp, domainBuf.length + txRlp.length); +/** + * Compute the paymaster authorization hash for a sponsored AA bundle. + * + * Matches `shell-chain/crates/core/src/transaction.rs::SignedTransaction::paymaster_signing_hash`: + * `blake3( 0x7F || from || batch_signing_hash )`. + * + * @param from - Sender address bound into the paymaster authorization. + * @param tx - The outer unsigned AA transaction. + * @param bundle - AA bundle containing a paymaster address. + * @param signatureType - Signature algorithm name or numeric id. Defaults to Dilithium3 (`0`). + * @returns 32-byte BLAKE3 paymaster hash as a `Uint8Array`. + */ +export function hashPaymasterTransaction( + from: AddressLike, + tx: ShellTransactionRequest, + bundle: AaBundle, + signatureType: SignatureTypeName | number = "Dilithium3", +): Uint8Array { + if (!bundle.paymaster) { + throw new Error("hashPaymasterTransaction: bundle.paymaster must be set"); + } - return hexToBytes(keccak256(bytesToHex(combined))); + return blake3( + concatBytes( + new Uint8Array([PAYMASTER_SIGNING_HASH_DOMAIN]), + shellAddressToBytes(from), + hashBatchTransaction(tx, bundle, signatureType), + ), + ); } /** diff --git a/tests/aa.test.mjs b/tests/aa.test.mjs index 4fb6146..7f53eb9 100644 --- a/tests/aa.test.mjs +++ b/tests/aa.test.mjs @@ -8,6 +8,7 @@ import { buildInnerTransfer, buildInnerCall, hashBatchTransaction, + hashPaymasterTransaction, hexBytes, AA_BUNDLE_TX_TYPE, AA_MAX_INNER_CALLS, @@ -77,7 +78,7 @@ test('hashBatchTransaction: returns 32-byte Uint8Array', () => { const { tx, aa_bundle } = buildBatchTransaction({ chainId: 1, nonce: 0, innerCalls: [MINIMAL_INNER_CALL] }); const hash = hashBatchTransaction(tx, aa_bundle); assert.ok(hash instanceof Uint8Array, 'result must be Uint8Array'); - assert.equal(hash.length, 32, 'hash must be 32 bytes (keccak256)'); + assert.equal(hash.length, 32, 'hash must be 32 bytes (BLAKE3-256)'); }); test('hashBatchTransaction: rejects non-batch tx_type', () => { @@ -117,16 +118,30 @@ test('hashBatchTransaction: paymaster changes the hash', () => { assert.notEqual(h1, h2, 'paymaster must change the hash'); }); -// Fixed-vector test — regenerated if chain encoding changes (these are SDK internal vectors) -test('hashBatchTransaction: known deterministic vector (chain_id=1, nonce=0, single null-to call)', () => { +test('hashBatchTransaction: known Shell-chain vector (chain_id=1, nonce=0, single null-to call)', () => { const { tx, aa_bundle } = buildBatchTransaction({ chainId: 1, nonce: 0, innerCalls: [{ to: null, value: '0x0', data: '0x1234', gas_limit: '0x5208' }], }); const hash = hexBytes(hashBatchTransaction(tx, aa_bundle)); - // Record the actual computed value (32-byte keccak256 — deterministic across Node versions). - assert.match(hash, /^0x[0-9a-f]{64}$/, 'hash must be 32-byte hex'); + assert.equal(hash, '0xb51bf0c95c8d978c3b0eabaff6ecef7781af18ccea1c879b91dc766b9c89d3b8'); +}); + +test('hashPaymasterTransaction: known Shell-chain vector', () => { + const { tx, aa_bundle } = buildSponsoredTransaction({ + chainId: 1, + nonce: 0, + innerCalls: [MINIMAL_INNER_CALL], + paymaster: '0x0000000000000000000000000000000000000000000000000000000000000099', + paymasterSignature: new Uint8Array([1]), + }); + const hash = hexBytes(hashPaymasterTransaction( + '0x0000000000000000000000000000000000000000000000000000000000000042', + tx, + aa_bundle, + )); + assert.equal(hash, '0x683de83ea8fe62b27c5383dc1ff33d803cc497e9434a08658f923a0bc71f7637'); }); // --------------------------------------------------------------------------- diff --git a/tests/browser.integration.test.mjs b/tests/browser.integration.test.mjs index b3162fc..f3f3266 100644 --- a/tests/browser.integration.test.mjs +++ b/tests/browser.integration.test.mjs @@ -7,7 +7,6 @@ import { buildTransferTransaction, createShellProvider, generateMlDsa65KeyPair, - hashTransaction, } from '../dist/index.js'; import { createJsonRpcFetchMock } from './helpers.mjs'; @@ -29,7 +28,6 @@ test('browser integration: dist exports work with fetch-based provider', async ( }); const signed = await signer.buildSignedTransaction({ tx, - txHash: hashTransaction(tx), includePublicKey: true, }); const txHash = await provider.sendTransaction(signed); diff --git a/tests/fixtures/rust-compatibility.json b/tests/fixtures/rust-compatibility.json index 1c6c49f..4ba4b85 100644 --- a/tests/fixtures/rust-compatibility.json +++ b/tests/fixtures/rust-compatibility.json @@ -21,7 +21,7 @@ ], "transactions": [ { - "hash_hex": "0x2b6a097d446710be8398814e1d98de63285ef0ae1b72867c815c460a9fa979cd", + "hash_hex": "0x321560a5fc66ad1f3d887ff8e71fd5f66ff98efa3c4daa47ea12debbfbd25d3a", "name": "plain_transfer", "tx": { "access_list": null, @@ -39,7 +39,7 @@ } }, { - "hash_hex": "0xfabfe79361da08c7c5655c1b8b4376b8c670896c8f57febbe9fb9e5376eb453c", + "hash_hex": "0x359516b89d7b8d20eb1567dea4bc82bc294e618679fb925dd30e201fc7982587", "name": "access_list_call", "tx": { "access_list": [ diff --git a/tests/node.integration.test.mjs b/tests/node.integration.test.mjs index 24bb016..22f7ab6 100644 --- a/tests/node.integration.test.mjs +++ b/tests/node.integration.test.mjs @@ -5,12 +5,13 @@ import { MlDsa65Adapter, ShellSigner, assertSignerMatchesKeystore, + buildBatchTransaction, buildTransferTransaction, decryptKeystore, exportEncryptedKeyJson, generateMlDsa65KeyPair, - hashTransaction, parseEncryptedKey, + withDecryptedKeystoreSigner, } from '../dist/index.js'; import { createKeystoreFixture } from './helpers.mjs'; @@ -40,7 +41,6 @@ test('node integration: parse/decrypt keystore and sign transaction', async () = }); const signed = await decryptedSigner.buildSignedTransaction({ tx, - txHash: hashTransaction(tx), includePublicKey: true, }); @@ -54,6 +54,48 @@ test('node integration: parse/decrypt keystore and sign transaction', async () = assert.match(json, /"cipher": "xchacha20-poly1305"/); }); +test('node integration: withDecryptedKeystoreSigner disposes the signer after use', async () => { + const { publicKey, secretKey } = generateMlDsa65KeyPair(); + const signer = new ShellSigner('MlDsa65', MlDsa65Adapter.fromKeyPair(publicKey, secretKey)); + const keystore = await createKeystoreFixture({ + secretKey, + publicKey, + address: signer.getAddress(), + keyType: 'mldsa65', + password: 'correct horse battery', + }); + + let callbackSigner; + await withDecryptedKeystoreSigner(keystore, 'correct horse battery', async (decryptedSigner) => { + callbackSigner = decryptedSigner; + const tx = buildTransferTransaction({ + chainId: 424242, + nonce: 1, + to: signer.getAddress(), + value: 1n, + }); + const signed = await decryptedSigner.buildSignedTransaction({ tx, includePublicKey: true }); + assert.equal(signed.signature.sig_type, 'ML-DSA-65'); + }); + + await assert.rejects(() => callbackSigner.sign(new Uint8Array([1])), /disposed/i); +}); + +test('node integration: AA tx requires aaBundle when auto-computing txHash', async () => { + const { publicKey, secretKey } = generateMlDsa65KeyPair(); + const signer = new ShellSigner('MlDsa65', MlDsa65Adapter.fromKeyPair(publicKey, secretKey)); + const { tx } = buildBatchTransaction({ + chainId: 424242, + nonce: 0, + innerCalls: [{ to: signer.getAddress(), value: '0x0', data: '0x', gas_limit: '0x5208' }], + }); + + await assert.rejects( + () => signer.buildSignedTransaction({ tx, includePublicKey: true }), + /aaBundle is required/i, + ); +}); + test('node integration: tampered keystore address is rejected', async () => { const { publicKey, secretKey } = generateMlDsa65KeyPair(); const signer = new ShellSigner('MlDsa65', MlDsa65Adapter.fromKeyPair(publicKey, secretKey));