feat(deploy): deploy via an existing factory — model and wizard flow - #26
Open
alanhwu wants to merge 18 commits into
Open
feat(deploy): deploy via an existing factory — model and wizard flow#26alanhwu wants to merge 18 commits into
alanhwu wants to merge 18 commits into
Conversation
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
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_callof that function — same arguments, same sender — returns the addresses it would create, so every product of a call is predicted at once, with:predictJarbut nopredictReleaser),msg.senderso callers cannot squat each other's addresses),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:
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-
createstrategy already means "the address is the predicted one, confirmed viagetCodebefore the lane advances", which is exactly right for a factory call whose receipt carries nocontractAddress.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:
ArtifactPicker) — its ABI drives everything; the factory itself is never deployed by the run.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 (
fulfilledBymust 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
fulfilledByacross 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,FactoryStrategyFieldsstill 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 newfulfilledBybranch inplanFromDraftmaps them back); the legacy carry-the-call form still does not round-trip (plantargetvs draftfactoryAddress), 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:
factoryvariant, so the whole session reset. Additive fix, no storage-key bump.ArtifactPickercould 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".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).Verified against a live chain
Full walkthrough on a local Anvil (chain 31337) with tjar's
Factoryactually deployed: Deployments → Deploy via factory → pickFactory+ its address →deploy(…)→ mapjar→TokenJar,releaser→ExchangeReleaser→ 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 directcast callof 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 viaeth_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) andfactorySchedule.test.tsfor the model — including the canonical call-plus-products shape — plus newfactoryValidation.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'sfulfilledBybranch, 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 cleanmainwith a different subset per run), frontend 310 passed (was 276),type-checkclean in all three packages,@ignite/apibuilds. New files are prettier-clean; every touched existing file's formatting posture is unchanged (verified by stash/--check/pop diff). The pre-existingno-useless-escapelint error inartifact.tsreproduces onmain.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