Skip to content

feat(deploy): deploy via an existing factory — model and wizard flow - #26

Open
alanhwu wants to merge 18 commits into
mainfrom
feat/factory-deployments
Open

feat(deploy): deploy via an existing factory — model and wizard flow#26
alanhwu wants to merge 18 commits into
mainfrom
feat/factory-deployments

Conversation

@alanhwu

@alanhwu alanhwu commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Implements #25.

Deploy contracts by calling an already-deployed factory, with every contract that call produces treated as a first-class deployment: predicted address, a step id later steps can point at, run artifact, and verification.

How products are discovered

A deploy function that returns addresses already declares what it creates, and names each one:

deploy(revenueToken, revenueRecipient, threshold, owner, configSetter, salt)
   outputs: jar:address, releaser:address

So the wizard reads the factory's ABI, lists the functions that could deploy, and reports what each produces — "this call deploys 2 contracts: jar, releaser" — rather than asking anyone to hand-write signatures.

The same fact solves prediction. An eth_call of that function — same arguments, same sender — returns the addresses it would create, so every product of a call is predicted at once, with:

  • no predict helper required (the motivating factory exposes predictJar but no predictReleaser),
  • no reconstruction of a salt the factory transforms (this one scopes it to msg.sender so callers cannot squat each other's addresses),
  • no need to know a product's constructor arguments, which the factory supplies itself.

One call, several products

The transaction is a plain call step, and every product points at it — no product carries the call, so none is privileged:

call-factory      kind: 'call'   target: <factory>, signature: 'deploy(…) returns (address jar, address releaser)', args
product-jar       kind: 'deploy' strategy: { kind: 'factory', fulfilledBy: 'call-factory', output: 'jar' }
product-releaser  kind: 'deploy' strategy: { kind: 'factory', fulfilledBy: 'call-factory', output: 'releaser' }

Because every product is a real deploy step, pointers, the run artifact and verification work with no new concepts — a jar+releaser factory can have its releaser configured by later call steps even though nobody knows its address when the plan is written. The post-receipt path needed no change: any non-create strategy already means "the address is the predicted one, confirmed via getCode before the lane advances", which is exactly right for a factory call whose receipt carries no contractAddress.

The "Deploy via factory" flow

The wizard now has a first-class door: a Deploy via factory action on the Deployments page, beside "New deployment". It opens the wizard with the Contracts station replaced by a Factory setup step:

  1. Pick the factory's own contract from a repo (ArtifactPicker) — its ABI drives everything; the factory itself is never deployed by the run.
  2. Enter the factory's deployed address (per-chain overrides live on the generated call step, where the existing per-chain target UI already is).
  3. Pick the deploy function from a dropdown of state-changing, address-returning candidates, each labelled with what it deploys.
  4. Ignite states "this call deploys N contracts: jar, releaser" and asks for an artifact per output — the same ArtifactPicker, once per product. One artifact may back several outputs; product contract ids append the output name so they never collide.

The setup step is deliberately structure-only. The call's arguments are filled on the generated Factory call card in Steps, which has the full argument editor — real parameter names, pointers, signer fill, per-chain overrides — instead of a lesser duplicate in the setup (the stored signature keeps parameter names and the returns clause, so argument keys survive the trip through the plain call step and core still discovers products from the outputs). Product cards ask only for what the operator has power over: a factory supplies its product's constructor arguments onchain and Ignite builds no initcode for it, so the constructor and library editors are gone from factory products, and a fulfilled product — which sends no transaction of its own — also drops gas, value and signer, all of which live on the fulfilling call.

Continue materializes the canonical shape above into the draft and the rest of the wizard runs unchanged. Materialization is a reconciliation anchored on a call-step id minted when the flow starts: re-entering the Factory step and remapping an output replaces exactly that product, keeps operator edits to the call step, and never duplicates steps. Before materialization the setup fields edit staging state; afterwards the address binds directly to the generated call step, so the setup step and the step card cannot disagree.

Structural safety follows the model: a product cannot be moved above its call (fulfilledBy must resolve to an earlier step), the fulfilling call cannot be removed from under its products, its function is read-only on the call card (products read their addresses from its outputs; the Factory step is where the function changes), and deploying plain contracts from a repo abandons an un-materialized factory setup instead of mixing the two flows.

One entry point, deliberately

The per-step "Factory" strategy option is retired from the strategy picker. It was the wrong door: it forces the operator to declare "I am deploying TokenJar" before describing the call, and it cannot express the multi-product shape without hand-wiring fulfilledBy across step cards. Everything it could do, the flow does. What remains for steps that already carry the strategy (hydrated workflows, existing drafts): the option still displays, FactoryStrategyFields still renders for carry-the-call steps, and a fulfilled product shows which call deploys it instead of a dead form. Flow-generated plans round-trip through save-as-workflow and re-hydration (the new fulfilledBy branch in planFromDraft maps them back); the legacy carry-the-call form still does not round-trip (plan target vs draft factoryAddress), which is one more reason the flow is the single entry.

Fixes the flow surfaced

Walking the flow end to end against a live chain exposed four real gaps, each now fixed with a test written first:

  • Factory drafts were silently lost on reload — the persisted-draft strategy union had no factory variant, so the whole session reset. Additive fix, no storage-key bump.
  • ArtifactPicker could not see the session workspace — it listed only local/cloned/orphaned repos, so on a fresh setup (workspace-only, exactly where this flow starts) it rendered "No options found".
  • Validation demanded constructor arguments from factory products (core/validation.ts) — but the factory supplies those onchain; every valid factory plan failed review. Products now skip the constructor check while the deploy call keeps full argument discipline (as the call step, or against the deploy function for a carry-the-call step).
  • Factory-only plans showed no predicted addresses — the create2 item's "no create2 steps" early return dropped the eth_call predictions on the floor. They now flow into the review details, and the provisional chip prefers the prediction's own note ("returned by deploy(…)") over the misleading "mined during run".

Verified against a live chain

Full walkthrough on a local Anvil (chain 31337) with tjar's Factory actually deployed: Deployments → Deploy via factory → pick Factory + its address → deploy(…) → map jarTokenJar, releaserExchangeReleaser → named args → Chains/RPC/Signers → Review. Review came back all green with one factory transaction scheduled, no transaction for either product, and both predicted addresses byte-identical to a direct cast call of the deploy function with the run's signer as sender. A call step added in Steps targeted the releaser through a pointer and simulated successfully via eth_simulateV1 — the pointer resolved to the predicted address and the call executed against the contract the simulated factory call had just created.

Testing

Core: factory.test.ts (10) and factorySchedule.test.ts for the model — including the canonical call-plus-products shape — plus new factoryValidation.test.ts (3) for the review fixes. Frontend: 34 new tests across the signature helpers (tuple expansion, product naming), the draft reducers (materialization, reconciliation, remap-replaces-one-product, structural guards), planFromDraft's fulfilledBy branch, draft persistence round-trips, the setup blocker, the entry action, repoChoicesFor, and the provisional label.

Suites: core 1340 passed (the 3 failures are the documented flaky repos/versionMaterialization.test.ts, failing on clean main with a different subset per run), frontend 310 passed (was 276), type-check clean in all three packages, @ignite/api builds. New files are prettier-clean; every touched existing file's formatting posture is unchanged (verified by stash/--check/pop diff). The pre-existing no-useless-escape lint error in artifact.ts reproduces on main.

Not included

Call-only runs — a plan with no deployment at all (a governance handoff or config batch against existing contracts) still requires at least one contract. Noted in #25 as a follow-up; nothing here blocks it. The factory flow satisfies the constraint naturally because products are contracts.

🤖 Generated with Claude Code

alanhwu and others added 12 commits August 6, 2026 19:18
Adds a `factory` deployment strategy: instead of submitting initcode, the
step's transaction CALLS an already-deployed factory, and the contract it
produces keeps everything a directly-deployed contract has — a predicted
address, pointers from later steps, the run artifact and verification.

The product address is predicted by the factory's own helper (one eth_call,
e.g. predictJar(address,bytes32)) by default, because a factory is free to
transform the salt — commonly scoping it to msg.sender so callers cannot
squat each other's addresses — which raw CREATE2 math would silently get
wrong. Raw CREATE2 from the factory as deployer stays available for factories
without a helper; it uses the product's creation bytecode as-is, since a
factory supplies its product's constructor arguments itself.

