Skip to content

core: Safe/multisig + external-signer deploy path (no raw private key required) - #174

Open
robercano-ghbot wants to merge 1 commit into
mainfrom
feat/issue-154-signer-propose-mode
Open

core: Safe/multisig + external-signer deploy path (no raw private key required)#174
robercano-ghbot wants to merge 1 commit into
mainfrom
feat/issue-154-signer-propose-mode

Conversation

@robercano-ghbot

Copy link
Copy Markdown
Collaborator

Closes #154

Summary

Adds a pluggable signer seam and a "propose" execution mode to @redeploy/core, so deploy/config transactions can be signed by an external signer or collected into a Safe-compatible batch instead of being broadcast directly with a raw DEPLOYER_PRIVATE_KEY.

1. Signer seam (packages/core/src/provider/signer.ts)

jsonRpc.ts's signing logic is generalized behind a narrow Signer interface (address + signTransaction/signMessage/signTypedData, structurally a subset of viem's LocalAccount). jsonRpcProvider({ rpcUrl, privateKey }) is unchanged — same signature, same behavior — it now just derives a Signer via privateKeySigner() and delegates to the new signerProvider({ rpcUrl, signer }). External signers (hardware wallet, remote KMS, ...) implement Signer and call signerProvider() directly. No new fields were added to DeployOptions — this follows the existing injection pattern (provider is already the seam).

2 & 4. Propose mode + the journal-safety invariant (packages/core/src/propose/)

proposeDeploy() runs the exact same validate → resolve-crossRef → resolve-resolver → compile pipeline as deploy(), then runs Ignition's real deploy() engine against an in-memory collectingProvider that intercepts eth_sendTransaction and never broadcasts — so batch ordering, constructor-arg encoding, proxy expansion, and CREATE address prediction are byte-for-byte what a real deploy() would produce, with zero reinvented logic.

The load-bearing invariant: proposeDeploy() never passes the caller's real deploymentDir to Ignition.

  • No deploymentDir given → Ignition runs against a throwaway temp dir, deleted on return. No journal is ever created anywhere visible to the caller.
  • deploymentDir given (resume case) → its journal.jsonl is copied (read-only on the original) into the same throwaway dir; Ignition sees the resume state and only collects transactions for NOT-yet-complete futures. The real deploymentDir is opened at most once, for a read, never for a write.

Proven in test/propose.test.ts's journal-invariant suite via a byte-for-byte comparison of a real journal.jsonl before/after a proposeDeploy() call against a partially-deployed spec (plus a full subsequent deploy() resume showing resumability was untouched).

3. Safe batch output (packages/core/src/propose/safeBatch.ts)

buildSafeBatch() converts ProposeResult.transactions into the Safe Transaction Builder batch JSON schema (version/chainId/createdAt/meta/transactions[]). Raw contract-creation steps (to: null) have no Safe representation (Safe can't originate a CREATE) — buildSafeBatch() throws SafeBatchError("UNSUPPORTED_CREATION") rather than silently emitting an unusable batch. Config/call-only batches are fully supported.

5. Docs

packages/core/README.md gets a new "Signers and propose mode" section covering the pluggable signer seam and the full propose → Safe execution → resume operator flow.

Deferred (documented seams, not implemented)

  • Safe Transaction Service API (actually submitting the proposal) — needs an EIP-712 signature from a real Safe owner + a live/heavily-mocked HTTP round-trip; not "cheap and fully unit-testable without network" as the issue's bar requires. buildSafeBatch()'s output is the seam for a follow-up HTTP client.
  • CREATE2-factory routing for contract-creation Safe batches — real Safe-based deployment of new contracts needs a deterministic factory (Safe can't send raw CREATE); out of scope here, documented in both the README and safeBatch.ts's module doc.
  • Automatic journal reconciliation after Safe execution — bridging "the Safe executed 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 tracks, was the sender) is not implemented; the confirm-then-resume flow documents this boundary explicitly.

Test plan

  • pnpm -F @redeploy/core build
  • pnpm -F @redeploy/core lint
  • pnpm -F @redeploy/core typecheck
  • pnpm -F @redeploy/core test (513 tests, incl. new signer.test.ts, propose.test.ts, safeBatch.test.ts)
  • pnpm -F @redeploy/core coverage (packages/core: 94.41% stmts, threshold 80)
  • Full monorepo gates via .claude/scripts/gate.sh {build,lint,typecheck,test,coverage} from repo root — all green

…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.
@robercano-ghbot robercano-ghbot added the needs-human Loop escalated: manual triage required label Aug 6, 2026
@robercano-ghbot

Copy link
Copy Markdown
Collaborator Author

core: Safe/multisig + external-signer deploy path (no raw private key required) (not yet reviewed)

@robercano-ghbot robercano-ghbot added needs-human Loop escalated: manual triage required and removed needs-human Loop escalated: manual triage required labels Aug 8, 2026
@robercano-ghbot

Copy link
Copy Markdown
Collaborator Author

core: Safe/multisig + external-signer deploy path (no raw private key required) (not yet reviewed)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-human Loop escalated: manual triage required

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[wave 2] core: Safe/multisig + external-signer deploy path (no raw private key required)

2 participants