Skip to content

feat(core): cross-chain dependency refs — resolve an arg to a contract deployed on another network (#159) - #172

Merged
robercano-ghbot merged 5 commits into
mainfrom
feat/issue-159-cross-chain-refs
Jul 30, 2026
Merged

feat(core): cross-chain dependency refs — resolve an arg to a contract deployed on another network (#159)#172
robercano-ghbot merged 5 commits into
mainfrom
feat/issue-159-cross-chain-refs

Conversation

@robercano-ghbot

Copy link
Copy Markdown
Collaborator

Closes #159.

What

Adds a cross-chain dependency ref to the @redeploy/core spec: a contract targeting network B can declare a constructor arg that resolves to the address of a contract already deployed on network A (e.g. an L2 bridge adapter taking its L1 counterpart's address). This adds the cross-network edge to the spec graph on top of the existing multi-network / per-network-journal machinery.

How

  • Spec: new arg kind CrossRefArg { kind: "crossRef", network, contract } added to the ContractArg union, with a matching zod crossRefArgSchema (both fields non-empty). Validation is shape-only — a crossRef is deliberately not checked against same-spec ids (no false MISSING_REF/SELF_REFERENCE), contributes no intra-spec ordering edge (detectCycles / buildCreationOrder), and is excluded from simulate's dependsOn.
  • Resolution: a new pre-resolution pass resolveCrossRefArgs(spec, { journals }) (mirroring the existing resolveSpecResolverArgs) reads the referenced network's Ignition journal via core's own status() primitive and substitutes the resolved address as a { kind: "literal" } arg before compile. The journal-reading helper (loadResolvedAddressesFromJournal) was extracted into a reusable resolve/journal.ts#loadAddressesFromJournal and deploy.ts refactored to call it (behavior-identical).
  • No dependency cycle: core reads the other network's journal through Ignition's own status() — it does not import @redeploy/reader (core is the base package; reader builds on core). The network→journal-dir map is injected by the caller (DeployOptions.crossNetworkJournals), so core never owns a network registry.
  • Hard errors, fail-closed: CrossRefError("CROSS_REF_UNKNOWN_NETWORK") when the referenced network isn't in the injected map; CrossRefError("CROSS_REF_NOT_DEPLOYED") when the source contract isn't in that network's journal yet (or the journal is missing) — re-wrapped as DeployError("CROSS_REF_ERROR") and thrown before any transaction is broadcast. Cross-chain ordering is the operator's responsibility in v1 (no cross-chain orchestrator, by design).
  • Compile guard: compileSpec throws UNRESOLVED_CROSS_REF_ARG if an unresolved crossRef ever reaches it (across constructor args, upgradeable.initializer.args, and proxyAdminOwner), so a crossRef can never silently reach Ignition.
  • Simulate/plan: PlannedStep gains a distinct crossRefs field so plan/diff consumers can surface cross-network edges distinctly (studio rendering is a follow-up).
  • Hardening (from review): the journal address lookup is Object.hasOwn-guarded (and the map built with Object.create(null)) so a contract id equal to a prototype key can't bypass the not-deployed hard error; the resolved value is isAddress-validated before it's injected as calldata.

Backward compatibility

Fully backward compatible: specs with no crossRef args do zero extra work and no extra I/O (short-circuit via specHasCrossRefArgs), and the single-network resolver path is unchanged.

Tests

~57 new vitest cases across crossRef, crossRefAddressGuard, deploy, simulate, compile, and spec test files, including real-journal resolution (source network seeded via a genuine deploy() over an in-memory EIP-1193 fake — no anvil), both hard-error paths asserting zero tx sent before failure, prototype-key contract/network guards, invalid-address guard, all three arg positions, and backward-compat. @redeploy/core coverage 94% (threshold 80%). Repo-wide pnpm -r build + pnpm -r typecheck pass (all downstream packages compile against the new exports).

Review

Reviewed adversarially through 4 lenses (correctness, tests, security, performance) — all approved; the one cross-cutting hardening finding (prototype-pollution guard on the address lookup) is applied in this branch.

Follow-ups (intentionally out of scope — core-only PR)

  • @redeploy/deploy-server: wire crossNetworkJournals from its NetworksRegistry (per-network deploymentDir).
  • @redeploy/studio: render cross-network edges from PlannedStep.crossRefs.
  • Deeper @redeploy/reader address-book integration as an alternative journal source (must stay caller-side to preserve the core→reader dependency direction).

🤖 Generated with Claude Code

robercano and others added 5 commits July 30, 2026 11:34
…part 1 (issue #159)

Adds `{ kind: "crossRef", network, contract }` to the ContractArg union: a
reference to a contract deployed on a DIFFERENT network, resolved to a
literal address at deploy time (part 2 of this feature). Shape-validated by
zod (non-empty network/contract) and deliberately excluded from
validateSpec's ref/cycle checks — the target lives outside this spec's own
id space, so MISSING_REF/SELF_REFERENCE/CYCLE checks would be meaningless.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
compileSpec() must never see a crossRef arg — they are pre-resolved to
literals before compilation (see the next commit). Mirrors the existing
UNRESOLVED_RESOLVER_ARG guard: mapContractArg() throws
CompileError("UNRESOLVED_CROSS_REF_ARG") for a direct caller who bypasses
the pre-resolution pass. buildCreationOrder() already excludes crossRef from
build-order edges (only "ref"/"expr" are handled there) — added tests
proving no phantom dependency is introduced.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…n (issue #159)

Extracts deploy.ts's private journal-address-read helper into a reusable
resolve/journal.ts (loadAddressesFromJournal) — a pure extraction, same
Ignition status() + stripModulePrefix behavior. Adds resolve/crossRef.ts's
resolveCrossRefArgs(), which walks a spec and replaces every `{ kind:
"crossRef" }` arg (constructor args, upgradeable.initializer.args,
upgradeable.proxyAdminOwner) with a literal address read from the target
network's journal (via an injected ResolveCrossRefOptions.journals map — core
has no network registry of its own). Throws CrossRefError with
CROSS_REF_UNKNOWN_NETWORK or CROSS_REF_NOT_DEPLOYED on failure.

Tests build real journals via the in-memory fake EIP-1193 provider (no anvil
needed) to exercise the actual Ignition status() read path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…159)

deploy() gains DeployOptions.crossNetworkJournals: an injected map of network
name -> journal location. When the spec has crossRef args, deploy() resolves
them (via resolveCrossRefArgs) BEFORE the existing resolver pre-resolution
pass — both are pre-compile literal substitutions, ordered crossRef ->
resolver. CrossRefError is caught and re-wrapped as
DeployError("CROSS_REF_ERROR"), mirroring the ResolveError wrapping already
in place. Fully backward compatible: specs with no crossRef args touch zero
extra I/O.

simulate()'s PlannedStep gains a distinct `crossRefs` field (network +
contract pairs) — kept separate from `dependsOn` since a crossRef targets a
different network's deployment, never a same-run build/deploy-order
dependency. Studio rendering of cross-network edges is a follow-up.

Exports CrossRefArg, crossRefArgSchema, CrossNetworkJournal,
ResolveCrossRefOptions, resolveCrossRefArgs, specHasCrossRefArgs,
CrossRefError, and CrossRefErrorCode from the package root.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…(issue #159)

Mirror the existing Object.hasOwn guard on options.journals[network] with the
same guard on the loaded addresses[contract] map, so a contract id equal to a
prototype key (e.g. "__proto__", "constructor", "toString") throws the normal
CrossRefError("CROSS_REF_NOT_DEPLOYED") instead of silently resolving to an
inherited Object.prototype member. Also builds the address map in
loadAddressesFromJournal with Object.create(null) for defense in depth.

Additionally validate the resolved value with viem's isAddress before
substituting it as a { kind: "literal" } constructor arg, so a journal that
somehow yields a non-address string can never be injected into on-chain
calldata — refusing with the same CROSS_REF_NOT_DEPLOYED error instead.
@robercano-ghbot
robercano-ghbot merged commit 5150961 into main Jul 30, 2026
6 checks passed
@robercano-ghbot
robercano-ghbot deleted the feat/issue-159-cross-chain-refs branch July 30, 2026 10:17
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.

[wave 3] core: cross-chain dependency refs (contract on chain B references a deployment on chain A)

2 participants