Modelling this as a deploy strategy rather than a call is what keeps the rest
intact: the post-receipt path already treats any non-create strategy as
"address is the predicted one, confirmed by getCode", which is exactly right
for a factory call whose receipt carries no contractAddress.

Refs #25. Products beyond the first from a single call, and call-only runs,
remain follow-ups.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replaces the predict-helper/CREATE2 prediction modes with the deploy function
itself. A function that returns addresses declares the contracts its call
produces and names them in its ABI outputs, so:

- the wizard reads the factory's ABI, lists its deploy functions, and reports
  what each one produces ("this call deploys 2 contracts") instead of asking
  for typed signatures and a separate predict function;
- one eth_call of that same function — same arguments, same sender — yields
  every product address at once, needing no predict helper and no
  reconstruction of a salt the factory may transform.

A call producing several contracts is modelled as one step per product: the
first sends the transaction, the others name it with `fulfilledBy` and send
nothing. Each product therefore keeps a step id, so later steps can point at
it — the releaser of a jar+releaser factory can be configured by subsequent
calls even though its address is unknown when the plan is written.

This supersedes the typed-signature shape in the previous commit, which asked
the operator to hand-write signatures Ignite could read from the artifact —
the same class of mismatch as #9 — and had no answer for a factory whose
second product has no predict helper.

