From f15508281f14f999951db9dd6fee71aee27f05fd Mon Sep 17 00:00:00 2001 From: nkechiogbuji Date: Wed, 29 Jul 2026 12:02:22 +0100 Subject: [PATCH] Develop offline transaction signing utility for cold storage --- README.md | 70 ++++++ package.json | 20 +- src/index.ts | 24 +- src/security/OfflineSigner.ts | 315 +++++++++++++++++++++++ src/security/index.ts | 9 + tests/security/OfflineSigner.test.ts | 362 +++++++++++++++++++++++++++ tests/security/no-network.test.ts | 154 ++++++++++++ tsconfig.cjs.json | 8 - tsconfig.esm.json | 7 - 9 files changed, 935 insertions(+), 34 deletions(-) create mode 100644 src/security/OfflineSigner.ts create mode 100644 src/security/index.ts create mode 100644 tests/security/OfflineSigner.test.ts create mode 100644 tests/security/no-network.test.ts diff --git a/README.md b/README.md index 9d36a370..d2b62ee0 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,76 @@ const isValid = await verify(messageHash, signature, publicKey) Both backends produce identical, low-S-normalized, RFC6979-deterministic output for the same input — the WASM path is purely a performance optimization, never a behavioral change. +## 🔒 Offline Transaction Signing (Cold Storage) + +`whitechain-sdk/security` exposes `OfflineSigner`, a dedicated signer for **air-gapped** environments — machines with no network connection at all, such as a cold-storage laptop or hardware-adjacent signing device. It constructs and signs legacy and EIP-1559 transactions using only values you supply directly. + +**Zero network dependencies, by construction:** `OfflineSigner` does not accept a provider, transport, public client, wallet client, RPC URL, chain RPC configuration, or SDK context of any kind — there is no parameter slot for one. It never calls `fetch`, `XMLHttpRequest`, `WebSocket`, an HTTP library, a viem client action, or a network-discovery function, and it never calls `getNetwork`, `getChainId`, `getTransactionCount`, `estimateGas`, `getGasPrice`, `estimateFeesPerGas`, `prepareTransactionRequest`, or any other helper that could silently fill in a missing field via RPC. Every field must come from you; a missing or invalid one fails immediately and locally with a `ValidationError`, never a network error. + +### The three-stage air-gapped workflow + +**1. Online preparation** *(internet-connected machine)* +- Fetch the account's next nonce, the current chain ID, an appropriate gas limit, and current fee data (`maxFeePerGas`/`maxPriorityFeePerGas`, or `gasPrice` for a legacy transaction). +- Assemble these into a plain, unsigned transaction object. +- Transfer **only this unsigned transaction data** to the offline environment (QR code, USB drive, manual entry) — never the private key in the other direction. + +**2. Offline signing** *(air-gapped machine, no network connection)* +- Import or otherwise securely provide the private key to this machine. +- Construct an `OfflineSigner` with it. +- Call `signTransaction(...)`, passing only the manually supplied values from stage 1. +- Export the resulting raw signed transaction hex. +- The private key never leaves this machine — it is never transmitted, logged, or included in any error message. + +**3. Online broadcast** *(internet-connected machine)* +- Transfer only the signed raw transaction hex back — never the private key. +- Broadcast it via `eth_sendRawTransaction` (or `publicClient.sendRawTransaction({ serializedTransaction })`) on any online node. +- A successfully *signed* transaction can still fail at this stage: if the nonce, fee levels, account balance, or other chain state changed between stage 1 and stage 3 (for example, another transaction from the same account was mined in between), the node may reject it. Re-run stage 1 with fresh values if that happens. + +```ts +import { OfflineSigner } from 'whitechain-sdk/security' + +// Stage 2 — air-gapped machine. `privateKey` never leaves this process. +const signer = new OfflineSigner(privateKey) // 0x-prefixed hex or Uint8Array + +const signed = await signer.signTransaction({ + type: 'eip1559', + chainId: 1, + nonce: 12, // from stage 1, supplied by you + to: '0xRecipient...', + value: 1_000000000_000000000n, // 1 ETH, in wei + gas: 21_000n, // from stage 1, supplied by you + maxFeePerGas: 30_000000000n, // from stage 1, supplied by you + maxPriorityFeePerGas: 2_000000000n, // required — no RPC to estimate a default from +}) + +// signed.raw is the payload for stage 3 — hand it to an online node: +// await publicClient.sendRawTransaction({ serializedTransaction: signed.raw }) +console.log(signed.raw) // 0x02... (ready to broadcast) +console.log(signed.hash) // keccak256(signed.raw) — matches eth_sendRawTransaction's return value +console.log(signed.from) // the address that produced the signature +``` + +Legacy (pre-EIP-1559) transactions are supported the same way, priced with `gasPrice` instead of `maxFeePerGas`/`maxPriorityFeePerGas`: + +```ts +const signed = await signer.signTransaction({ + chainId: 1, + nonce: 12, + to: '0xRecipient...', + value: 0n, + gas: 21_000n, + gasPrice: 20_000000000n, +}) +``` + +Contract creation is supported by omitting `to` (or passing `null`) alongside deployment `data` — this is only ever intentional, since a normal transfer or call always specifies `to`. + +All fields are validated locally and strictly: private key format/length, `chainId`/`nonce` as safe integers, a positive `gas` limit, non-negative fee/value fields, `maxFeePerGas >= maxPriorityFeePerGas`, address format/checksum, and well-formed hex call data. Every failure throws `ValidationError` synchronously or via a rejected promise — never a network error, since no network call is ever made. + +### ⚠️ Security warning + +`OfflineSigner` guarantees that the **signing step itself** performs no network I/O — that guarantee is enforced in code and covered by tests. It **cannot** guarantee the security of anything around that step: the operating system on the air-gapped machine, the removable media used to move data across the gap, how or where the private key is generated and stored, or the physical transfer process. Those remain entirely your responsibility. Treat the offline machine as if it will eventually be compromised, and design your key-management practices accordingly. + ## 🔌 Plugin System The SDK ships a first-class plugin architecture so community developers can extend the `WhitechainSDK` instance with custom namespaces — NFT marketplace helpers, lending calculators, analytics modules — without forking the core SDK or adding bloat to the core bundle. diff --git a/package.json b/package.json index 533a7cd6..e154c703 100644 --- a/package.json +++ b/package.json @@ -36,10 +36,16 @@ "types": "./dist/esm/subgraph/index.d.ts", "import": "./dist/esm/subgraph/index.js", "require": "./dist/cjs/subgraph/index.js" + }, "./crypto": { "types": "./dist/esm/crypto/index.d.ts", "import": "./dist/esm/crypto/index.js", "require": "./dist/cjs/crypto/index.js" + }, + "./security": { + "types": "./dist/esm/security/index.d.ts", + "import": "./dist/esm/security/index.js", + "require": "./dist/cjs/security/index.js" } }, "files": [ @@ -47,8 +53,6 @@ "cli/templates" ], "scripts": { - "build": "tsc -p tsconfig.esm.json && tsc -p tsconfig.cjs.json && node -e \"fs.mkdirSync('dist/esm', {recursive: true}); fs.writeFileSync('dist/esm/package.json', '{\\\"type\\\": \\\"module\\\"}')\" && node -e \"fs.mkdirSync('dist/cjs', {recursive: true}); fs.writeFileSync('dist/cjs/package.json', '{\\\"type\\\": \\\"commonjs\\\"}')\"", - "typecheck": "tsc -p tsconfig.json --noEmit", "build": "tsc --skipLibCheck -p tsconfig.esm.json && tsc --skipLibCheck -p tsconfig.cjs.json && node -e \"fs.mkdirSync('dist/esm', {recursive: true}); fs.writeFileSync('dist/esm/package.json', '{\\\"type\\\": \\\"module\\\"}')\" && node -e \"fs.mkdirSync('dist/cjs', {recursive: true}); fs.writeFileSync('dist/cjs/package.json', '{\\\"type\\\": \\\"commonjs\\\"}')\"", "typecheck": "tsc --skipLibCheck -p tsconfig.json --noEmit", "test": "vitest run", @@ -63,28 +67,22 @@ "author": "", "license": "MIT", "dependencies": { + "@noble/curves": "^1.9.0", + "abitype": "^1.3.0", "chalk": "^6.0.0", "commander": "^15.0.0", "prompts": "^2.4.2", + "tiny-secp256k1": "^2.2.4", "viem": "^2.8.0" }, "devDependencies": { "@types/node": "^26.1.1", "@types/prompts": "^2.4.9", - "abitype": "^1.3.0", - "viem": "^2.8.0", - "@noble/curves": "^1.9.0", - "tiny-secp256k1": "^2.2.4" - }, - "devDependencies": { - "@types/node": "^26.1.1", "@types/ws": "^8.5.10", "@vitest/coverage-v8": "^1.6.1", "typedoc": "^0.28.20", "typescript": "^5.4.0", "vitest": "^1.3.1", "ws": "^8.16.0" - "ws": "^8.16.0", - "vitest": "^1.3.1" } } diff --git a/src/index.ts b/src/index.ts index 69261ceb..66007669 100644 --- a/src/index.ts +++ b/src/index.ts @@ -72,7 +72,7 @@ export { type GetOnChainNonceFn, } from './wallet/index.js' - +export { IpcProvider, type IpcProviderOptions, } from './providers/IpcProvider.js' @@ -99,13 +99,11 @@ export type { Multicall3CallResult, Multicall3Options, } from './types/multicall.js' - Contract, - type ContractClient, +export { ContractWrapper, type ContractWrapperOptions, type ReadCallOptions, -} from './core/index.js' -export { HDWallet, createHDWallet, type HDWalletOptions } from './wallet/HDWallet.js' +} from './core/ContractWrapper.js' export { SubgraphClient, createSubgraphClient, @@ -117,9 +115,6 @@ export { type GetTopTradersOptions, type GetTradesOptions, } from './subgraph/index.js' -export { Contract, type ContractClient } from './core/Contract.js' -export { HDWallet, createHDWallet, type HDWalletOptions } from './wallet/HDWallet.js' -export { Contract, type ContractClient } from './core/Contract.js' export { MockProvider, @@ -154,3 +149,16 @@ export type { SDKLogger, PluginMeta, } from './interfaces/ISDKPlugin.js' + +// --------------------------------------------------------------------------- +// Offline / air-gapped transaction signing (cold storage) +// --------------------------------------------------------------------------- + +export { + OfflineSigner, + signOfflineTransaction, + type OfflineTransaction, + type OfflineLegacyTransaction, + type OfflineEip1559Transaction, + type SignedOfflineTransaction, +} from './security/index.js' diff --git a/src/security/OfflineSigner.ts b/src/security/OfflineSigner.ts new file mode 100644 index 00000000..88cd2c40 --- /dev/null +++ b/src/security/OfflineSigner.ts @@ -0,0 +1,315 @@ +/** + * Air-gapped Ethereum-compatible transaction signing for cold storage. + * + * `OfflineSigner` constructs and signs legacy and EIP-1559 transactions using + * only values supplied directly by the caller. This module has zero network + * dependencies: + * + * - it does not accept a provider, transport, public client, wallet client, + * RPC URL, chain RPC configuration, or SDK context of any kind; + * - it never calls `fetch`, `XMLHttpRequest`, `WebSocket`, an HTTP library, + * a viem client action, or a network-discovery function; + * - it never calls `getNetwork`, `getChainId`, `getTransactionCount`, + * `estimateGas`, `getGasPrice`, `estimateFeesPerGas`, + * `prepareTransactionRequest`, or any other helper that could silently + * fill in a missing field via RPC. + * + * Every value needed to sign — `nonce`, `chainId`, `gas`, `to`, `value`, + * `data`, and the fee fields for the chosen transaction type — must be + * supplied by the caller. There is no fallback and no lookup: a missing or + * invalid field always fails immediately and locally via {@link ValidationError}. + * + * The three-stage air-gapped workflow this module is designed for: + * 1. **Online preparation**: on an internet-connected machine, look up the + * nonce, chain ID, gas limit, and fee data, then assemble an + * {@link OfflineTransaction}. Only this unsigned data crosses over to + * the offline machine. + * 2. **Offline signing** (this module): on the air-gapped machine, provide + * the private key to {@link OfflineSigner}, sign using only the + * manually supplied values, and export the resulting {@link raw} hex. + * The private key never leaves this machine. + * 3. **Online broadcast**: transfer only the signed {@link raw} hex back to + * an online machine and submit it via `eth_sendRawTransaction`. A + * signed transaction can still fail at this stage if the nonce, fees, + * account balance, or chain state changed since stage 1. + * + * Security note: this module guarantees the *signing step* performs no + * network I/O. It cannot guarantee the security of the surrounding + * environment — the operating system, removable media used to transfer + * data, where/how the private key is stored, or the transfer process + * itself all remain the caller's responsibility. + * + * @see https://eips.ethereum.org/EIPS/eip-1559 + */ + +import { + keccak256, + parseTransaction, + type Address, + type Hex, + type TransactionSerializable, + type TransactionSerializableEIP1559, + type TransactionSerializableLegacy, +} from 'viem' +import { privateKeyToAccount } from 'viem/accounts' +import { ValidationError } from '../errors/index.js' +import { assertChecksumAddress } from '../utils/address.js' + +/** Fields common to both supported offline transaction shapes. */ +interface OfflineTransactionBase { + /** EIP-155 chain ID the transaction is valid on. */ + chainId: number + /** Account nonce. Must be supplied by the caller — never fetched. */ + nonce: number + /** Recipient address, or `null`/omitted for a contract-creation transaction. */ + to?: Address | null + /** Value to transfer, in wei. Defaults to `0n`. */ + value?: bigint + /** Call data / contract creation code. Defaults to `'0x'`. */ + data?: Hex + /** Gas limit for the transaction. Must be a positive bigint, supplied by the caller — never estimated. */ + gas: bigint +} + +/** A pre-EIP-1559 (type 0) transaction, priced with a flat `gasPrice`. */ +export interface OfflineLegacyTransaction extends OfflineTransactionBase { + type?: 'legacy' + /** Price per unit of gas, in wei. Must be supplied by the caller — never fetched. */ + gasPrice: bigint +} + +/** An EIP-1559 (type 2) transaction, priced with fee-market fields. */ +export interface OfflineEip1559Transaction extends OfflineTransactionBase { + type: 'eip1559' + /** Maximum total fee per unit of gas (base fee + priority fee), in wei. */ + maxFeePerGas: bigint + /** + * Maximum priority fee (tip) per unit of gas, in wei. Required — an + * air-gapped signer has no RPC to derive a safe default from, so this + * must be an explicit, deliberate choice by the caller. + */ + maxPriorityFeePerGas: bigint +} + +/** A transaction ready to be signed entirely offline. */ +export type OfflineTransaction = OfflineLegacyTransaction | OfflineEip1559Transaction + +/** The result of signing an {@link OfflineTransaction}. */ +export interface SignedOfflineTransaction { + /** + * The fully serialized, signed transaction as raw hex. Pass this directly + * to `eth_sendRawTransaction` (or `publicClient.sendRawTransaction`) on + * any online node to broadcast it. + */ + raw: Hex + /** keccak256 hash of {@link raw} — matches the hash `eth_sendRawTransaction` returns. */ + hash: Hex + /** The address that produced the signature, derived from the private key. */ + from: Address +} + +const HEX_DATA_RE = /^0x([0-9a-fA-F]{2})*$/ +const PRIVATE_KEY_HEX_RE = /^0x[0-9a-fA-F]{64}$/ + +function normalizePrivateKey(privateKey: Hex | Uint8Array): Hex { + if (typeof privateKey === 'string') { + if (!PRIVATE_KEY_HEX_RE.test(privateKey)) { + throw new ValidationError('privateKey must be a 32-byte hex string (0x-prefixed, 64 hex chars)') + } + return privateKey as Hex + } + if (privateKey instanceof Uint8Array) { + if (privateKey.length !== 32) { + throw new ValidationError(`privateKey must be 32 bytes, got ${privateKey.length}`) + } + const hex = Array.from(privateKey, (b) => b.toString(16).padStart(2, '0')).join('') + return `0x${hex}` as Hex + } + throw new ValidationError('privateKey must be a hex string or Uint8Array') +} + +function assertSafeInteger(name: string, value: number): void { + if (typeof value !== 'number' || !Number.isSafeInteger(value)) { + throw new ValidationError(`${name} must be a safe integer, got ${String(value)}`) + } +} + +function assertPositiveSafeInteger(name: string, value: number): void { + assertSafeInteger(name, value) + if (value <= 0) { + throw new ValidationError(`${name} must be a positive integer, got ${value}`) + } +} + +function assertNonNegativeSafeInteger(name: string, value: number): void { + assertSafeInteger(name, value) + if (value < 0) { + throw new ValidationError(`${name} must be a non-negative integer, got ${value}`) + } +} + +function assertNonNegativeBigInt(name: string, value: bigint): void { + if (typeof value !== 'bigint') { + throw new ValidationError(`${name} must be a bigint, got ${typeof value}`) + } + if (value < 0n) { + throw new ValidationError(`${name} must be non-negative, got ${value}`) + } +} + +function assertPositiveBigInt(name: string, value: bigint): void { + if (typeof value !== 'bigint') { + throw new ValidationError(`${name} must be a bigint, got ${typeof value}`) + } + if (value <= 0n) { + throw new ValidationError(`${name} must be positive, got ${value}`) + } +} + +function assertValidData(data: Hex): void { + if (!HEX_DATA_RE.test(data)) { + throw new ValidationError(`data must be a 0x-prefixed hex string with an even number of digits, got ${data}`) + } +} + +/** + * Validates `to` is a structurally valid 20-byte address. Plain lowercase + * or uppercase addresses (no checksum) are accepted as-is; mixed-case + * addresses must carry a *correct* EIP-55 checksum, catching the common + * typo of a single flipped character before it becomes an irreversible + * mistake. + */ +function assertValidToAddress(to: string): void { + if (!/^0x[0-9a-fA-F]{40}$/.test(to)) { + throw new ValidationError(`to is not a valid address: ${to}`) + } + const lower = to.toLowerCase() + const upper = to.toUpperCase().replace('X', 'x') + const isMixedCase = to !== lower && to !== upper + if (isMixedCase) { + assertChecksumAddress(to, 'to') + } +} + +/** + * Validates an {@link OfflineTransaction} and normalizes its optional fields. + * @throws {ValidationError} if any field is missing, malformed, or inconsistent. + */ +function validateTransaction(tx: OfflineTransaction): Required> { + assertPositiveSafeInteger('chainId', tx.chainId) + assertNonNegativeSafeInteger('nonce', tx.nonce) + assertPositiveBigInt('gas', tx.gas) + + const to = tx.to ?? null + if (to !== null) { + assertValidToAddress(to) + } + + const value = tx.value ?? 0n + assertNonNegativeBigInt('value', value) + + const data = tx.data ?? '0x' + assertValidData(data) + + if (tx.type === 'eip1559') { + assertNonNegativeBigInt('maxFeePerGas', tx.maxFeePerGas) + assertNonNegativeBigInt('maxPriorityFeePerGas', tx.maxPriorityFeePerGas) + if (tx.maxPriorityFeePerGas > tx.maxFeePerGas) { + throw new ValidationError('maxPriorityFeePerGas must not exceed maxFeePerGas') + } + } else { + assertNonNegativeBigInt('gasPrice', tx.gasPrice) + } + + return { to, value, data } +} + +function toSerializable(tx: OfflineTransaction): TransactionSerializable { + const { to, value, data } = validateTransaction(tx) + + if (tx.type === 'eip1559') { + const serializable: TransactionSerializableEIP1559 = { + type: 'eip1559', + chainId: tx.chainId, + nonce: tx.nonce, + to, + value, + data, + gas: tx.gas, + maxFeePerGas: tx.maxFeePerGas, + maxPriorityFeePerGas: tx.maxPriorityFeePerGas, + } + return serializable + } + + const serializable: TransactionSerializableLegacy = { + type: 'legacy', + chainId: tx.chainId, + nonce: tx.nonce, + to, + value, + data, + gas: tx.gas, + gasPrice: tx.gasPrice, + } + return serializable +} + +/** + * Signs an {@link OfflineTransaction} with `privateKey`, entirely offline. + * + * All transaction fields (nonce, gas, fees, chain ID) must be supplied by + * the caller — this function never reads them from a node. It performs no + * network I/O of any kind. + * + * @throws {ValidationError} if `privateKey` or any transaction field is invalid. + */ +export async function signOfflineTransaction( + privateKey: Hex | Uint8Array, + tx: OfflineTransaction, +): Promise { + const normalizedKey = normalizePrivateKey(privateKey) + const serializable = toSerializable(tx) + const account = privateKeyToAccount(normalizedKey) + + const raw = await account.signTransaction(serializable) + const hash = keccak256(raw) + + return { raw, hash, from: account.address } +} + +/** + * A dedicated, air-gapped transaction signer for cold storage use. + * + * Construct with a private key; the key is held in memory only for the + * lifetime of the instance and is never transmitted or logged. + * {@link signTransaction} performs no network I/O — every value it needs + * (nonce, gas limit, fees, chain ID) must be supplied by the caller. + */ +export class OfflineSigner { + /** The address corresponding to this signer's private key. */ + public readonly address: Address + + private readonly privateKey: Hex + + constructor(privateKey: Hex | Uint8Array) { + this.privateKey = normalizePrivateKey(privateKey) + this.address = privateKeyToAccount(this.privateKey).address + } + + /** + * Signs `tx` and returns the serialized raw transaction, ready to be + * broadcast by a separate, online node via `eth_sendRawTransaction`. + * + * Performs no network I/O — `tx` must already contain the nonce, gas + * limit, fee fields, and chain ID; none of them are looked up here. + * + * @throws {ValidationError} if any field of `tx` is invalid. + */ + async signTransaction(tx: OfflineTransaction): Promise { + return signOfflineTransaction(this.privateKey, tx) + } +} + +/** Re-exported for convenience when recovering/inspecting a signed payload in tests or tooling. */ +export { parseTransaction } diff --git a/src/security/index.ts b/src/security/index.ts new file mode 100644 index 00000000..63888f8c --- /dev/null +++ b/src/security/index.ts @@ -0,0 +1,9 @@ +export { + OfflineSigner, + signOfflineTransaction, + parseTransaction, + type OfflineTransaction, + type OfflineLegacyTransaction, + type OfflineEip1559Transaction, + type SignedOfflineTransaction, +} from './OfflineSigner.js' diff --git a/tests/security/OfflineSigner.test.ts b/tests/security/OfflineSigner.test.ts new file mode 100644 index 00000000..2c6ccc2b --- /dev/null +++ b/tests/security/OfflineSigner.test.ts @@ -0,0 +1,362 @@ +import { describe, it, expect } from 'vitest' +import { parseTransaction, recoverTransactionAddress } from 'viem' +import { + OfflineSigner, + signOfflineTransaction, + type OfflineEip1559Transaction, + type OfflineLegacyTransaction, +} from '../../src/security/OfflineSigner.js' +import { ValidationError } from '../../src/errors/index.js' + +// Standard Anvil/Hardhat test account #0 — same fixture already used by +// tests/wallet/HDWallet.test.ts, so the expected address is a known value. +const PRIVATE_KEY = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' as const +const ADDRESS = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266' +const RECIPIENT = '0x70997970C51812dc3A010C7d01b50e0d17dc79C8' + +const baseLegacy: OfflineLegacyTransaction = { + chainId: 1, + nonce: 5, + to: RECIPIENT, + value: 1_000_000_000_000_000_000n, + gas: 21_000n, + gasPrice: 20_000_000_000n, +} + +const baseEip1559: OfflineEip1559Transaction = { + type: 'eip1559', + chainId: 1, + nonce: 5, + to: RECIPIENT, + value: 1_000_000_000_000_000_000n, + gas: 21_000n, + maxFeePerGas: 30_000_000_000n, + maxPriorityFeePerGas: 2_000_000_000n, +} + +describe('OfflineSigner — legacy transactions', () => { + it('produces a 0x-prefixed signed payload', async () => { + const signer = new OfflineSigner(PRIVATE_KEY) + const signed = await signer.signTransaction(baseLegacy) + + expect(signed.raw).toMatch(/^0x[0-9a-fA-F]+$/) + expect(signed.hash).toMatch(/^0x[0-9a-fA-F]{64}$/) + expect(signed.from).toBe(ADDRESS) + }) + + it('round-trips through parseTransaction preserving chainId, nonce, to, value, gas, and gasPrice', async () => { + const signed = await signOfflineTransaction(PRIVATE_KEY, baseLegacy) + const parsed = parseTransaction(signed.raw) + + expect(parsed.chainId).toBe(baseLegacy.chainId) + expect(parsed.nonce).toBe(baseLegacy.nonce) + expect(parsed.to?.toLowerCase()).toBe(RECIPIENT.toLowerCase()) + expect(parsed.value).toBe(baseLegacy.value) + expect(parsed.gas).toBe(baseLegacy.gas) + expect((parsed as { gasPrice?: bigint }).gasPrice).toBe(baseLegacy.gasPrice) + }) + + it('recovers the signer address from the raw payload alone', async () => { + const signed = await signOfflineTransaction(PRIVATE_KEY, baseLegacy) + const recovered = await recoverTransactionAddress({ serializedTransaction: signed.raw }) + expect(recovered).toBe(ADDRESS) + }) + + it('supports contract-creation transactions (to omitted) with calldata preserved', async () => { + const signed = await signOfflineTransaction(PRIVATE_KEY, { + ...baseLegacy, + to: null, + data: '0x60806040', + }) + const parsed = parseTransaction(signed.raw) + expect(parsed.to).toBeFalsy() + expect((parsed as { data?: string }).data).toBe('0x60806040') + }) + + it('supports a native value transfer with empty calldata', async () => { + const signed = await signOfflineTransaction(PRIVATE_KEY, { ...baseLegacy }) + const parsed = parseTransaction(signed.raw) + expect(parsed.value).toBe(baseLegacy.value) + expect((parsed as { data?: string }).data ?? '0x').toBe('0x') + }) + + it('supports a zero-value calldata-only transaction', async () => { + const signed = await signOfflineTransaction(PRIVATE_KEY, { + ...baseLegacy, + value: 0n, + data: '0xa9059cbb000000000000000000000000', + }) + const parsed = parseTransaction(signed.raw) + expect(parsed.value ?? 0n).toBe(0n) + expect((parsed as { data?: string }).data).toBe('0xa9059cbb000000000000000000000000') + }) +}) + +describe('OfflineSigner — EIP-1559 transactions', () => { + it('produces a 0x02-prefixed signed payload', async () => { + const signed = await signOfflineTransaction(PRIVATE_KEY, baseEip1559) + expect(signed.raw).toMatch(/^0x02[0-9a-fA-F]+$/) + expect(signed.from).toBe(ADDRESS) + }) + + it('round-trips through parseTransaction preserving chainId, nonce, to, value, gas, and fee fields', async () => { + const signed = await signOfflineTransaction(PRIVATE_KEY, baseEip1559) + const parsed = parseTransaction(signed.raw) + + expect(parsed.type).toBe('eip1559') + expect(parsed.chainId).toBe(baseEip1559.chainId) + expect(parsed.nonce).toBe(baseEip1559.nonce) + expect(parsed.to?.toLowerCase()).toBe(RECIPIENT.toLowerCase()) + expect(parsed.value).toBe(baseEip1559.value) + expect(parsed.gas).toBe(baseEip1559.gas) + expect((parsed as { maxFeePerGas?: bigint }).maxFeePerGas).toBe(baseEip1559.maxFeePerGas) + expect((parsed as { maxPriorityFeePerGas?: bigint }).maxPriorityFeePerGas).toBe( + baseEip1559.maxPriorityFeePerGas, + ) + }) + + it('recovers the signer address from the raw payload alone', async () => { + const signed = await signOfflineTransaction(PRIVATE_KEY, baseEip1559) + const recovered = await recoverTransactionAddress({ serializedTransaction: signed.raw }) + expect(recovered).toBe(ADDRESS) + }) + + it('rejects maxPriorityFeePerGas greater than maxFeePerGas', async () => { + await expect( + signOfflineTransaction(PRIVATE_KEY, { + ...baseEip1559, + maxFeePerGas: 1_000n, + maxPriorityFeePerGas: 2_000n, + }), + ).rejects.toThrow(ValidationError) + }) + + it('accepts maxPriorityFeePerGas equal to maxFeePerGas', async () => { + const signed = await signOfflineTransaction(PRIVATE_KEY, { + ...baseEip1559, + maxFeePerGas: 5_000n, + maxPriorityFeePerGas: 5_000n, + }) + expect(signed.raw).toMatch(/^0x02[0-9a-fA-F]+$/) + }) +}) + +describe('OfflineSigner — determinism', () => { + it('produces byte-identical output for the same key and transaction', async () => { + const first = await signOfflineTransaction(PRIVATE_KEY, baseLegacy) + const second = await signOfflineTransaction(PRIVATE_KEY, baseLegacy) + expect(first.raw).toBe(second.raw) + expect(first.hash).toBe(second.hash) + }) + + it('produces byte-identical output for EIP-1559 transactions too', async () => { + const first = await signOfflineTransaction(PRIVATE_KEY, baseEip1559) + const second = await signOfflineTransaction(PRIVATE_KEY, baseEip1559) + expect(first.raw).toBe(second.raw) + }) + + it('changing only the nonce changes the signed payload', async () => { + const a = await signOfflineTransaction(PRIVATE_KEY, { ...baseLegacy, nonce: 5 }) + const b = await signOfflineTransaction(PRIVATE_KEY, { ...baseLegacy, nonce: 6 }) + expect(a.raw).not.toBe(b.raw) + expect(a.hash).not.toBe(b.hash) + }) +}) + +describe('OfflineSigner — large bigint values', () => { + it('signs a transaction with a very large but valid value, gas, and fee fields', async () => { + const signed = await signOfflineTransaction(PRIVATE_KEY, { + type: 'eip1559', + chainId: 1, + nonce: 0, + to: RECIPIENT, + value: 1_000_000_000_000_000_000_000_000n, // 1,000,000 ETH in wei + gas: 30_000_000n, // near a realistic block gas limit + maxFeePerGas: 500_000_000_000n, // 500 gwei + maxPriorityFeePerGas: 10_000_000_000n, + }) + const parsed = parseTransaction(signed.raw) + expect(parsed.value).toBe(1_000_000_000_000_000_000_000_000n) + expect(parsed.gas).toBe(30_000_000n) + }) +}) + +describe('OfflineSigner — private key validation', () => { + it('rejects a malformed hex private key', () => { + expect(() => new OfflineSigner('0xdeadbeef' as `0x${string}`)).toThrow(ValidationError) + }) + + it('rejects a private key missing the 0x prefix', () => { + expect( + () => new OfflineSigner('ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' as `0x${string}`), + ).toThrow(ValidationError) + }) + + it('rejects a wrong-length private key Uint8Array', () => { + expect(() => new OfflineSigner(new Uint8Array(16))).toThrow(ValidationError) + }) + + it('rejects a non-string, non-Uint8Array private key', () => { + expect(() => new OfflineSigner(12345 as unknown as `0x${string}`)).toThrow(ValidationError) + }) + + it('accepts a Uint8Array private key equivalent to its hex form', async () => { + const bytes = Uint8Array.from(Buffer.from(PRIVATE_KEY.slice(2), 'hex')) + const signer = new OfflineSigner(bytes) + expect(signer.address).toBe(ADDRESS) + }) +}) + +describe('OfflineSigner — transaction field validation', () => { + it('rejects a negative nonce', async () => { + await expect(signOfflineTransaction(PRIVATE_KEY, { ...baseLegacy, nonce: -1 })).rejects.toThrow( + ValidationError, + ) + }) + + it('rejects a non-integer nonce', async () => { + await expect(signOfflineTransaction(PRIVATE_KEY, { ...baseLegacy, nonce: 1.5 })).rejects.toThrow( + ValidationError, + ) + }) + + it('rejects an invalid (non-integer) chainId', async () => { + await expect(signOfflineTransaction(PRIVATE_KEY, { ...baseLegacy, chainId: 1.5 })).rejects.toThrow( + ValidationError, + ) + }) + + it('rejects a chainId of 0', async () => { + await expect(signOfflineTransaction(PRIVATE_KEY, { ...baseLegacy, chainId: 0 })).rejects.toThrow( + ValidationError, + ) + }) + + it('rejects a negative chainId', async () => { + await expect(signOfflineTransaction(PRIVATE_KEY, { ...baseLegacy, chainId: -1 })).rejects.toThrow( + ValidationError, + ) + }) + + it('rejects a zero gas limit', async () => { + await expect(signOfflineTransaction(PRIVATE_KEY, { ...baseLegacy, gas: 0n })).rejects.toThrow( + ValidationError, + ) + }) + + it('rejects a negative gas limit', async () => { + await expect(signOfflineTransaction(PRIVATE_KEY, { ...baseLegacy, gas: -1n })).rejects.toThrow( + ValidationError, + ) + }) + + it('rejects a negative value', async () => { + await expect(signOfflineTransaction(PRIVATE_KEY, { ...baseLegacy, value: -1n })).rejects.toThrow( + ValidationError, + ) + }) + + it('rejects a negative gasPrice on a legacy transaction', async () => { + await expect(signOfflineTransaction(PRIVATE_KEY, { ...baseLegacy, gasPrice: -1n })).rejects.toThrow( + ValidationError, + ) + }) + + it('rejects a negative maxFeePerGas on an EIP-1559 transaction', async () => { + await expect( + signOfflineTransaction(PRIVATE_KEY, { ...baseEip1559, maxFeePerGas: -1n }), + ).rejects.toThrow(ValidationError) + }) + + it('rejects a negative maxPriorityFeePerGas on an EIP-1559 transaction', async () => { + await expect( + signOfflineTransaction(PRIVATE_KEY, { ...baseEip1559, maxPriorityFeePerGas: -1n }), + ).rejects.toThrow(ValidationError) + }) + + it('rejects a malformed "to" address', async () => { + await expect( + signOfflineTransaction(PRIVATE_KEY, { ...baseLegacy, to: '0xnotanaddress' as `0x${string}` }), + ).rejects.toThrow(ValidationError) + }) + + it('rejects an incorrectly checksummed mixed-case "to" address', async () => { + const badChecksum = '0x70997970C51812dc3A010C7d01b50e0d17dc79c9' // last char flipped + await expect( + signOfflineTransaction(PRIVATE_KEY, { ...baseLegacy, to: badChecksum as `0x${string}` }), + ).rejects.toThrow(ValidationError) + }) + + it('accepts a plain lowercase "to" address (no checksum required)', async () => { + const signed = await signOfflineTransaction(PRIVATE_KEY, { + ...baseLegacy, + to: RECIPIENT.toLowerCase() as `0x${string}`, + }) + expect(signed.from).toBe(ADDRESS) + }) + + it('rejects malformed call data', async () => { + await expect( + signOfflineTransaction(PRIVATE_KEY, { ...baseLegacy, data: '0xzz' as `0x${string}` }), + ).rejects.toThrow(ValidationError) + }) + + it('rejects call data with an odd number of hex digits', async () => { + await expect( + signOfflineTransaction(PRIVATE_KEY, { ...baseLegacy, data: '0xabc' as `0x${string}` }), + ).rejects.toThrow(ValidationError) + }) +}) + +describe('OfflineSigner — missing mandatory fields fail locally', () => { + it('rejects a transaction missing nonce without attempting any lookup', async () => { + const originalFetch = globalThis.fetch + globalThis.fetch = (() => { + throw new Error('a network lookup was attempted to fill a missing field') + }) as typeof fetch + + try { + const incomplete = { ...baseLegacy } as Partial + delete incomplete.nonce + await expect( + signOfflineTransaction(PRIVATE_KEY, incomplete as OfflineLegacyTransaction), + ).rejects.toThrow(ValidationError) + } finally { + globalThis.fetch = originalFetch + } + }) + + it('rejects a transaction missing gas without attempting any lookup', async () => { + const originalFetch = globalThis.fetch + globalThis.fetch = (() => { + throw new Error('a network lookup was attempted to fill a missing field') + }) as typeof fetch + + try { + const incomplete = { ...baseLegacy } as Partial + delete incomplete.gas + await expect( + signOfflineTransaction(PRIVATE_KEY, incomplete as OfflineLegacyTransaction), + ).rejects.toThrow(ValidationError) + } finally { + globalThis.fetch = originalFetch + } + }) + + it('rejects an EIP-1559 transaction missing maxPriorityFeePerGas without attempting any lookup', async () => { + const originalFetch = globalThis.fetch + globalThis.fetch = (() => { + throw new Error('a network lookup was attempted to fill a missing field') + }) as typeof fetch + + try { + const incomplete = { ...baseEip1559 } as Partial + delete incomplete.maxPriorityFeePerGas + await expect( + signOfflineTransaction(PRIVATE_KEY, incomplete as OfflineEip1559Transaction), + ).rejects.toThrow(ValidationError) + } finally { + globalThis.fetch = originalFetch + } + }) +}) diff --git a/tests/security/no-network.test.ts b/tests/security/no-network.test.ts new file mode 100644 index 00000000..cb64b132 --- /dev/null +++ b/tests/security/no-network.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, afterEach } from 'vitest' +import { readFileSync, readdirSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { signOfflineTransaction, OfflineSigner } from '../../src/security/OfflineSigner.js' + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..') +const securitySrcDir = join(repoRoot, 'src', 'security') + +function listTsFiles(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = join(dir, entry.name) + if (entry.isDirectory()) return listTsFiles(full) + return entry.name.endsWith('.ts') ? [full] : [] + }) +} + +/** + * Strips block and line comments so the forbidden-symbol scan only matches + * actual code — this module's own JSDoc intentionally *names* the RPC + * methods it must never call, which would otherwise self-trigger the check. + */ +function stripComments(source: string): string { + return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '') +} + +const FORBIDDEN_PATTERNS: Array<{ name: string; pattern: RegExp }> = [ + { name: 'fetch(', pattern: /\bfetch\s*\(/ }, + { name: 'http(', pattern: /\bhttp\s*\(/ }, + { name: 'webSocket(', pattern: /\bwebSocket\s*\(/ }, + { name: 'WebSocket', pattern: /\bnew WebSocket\b/ }, + { name: 'XMLHttpRequest', pattern: /XMLHttpRequest/ }, + { name: 'createPublicClient', pattern: /createPublicClient/ }, + { name: 'createWalletClient', pattern: /createWalletClient/ }, + { name: 'createTransport', pattern: /createTransport/ }, + { name: 'getNetwork(', pattern: /getNetwork\s*\(/ }, + { name: 'getChainId(', pattern: /getChainId\s*\(/ }, + { name: 'getTransactionCount(', pattern: /getTransactionCount\s*\(/ }, + { name: 'estimateGas(', pattern: /estimateGas\s*\(/ }, + { name: 'getGasPrice(', pattern: /getGasPrice\s*\(/ }, + { name: 'estimateFeesPerGas(', pattern: /estimateFeesPerGas\s*\(/ }, + { name: 'prepareTransactionRequest', pattern: /prepareTransactionRequest/ }, + { name: 'node:http(s) import', pattern: /from ['"]node:https?['"]/ }, +] + +/** + * Issue #65: "Develop Offline Transaction Signing Utility for Cold Storage". + * OfflineSigner must construct and sign transactions in a fully air-gapped + * environment — these checks fail loudly if any RPC/network primitive is + * ever introduced into src/security, rather than relying on review to catch it. + */ +describe('OfflineSigner has no network operations (air-gapped guarantee)', () => { + it('never references RPC/network/auto-fetch symbols in src/security', () => { + const files = listTsFiles(securitySrcDir) + expect(files.length).toBeGreaterThan(0) + + for (const file of files) { + const code = stripComments(readFileSync(file, 'utf-8')) + for (const { name, pattern } of FORBIDDEN_PATTERNS) { + expect(code, `${file} should not reference "${name}" in code`).not.toMatch(pattern) + } + } + }) + + it('never imports a provider/transport/client/network module', () => { + const files = listTsFiles(securitySrcDir) + for (const file of files) { + const content = readFileSync(file, 'utf-8') + expect(content, `${file} should not import a network provider`).not.toMatch( + /from ['"].*\/(providers|network)\//, + ) + } + }) + + it('never imports axios or another HTTP client dependency', () => { + const files = listTsFiles(securitySrcDir) + for (const file of files) { + const content = readFileSync(file, 'utf-8') + expect(content, `${file} should not import an HTTP client`).not.toMatch( + /from ['"](axios|node-fetch|undici|ethers)['"]/, + ) + } + }) +}) + +const PRIVATE_KEY = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' as const +const RECIPIENT = '0x70997970C51812dc3A010C7d01b50e0d17dc79C8' + +describe('OfflineSigner never performs network I/O at runtime', () => { + const originalFetch = globalThis.fetch + const originalWebSocket = (globalThis as { WebSocket?: unknown }).WebSocket + const originalXHR = (globalThis as { XMLHttpRequest?: unknown }).XMLHttpRequest + + afterEach(() => { + globalThis.fetch = originalFetch + ;(globalThis as { WebSocket?: unknown }).WebSocket = originalWebSocket + ;(globalThis as { XMLHttpRequest?: unknown }).XMLHttpRequest = originalXHR + }) + + it('signs successfully with fetch, WebSocket, and XMLHttpRequest all replaced with throwing spies', async () => { + globalThis.fetch = (() => { + throw new Error('network access attempted via fetch()') + }) as typeof fetch + ;(globalThis as { WebSocket?: unknown }).WebSocket = class { + constructor() { + throw new Error('network access attempted via WebSocket') + } + } + ;(globalThis as { XMLHttpRequest?: unknown }).XMLHttpRequest = class { + constructor() { + throw new Error('network access attempted via XMLHttpRequest') + } + } + + const signed = await signOfflineTransaction(PRIVATE_KEY, { + chainId: 1, + nonce: 0, + to: RECIPIENT, + value: 0n, + gas: 21_000n, + gasPrice: 1_000_000_000n, + }) + expect(signed.raw).toMatch(/^0x[0-9a-fA-F]+$/) + + const signer = new OfflineSigner(PRIVATE_KEY) + const signedAgain = await signer.signTransaction({ + type: 'eip1559', + chainId: 1, + nonce: 1, + to: RECIPIENT, + value: 0n, + gas: 21_000n, + maxFeePerGas: 2_000_000_000n, + maxPriorityFeePerGas: 1_000_000_000n, + }) + expect(signedAgain.raw).toMatch(/^0x02[0-9a-fA-F]+$/) + }) + + it('requires no provider, client, or network argument to construct or sign', async () => { + // OfflineSigner's constructor takes only a private key, and signTransaction + // takes only the transaction object — there is no parameter slot for a + // provider/client/RPC config, so this is also enforced at the type level. + const signer = new OfflineSigner(PRIVATE_KEY) + const signed = await signer.signTransaction({ + chainId: 1, + nonce: 0, + to: RECIPIENT, + value: 0n, + gas: 21_000n, + gasPrice: 1_000_000_000n, + }) + expect(signed.from).toBe('0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266') + }) +}) diff --git a/tsconfig.cjs.json b/tsconfig.cjs.json index 6258683d..e8977070 100644 --- a/tsconfig.cjs.json +++ b/tsconfig.cjs.json @@ -9,12 +9,4 @@ }, "include": ["src"], "exclude": ["node_modules", "dist"] - "compilerOptions": { - "target": "ES2020", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "outDir": "dist/cjs", - "skipLibCheck": true - }, - "include": ["src"] } diff --git a/tsconfig.esm.json b/tsconfig.esm.json index 3e051b8d..df97957f 100644 --- a/tsconfig.esm.json +++ b/tsconfig.esm.json @@ -8,11 +8,4 @@ }, "include": ["src", "cli"], "exclude": ["node_modules", "dist", "cli/templates"] - "lib": ["ES2022", "DOM"], - "skipLibCheck": true, - "types": ["node"] - "skipLibCheck": true - }, - "include": ["src"], - "exclude": ["node_modules", "dist"] }