Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
37 changes: 35 additions & 2 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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";
Loading