Refs #25.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Products no longer need one of their number to carry the factory call. A
plain call step makes the call — which is what it is — and every product
points at it with `fulfilledBy`, so none is privileged.

This is what the wizard needs to stop asking "which contract are you
deploying?" before a factory deployment. Calling a factory that produces a
jar and a releaser deploys both; making the operator nominate one of them as
the contract, with the other riding along, misrepresents the operation.

`fulfilledBy` is exempt from the rule that pointers name deploy steps (it
names the caller, ordinarily a call step) but must still resolve to an
earlier step, so the call always precedes the products it creates.

Refs #25.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The deploy-via-factory flow needs the ABI reading that FactoryStrategyFields
kept private — candidate filtering, product discovery, and a signature
builder. The new builder keeps parameter names (argument fields stay keyed by
real names through a plain call step) and expands tuple components, which the
shallow ABI type field cannot express.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The flow's composer state lives on the draft as factorySetup: structure
(factory artifact, deploy function, output-to-artifact mapping) persists for
the session, while address and args are only staged until applyFactorySetup
materializes the canonical shape — one plain call step plus one fulfilledBy
product step per mapped output. Reconciliation is anchored on a call step id
minted at flow start, so re-applying after edits replaces exactly the
products that changed and never accumulates duplicates. Product contract ids
append the output name because one artifact can back several outputs.

Guards keep the generated shape coherent: a product cannot move above its
call (fulfilledBy must resolve to an earlier step), the fulfilling call
cannot be removed from under its products, and deploying plain contracts
abandons an un-materialized setup instead of mixing the two flows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The draft-to-plan boundary required every factory strategy to carry the
factory address and deploy function, which contradicts the canonical shape:
a fulfilled product's transaction lives on the step named by fulfilledBy.
Emit the product form (fulfilledBy + output only) so generated flows and
re-hydrated workflow documents round-trip without inventing call fields.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The restored-draft strategy union had no factory variant, so any draft
holding one failed the parse and the whole session silently reset on reload.
Add the variant plus the flow's factorySetup, both additive — no storage key
bump. A pre-materialization setup (no contracts yet) still refuses restore,
matching the no-contracts-no-session invariant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The step that replaces Contracts in factory mode: pick the factory's
artifact (its ABI drives the function list), enter its deployed address,
choose the deploy function, map each returned address to a product artifact,
and fill the call's arguments under their real parameter names. Before
materialization the fields edit factorySetup staging; afterwards address and
args bind straight to the generated call step so the setup and the step card
can never disagree. factorySetupBlocker surfaces the first missing stage
next to the wizard's Continue button.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A "Deploy via factory" action beside "New deployment" enters the wizard
with ?flow=factory, which seeds the flow into an empty draft. In factory
mode the first wizard station is the Factory setup instead of Contracts;
Continue materializes the call and product steps and the rest of the wizard
runs unchanged. A plain /deploy entry clears an un-materialized setup so an
abandoned flow cannot hijack "New deployment", while the Deployments page
routes "Manage deployment" through the flow param so resuming a setup in
progress never loses it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The per-step Factory strategy option was the wrong door: it forces the
operator to declare a primary product before describing the call, and it
cannot express the multi-product shape without hand-wiring fulfilledBy
across step cards. The strategy picker no longer offers it; the option (and
FactoryStrategyFields) remains rendered for steps that already carry the
strategy — hydrated workflows and existing drafts keep working.

Flow-generated pieces get honest presentation instead of dead forms: a
fulfilled product shows which call deploys it, and the fulfilling call card
hides removal and locks its function (products cannot outlive the call, and
its outputs are their addresses). The prepare button is suppressed for
factory strategies because the endpoint rejects them — products are
predicted by the validation-time eth_call instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The picker built its repository list from local, cloned and orphaned
version groups only, so on a fresh setup — where the session workspace is
the only repo — it rendered "No options found" and the deploy-via-factory
flow dead-ended at its first field. The workspace joins the list first,
deduplicated against an identical attached local repo, and its new-version
flow treats the path as the filesystem checkout it is.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two gaps surfaced by walking the deploy-via-factory flow end to end against
a live chain. The args item demanded constructor arguments from factory
products — but a factory supplies its product's constructor arguments
itself, so every valid factory plan failed review. Products now skip the
constructor check; the deploy call keeps full argument discipline, either as
the ordinary call step named by fulfilledBy or, for a carry-the-call step,
against the deploy function's inputs.

