From 80ce8fdd667b18c72721e1de392c5b3d145139c2 Mon Sep 17 00:00:00 2001 From: Roberto Cano <3525807+robercano@users.noreply.github.com> Date: Thu, 6 Aug 2026 22:44:38 +0200 Subject: [PATCH] feat(core): pluggable signer seam + Safe/multisig propose mode (issue #154) Generalizes jsonRpcProvider's hard-wired private-key signing into a pluggable Signer interface (provider/signer.ts) - jsonRpcProvider() keeps its exact original signature/behavior, delegating to the new signerProvider() for external signers (hardware wallet, remote KMS, etc). Adds proposeDeploy() (propose/propose.ts): runs the same validate/resolve/ compile pipeline as deploy(), but collects the ordered transaction batch Ignition would send via an in-memory collectingProvider instead of broadcasting. Never touches the real, resumable deploymentDir/journal - proven by test/propose.test.ts's journal-invariant suite (byte-for-byte comparison before/after a propose() call against a partially-deployed spec). buildSafeBatch() (propose/safeBatch.ts) emits a Safe Transaction Builder-compatible batch JSON for config/call-only batches; raw contract-creation steps (to: null) are documented as an out-of-scope seam (Safe cannot originate raw CREATE) rather than silently mis-emitted. Documents the propose -> Safe execution -> resume operator flow in packages/core/README.md. --- packages/core/README.md | 141 +++++++ packages/core/src/index.ts | 37 +- .../core/src/propose/collectingProvider.ts | 282 +++++++++++++ packages/core/src/propose/errors.ts | 51 +++ packages/core/src/propose/propose.ts | 326 +++++++++++++++ packages/core/src/propose/safeBatch.ts | 156 ++++++++ packages/core/src/provider/jsonRpc.ts | 65 ++- packages/core/src/provider/signer.ts | 80 ++++ packages/core/test/propose.test.ts | 375 ++++++++++++++++++ packages/core/test/safeBatch.test.ts | 116 ++++++ packages/core/test/signer.test.ts | 156 ++++++++ 11 files changed, 1772 insertions(+), 13 deletions(-) create mode 100644 packages/core/src/propose/collectingProvider.ts create mode 100644 packages/core/src/propose/errors.ts create mode 100644 packages/core/src/propose/propose.ts create mode 100644 packages/core/src/propose/safeBatch.ts create mode 100644 packages/core/src/provider/signer.ts create mode 100644 packages/core/test/propose.test.ts create mode 100644 packages/core/test/safeBatch.test.ts create mode 100644 packages/core/test/signer.test.ts diff --git a/packages/core/README.md b/packages/core/README.md index 0d53414..7a7b752 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -107,3 +107,144 @@ with the previous one — deploying an incompatible implementation will corrupt storage. Run your own storage-layout check before upgrading, e.g. the [`@openzeppelin/upgrades-core`](https://github.com/OpenZeppelin/openzeppelin-upgrades) / `hardhat-upgrades` plugin's validator, or `forge inspect --pretty storage-layout`. + +## Signers and propose mode (issue #154) + +Production teams generally don't want a raw `DEPLOYER_PRIVATE_KEY` sitting in an +environment variable — they sign through a hardware wallet, a remote KMS, or (most +commonly) a Safe multisig. This section covers both pieces: the pluggable signer seam, +and "propose mode" — collecting a transaction batch instead of broadcasting it. + +### Pluggable signers + +`jsonRpc.ts`'s signing step is factored into a narrow `Signer` interface +(`provider/signer.ts`): + +```ts +export interface Signer { + readonly address: `0x${string}`; + signTransaction(tx): Promise<`0x${string}`>; + signMessage(args: { message: { raw: `0x${string}` } }): Promise<`0x${string}`>; + signTypedData(args): Promise<`0x${string}`>; +} +``` + +`jsonRpcProvider({ rpcUrl, privateKey })` is **unchanged** — same signature, same +behavior — it just derives a `Signer` internally via `privateKeySigner(privateKey)` and +delegates to the new, more general `signerProvider({ rpcUrl, signer })`. To wire up an +external signer (hardware wallet, remote KMS, etc.), implement `Signer` yourself and call +`signerProvider()` directly: + +```ts +import { signerProvider, deploy } from "@redeploy/core"; + +const provider = signerProvider({ + rpcUrl: process.env.RPC_URL!, + signer: myHardwareWalletSigner, // implements Signer +}); + +await deploy({ spec, provider, accounts: [myHardwareWalletSigner.address], ... }); +``` + +This follows the same injection pattern `DeployOptions` already uses for +`provider`/`accounts`/`defaultSender` — no new options were added to `DeployOptions` +itself; the signer seam lives one level down, in how you construct the `provider` you +pass in. + +### Propose mode: collecting a batch instead of broadcasting + +`proposeDeploy()` runs the exact same validate → resolve → compile pipeline as +`deploy()`, but instead of broadcasting transactions it returns the ordered batch +Ignition *would* have sent: + +```ts +import { proposeDeploy, buildSafeBatch } from "@redeploy/core"; + +const { transactions } = await proposeDeploy({ + spec, provider, accounts, artifactResolver, + deploymentDir, // OPTIONAL — see "resuming" below +}); + +const safeBatch = buildSafeBatch(transactions, { chainId: 1, name: "My deploy" }); +// -> write safeBatch to a .json file and import it in the Safe Transaction Builder UI +``` + +`transactions` is `{ to, data, value }[]`, in send order. `buildSafeBatch()` converts +that into the Safe Transaction Builder's batch JSON schema (`version`, `chainId`, +`createdAt`, `meta`, `transactions[]`). + +**Why this reuses Ignition's real engine instead of a hand-rolled planner:** unlike +`simulate()` (chain-free, no real addresses/calldata), `proposeDeploy()` runs Ignition's +actual `deploy()` against an in-memory `collectingProvider` (`propose/collectingProvider.ts`) +that intercepts `eth_sendTransaction` and never broadcasts. Everything downstream — +constructor-arg encoding, dependency batching, proxy expansion, CREATE address +prediction — is therefore identical to what a real `deploy()` run would produce, with +zero duplicated logic. + +#### The journal invariant + +**A transaction that is only proposed — never signed and broadcast — must never be +recorded as complete in your real, resumable journal.** `proposeDeploy()` guarantees +this structurally, not by convention: it never passes your real `deploymentDir` to +Ignition. Either: + +- **Fresh proposal** (no `deploymentDir` given): Ignition runs against a throwaway temp + directory, deleted when `proposeDeploy()` returns. No journal is ever created at any + path you can see. +- **Resuming** (`deploymentDir` given, pointing at a real, partially-complete + deployment): its `journal.jsonl` is **copied** (read-only on the original) into the + same throwaway temp directory. Ignition sees the real resume state — it skips + already-COMPLETE futures exactly like a normal resume — and only *collects* + transactions for futures that are NOT yet complete. The copy (and anything Ignition + appends to it during this run) is deleted at the end. Your real `deploymentDir` is + opened at most once, for a read, and never for a write. + +See `test/propose.test.ts`'s "journal invariant" suite for the executable proof +(byte-for-byte comparison of the real `journal.jsonl` before/after a `proposeDeploy()` +call against a partially-deployed spec). + +#### The confirm-then-resume operator flow + +1. **Propose**: `proposeDeploy({ ..., deploymentDir })` → `buildSafeBatch()` → write the + JSON, import it into the Safe Transaction Builder UI (or feed `transactions` to + whatever external-signer tooling you use). +2. **Execute**: the Safe's signers (or the external signer, one transaction at a time) + actually sign and execute the batch on-chain. +3. **Resume**: re-run a **normal** `deploy()` against the same `deploymentDir`, using a + `provider`/`accounts` combination that reflects who actually sent the transactions. + +Step 3 is deliberately where this feature's scope ends. `deploy()`'s idempotent +journal-replay only recognizes a future as complete when it observes the matching +on-chain result through Ignition's own execution engine — bridging "the Safe executed +batch X" back into Ignition's journal format automatically (so a plain resume also works +when the **Safe itself**, not an EOA/signer Ignition's engine already tracks, was the +sender) is **not implemented here**. It is a real, documented follow-up seam — most +likely a small journal-reconciliation tool that reads the Safe's executed-transaction +history and imports the resulting addresses, rather than anything `propose/propose.ts` +itself should own. + +#### Safe Transaction Service API — not implemented (documented seam) + +Issue #154 allows, but doesn't require, actually *submitting* the proposal via Safe's +Transaction Service API rather than stopping at the batch JSON. That endpoint needs an +EIP-712 signature from a real Safe owner and a live (or heavily-mocked) HTTP round-trip — +properly unit-testing it without a network dependency means modeling Safe's API contract +in real detail, which didn't fit this change's scope ("only if it comes cheap and is +fully unit-testable without network" — it isn't, here). `buildSafeBatch()`'s output is +the seam: a thin HTTP client can be layered on top of it in a follow-up. + +#### Contract-creation steps and Safe batches (documented limitation) + +A `ProposedTransaction` with `to: null` is a raw contract-CREATION transaction — +Ignition's basic deploy strategy has no factory, so it sends a plain CREATE. Safe has no +representation for this: a Safe transaction is always a `CALL`/`DELEGATECALL` to an +explicit `to` address; a Safe cannot originate a raw `CREATE`. `buildSafeBatch()` throws +`SafeBatchError("UNSUPPORTED_CREATION", ...)` if the batch contains any such entry, +rather than silently emitting a batch Safe's own tooling would reject. + +Config/call-only batches (post-deployment configuration — every transaction already has +a real `to`) are fully supported today. Real Safe-based **deployment** of new contracts +requires routing creation through a deterministic factory (e.g. the widely-used CREATE2 +proxy at `0x4e59b44847b379578588920cA78FbF26c0B4956`), turning the step into an ordinary +call. Wiring that up — an alternate Ignition strategy, or a compile-time rewrite of +creation futures into factory calls — is explicitly out of scope for this change. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index fbf2a0f..9559a38 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -74,8 +74,15 @@ export { simulate } from "./simulate/simulate.js"; export { foundryArtifactResolver } from "./resolvers/foundry.js"; // EIP-1193 provider factory — wire deploy() with a local key + JSON-RPC URL -export type { JsonRpcProviderOptions } from "./provider/jsonRpc.js"; -export { jsonRpcProvider } from "./provider/jsonRpc.js"; +export type { JsonRpcProviderOptions, SignerProviderOptions } from "./provider/jsonRpc.js"; +export { jsonRpcProvider, signerProvider } from "./provider/jsonRpc.js"; + +// Pluggable signer seam (issue #154) — generalizes jsonRpcProvider's signing +// step so deploy/config transactions can be signed by an external signer +// (hardware wallet, remote KMS, ...) instead of a raw private key. See +// provider/signer.ts for the full Signer contract. +export type { Signer } from "./provider/signer.js"; +export { privateKeySigner } from "./provider/signer.js"; // Typed resolver escape-hatch (Layer 2) — async pre-deploy resolution of // `{ kind: "resolver" }` args against an injected ResolverRegistry, wired via @@ -105,3 +112,29 @@ export type { CrossNetworkJournal, ResolveCrossRefOptions } from "./resolve/cros export { resolveCrossRefArgs, specHasCrossRefArgs } from "./resolve/crossRef.js"; export type { CrossRefErrorCode } from "./resolve/crossRefErrors.js"; export { CrossRefError } from "./resolve/crossRefErrors.js"; + +// Propose execution mode (issue #154) — collects the ordered batch of +// transactions a deployment WOULD send (to/data/value) instead of +// broadcasting them, for external-signer / Safe-multisig workflows. See +// propose/propose.ts for the full design, especially the "JOURNAL SAFETY" +// section (proposed-but-not-executed transactions are never journaled as +// complete), and this package's README ("Propose mode") for the +// propose -> Safe execution -> resume operator flow. +export type { ProposeOptions, ProposeResult, ProposedTransaction } from "./propose/propose.js"; +export { proposeDeploy } from "./propose/propose.js"; +export type { ProposeErrorCode } from "./propose/errors.js"; +export { ProposeError } from "./propose/errors.js"; + +// Safe Transaction Builder-compatible JSON batch output (issue #154, point 3) +// — a pure, offline transform from ProposeResult.transactions to the Safe +// Transaction Builder batch schema. See safeBatch.ts's module doc for the +// documented scope boundary (raw contract-creation steps, `to: null`, +// cannot be represented — throws SafeBatchError) and the Safe Transaction +// Service API seam (deliberately not implemented). +export type { + SafeBatchErrorCode, + SafeBatchJson, + SafeBatchTransactionJson, + BuildSafeBatchOptions, +} from "./propose/safeBatch.js"; +export { buildSafeBatch, SafeBatchError } from "./propose/safeBatch.js"; diff --git a/packages/core/src/propose/collectingProvider.ts b/packages/core/src/propose/collectingProvider.ts new file mode 100644 index 0000000..f6c519f --- /dev/null +++ b/packages/core/src/propose/collectingProvider.ts @@ -0,0 +1,282 @@ +/** + * A "collecting" EIP-1193 provider used ONLY by propose/propose.ts's + * ephemeral, in-memory Ignition run. + * + * DESIGN — why this exists and why it is SAFE + * ============================================ + * Propose mode wants to reuse Ignition's REAL execution engine (dependency + * batching, constructor-arg encoding, upgradeable-proxy expansion, CREATE + * address computation) rather than reimplementing any of it — see this + * package's CLAUDE.md "don't reinvent Ignition" rule. But it must NEVER + * actually broadcast a transaction, and it must NEVER let Ignition write a + * "complete" journal entry for a transaction nobody signed/sent yet (see + * propose.ts's module doc for the full journal-safety argument). + * + * This provider satisfies both constraints by intercepting the + * send-transaction / poll-for-receipt cycle ENTIRELY in memory: + * - `eth_sendTransaction` is never broadcast. The `{to, data, value}` is + * appended to an in-order `transactions` array (the propose-mode batch) + * and a locally-synthesized tx hash is returned. + * - `eth_getTransactionReceipt` / `eth_getTransactionByHash` answer with a + * synthetic "already mined" receipt for hashes THIS provider minted, + * computing the CREATE-predicted contract address via viem's + * `getContractAddress` (nonce-based, matching real EVM CREATE + * semantics) so downstream `ref` args resolve to a plausible address — + * Ignition needs this to compile/encode LATER futures that depend on + * EARLIER ones in the batch. + * - `eth_getTransactionCount` / `eth_getCode` / `eth_estimateGas` / + * `eth_gasPrice` / `eth_blockNumber` / `eth_getBlockByNumber` are all + * answered LOCALLY too, once nonces start being tracked — the real chain + * is deliberately NEVER consulted about addresses that only exist inside + * this ephemeral sandbox (they'd correctly report "no code here", which + * would corrupt Ignition's view of its own in-progress batch). + * + * The ONLY calls that ever reach the real network are ONE-TIME bootstrap + * reads (current nonce, chain id) for each address the FIRST time it is + * seen — done through the real `EIP1193Provider` the caller supplies. Since + * this whole provider is discarded at the end of `propose()` and is never + * wired to Ignition's real (disk-backed) journal loader, nothing it does can + * ever reach — let alone corrupt — the caller's persistent `deploymentDir`. + * + * This mirrors the exact method surface already proven sufficient to drive a + * REAL Ignition `deploy()` run in `test/deploy.test.ts` / + * `test/testHelpers/fakeEip1193Provider.ts` (both use an equivalent + * fully-local fake provider for the same reason: unit-testable, no network). + */ + +import { getContractAddress } from "viem"; +import type { EIP1193Provider } from "@nomicfoundation/ignition-core"; + +/** One transaction captured from an intercepted `eth_sendTransaction` call, in send order. */ +export interface CollectedTransaction { + /** `null` for a contract-creation transaction (Ignition's basic CREATE strategy). */ + readonly to: string | null; + /** Calldata (or init code + encoded constructor args, for creation). */ + readonly data: string; + /** Value in wei, as a decimal string. */ + readonly value: string; +} + +export interface CollectingProviderOptions { + /** + * The REAL provider used ONLY for one-time bootstrap reads (starting nonce, + * chain id) — see this module's doc. Never used to broadcast or to read + * anything about addresses this collecting provider itself invents. + */ + realProvider: EIP1193Provider; +} + +export interface CollectingProviderResult { + /** The EIP-1193 provider to hand to Ignition's `deploy()` in place of a real one. */ + readonly provider: EIP1193Provider; + /** + * The ordered batch of transactions Ignition attempted to send, in send + * order. Populated DURING the `deploy()` call this provider is used + * for — read this AFTER that call resolves. + */ + readonly transactions: CollectedTransaction[]; +} + +/** Builds a fresh collecting provider + its (initially empty) captured-transactions array. */ +export function makeCollectingProvider(options: CollectingProviderOptions): CollectingProviderResult { + const { realProvider } = options; + + const transactions: CollectedTransaction[] = []; + const receipts = new Map< + string, + { + readonly contractAddress: string | null; + /** The block this synthetic tx was "mined" in — FIXED at send time, never + * recomputed from the live `blockNumber` counter (which keeps advancing + * to simulate chain progress). Ignition derives confirmation depth as + * `currentBlock - minedBlockNumber`; reusing the live counter here would + * make that delta permanently 0 and hang forever waiting for + * confirmations that can never arrive. */ + readonly minedBlockNumber: number; + readonly from: string; + readonly nonce: number; + } + >(); + const nonces = new Map(); + const bootstrapped = new Set(); + let chainIdHex: string | undefined; + let blockNumber = 0; + let blockNumberBootstrapped = false; + let hashCounter = 0; + + /** Lazily fetches (once) the real starting nonce for `address` from the real provider. */ + async function ensureNonceBootstrapped(address: string): Promise { + const key = address.toLowerCase(); + if (bootstrapped.has(key)) return; + bootstrapped.add(key); + const startNonceHex = (await realProvider.request({ + method: "eth_getTransactionCount", + params: [address, "latest"], + })) as string; + nonces.set(key, Number(BigInt(startNonceHex))); + } + + async function ensureChainId(): Promise { + if (chainIdHex === undefined) { + chainIdHex = (await realProvider.request({ method: "eth_chainId" })) as string; + } + return chainIdHex; + } + + /** + * Lazily seeds the ephemeral block-number counter from the REAL chain's + * current height, plus a large safety margin. + * + * WHY THIS MATTERS: Ignition's nonce-sync check (`getNonceSyncMessages`, + * run at the START of every `deploy()` call) computes + * `confirmedBlockNumber = block.number - requiredConfirmations + 1` + * (default `requiredConfirmations` is 5) and, if that's negative (i.e. + * `block.number` is too low), treats EVERY nonce already used by the + * sender as "possibly unconfirmed" and throws `WAITING_FOR_CONFIRMATIONS` + * — exactly the failure this margin avoids. This matters specifically for + * propose()'s RESUME case (see propose.ts): the sender's nonce is already + * > 0 (from real prior deploys) by the time this ephemeral run starts, so + * starting the fake block counter at "1" would spuriously trip that check + * even though nothing is actually unconfirmed. + */ + async function ensureBlockNumberBootstrapped(): Promise { + if (blockNumberBootstrapped) return; + blockNumberBootstrapped = true; + const startBlockHex = (await realProvider.request({ method: "eth_blockNumber" })) as string; + blockNumber = Number(BigInt(startBlockHex)) + 1000; + } + + const provider: EIP1193Provider = { + async request({ + method, + params, + }: { + method: string; + params?: readonly unknown[] | object; + }): Promise { + const p = Array.isArray(params) ? params : []; + + // Bootstrap the block-number counter (once) before handling ANY + // request — several branches below read/increment `blockNumber`, and + // it must be seeded from the real chain height first (see + // ensureBlockNumberBootstrapped's doc comment). + await ensureBlockNumberBootstrapped(); + + switch (method) { + case "hardhat_getAutomine": + case "web3_clientVersion": + throw new Error(`collectingProvider: unsupported method "${method}"`); + + case "eth_chainId": + return ensureChainId(); + + case "eth_accounts": + case "eth_requestAccounts": + // Not used for account discovery in propose mode — Ignition is + // given `accounts` explicitly by the caller (see propose.ts). + return []; + + case "eth_blockNumber": + return "0x" + blockNumber.toString(16); + + case "eth_getBlockByNumber": + blockNumber += 1; + return { + number: "0x" + blockNumber.toString(16), + hash: "0x" + blockNumber.toString(16).padStart(64, "0"), + }; + + case "eth_getTransactionCount": { + const address = p[0] as string; + await ensureNonceBootstrapped(address); + return "0x" + (nonces.get(address.toLowerCase()) ?? 0).toString(16); + } + + case "eth_gasPrice": + return "0x3b9aca00"; + + case "eth_estimateGas": + return "0x30d40"; + + case "eth_getCode": + // Always report "has code" so Ignition's own idempotency checks + // (if any) don't second-guess a future this ephemeral run just + // "deployed". Matches test/testHelpers/fakeEip1193Provider.ts. + return "0x6001"; + + case "eth_call": + return "0x"; + + case "eth_sendTransaction": { + const txParams = p[0] as Record; + const from = (txParams.from as string | undefined) ?? ""; + await ensureNonceBootstrapped(from); + + const to = (txParams.to as string | null | undefined) ?? null; + const data = (txParams.data as string | undefined) ?? "0x"; + const value = + txParams.value !== undefined ? BigInt(txParams.value as string).toString() : "0"; + transactions.push({ to, data, value }); + + const fromKey = from.toLowerCase(); + const usedNonce = nonces.get(fromKey) ?? 0; + nonces.set(fromKey, usedNonce + 1); + + hashCounter += 1; + const txHash = `0x${hashCounter.toString(16).padStart(4, "0")}${"fe".repeat(30)}`; + + const contractAddress = + to === null && from !== "" + ? getContractAddress({ from: from as `0x${string}`, nonce: BigInt(usedNonce) }) + : null; + + blockNumber += 1; + receipts.set(txHash, { + contractAddress, + minedBlockNumber: blockNumber, + from, + nonce: usedNonce, + }); + return txHash; + } + + case "eth_getTransactionByHash": { + const txHash = p[0] as string; + const receipt = receipts.get(txHash); + if (receipt === undefined) return null; + const chainId = await ensureChainId(); + return { + hash: txHash, + blockHash: "0x" + receipt.minedBlockNumber.toString(16).padStart(64, "0"), + blockNumber: "0x" + receipt.minedBlockNumber.toString(16), + from: receipt.from, + to: null, + input: "0x", + value: "0x0", + chainId, + nonce: "0x" + receipt.nonce.toString(16), + gasPrice: "0x3b9aca00", + }; + } + + case "eth_getTransactionReceipt": { + const txHash = p[0] as string; + const receipt = receipts.get(txHash); + if (receipt === undefined) return null; + return { + blockHash: "0x" + receipt.minedBlockNumber.toString(16).padStart(64, "0"), + blockNumber: "0x" + receipt.minedBlockNumber.toString(16), + status: "0x1", + contractAddress: receipt.contractAddress, + logs: [], + }; + } + + default: + throw new Error(`collectingProvider: unhandled method "${method}"`); + } + }, + }; + + return { provider, transactions }; +} diff --git a/packages/core/src/propose/errors.ts b/packages/core/src/propose/errors.ts new file mode 100644 index 0000000..764a245 --- /dev/null +++ b/packages/core/src/propose/errors.ts @@ -0,0 +1,51 @@ +/** + * Error types for the propose module (issue #154). + * + * Mirrors deploy/errors.ts's DeployError shape and error codes for the parts + * of the pipeline propose() shares with deploy() (validate -> crossRef -> + * resolver -> compile — see propose/propose.ts), plus one propose-specific + * code for failures during the ephemeral collection run itself. + */ + +import type { SpecError } from "../spec/validate.js"; + +/** Discriminated error codes for ProposeError. */ +export type ProposeErrorCode = + /** The DeploymentSpec provided to propose() failed validateSpec(). */ + | "INVALID_SPEC" + /** An internal invariant was violated during spec compilation. */ + | "COMPILE_ERROR" + /** A `{ kind: "resolver" }` arg named a resolver absent from `ProposeOptions.resolvers`. */ + | "UNKNOWN_RESOLVER" + /** A resolver function threw, or a spec parameter could not be coerced. */ + | "RESOLVER_ERROR" + /** A `{ kind: "crossRef" }` arg named a network absent from `ProposeOptions.crossNetworkJournals`. */ + | "CROSS_REF_ERROR" + /** + * The ephemeral collection run did not complete successfully (Ignition + * reported a non-successful DeploymentResult against the collecting + * provider — e.g. an unhandled RPC method, or an on-chain-execution + * future such as a static call that the collecting provider cannot + * satisfy). Check ProposeError.message for detail. + */ + | "EXECUTION_FAILED"; + +/** + * Thrown by proposeDeploy() when the DeploymentSpec is invalid, cannot be + * compiled, or the ephemeral collection run fails. Never thrown for "the + * batch worked but contains a step propose mode can't represent" — + * see safeBatch.ts's own ProposeError("EXECUTION_FAILED", ...) equivalent, + * SafeBatchError, for that case. + */ +export class ProposeError extends Error { + readonly code: ProposeErrorCode; + /** The raw SpecError list when code === "INVALID_SPEC". Undefined otherwise. */ + readonly specErrors?: SpecError[]; + + constructor(code: ProposeErrorCode, message: string, specErrors?: SpecError[]) { + super(message); + this.name = "ProposeError"; + this.code = code; + this.specErrors = specErrors; + } +} diff --git a/packages/core/src/propose/propose.ts b/packages/core/src/propose/propose.ts new file mode 100644 index 0000000..bfb6e9d --- /dev/null +++ b/packages/core/src/propose/propose.ts @@ -0,0 +1,326 @@ +/** + * Propose execution mode for @redeploy/core (issue #154). + * + * `proposeDeploy()` runs the SAME spec-resolution + compilation pipeline as + * `deploy()` (validate -> resolve crossRef args -> resolve resolver args -> + * compileSpec), but instead of broadcasting transactions with the real + * `provider`, it collects the ordered batch of transactions Ignition WOULD + * have sent (`to`/`data`/`value`, in send order) and returns them — no + * transaction is ever signed or broadcast by this function. + * + * WHY REUSE IGNITION'S ENGINE INSTEAD OF REINVENTING A PLANNER + * ============================================================== + * `simulate()` (simulate/simulate.ts) is deliberately chain-free and does + * NOT compile a real Ignition module or assign addresses — it can't produce + * real transaction calldata (constructor-arg encoding, proxy expansion, + * predicted addresses for `ref`-dependent futures). Reimplementing + * Ignition's constructor-arg encoding / dependency batching / CREATE address + * prediction here would violate this package's "don't reinvent what + * Ignition provides" rule (CLAUDE.md) and would drift from Ignition's actual + * behavior over time. + * + * Instead, `proposeDeploy()` runs Ignition's REAL `deploy()` engine against + * a `collectingProvider` (see propose/collectingProvider.ts) that intercepts + * `eth_sendTransaction` and never lets it reach a real chain. Everything + * downstream of "what transaction would Ignition send next" — encoding, + * ordering, proxy expansion, address prediction — is therefore IDENTICAL to + * a real `deploy()` run against the same spec. + * + * JOURNAL SAFETY (the load-bearing invariant — issue #154, point 4) + * ==================================================================== + * `proposeDeploy()` NEVER passes the caller's real `deploymentDir` to + * Ignition's `deploy()`. Instead: + * + * - If `ProposeOptions.deploymentDir` is omitted, Ignition runs against a + * throwaway temp directory that is deleted when `proposeDeploy()` + * returns. No journal.jsonl is ever created at any caller-visible path. + * + * - If `ProposeOptions.deploymentDir` IS given (the "propose the REMAINING + * work of a partially-completed deployment" case — see this package's + * README, "Propose mode" section, for the full confirm-then-resume + * flow), its `journal.jsonl` is COPIED (read-only on the original) into + * the same throwaway temp directory before Ignition runs. Ignition then + * sees the real resume state (skips already-COMPLETE futures, exactly + * like a normal `deploy()` resume) and only sends — i.e. only collects — + * transactions for futures that are NOT yet complete. The throwaway copy + * (with whatever new entries Ignition appends to IT during this + * ephemeral run) is deleted at the end. The caller's real + * `deploymentDir` is opened at most once, for a read, and is NEVER + * opened for a write by this function. + * + * Either way, a transaction that is only PROPOSED (never signed/broadcast by + * an external signer or Safe) can never end up recorded as COMPLETE in the + * real, resumable journal — because that journal is never the one Ignition + * writes to here. See test/propose.test.ts's "journal invariant" tests for + * the executable proof. + * + * WHAT HAPPENS AFTER THE SAFE EXECUTES THE BATCH (confirm-then-resume) + * ======================================================================== + * See this package's README ("Propose mode") for the full operator flow. + * In short: `proposeDeploy()` only PLANS; it never updates the real journal. + * After the proposed batch is actually executed on-chain (by the Safe's + * signers, or by an external signer processing the batch one at a time), + * the operator resumes with a NORMAL `deploy()` call using a `provider` + * capable of observing that the transactions already succeeded (e.g. a + * signer whose `accounts[0]` IS the address that actually sent them) so + * Ignition's own idempotent journal-replay logic takes back over. Bridging + * "the Safe executed transaction batch X" back into Ignition's own journal + * format (so a plain `deploy()` resume also works when the SAFE itself — + * not an EOA the journal already recognizes — was the sender) is explicitly + * OUT OF SCOPE for this change; see the README for the documented seam. + */ + +import { deploy as ignitionDeploy, DeploymentResultType } from "@nomicfoundation/ignition-core"; +import type { + ArtifactResolver, + DeploymentParameters, + EIP1193Provider, +} from "@nomicfoundation/ignition-core"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { DeploymentSpec } from "../spec/types.js"; +import { validateSpec } from "../spec/validate.js"; +import { compileSpec } from "../compile/compile.js"; +import type { ResolverRegistry } from "../resolve/registry.js"; +import { + buildResolverParams, + resolveSpecResolverArgs, + specHasResolverArgs, +} from "../resolve/resolveSpec.js"; +import { ResolveError } from "../resolve/errors.js"; +import { loadAddressesFromJournal } from "../resolve/journal.js"; +import type { CrossNetworkJournal } from "../resolve/crossRef.js"; +import { resolveCrossRefArgs, specHasCrossRefArgs } from "../resolve/crossRef.js"; +import { CrossRefError } from "../resolve/crossRefErrors.js"; +import { ProposeError } from "./errors.js"; +import { makeCollectingProvider } from "./collectingProvider.js"; +import type { CollectedTransaction } from "./collectingProvider.js"; + +// Re-export error types for public API surface +export type { ProposeErrorCode } from "./errors.js"; +export { ProposeError } from "./errors.js"; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/** + * One proposed transaction, in the order Ignition would have sent it. + * + * `to === null` marks a contract-CREATION transaction (Ignition's basic + * strategy uses raw CREATE — no factory). See safeBatch.ts's module doc for + * why this specific shape is NOT directly representable in a Safe + * Transaction Builder batch, and the documented seam for that gap. + */ +export interface ProposedTransaction { + /** `null` for contract creation; the target contract address for a call. */ + readonly to: string | null; + /** Calldata — init code + encoded constructor args for creation, or a function call. */ + readonly data: string; + /** Value in wei, as a decimal string. */ + readonly value: string; +} + +/** + * Options for proposeDeploy(). Mirrors DeployOptions (deploy/deploy.ts) — + * same injectable seams (`provider`, `accounts`, `resolvers`, + * `crossNetworkJournals`) — except `deploymentDir` is OPTIONAL and, when + * given, is used STRICTLY READ-ONLY (see this file's module doc, "JOURNAL + * SAFETY"). There is no `preflight` option: propose mode never broadcasts, + * so pre-broadcast safety checks (chain id / balance / gas price) don't + * apply the same way here — call `runPreflight()` (deploy/preflight.ts) + * yourself first if you want an equivalent sanity check before proposing. + */ +export interface ProposeOptions { + /** The declarative deployment spec. Will be validated before compilation. */ + spec: DeploymentSpec; + /** + * An EIP-1193 compatible provider for on-chain READS ONLY (current nonce, + * chain id, and any resolver-arg reads). NEVER used to sign or broadcast — + * see collectingProvider.ts. + */ + provider: EIP1193Provider; + /** List of signer accounts (hex addresses). The first account is the default sender. */ + accounts: string[]; + /** Resolves Solidity artifacts (ABI, bytecode) by contract name. */ + artifactResolver: ArtifactResolver; + /** Ignition module ID. Defaults to "Deployment". Must match the real deployment's, if resuming. */ + moduleId?: string; + /** Ignition deployment parameters keyed by module ID. */ + deploymentParameters?: DeploymentParameters; + /** Override the default sender (must be one of `accounts`). Defaults to `accounts[0]`. */ + defaultSender?: string; + /** Injectable resolver registry for `{ kind: "resolver" }` args. See DeployOptions.resolvers. */ + resolvers?: ResolverRegistry; + /** Injected map of network name -> journal location, for `{ kind: "crossRef" }` args. */ + crossNetworkJournals?: Record; + /** + * OPTIONAL path to an EXISTING real deployment's directory. When given, + * its `journal.jsonl` is read (NEVER written) to determine which futures + * are already complete, so proposeDeploy() only proposes the REMAINING + * work — the "propose mode as a resume step" case. Omit this to propose a + * fresh deployment from scratch. + */ + deploymentDir?: string; +} + +/** The result of a proposeDeploy() call. */ +export interface ProposeResult { + /** The ordered batch of transactions Ignition would have sent, never broadcast. */ + readonly transactions: ProposedTransaction[]; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Plan a DeploymentSpec's transactions WITHOUT broadcasting or signing any of + * them, and WITHOUT ever marking any future complete in a real, resumable + * journal — see this file's module doc for the full design and the + * "JOURNAL SAFETY" invariant. + * + * @throws ProposeError mirroring DeployError's codes for the shared + * validate/resolve/compile pipeline (INVALID_SPEC, UNKNOWN_RESOLVER, + * RESOLVER_ERROR, CROSS_REF_ERROR, COMPILE_ERROR), plus EXECUTION_FAILED + * if the ephemeral collection run itself does not complete successfully. + */ +export async function proposeDeploy(options: ProposeOptions): Promise { + const { + spec, + provider, + accounts, + artifactResolver, + moduleId, + deploymentParameters, + defaultSender, + deploymentDir, + } = options; + + // --- 1. Validate spec ------------------------------------------------------- + const validateResult = validateSpec(spec); + if (!validateResult.ok) { + throw new ProposeError( + "INVALID_SPEC", + `DeploymentSpec validation failed with ${validateResult.errors.length} error(s): ${validateResult.errors.map((e) => e.message).join("; ")}`, + validateResult.errors, + ); + } + + const effectiveModuleId = moduleId ?? "Deployment"; + let specForCompile: DeploymentSpec = validateResult.spec; + + // --- 2. Resolve `crossRef` args (mirrors deploy.ts step 2) ------------------ + if (specHasCrossRefArgs(specForCompile)) { + try { + specForCompile = await resolveCrossRefArgs(specForCompile, { + journals: options.crossNetworkJournals ?? {}, + }); + } catch (err) { + if (err instanceof CrossRefError) { + throw new ProposeError("CROSS_REF_ERROR", err.message); + } + const msg = err instanceof Error ? err.message : String(err); + throw new ProposeError( + "CROSS_REF_ERROR", + `Failed to resolve DeploymentSpec crossRef args: ${msg}`, + ); + } + } + + // --- 3. Resolve `resolver` args (mirrors deploy.ts step 3) ------------------ + // + // Uses the REAL `provider` for any on-chain reads a resolver performs, and + // (if resuming) the REAL deploymentDir's journal for already-known + // addresses — both READ-ONLY, same as deploy(). This runs BEFORE the + // collecting provider even exists. + if (specHasResolverArgs(specForCompile)) { + try { + const resolvedAddresses = deploymentDir + ? await loadAddressesFromJournal(deploymentDir, effectiveModuleId) + : {}; + const params = buildResolverParams(specForCompile, effectiveModuleId, deploymentParameters); + specForCompile = await resolveSpecResolverArgs(specForCompile, { + registry: options.resolvers ?? {}, + params, + resolvedAddresses, + provider, + }); + } catch (err) { + if (err instanceof ResolveError) { + throw new ProposeError( + err.code === "UNKNOWN_RESOLVER" ? "UNKNOWN_RESOLVER" : "RESOLVER_ERROR", + err.message, + ); + } + const msg = err instanceof Error ? err.message : String(err); + throw new ProposeError( + "RESOLVER_ERROR", + `Failed to resolve DeploymentSpec resolver args: ${msg}`, + ); + } + } + + // --- 4. Compile spec into an Ignition module -------------------------------- + let ignitionModule; + try { + ignitionModule = compileSpec(specForCompile, { moduleId }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new ProposeError( + "COMPILE_ERROR", + `Failed to compile DeploymentSpec into an Ignition module: ${msg}`, + ); + } + + // --- 5. Ephemeral collection run — see "JOURNAL SAFETY" in the module doc --- + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "redeploy-propose-")); + try { + if (deploymentDir !== undefined) { + const realJournalPath = path.join(deploymentDir, "journal.jsonl"); + if (fs.existsSync(realJournalPath)) { + // READ-ONLY on the caller's real deploymentDir: copy it into the + // throwaway tmpDir. Every subsequent read/write for THIS run targets + // ONLY tmpDir — the original path is never opened for writing. + fs.cpSync(deploymentDir, tmpDir, { recursive: true }); + } + } + + const { provider: collectingProvider, transactions } = makeCollectingProvider({ + realProvider: provider, + }); + + const ignitionResult = await ignitionDeploy({ + ignitionModule, + provider: collectingProvider, + accounts, + deploymentDir: tmpDir, + artifactResolver, + deploymentParameters: deploymentParameters ?? {}, + defaultSender, + }); + + if (ignitionResult.type !== DeploymentResultType.SUCCESSFUL_DEPLOYMENT) { + throw new ProposeError( + "EXECUTION_FAILED", + `Propose-mode collection run did not complete successfully (Ignition result type: ${ignitionResult.type}). ` + + `This usually means a future in the spec could not be planned by the ephemeral collecting provider.`, + ); + } + + return { + transactions: transactions.map( + (tx: CollectedTransaction): ProposedTransaction => ({ + to: tx.to, + data: tx.data, + value: tx.value, + }), + ), + }; + } finally { + // ALWAYS discard the throwaway directory — this run's state (and the + // copied journal snapshot, if any) never persists anywhere. + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +} diff --git a/packages/core/src/propose/safeBatch.ts b/packages/core/src/propose/safeBatch.ts new file mode 100644 index 0000000..3ffec1c --- /dev/null +++ b/packages/core/src/propose/safeBatch.ts @@ -0,0 +1,156 @@ +/** + * Safe Transaction Builder-compatible JSON batch output (issue #154, point 3). + * + * `buildSafeBatch()` converts a `ProposedTransaction[]` (from + * `proposeDeploy()` — propose/propose.ts) into the minimal JSON shape the + * Safe Transaction Builder UI/CLI accepts for a batch import: `version`, + * `chainId`, `createdAt`, `meta`, and `transactions[]` with `to`/`value`/ + * `data`. This is a pure, offline transform — no network access, no Safe + * Transaction Service API call (see the module doc below for that seam). + * + * SCOPE BOUNDARY — contract CREATION cannot be represented (documented seam) + * ============================================================================== + * A `ProposedTransaction` with `to === null` is a raw contract-CREATION + * transaction — exactly what Ignition's basic strategy sends for `m.contract` + * futures (no `to`, `data` is init code + encoded constructor args). The Safe + * Transaction Builder format — and Safe's `execTransaction` itself — has NO + * representation for this: a Safe transaction is always a `CALL` (or + * `DELEGATECALL`) to an explicit `to` address; Safe contracts cannot + * originate a raw `CREATE`. + * + * Production Safe-based contract deployment works around this by routing + * creation through a deterministic deployment factory (e.g. the widely-used + * CREATE2 proxy at `0x4e59b44847b379578588920cA78FbF26c0B4956`), which turns + * the step into an ordinary CALL (`to: `, `data: `). + * Wiring that up (an alternate Ignition strategy, or a compile-time rewrite + * of creation futures into factory calls) is EXPLICITLY OUT OF SCOPE for + * this change — `buildSafeBatch()` throws a clear `SafeBatchError` if the + * input contains any `to === null` entry, rather than silently emitting an + * invalid batch. Config/call-only batches (post-deployment configuration — + * every transaction already has a real `to`) are fully supported today. + * + * SAFE TRANSACTION SERVICE API (OPTIONAL — NOT IMPLEMENTED) + * ============================================================= + * Issue #154 allows (but does not require) actually PROPOSING the batch via + * Safe's Transaction Service API (`POST /v1/safes/{address}/multisig-transactions/`), + * "only if it comes cheap and is fully unit-testable without network". That + * endpoint requires an EIP-712 signature over the Safe transaction hash from + * an actual Safe owner and a live (or heavily mocked) HTTP round-trip to + * Safe's infrastructure — properly unit-testing that without a network + * dependency means mocking Safe's API contract in detail, which is a + * meaningfully sized follow-up, not a "comes cheap" addition here. This file + * deliberately stops at producing the batch JSON — a caller can feed it to + * the Safe Transaction Builder UI directly (drag-and-drop import) today, or + * a follow-up ticket can add a thin HTTP client on top of this same + * `SafeBatchJson` shape. + */ + +import type { ProposedTransaction } from "./propose.js"; + +/** Discriminated error code for SafeBatchError. */ +export type SafeBatchErrorCode = "UNSUPPORTED_CREATION"; + +/** + * Thrown by buildSafeBatch() when the input batch contains a transaction + * that cannot be represented in the Safe Transaction Builder format — see + * this file's module doc, "SCOPE BOUNDARY". + */ +export class SafeBatchError extends Error { + readonly code: SafeBatchErrorCode; + + constructor(code: SafeBatchErrorCode, message: string) { + super(message); + this.name = "SafeBatchError"; + this.code = code; + } +} + +/** One transaction entry in the Safe Transaction Builder batch JSON schema. */ +export interface SafeBatchTransactionJson { + readonly to: string; + readonly value: string; + readonly data: string; +} + +/** The Safe Transaction Builder batch JSON schema (minimal viable subset). */ +export interface SafeBatchJson { + readonly version: string; + readonly chainId: string; + readonly createdAt: number; + readonly meta: { + readonly name: string; + readonly description: string; + readonly txBuilderVersion: string; + }; + readonly transactions: SafeBatchTransactionJson[]; +} + +/** Options for buildSafeBatch(). */ +export interface BuildSafeBatchOptions { + /** The chain id the batch targets (e.g. 1 for mainnet). */ + readonly chainId: number; + /** Optional human-readable batch name. Defaults to "reDeploy batch". */ + readonly name?: string; + /** Optional human-readable description. Defaults to "". */ + readonly description?: string; + /** + * Override for `Date.now()` — lets tests assert an exact `createdAt` + * without mocking global time. Defaults to `Date.now`. + */ + readonly now?: () => number; +} + +/** + * Builds a Safe Transaction Builder-compatible batch JSON object from an + * ORDERED list of proposed transactions (see propose/propose.ts's + * `ProposeResult.transactions` — order is preserved verbatim here, matching + * how the Transaction Builder / Safe's `MultiSend` executes batch entries + * strictly in array order). + * + * SECURITY: no private key or signer material ever flows through this + * function — it operates purely on the already-public `to`/`data`/`value` + * transaction shape. + * + * @throws SafeBatchError with code "UNSUPPORTED_CREATION" if any transaction + * has `to === null` (a raw contract-creation step) — see this file's + * module doc, "SCOPE BOUNDARY". + */ +export function buildSafeBatch( + transactions: readonly ProposedTransaction[], + options: BuildSafeBatchOptions, +): SafeBatchJson { + const creationIndexes = transactions + .map((tx, index) => ({ tx, index })) + .filter(({ tx }) => tx.to === null) + .map(({ index }) => index); + + if (creationIndexes.length > 0) { + throw new SafeBatchError( + "UNSUPPORTED_CREATION", + `buildSafeBatch: ${creationIndexes.length} transaction(s) at index [${creationIndexes.join(", ")}] ` + + `are raw contract-creation steps ("to": null). The Safe Transaction Builder format has no ` + + `representation for a raw CREATE — Safe's execTransaction always calls an explicit "to" address. ` + + `Production Safe-based contract deployment requires routing creation through a deterministic ` + + `deployment factory (e.g. a CREATE2 proxy) so the step becomes an ordinary call. This is a ` + + `documented, deferred seam (see packages/core/README.md's "Propose mode" section) — NOT ` + + `implemented by buildSafeBatch(). Config/call-only batches (every transaction has a non-null ` + + `"to") are fully supported.`, + ); + } + + return { + version: "1.0", + chainId: String(options.chainId), + createdAt: (options.now ?? Date.now)(), + meta: { + name: options.name ?? "reDeploy batch", + description: options.description ?? "", + txBuilderVersion: "1.16.5", + }, + transactions: transactions.map((tx) => ({ + to: tx.to as string, + value: tx.value, + data: tx.data, + })), + }; +} diff --git a/packages/core/src/provider/jsonRpc.ts b/packages/core/src/provider/jsonRpc.ts index aecb98b..0396660 100644 --- a/packages/core/src/provider/jsonRpc.ts +++ b/packages/core/src/provider/jsonRpc.ts @@ -2,7 +2,7 @@ * EIP-1193 provider factory for @redeploy/core. * * Creates a provider backed by viem that: - * - Signs transactions LOCALLY with the supplied private key account + * - Signs transactions LOCALLY via a pluggable `Signer` (see provider/signer.ts) * - Broadcasts signed transactions via eth_sendRawTransaction over HTTP JSON-RPC * - Forwards all read-only methods verbatim to the JSON-RPC transport * @@ -16,20 +16,31 @@ * sign locally -- local signing only happens through viem's high-level action API * (walletClient.sendTransaction). Therefore we implement the signing layer ourselves: * - * - eth_accounts / eth_requestAccounts -> return [account.address] immediately (no RPC) + * - eth_accounts / eth_requestAccounts -> return [signer.address] immediately (no RPC) * - eth_sendTransaction -> sign locally, broadcast via eth_sendRawTransaction * - eth_signTransaction -> sign locally, return signed raw tx (no broadcast) - * - personal_sign -> route to account.signMessage (params: [data, address]) - * - eth_sign -> route to account.signMessage (params: [address, data]) - * - eth_signTypedData_v4 / _v3 -> route to account.signTypedData (parse JSON param) + * - personal_sign -> route to signer.signMessage (params: [data, address]) + * - eth_sign -> route to signer.signMessage (params: [address, data]) + * - eth_signTypedData_v4 / _v3 -> route to signer.signTypedData (parse JSON param) * - ALL OTHER methods -> forward verbatim to the transport * + * SIGNER SEAM (issue #154) + * ======================== + * The signing step is delegated to a pluggable `Signer` (provider/signer.ts) + * instead of being hard-wired to a raw private key. `jsonRpcProvider()` keeps + * its ORIGINAL signature/behavior unchanged for backwards compatibility — it + * derives a `Signer` from the private key via `privateKeySigner()` and hands + * it to `signerProvider()`, the generalized factory below. Callers who need a + * hardware wallet / remote KMS / any other external signer use + * `signerProvider({ rpcUrl, signer })` directly with their own `Signer` + * implementation. + * * SECURITY * ======== * The private key is consumed once to derive the viem account and is NEVER * stored on the returned object, logged, printed, or included in error messages. - * The only time the private key is in memory is during `privateKeyToAccount()` - * inside this factory call. + * The only time the private key is in memory is during `privateKeySigner()`'s + * call to `privateKeyToAccount()`, inside signer construction. */ import { @@ -38,8 +49,11 @@ import { type TransactionSerializableLegacy, type TransactionSerializableEIP1559, } from "viem"; -import { privateKeyToAccount } from "viem/accounts"; import type { EIP1193Provider } from "@nomicfoundation/ignition-core"; +import { privateKeySigner, type Signer } from "./signer.js"; + +export type { Signer } from "./signer.js"; +export { privateKeySigner } from "./signer.js"; /** * Options for jsonRpcProvider. @@ -53,6 +67,21 @@ export interface JsonRpcProviderOptions { privateKey: string; } +/** + * Options for signerProvider — the generalized form of jsonRpcProvider that + * accepts any pluggable `Signer` instead of a raw private key. + * + * @property rpcUrl - Full HTTP/HTTPS RPC endpoint URL. + * @property signer - A `Signer` (see provider/signer.ts) that performs LOCAL + * signing on behalf of its `address`. Use `privateKeySigner(pk)` for the + * original private-key behavior, or supply your own implementation backed + * by a hardware wallet, remote KMS, etc. + */ +export interface SignerProviderOptions { + rpcUrl: string; + signer: Signer; +} + /** * Creates an EIP-1193 compatible provider that uses viem under the hood. * @@ -64,12 +93,26 @@ export interface JsonRpcProviderOptions { * invalid (not a valid secp256k1 scalar). */ export function jsonRpcProvider({ rpcUrl, privateKey }: JsonRpcProviderOptions): EIP1193Provider { - // Derive the account from the private key. - // `privateKeyToAccount` validates the key. Any error thrown here does NOT + // Derive the signer from the private key. + // `privateKeySigner` validates the key. Any error thrown here does NOT // include the private key in its message (viem produces "Invalid private key" // type errors without echoing the value). - const account = privateKeyToAccount(privateKey as `0x${string}`); + const signer = privateKeySigner(privateKey); + return signerProvider({ rpcUrl, signer }); +} +/** + * Creates an EIP-1193 compatible provider that uses viem under the hood, + * signing LOCALLY via the supplied pluggable `Signer` (see provider/signer.ts) + * instead of a hard-wired private key. This is the generalized form of + * `jsonRpcProvider()` — see this file's module doc ("SIGNER SEAM") for the + * full design. + * + * The transport is used only for read methods and for broadcasting signed + * transactions via eth_sendRawTransaction. The node never receives an + * unsigned eth_sendTransaction. + */ +export function signerProvider({ rpcUrl, signer: account }: SignerProviderOptions): EIP1193Provider { const transport = http(rpcUrl); // PublicClient: used for all read-only RPC forwarding and for filling in diff --git a/packages/core/src/provider/signer.ts b/packages/core/src/provider/signer.ts new file mode 100644 index 0000000..13d3190 --- /dev/null +++ b/packages/core/src/provider/signer.ts @@ -0,0 +1,80 @@ +/** + * Pluggable signer seam for @redeploy/core's provider layer. + * + * DESIGN + * ====== + * `jsonRpc.ts` originally derived a viem local account DIRECTLY from a raw + * private key and used it to sign every transaction/message. Issue #154 asks + * for an external-signer / Safe-proposal deploy path so production teams + * don't need a raw `DEPLOYER_PRIVATE_KEY` at all. + * + * Rather than re-architecting `DeployOptions` (it already has an injection + * seam — `provider: EIP1193Provider`, see deploy/deploy.ts), we generalize + * ONE level down: `jsonRpc.ts`'s signing logic is parameterized over a + * `Signer` interface instead of a raw private key. `jsonRpcProvider()` keeps + * its exact existing signature/behavior (private-key -> local viem account) + * as one `Signer` implementation (`privateKeySigner`); callers who want a + * hardware wallet, remote KMS, or any other external signer implement this + * SAME narrow interface and pass it to `signerProvider()` instead. + * + * `Signer` is intentionally a minimal structural subset of viem's account + * `CustomSource` shape (address + the three signing methods jsonRpc.ts + * actually calls) — NOT the full `LocalAccount`/`PrivateKeyAccount` type, + * which carries local-key-only fields (`publicKey`, `source`, `type`) that an + * external signer (e.g. a hardware wallet or a remote signing service) has no + * reason to implement. Any viem local account (including the one + * `privateKeyToAccount` returns) satisfies `Signer` structurally with no + * adapter needed. + */ + +import { privateKeyToAccount } from "viem/accounts"; +import type { + TransactionSerializableLegacy, + TransactionSerializableEIP1559, + TypedDataDomain, + TypedDataParameter, +} from "viem"; + +/** + * A pluggable transaction/message signer. + * + * Implementations MUST sign locally (or via whatever secure channel they + * wrap — an HSM, a hardware wallet, a remote KMS) and MUST NEVER expose the + * underlying key material through this interface. + */ +export interface Signer { + /** The 0x-prefixed address this signer signs on behalf of. */ + readonly address: `0x${string}`; + + /** Sign a serializable transaction (legacy or EIP-1559) and return the raw signed tx hex. */ + signTransaction( + transaction: TransactionSerializableLegacy | TransactionSerializableEIP1559, + ): Promise<`0x${string}`>; + + /** Sign a raw message (as used by `personal_sign` / `eth_sign`). */ + signMessage(args: { message: { raw: `0x${string}` } }): Promise<`0x${string}`>; + + /** Sign EIP-712 typed data (as used by `eth_signTypedData_v3` / `_v4`). */ + signTypedData(args: { + domain?: TypedDataDomain; + types: Record; + primaryType: string; + message: Record; + }): Promise<`0x${string}`>; +} + +/** + * Builds a `Signer` backed by a raw private key, signing LOCALLY via viem's + * `privateKeyToAccount`. This is the exact same account construction + * `jsonRpcProvider()` has always used — extracted here so it can also be + * passed explicitly to `signerProvider()` (e.g. by callers who want to build + * the signer once and reuse it across multiple providers/networks). + * + * SECURITY: the private key is consumed once to derive the account and is + * NEVER stored on the returned object, logged, or included in error + * messages — see jsonRpc.ts's module doc for the full security note (it + * still applies unchanged; this function is a pure extraction). + */ +export function privateKeySigner(privateKey: string): Signer { + return privateKeyToAccount(privateKey as `0x${string}`); +} diff --git a/packages/core/test/propose.test.ts b/packages/core/test/propose.test.ts new file mode 100644 index 0000000..5feac67 --- /dev/null +++ b/packages/core/test/propose.test.ts @@ -0,0 +1,375 @@ +/** + * Tests for proposeDeploy() (propose/propose.ts) — issue #154. + * + * ARCHITECTURE + * ============ + * Like deploy.test.ts, we run the REAL Ignition engine (via proposeDeploy(), + * which internally calls Ignition's real deploy()) against a fake in-memory + * "real provider" (test/testHelpers/fakeEip1193Provider.ts) — no network, no + * anvil. proposeDeploy() wraps that fake provider in its own internal + * collectingProvider (propose/collectingProvider.ts), which is what actually + * talks to Ignition; the fake provider here only stands in for the "real + * chain" that collectingProvider bootstraps nonce/chainId from. + * + * COVERAGE (per issue #154's Definition of Done) + * ================================================= + * 1. Basic propose-mode batch collection: N contracts -> N collected + * transactions, none broadcast (the fake "real" provider's send-tx + * counter never increases). + * 2. Ordering + address threading: a `ref`-dependent 2-contract spec + * produces transactions in dependency order, and the SECOND contract's + * encoded constructor arg is the CREATE-predicted address of the first + * — proving proposeDeploy() reuses Ignition's real encoding/ordering + * rather than reimplementing it. + * 3. Journal invariant (the load-bearing part): proposing (a) a + * brand-new deployment with no pre-existing deploymentDir, and (b) the + * REMAINING work of a partially-deployed spec (resume case) — in both + * cases the caller's real, resumable journal.jsonl is never created or + * modified by proposeDeploy(). + */ + +import { describe, it, expect, afterEach } from "vitest"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { decodeDeployData, getContractAddress } from "viem"; +import type { DeploymentSpec } from "../src/index.js"; +import { deploy, proposeDeploy, ProposeError } from "../src/index.js"; +import { + makeFakeProvider, + makeFakeArtifactResolver, + FAKE_ACCOUNTS, + makeTmpDir, + rmTmpDir, +} from "./testHelpers/fakeEip1193Provider.js"; + +const FAKE_BYTECODE = + "0x60806040526000805534801561001457600080fd5b50610100806100246000396000f3fe"; + +function buildTypedConstructorAbi(types: string[]): object[] { + const inputs = types.map((type, i) => ({ name: `arg${i}`, type, internalType: type })); + return [{ type: "constructor", inputs, stateMutability: "nonpayable" }]; +} + +function makeTypedArtifactResolver(abiByContract: Record) { + return { + async loadArtifact(contractName: string) { + return { + contractName, + sourceName: `contracts/${contractName}.sol`, + bytecode: FAKE_BYTECODE, + abi: abiByContract[contractName] ?? [], + linkReferences: {}, + }; + }, + async getBuildInfo() { + return undefined; + }, + }; +} + +const THREE_CONTRACT_SPEC: DeploymentSpec = { + version: 1, + contracts: [ + { id: "registry", contract: "Registry" }, + { id: "token", contract: "Token" }, + { id: "vault", contract: "Vault", after: ["registry", "token"] }, + ], +}; +const THREE_CONTRACT_ARG_COUNTS: Record = { Registry: 0, Token: 0, Vault: 0 }; + +// --------------------------------------------------------------------------- +// 1. Basic batch collection — nothing broadcast +// --------------------------------------------------------------------------- + +describe("proposeDeploy() — basic batch collection", () => { + it("collects one transaction per contract, all as creation (to: null)", async () => { + const result = await proposeDeploy({ + spec: THREE_CONTRACT_SPEC, + provider: makeFakeProvider(), + accounts: FAKE_ACCOUNTS, + artifactResolver: makeFakeArtifactResolver(THREE_CONTRACT_ARG_COUNTS), + }); + + expect(result.transactions).toHaveLength(3); + for (const tx of result.transactions) { + expect(tx.to).toBeNull(); + expect(tx.data.startsWith("0x")).toBe(true); + expect(tx.value).toBe("0"); + } + }, 30_000); + + it("never broadcasts — the underlying fake chain's send counter stays at 0", async () => { + // makeFakeProvider() tracks sends via eth_sendTransaction on ITS state; + // proposeDeploy() must never call it (only the internal collectingProvider + // intercepts sends). We prove this indirectly: a fresh fake provider used + // as the "real" read-only provider still answers eth_getTransactionCount + // with 0 (nothing was ever sent through it) after proposeDeploy() returns. + const provider = makeFakeProvider(); + + await proposeDeploy({ + spec: THREE_CONTRACT_SPEC, + provider, + accounts: FAKE_ACCOUNTS, + artifactResolver: makeFakeArtifactResolver(THREE_CONTRACT_ARG_COUNTS), + }); + + const nonceAfter = await provider.request({ + method: "eth_getTransactionCount", + params: [FAKE_ACCOUNTS[0], "latest"], + }); + expect(nonceAfter).toBe("0x0"); + }, 30_000); + + it("throws ProposeError(INVALID_SPEC) for a malformed spec, without touching any provider", async () => { + await expect( + proposeDeploy({ + // @ts-expect-error -- intentionally invalid for this test + spec: { version: 1, contracts: [{ id: "", contract: "Foo" }] }, + provider: makeFakeProvider(), + accounts: FAKE_ACCOUNTS, + artifactResolver: makeFakeArtifactResolver(), + }), + ).rejects.toBeInstanceOf(ProposeError); + }); + + it("throws ProposeError(UNKNOWN_RESOLVER) when a resolver arg names a resolver absent from ProposeOptions.resolvers", async () => { + const spec: DeploymentSpec = { + version: 1, + contracts: [ + { id: "vault", contract: "Vault", args: [{ kind: "resolver", name: "missingResolver" }] }, + ], + }; + + let thrown: unknown; + try { + await proposeDeploy({ + spec, + provider: makeFakeProvider(), + accounts: FAKE_ACCOUNTS, + artifactResolver: makeFakeArtifactResolver({ Vault: 1 }), + // resolvers deliberately omitted + }); + } catch (err) { + thrown = err; + } + + expect(thrown).toBeInstanceOf(ProposeError); + expect((thrown as ProposeError).code).toBe("UNKNOWN_RESOLVER"); + }); + + it("throws ProposeError(CROSS_REF_ERROR) when a crossRef arg names an unknown network", async () => { + const spec: DeploymentSpec = { + version: 1, + contracts: [ + { + id: "vault", + contract: "Vault", + args: [{ kind: "crossRef", network: "unknown-network", contract: "registry" }], + }, + ], + }; + + let thrown: unknown; + try { + await proposeDeploy({ + spec, + provider: makeFakeProvider(), + accounts: FAKE_ACCOUNTS, + artifactResolver: makeFakeArtifactResolver({ Vault: 1 }), + // crossNetworkJournals deliberately omitted + }); + } catch (err) { + thrown = err; + } + + expect(thrown).toBeInstanceOf(ProposeError); + expect((thrown as ProposeError).code).toBe("CROSS_REF_ERROR"); + }); + + it("throws ProposeError(EXECUTION_FAILED) when the ephemeral collection run does not complete successfully", async () => { + // Ignition's own execution-time ABI encoding validates constructor arg + // COUNT against the artifact's ABI -- validateSpec has no visibility into + // artifacts, so this is NOT caught until the ephemeral collection run + // itself, surfacing as a non-SUCCESSFUL DeploymentResult (VALIDATION_ERROR) + // rather than a thrown compile-time error. This is the genuinely reachable + // path to ProposeError("EXECUTION_FAILED", ...). + const spec: DeploymentSpec = { + version: 1, + contracts: [{ id: "vault", contract: "Vault", args: [{ kind: "literal", value: "x" }] }], + }; + + let thrown: unknown; + try { + await proposeDeploy({ + spec, + provider: makeFakeProvider(), + accounts: FAKE_ACCOUNTS, + // ABI declares a 0-arg constructor; the spec supplies 1 arg. + artifactResolver: makeTypedArtifactResolver({ Vault: [] }), + }); + } catch (err) { + thrown = err; + } + + expect(thrown).toBeInstanceOf(ProposeError); + expect((thrown as ProposeError).code).toBe("EXECUTION_FAILED"); + }); + + // COMPILE_ERROR reachability note: mirrors deploy.test.ts's documented + // finding (see that file's "COMPILE_ERROR catch branch — reachability + // analysis" section) verbatim — compileSpec() only throws CompileError for + // invariants validateSpec() ALREADY guards (UNSUPPORTED_LITERAL, + // INTERNAL_INVARIANT), so the COMPILE_ERROR catch in proposeDeploy() (which + // shares the exact same validate -> compile pipeline as deploy()) is + // unreachable for any validateSpec()-passing input. No contrived test is + // added for it, for the same reason deploy.test.ts gives. +}); + +// --------------------------------------------------------------------------- +// 2. Ordering + address threading through `ref` args +// --------------------------------------------------------------------------- + +describe("proposeDeploy() — ordering and ref-address threading", () => { + it("orders transactions by dependency and threads the predicted address of an earlier future into a later one", async () => { + const spec: DeploymentSpec = { + version: 1, + contracts: [ + { id: "registry", contract: "Registry" }, + { id: "vault", contract: "Vault", args: [{ kind: "ref", contract: "registry" }] }, + ], + }; + + const provider = makeFakeProvider(); + const result = await proposeDeploy({ + spec, + provider, + accounts: FAKE_ACCOUNTS, + artifactResolver: makeTypedArtifactResolver({ + Registry: buildTypedConstructorAbi([]), + Vault: buildTypedConstructorAbi(["address"]), + }), + }); + + expect(result.transactions).toHaveLength(2); + // Both are creations, in order: registry (0-arg) then vault (1 address arg). + expect(result.transactions[0]!.to).toBeNull(); + expect(result.transactions[1]!.to).toBeNull(); + + // The predicted address for "registry" is CREATE(sender, startNonce=0) -- + // the fake provider reports nonce 0 for a never-before-seen address. + const predictedRegistryAddress = getContractAddress({ + from: FAKE_ACCOUNTS[0] as `0x${string}`, + nonce: 0n, + }); + + const decoded = decodeDeployData({ + abi: buildTypedConstructorAbi(["address"]), + bytecode: FAKE_BYTECODE as `0x${string}`, + data: result.transactions[1]!.data as `0x${string}`, + }); + expect((decoded.args as [string])[0].toLowerCase()).toBe(predictedRegistryAddress.toLowerCase()); + }, 30_000); +}); + +// --------------------------------------------------------------------------- +// 3. Journal invariant — the load-bearing part of issue #154 +// --------------------------------------------------------------------------- + +describe("proposeDeploy() — journal invariant: never journals proposed-but-unexecuted transactions", () => { + let tmpDir: string | undefined; + afterEach(() => { + if (tmpDir) rmTmpDir(tmpDir); + tmpDir = undefined; + }); + + it("never creates a journal at a fresh deploymentDir path passed for resume lookup", async () => { + tmpDir = makeTmpDir(); + // Use a path INSIDE tmpDir that does not exist yet -- simulates "propose + // against a deploymentDir nothing has been deployed to yet". + const neverDeployedDir = path.join(tmpDir, "never-deployed"); + + const result = await proposeDeploy({ + spec: THREE_CONTRACT_SPEC, + provider: makeFakeProvider(), + accounts: FAKE_ACCOUNTS, + artifactResolver: makeFakeArtifactResolver(THREE_CONTRACT_ARG_COUNTS), + deploymentDir: neverDeployedDir, + }); + + expect(result.transactions).toHaveLength(3); + // The invariant: proposeDeploy() must not have created ANYTHING at the + // caller-supplied deploymentDir path -- it only ever reads from it. + expect(fs.existsSync(neverDeployedDir)).toBe(false); + }, 30_000); + + it("leaves an EXISTING real journal byte-for-byte unchanged, and proposes only the NOT-yet-deployed remainder", async () => { + tmpDir = makeTmpDir(); + const provider = makeFakeProvider(); + const baseSpec: DeploymentSpec = { + version: 1, + contracts: [ + { id: "registry", contract: "Registry" }, + { id: "token", contract: "Token" }, + ], + }; + + // Really deploy registry + token into the real, resumable journal. + const deployResult = await deploy({ + spec: baseSpec, + provider, + accounts: FAKE_ACCOUNTS, + deploymentDir: tmpDir, + artifactResolver: makeFakeArtifactResolver({ Registry: 0, Token: 0 }), + }); + expect(deployResult.success).toBe(true); + + const journalPath = path.join(tmpDir, "journal.jsonl"); + expect(fs.existsSync(journalPath)).toBe(true); + const journalBefore = fs.readFileSync(journalPath, "utf8"); + const statBefore = fs.statSync(journalPath); + + // Grow the spec with a new "vault" contract that depends on both + // already-deployed contracts, and propose the REMAINING work. + const grownSpec: DeploymentSpec = { + version: 1, + contracts: [ + ...baseSpec.contracts, + { id: "vault", contract: "Vault", after: ["registry", "token"] }, + ], + }; + + const proposeResult = await proposeDeploy({ + spec: grownSpec, + provider, + accounts: FAKE_ACCOUNTS, + artifactResolver: makeFakeArtifactResolver({ Registry: 0, Token: 0, Vault: 0 }), + deploymentDir: tmpDir, + }); + + // Only "vault" was not yet complete -- exactly one proposed transaction. + expect(proposeResult.transactions).toHaveLength(1); + expect(proposeResult.transactions[0]!.to).toBeNull(); + + // THE INVARIANT: the real journal is byte-for-byte unchanged. + const journalAfter = fs.readFileSync(journalPath, "utf8"); + expect(journalAfter).toBe(journalBefore); + const statAfter = fs.statSync(journalPath); + expect(statAfter.mtimeMs).toBe(statBefore.mtimeMs); + expect(statAfter.size).toBe(statBefore.size); + + // A subsequent REAL deploy() resume still sees registry+token as + // complete (from the untouched journal) and only sends 1 new tx (vault) + // -- proving the propose() call above left resumability fully intact. + const resumeResult = await deploy({ + spec: grownSpec, + provider, + accounts: FAKE_ACCOUNTS, + deploymentDir: tmpDir, + artifactResolver: makeFakeArtifactResolver({ Registry: 0, Token: 0, Vault: 0 }), + }); + expect(resumeResult.success).toBe(true); + expect(resumeResult.deployedAddresses["registry"]).toBe(deployResult.deployedAddresses["registry"]); + expect(resumeResult.deployedAddresses["token"]).toBe(deployResult.deployedAddresses["token"]); + expect(resumeResult.deployedAddresses["vault"]).toMatch(/^0x/); + }, 30_000); +}); diff --git a/packages/core/test/safeBatch.test.ts b/packages/core/test/safeBatch.test.ts new file mode 100644 index 0000000..9384f79 --- /dev/null +++ b/packages/core/test/safeBatch.test.ts @@ -0,0 +1,116 @@ +/** + * Tests for buildSafeBatch() (propose/safeBatch.ts) — issue #154, point 3. + * + * Pure, offline, no network — buildSafeBatch() is a plain data transform. + */ + +import { describe, it, expect } from "vitest"; +import { buildSafeBatch, SafeBatchError } from "../src/index.js"; +import type { ProposedTransaction } from "../src/index.js"; + +describe("buildSafeBatch() — call-only batches (fully supported)", () => { + it("produces the Safe Transaction Builder batch JSON shape", () => { + const transactions: ProposedTransaction[] = [ + { to: "0x1111111111111111111111111111111111111111", data: "0xabcdef", value: "0" }, + { to: "0x2222222222222222222222222222222222222222", data: "0x", value: "1000000000000000000" }, + ]; + + const batch = buildSafeBatch(transactions, { + chainId: 1, + name: "My batch", + description: "Post-deploy config", + now: () => 1700000000000, + }); + + expect(batch).toEqual({ + version: "1.0", + chainId: "1", + createdAt: 1700000000000, + meta: { + name: "My batch", + description: "Post-deploy config", + txBuilderVersion: "1.16.5", + }, + transactions: [ + { to: "0x1111111111111111111111111111111111111111", value: "0", data: "0xabcdef" }, + { to: "0x2222222222222222222222222222222222222222", value: "1000000000000000000", data: "0x" }, + ], + }); + }); + + it("preserves transaction order exactly (Safe executes batch entries in array order)", () => { + const transactions: ProposedTransaction[] = [ + { to: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", data: "0x01", value: "0" }, + { to: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", data: "0x02", value: "0" }, + { to: "0xcccccccccccccccccccccccccccccccccccccccc".slice(0, 42), data: "0x03", value: "0" }, + ]; + + const batch = buildSafeBatch(transactions, { chainId: 31337 }); + + expect(batch.transactions.map((t) => t.data)).toEqual(["0x01", "0x02", "0x03"]); + }); + + it("defaults name/description when omitted", () => { + const batch = buildSafeBatch([], { chainId: 1 }); + expect(batch.meta.name).toBe("reDeploy batch"); + expect(batch.meta.description).toBe(""); + }); + + it("uses Date.now() when no `now` override is supplied", () => { + const before = Date.now(); + const batch = buildSafeBatch([], { chainId: 1 }); + const after = Date.now(); + expect(batch.createdAt).toBeGreaterThanOrEqual(before); + expect(batch.createdAt).toBeLessThanOrEqual(after); + }); + + it("stringifies chainId", () => { + const batch = buildSafeBatch([], { chainId: 42161 }); + expect(batch.chainId).toBe("42161"); + }); +}); + +describe("buildSafeBatch() — raw contract-creation steps are unsupported (documented seam)", () => { + it("throws SafeBatchError with code UNSUPPORTED_CREATION when any tx has to: null", () => { + const transactions: ProposedTransaction[] = [ + { to: "0x1111111111111111111111111111111111111111", data: "0xabcdef", value: "0" }, + { to: null, data: "0x60806040", value: "0" }, + ]; + + let thrown: unknown; + try { + buildSafeBatch(transactions, { chainId: 1 }); + } catch (err) { + thrown = err; + } + + expect(thrown).toBeInstanceOf(SafeBatchError); + expect((thrown as SafeBatchError).code).toBe("UNSUPPORTED_CREATION"); + expect((thrown as SafeBatchError).message).toContain("index [1]"); + }); + + it("reports ALL creation-step indexes when multiple are present", () => { + const transactions: ProposedTransaction[] = [ + { to: null, data: "0x01", value: "0" }, + { to: "0x1111111111111111111111111111111111111111", data: "0x02", value: "0" }, + { to: null, data: "0x03", value: "0" }, + ]; + + let thrown: unknown; + try { + buildSafeBatch(transactions, { chainId: 1 }); + } catch (err) { + thrown = err; + } + + expect(thrown).toBeInstanceOf(SafeBatchError); + expect((thrown as SafeBatchError).message).toContain("index [0, 2]"); + }); + + it("does not throw for an all-call batch even if data looks like init code", () => { + const transactions: ProposedTransaction[] = [ + { to: "0x1111111111111111111111111111111111111111", data: "0x608060", value: "0" }, + ]; + expect(() => buildSafeBatch(transactions, { chainId: 1 })).not.toThrow(); + }); +}); diff --git a/packages/core/test/signer.test.ts b/packages/core/test/signer.test.ts new file mode 100644 index 0000000..4ce20e6 --- /dev/null +++ b/packages/core/test/signer.test.ts @@ -0,0 +1,156 @@ +/** + * Tests for the pluggable Signer seam (provider/signer.ts, provider/jsonRpc.ts's + * signerProvider()) — issue #154. + * + * Strategy: mock viem's createPublicClient/http at the module level (same + * approach as test/jsonRpc.test.ts) so no actual HTTP connections are made, + * then supply a HAND-ROLLED `Signer` object (NOT derived from + * `privateKeyToAccount`) to `signerProvider()` — proving the seam is truly + * pluggable: any object satisfying the narrow `Signer` interface works, + * simulating an external signer (hardware wallet, remote KMS, etc.). + * + * We also assert `jsonRpcProvider()` is unchanged: it still derives its + * signer via `privateKeySigner()` (which calls `privateKeyToAccount` + * internally) and behaves identically to before this refactor — covered + * already by test/jsonRpc.test.ts's full behavioral suite; here we only + * check that `privateKeySigner()` returns an object satisfying `Signer`. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { Mock } from "vitest"; + +let transportRequestSpy: Mock; + +vi.mock("viem", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + createPublicClient: vi.fn(() => ({ + request: (...args: unknown[]) => transportRequestSpy(...args), + getTransactionCount: vi.fn().mockResolvedValue(0), + getChainId: vi.fn().mockResolvedValue(1), + })), + http: vi.fn((url: string) => ({ type: "http", url })), + }; +}); + +import { signerProvider, privateKeySigner } from "../src/index.js"; +import type { Signer } from "../src/index.js"; + +const FAKE_RPC_URL = "http://localhost:8545"; +const EXTERNAL_SIGNER_ADDRESS = "0x1234567890123456789012345678901234567890" as const; +const FAKE_SIGNED_TX = + "0x02f8748201c884028fa6aa8502540be4008252089412345678deadbeef00000000000000000000000087038d7ea4c6800080c001a0aabb00112233445566778899a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9a001122334455667788" as `0x${string}`; +const FAKE_TX_HASH = "0x" + "cd".repeat(32); + +beforeEach(() => { + transportRequestSpy = vi.fn(); +}); + +// --------------------------------------------------------------------------- +// signerProvider() accepts ANY object satisfying the Signer interface +// --------------------------------------------------------------------------- + +describe("signerProvider -- pluggable external Signer", () => { + function makeExternalSigner(): { signer: Signer; spies: Record } { + const signTransactionSpy = vi.fn().mockResolvedValue(FAKE_SIGNED_TX); + const signMessageSpy = vi.fn().mockResolvedValue("0xexternalsig"); + const signTypedDataSpy = vi.fn().mockResolvedValue("0xexternaltypedsig"); + + // Deliberately NOT built via privateKeyToAccount/privateKeySigner -- + // simulates an external signer (hardware wallet / remote KMS client) + // that only implements the narrow Signer contract. + const signer: Signer = { + address: EXTERNAL_SIGNER_ADDRESS, + signTransaction: signTransactionSpy, + signMessage: signMessageSpy, + signTypedData: signTypedDataSpy, + }; + + return { signer, spies: { signTransactionSpy, signMessageSpy, signTypedDataSpy } }; + } + + it("returns an EIP-1193 shaped provider exposing ONLY request()", () => { + const { signer } = makeExternalSigner(); + const provider = signerProvider({ rpcUrl: FAKE_RPC_URL, signer }); + + expect(typeof provider.request).toBe("function"); + expect(Object.keys(provider)).toEqual(["request"]); + }); + + it("eth_accounts returns the external signer's address without hitting the transport", async () => { + const { signer } = makeExternalSigner(); + const provider = signerProvider({ rpcUrl: FAKE_RPC_URL, signer }); + + const result = await provider.request({ method: "eth_accounts" }); + + expect(result).toEqual([EXTERNAL_SIGNER_ADDRESS]); + expect(transportRequestSpy).not.toHaveBeenCalled(); + }); + + it("eth_sendTransaction signs via the external signer, never via a private key", async () => { + const { signer, spies } = makeExternalSigner(); + transportRequestSpy.mockResolvedValueOnce(FAKE_TX_HASH); + + const provider = signerProvider({ rpcUrl: FAKE_RPC_URL, signer }); + const result = await provider.request({ + method: "eth_sendTransaction", + params: [ + { + from: EXTERNAL_SIGNER_ADDRESS, + to: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd", + data: "0x1234", + gas: "0x5208", + gasPrice: "0x3b9aca00", + nonce: "0x0", + chainId: "0x1", + }, + ], + }); + + expect(spies.signTransactionSpy).toHaveBeenCalledTimes(1); + // Node only ever sees the ALREADY-signed raw transaction. + const methods = transportRequestSpy.mock.calls.map((c) => (c[0] as { method: string }).method); + expect(methods).not.toContain("eth_sendTransaction"); + expect(methods).toContain("eth_sendRawTransaction"); + expect(result).toBe(FAKE_TX_HASH); + }); + + it("personal_sign routes to the external signer's signMessage", async () => { + const { signer, spies } = makeExternalSigner(); + const provider = signerProvider({ rpcUrl: FAKE_RPC_URL, signer }); + + const result = await provider.request({ + method: "personal_sign", + params: ["0x68656c6c6f", EXTERNAL_SIGNER_ADDRESS], + }); + + expect(spies.signMessageSpy).toHaveBeenCalledWith({ message: { raw: "0x68656c6c6f" } }); + expect(result).toBe("0xexternalsig"); + }); +}); + +// --------------------------------------------------------------------------- +// privateKeySigner() -- the extracted original behavior, still Signer-shaped +// --------------------------------------------------------------------------- + +describe("privateKeySigner -- backwards-compatible private-key Signer", () => { + it("returns an object satisfying the Signer interface (address + 3 sign methods)", () => { + const FAKE_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + const signer = privateKeySigner(FAKE_PRIVATE_KEY); + + expect(typeof signer.address).toBe("string"); + expect(signer.address.startsWith("0x")).toBe(true); + expect(typeof signer.signTransaction).toBe("function"); + expect(typeof signer.signMessage).toBe("function"); + expect(typeof signer.signTypedData).toBe("function"); + }); + + it("does not expose the private key on the returned signer object", () => { + const FAKE_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + const signer = privateKeySigner(FAKE_PRIVATE_KEY); + + expect(JSON.stringify(signer)).not.toContain(FAKE_PRIVATE_KEY); + expect(JSON.stringify(signer)).not.toContain(FAKE_PRIVATE_KEY.slice(2)); + }); +});