The create2 item's early return also dropped every prediction whenever the
plan had no create2/plugin step, which is exactly the factory-only case: the
products' addresses were predicted by the eth_call snapshot and then never
shown. The early return now carries factory predictions into the review
details, and the review chip prefers the entry's own note ("returned by …")
over the misleading "mined during run".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@alanhwu alanhwu changed the title feat(deploy): deploy through an existing factory contract feat(deploy): deploy via an existing factory — model and wizard flow Aug 7, 2026
alanhwu and others added 6 commits August 6, 2026 22:52
ArtifactPicker's contract-type section offers artifacts SOURCED from
contract-type plugins (standard proxy templates). Inside the factory flow it
read as a classification field instead — an operator mapping plainly
immutable products saw only "Transparent proxy" and "UUPS" and went looking
for a missing "Immutable" option. No such classification is being asked:
picking from the repo IS the immutable path, a proxy template can never
carry a factory's deploy functions, and products verify against the source
the factory actually creates. The flow's pickers now opt out via a new
showContractTypes prop; the verify page keeps the section, and the real
Immutable/proxy choice remains on the step cards in Steps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…pace

Offering the session workspace in ArtifactPicker added its version rows to
the dropdown, but the pin lookups still searched only local/cloned repos and
orphaned groups. Selecting the workspace's own version tag therefore
resolved no frameworks, the artifact listing never dispatched, and the
picker sat on "Loading contracts…" forever. One pinnedVersionSummary helper
now answers every walks-the-versions question from the same entry set the
dropdown is built from, so a pin resolves wherever it lives.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Operator feedback from the first real use of the flow. The Factory setup
step duplicated the call's argument fields with a worse editor — no
pointers, no signer fill, no per-chain overrides — so the fields move
entirely to the generated Factory call card in Steps, and the setup step
becomes structure-only: factory, address, deploy function, product mapping.
The staged-args model disappears with them (the call step is created
argument-less and owns its args from birth).

Product cards stop asking for what the operator has no power over: a
factory supplies its product's constructor arguments onchain and Ignite
builds no initcode for it, so the constructor and library editors collected
values nothing reads — and a fulfilled product sends no transaction of its
own, so gas, value and signer belong to the fulfilling call. A product card
is now just its identity, wrapper choice, and how it comes to exist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…acts

Rematerialization rebuilds a version checkout from git, so it holds sources
but no out/ until something compiles it again — and the lifecycle's compile
may never have run, or failed (e.g. while plugin assets were broken).
Artifact listings survive such rebuilds through the fingerprint cache, so
the picker kept offering contracts whose data reads then failed with
ARTIFACT_NOT_FOUND: pick TokenJar from a pinned tag, get "Failed to get
artifact data". Reproduced against the live registry — the checkout on disk
had src/ and no out/.

getArtifactData and getVerificationBundle (the freeze path shares it) now
recover inside the materialized lease: on a miss, compile the checkout once
in place and reread; a failed compile surfaces the original miss. Live
workspaces deliberately keep the old behavior — their compiles belong to
the lifecycle, and a miss there means a wrong path that compiling cannot
fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Materialization upserts the version record from its ref options, and the
fresh-checkout path wrote refLabel/refKind from those options alone — so a
metadata-less caller erased whatever the record knew. The pinned artifact
reads passed a bare ref (no refKind), which started a cascade: the record
lost its refKind, pinForRepoVersion stopped emitting refs at all, and the
next rematerialization wiped the label too, leaving the picker rendering
the version as "hash · hash".

Three layers, so no single regression can repeat it: the compiler artifact
and bundle reads forward the pin's full ref metadata; ensureVersion never
erases stored ref identity it was not given; and the picker renders a
label-less version as a bare commit once instead of falling the commit into
the label slot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The generated factory call rendered the generic target picker — a select
sitting on "Other address…" above a bare paste box — for what is by
definition the existing factory's address. A fulfilling call whose target
is an address now shows one labeled field, "Existing factory address",
with per-chain overrides as plain address inputs; retargeting at a step
made no sense there (the flow's premise is a factory that already exists,
and the Factory step's address field binds to this same target). A
hydrated fulfilling call that does target a step keeps the full picker.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant